diff --git a/.codex/scripts/task_guard.sh b/.codex/scripts/task_guard.sh old mode 100755 new mode 100644 index f3cb9b8bff..6224e99ec2 --- a/.codex/scripts/task_guard.sh +++ b/.codex/scripts/task_guard.sh @@ -209,16 +209,6 @@ start_task() { fail_usage "another task guard is unfinished: $(sed -n '1p' "$state_dir/id") ($previous_status)" fi fi - if [[ -d "$state_dir" && -f "$state_dir/status" ]] && [[ "$(sed -n '1p' "$state_dir/status")" == "active" ]]; then - # 支持 active 会话一次性扩容;不覆盖 initial-dirty/protected,避免误改保护面。 - : - else - : > "$state_dir/initial-dirty" - : > "$state_dir/initial-staged" - : > "$state_dir/protected" - : > "$state_dir/protected-fingerprints" - printf 'active\n' > "$state_dir/status" - fi mkdir -p "$state_dir" : > "$state_dir/scope" : > "$state_dir/adopted" @@ -342,11 +332,7 @@ finish_task() { [[ -n "$path" ]] || continue path_matches_file "$path" "$state_dir/protected" && continue path_matches_file "$path" "$state_dir/scope" || continue - if [[ -e "$path" || -L "$path" ]]; then - git add -A -- "$path" - else - git update-index --remove -- "$path" - fi + git add -A -- "$path" task_change_count=$((task_change_count + 1)) done < <(list_dirty) ((task_change_count > 0)) || safety_gate "no task-owned changes to commit" diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index ba6af52128..a17dab6746 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -80,8 +80,6 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@v3 - with: - packages: platform-tools - name: Install Android packages run: | diff --git a/.gitignore b/.gitignore index 79b5200be5..514c6b00fa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,13 @@ out/ !third_party/mpv-native-overrides/aimagereader-stable/video/out/ !third_party/mpv-native-overrides/aimagereader-stable/video/out/hwdec/ !third_party/mpv-native-overrides/aimagereader-stable/video/out/hwdec/hwdec_aimagereader_vk_stable.c +!third_party/mpv-native-overrides/aimagereader-v556/video/out/ +!third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/ +third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/* +!third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_vk.c +!third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_vk.h +!third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader.comp +!third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_comp.h Release/ *.apk *.aab diff --git a/FFMPEG_MODE_SWITCH.md b/FFMPEG_MODE_SWITCH.md new file mode 100644 index 0000000000..95226693df --- /dev/null +++ b/FFMPEG_MODE_SWITCH.md @@ -0,0 +1,247 @@ +# FFmpeg 模式切换功能 + +## 功能说明 + +用户现在可以在设置中切换**四种**播放器渲染器实现: +- **NextLib**(手机 + TV 默认):使用第三方 `nextlib-media3ext` 库的自定义 FFmpeg 音频/视频渲染器 +- **Simple**:使用默认视频渲染路径,不使用 NextLib 视频渲染器;保留 FFmpeg 音频兜底 +- **Official**:仅使用 Media3 官方渲染器(保留自定义缓冲配置) +- **自动**:仅 EXO 内核生效。播放失败时在**当前解码**下自动遍历 NextLib → Simple → Official,三种都失败后再交给现有的解码/内核降级链(硬解→软解→换内核)。遍历不写盘,仅进程内临时覆盖;退出重播或换片后重置。 + +## 使用方法 + +### Leanback(电视端) +设置 → 播放器设置 → FFmpeg模式 → 点击循环切换 + +### Mobile(手机端) +设置 → 播放器 → FFmpeg模式 → 点击循环切换 + +显示:`NextLib` → `Official` → `Simple` → `自动` → `NextLib` ... +**注意**:切换后需退出视频重新播放才生效 + +**默认模式**:手机端与 TV 端均为 NextLib + +--- + +## 🎯 推荐使用顺序 + +**如果遇到卡顿掉帧,请按此顺序尝试:** + +### 1. **Simple 模式** +✅ 默认视频渲染路径接近 TV 项目 +✅ 无自定义 FFmpeg 视频渲染器 +✅ 保留 FFmpeg 音频兜底,避免系统解码器不支持的 iOS/HLS 音轨无声 +✅ 无自定义缓冲配置(使用 ExoPlayer 默认值) +✅ 最简单、最稳定 + +### 2. **NextLib 模式**(手机 + TV 默认) +⚠️ 使用第三方 FFmpeg 渲染器 +⚠️ 自定义缓冲配置 +⚠️ 最复杂,可能存在兼容性问题 +⚠️ 开始流畅后可能卡顿(过度缓冲) + +### 3. **Official 模式** +❌ 移除 NextLib 渲染器 +❌ 保留自定义缓冲配置 +❌ 可能从头卡到尾(某些格式不支持) + +--- + +## 技术实现 + +### 核心差异对比 + +| 特性 | NextLib | Official | Simple | +|------|---------|----------|--------| +| **FFmpeg 渲染器** | NextLib 音频 + 视频 | 无 | 仅音频兜底 | +| **LoadControl** | 自定义缓冲 | 自定义缓冲 | ExoPlayer 默认 | +| **ExoEnhanced** | 支持 | 支持 | 不支持 | +| **复杂度** | 高 | 中 | 低 | +| **与 TV 项目对比** | 完全不同 | 部分相似 | 视频路径相近,音频兜底增强 | + +### 1. NextLib 模式(mode = 0) + +```java +// 使用 FfmpegRenderersFactory 自定义渲染器 +private static class FfmpegRenderersFactory extends DefaultRenderersFactory { + @Override + protected void buildAudioRenderers(...) { + super.buildAudioRenderers(...); + out.add(..., new CompatFfmpegAudioRenderer(...)); + } + @Override + protected void buildVideoRenderers(...) { + super.buildVideoRenderers(...); + out.add(..., new FfmpegVideoRenderer(...)); + } +} + +// 自定义 LoadControl +builder.setLoadControl(buildLoadControl()); +``` + +**依赖**: `io.github.anilbeesetti:nextlib-media3ext:1.10.0-0.12.1` + +### 2. Official 模式(mode = 1) + +```java +// 仅使用标准 DefaultRenderersFactory +DefaultRenderersFactory factory = new DefaultRenderersFactory(App.get()); +return factory + .setEnableDecoderFallback(true) + .setExtensionRendererMode(Math.max(audioRenderMode, videoRenderMode)); + +// 自定义 LoadControl +builder.setLoadControl(buildLoadControl()); +``` + +### 3. Simple 模式(mode = 2) + +```java +// 使用默认视频路径,只追加 FFmpeg 音频兜底 +DefaultRenderersFactory factory = new FfmpegAudioFallbackRenderersFactory(App.get(), audioRenderMode, audioPrefer); +return factory + .setEnableDecoderFallback(true) + .setExtensionRendererMode(Math.max(audioRenderMode, videoRenderMode)); + +// 不设置 LoadControl,使用 ExoPlayer 默认值 +// builder.setLoadControl(...); // 注释掉 +``` + +**关键**:Simple 模式在 `buildPlayer` 中跳过 `setLoadControl`,但仍保留 FFmpeg 音频兜底。 + +--- + +## 修改文件列表 + +### 核心逻辑 +- `app/src/main/java/com/fongmi/android/tv/setting/PlayerSetting.java` + - 新增 `getFFmpegMode()` / `putFFmpegMode(int)` 方法 + - 支持 0=NextLib, 1=Official, 2=Simple + - **默认值:手机端与 TV 端均为 `0`(NextLib)** + +- `app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java` + - `buildPlayer` - Simple 模式跳过 `setLoadControl` + - `buildRenderersFactory` - 根据模式分发到三种实现 + - `buildNextLibRenderersFactory` - NextLib 模式 + - `buildOfficialRenderersFactory` - Official 模式 + - `buildSimpleRenderersFactory` - Simple 模式 + +### UI 界面(Leanback + Mobile) +- 布局文件 - 添加"FFmpeg模式"设置项 +- Activity/Fragment - 添加循环切换逻辑(0→1→2→0) +- 显示文本:`NextLib` / `Official` / `Simple` + +### 字符串资源 +- `strings.xml` (EN/CN/TW) - `player_ffmpeg_mode` + +--- + +## 问题诊断 + +### 用户反馈分析 + +**症状**: +- Official 模式:从头卡到尾(完全无法播放) +- NextLib 模式:开始流畅,后面卡顿掉帧 + +**原因分析**: +1. **Official 从头卡到尾** → 系统 MediaCodec 不支持该视频格式,需要 FFmpeg 软解 +2. **NextLib 开始流畅后卡顿** → FFmpeg 能解码,但可能是: + - 自定义缓冲配置过度缓冲,导致内存压力 + - 渲染器插入逻辑导致 AV 不同步 + - ExoEnhanced 参数过于激进 + +**解决方案**:两端统一默认 NextLib,卡顿时手动降级 +- ✅ 手机端与 TV 端均默认 NextLib,优先保证 iOS/HLS 等格式的音视频兼容 +- ✅ 遇到卡顿掉帧可手动切到 Simple,移除复杂的 NextLib 视频渲染器插入逻辑 +- ✅ Simple 模式仍保留 FFmpeg 音频兜底,避免系统音频解码器不支持时无声 +- ✅ Simple 模式使用 ExoPlayer 默认缓冲策略 + +--- + +## 技术细节 + +### LoadControl 差异 + +**TV 项目(Simple 模式):** +```java +ExoPlayer player = new ExoPlayer.Builder(App.get()) + .setTrackSelector(...) + .setRenderersFactory(...) + .setMediaSourceFactory(...) + .build(); // 不设置 LoadControl +``` + +**当前项目(NextLib/Official 模式):** +```java +builder.setLoadControl(new DefaultLoadControl.Builder() + .setBufferDurationsMs( + DEFAULT_MIN_BUFFER_MS * PlayerSetting.getBuffer(), // 用户设置放大 + DEFAULT_MAX_BUFFER_MS * PlayerSetting.getBuffer(), + ... + ) + .build()); +``` + +**问题**:用户设置的缓冲倍数可能导致过度缓冲,触发 GC 或内存压力。 + +### 渲染器链对比 + +**NextLib 模式:** +``` +1. MediaCodec 硬解渲染器(系统) +2. CompatFfmpegAudioRenderer(NextLib) +3. FfmpegVideoRenderer(NextLib) +``` + +**Official 模式:** +``` +1. MediaCodec 硬解渲染器(系统) +2. 无额外渲染器 +``` + +**Simple 模式:** +``` +1. MediaCodec 硬解渲染器(系统) +2. CompatFfmpegAudioRenderer(仅音频兜底) +``` + +通过 `setExtensionRendererMode` 控制是否优先使用扩展渲染器。 + +--- + +## 存储键 + +```java +Prefers.getInt("ffmpeg_mode", getDefaultFFmpegMode()) +// 0 = NextLib +// 1 = Official +// 2 = Simple +// 3 = 自动(AUTO):持久值;实际渲染时由 getEffectiveFFmpegMode() 解析成具体模式 +// getDefaultFFmpegMode(): mobile 与 leanback/TV 均为 0(NextLib) +``` + +## 自动(AUTO)模式 + +选择「自动」后,播放失败时会在**当前解码档位**下按 `FFMPEG_AUTO_ORDER`(NextLib → Simple → Official)依次重建播放器重试,用尽三种模式仍失败才交回既有的解码/内核降级链(硬解→软解→换内核)。整体次序为:硬解下遍历三模式 → 软解下遍历三模式 → 换下一个内核。 + +- 仅在 EXO 内核 + FFmpeg 模式为 AUTO 时生效(`fallbackFfmpegMode`)。 +- 只对**播放失败**(`PlaybackException`)触发;掉帧不触发(掉帧没有干净的布尔信号,且换渲染器需重建播放器,误判代价大)。 +- 遍历状态用 `ffmpegModeFallbackTried[]` 防重复;切软解与 `reset()` 时清空,运行时用进程内 `ffmpegModeOverride` 临时覆盖具体模式,不写盘。 + +--- + +## 未来优化建议 + +1. ~~**默认值调整**:手机端与 TV 端统一默认 NextLib~~ ✅ 已完成 +2. ~~**失败自动遍历**:新增「自动」模式,播放失败时遍历三种 FFmpeg 模式~~ ✅ 已完成 +3. **自动检测**:根据视频格式主动选择合适的模式(当前仅在失败后被动遍历) +4. **性能监控**:记录掉帧率,提示用户切换模式 + +--- + +**修改日期**: 2026-07-05 +**相关 Issue**: 用户反馈 TV 项目能正常播放的剧,当前项目卡顿掉帧 +**测试结论**: Official 从头卡到尾,NextLib 开始流畅后卡顿;Simple 保留音频兜底并移除 NextLib 视频渲染器 +**默认模式**: 手机端与 TV 端均为 NextLib diff --git a/README.md b/README.md index ae8225b451..300612a8e7 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,6 @@ https://github.com/user-attachments/assets/984c274f-8a9b-4857-b641-d251e061f5cc ## 文档 -按功能模块拆分的接入文档见 [**开发者接入文档**](docs/integration/README.md),覆盖总配置、点播源、Spider、Result/Vod、直播/EPG/回看、解析器、字幕、弹幕、播放结果、本地代理和扩展包。字段说明以当前仓库实际代码为准。 - 完整开发说明见 [**应用完整开发文档.md**](webhome-devkit/docs/应用完整开发文档.md),包含: - App 配置字段(点播、解析、直播、样式) diff --git a/app/build.gradle b/app/build.gradle index 9cbceec452..dbccf2e946 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,17 +16,6 @@ def buildTimestamp = new Date().format('yyyyMMddHHmm', TimeZone.getTimeZone('Asi def buildTime = fastRelease ? "fast-${buildTimestamp}" : buildTimestamp def releaseTag = (System.getenv('WEBHTV_RELEASE_TAG') ?: '').replace('\\', '\\\\').replace('"', '\\"') def apkSuffix = (System.getenv('WEBHTV_APK_SUFFIX') ?: '').replaceAll('[^A-Za-z0-9._-]', '') -def gitRevision = 'unavailable' -def gitState = 'unavailable' -def mpvSourceLock = new groovy.json.JsonSlurper().parse(rootProject.file('third_party/mpv-native-lock.json')) -def mpvSourceRevisions = ['mpv', 'ffmpeg', 'libplacebo'].collect { name -> "${name}=${mpvSourceLock.sources[name].commit}" }.join(' ') + " framework=${mpvSourceLock.builder.commit}" -try { - def revision = providers.exec { workingDir rootDir; commandLine 'git', 'rev-parse', 'HEAD' }.standardOutput.asText.get().trim() - if (revision ==~ /[0-9a-f]{40}/) { - gitRevision = revision - gitState = providers.exec { workingDir rootDir; commandLine 'git', 'status', '--porcelain', '--untracked-files=no' }.standardOutput.asText.get().trim().isEmpty() ? 'clean' : 'dirty' - } -} catch (Exception ignored) { /* Source archives may not contain Git metadata. */ } android { namespace = 'com.fongmi.android.tv' @@ -40,12 +29,8 @@ android { targetSdk libs.versions.targetSdk.get().toInteger() versionCode 560 versionName "5.6.0" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" buildConfigField "String", "BUILD_TIME", "\"${buildTime}\"" buildConfigField "String", "BUILD_TAG", "\"${releaseTag}\"" - buildConfigField "String", "GIT_REVISION", "\"${gitRevision}\"" - buildConfigField "String", "GIT_STATE", "\"${gitState}\"" - buildConfigField "String", "MPV_NATIVE_SOURCES", "\"${mpvSourceRevisions}\"" buildConfigField "String", "MEDIA3_VERSION", "\"${libs.versions.media3.get()}\"" javaCompileOptions { annotationProcessorOptions { @@ -84,13 +69,6 @@ android { viewBinding = true } - testOptions { - unitTests.includeAndroidResources = true - } - - // ASS rendering is part of normal builds; package the matching prebuilt ABI. - sourceSets.main.jniLibs.srcDir(rootProject.file('third_party/exo-ass-native/prebuilt')) - signingConfigs { release { if (hasReleaseSigning) { @@ -172,7 +150,6 @@ dependencies { implementation libs.palette implementation libs.recyclerview implementation libs.room.runtime - implementation libs.work.runtime implementation libs.splashscreen implementation libs.swiperefreshlayout implementation libs.viewpager2 @@ -203,17 +180,10 @@ dependencies { mobileImplementation libs.biometric mobileImplementation libs.zxing.android.embedded testImplementation libs.junit - testImplementation libs.work.testing annotationProcessor libs.room.compiler annotationProcessor libs.eventbus.annotation.processor annotationProcessor libs.glide.compiler coreLibraryDesugaring libs.desugar - testLeanbackImplementation 'org.robolectric:robolectric:4.16' - androidTestImplementation libs.junit - androidTestImplementation libs.work.testing + testImplementation libs.junit testImplementation libs.okhttp.mockwebserver - androidTestImplementation libs.androidx.test.runner - androidTestImplementation libs.androidx.test.rules - androidTestImplementation libs.androidx.test.core - androidTestImplementation libs.androidx.test.ext.junit } diff --git a/app/proguard-rules-media.pro b/app/proguard-rules-media.pro index 20ed725962..b496b1c3ad 100644 --- a/app/proguard-rules-media.pro +++ b/app/proguard-rules-media.pro @@ -197,6 +197,3 @@ public androidx.appcompat.app.AlertDialog$Builder setNegativeButton(int, android.content.DialogInterface$OnClickListener); public androidx.appcompat.app.AlertDialog create(); } --keepclasseswithmembernames class com.fongmi.android.tv.player.exo.ass.AssNative { - native ; -} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index c464f1750a..4bd03e33e5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -72,11 +72,6 @@ -keep class com.github.catvod.Proxy { *; } -keep class com.github.catvod.crawler.** { *; } -keep class * extends com.github.catvod.crawler.Spider -# Chaquopy bridge loaded by name from CatVod jar spiders (for example the -# csp_PyProxy sites in external configs). spring.jar resolves -# com.fongmi.chaquo.Loader and com.fongmi.chaquo.Spider by their literal class -# names via reflection, so R8 must not shrink or rename them in release builds. --keep class com.fongmi.chaquo.** { *; } # Jianpian -keep class com.p2p.** { *; } diff --git a/app/schemas/com.fongmi.android.tv.db.AppDatabase/45.json b/app/schemas/com.fongmi.android.tv.db.AppDatabase/45.json deleted file mode 100644 index 079b3197cd..0000000000 --- a/app/schemas/com.fongmi.android.tv.db.AppDatabase/45.json +++ /dev/null @@ -1,696 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 45, - "identityHash": "b28bd3ade9e71166248e9ef1c1b72156", - "entities": [ - { - "tableName": "Keep", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `siteName` TEXT, `vodName` TEXT, `vodPic` TEXT, `createTime` INTEGER NOT NULL, `type` INTEGER NOT NULL, `cid` INTEGER NOT NULL, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteName", - "columnName": "siteName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Site", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `searchable` INTEGER, `changeable` INTEGER, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchable", - "columnName": "searchable", - "affinity": "INTEGER" - }, - { - "fieldPath": "changeable", - "columnName": "changeable", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Live", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `keep` TEXT, `boot` INTEGER NOT NULL, `pass` INTEGER NOT NULL, PRIMARY KEY(`name`))", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keep", - "columnName": "keep", - "affinity": "TEXT" - }, - { - "fieldPath": "boot", - "columnName": "boot", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pass", - "columnName": "pass", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "name" - ] - } - }, - { - "tableName": "Track", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `key` TEXT, `name` TEXT, `format` TEXT, `selected` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "format", - "columnName": "format", - "affinity": "TEXT" - }, - { - "fieldPath": "selected", - "columnName": "selected", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Track_key_type", - "unique": true, - "columnNames": [ - "key", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Track_key_type` ON `${TABLE_NAME}` (`key`, `type`)" - } - ] - }, - { - "tableName": "Config", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `time` INTEGER NOT NULL, `url` TEXT, `json` TEXT, `name` TEXT, `logo` TEXT, `home` TEXT, `parse` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT" - }, - { - "fieldPath": "json", - "columnName": "json", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "logo", - "columnName": "logo", - "affinity": "TEXT" - }, - { - "fieldPath": "home", - "columnName": "home", - "affinity": "TEXT" - }, - { - "fieldPath": "parse", - "columnName": "parse", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Config_url_type", - "unique": true, - "columnNames": [ - "url", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_url_type` ON `${TABLE_NAME}` (`url`, `type`)" - } - ] - }, - { - "tableName": "Device", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `uuid` TEXT, `name` TEXT, `ip` TEXT, `type` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER" - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "ip", - "columnName": "ip", - "affinity": "TEXT" - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Device_uuid_name", - "unique": true, - "columnNames": [ - "uuid", - "name" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Device_uuid_name` ON `${TABLE_NAME}` (`uuid`, `name`)" - } - ] - }, - { - "tableName": "History", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `vodPic` TEXT, `wallPic` TEXT, `vodName` TEXT, `vodFlag` TEXT, `vodRemarks` TEXT, `episodeUrl` TEXT, `revSort` INTEGER NOT NULL, `revPlay` INTEGER NOT NULL, `createTime` INTEGER NOT NULL, `opening` INTEGER NOT NULL, `ending` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `speed` REAL NOT NULL, `speedOverride` INTEGER NOT NULL DEFAULT 0, `scale` INTEGER NOT NULL, `cid` INTEGER NOT NULL, `typeName` TEXT, `area` TEXT, `actor` TEXT, `director` TEXT, `year` TEXT, `tmdbId` INTEGER NOT NULL DEFAULT 0, `mediaType` TEXT DEFAULT '', `legacyKey` TEXT DEFAULT '', `tmdbSeasonNumber` INTEGER NOT NULL DEFAULT 0, `tmdbEpisodeNumber` INTEGER NOT NULL DEFAULT 0, `player` INTEGER NOT NULL DEFAULT -1, `subtitleSource` TEXT DEFAULT '', PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "wallPic", - "columnName": "wallPic", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodFlag", - "columnName": "vodFlag", - "affinity": "TEXT" - }, - { - "fieldPath": "vodRemarks", - "columnName": "vodRemarks", - "affinity": "TEXT" - }, - { - "fieldPath": "episodeUrl", - "columnName": "episodeUrl", - "affinity": "TEXT" - }, - { - "fieldPath": "revSort", - "columnName": "revSort", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "revPlay", - "columnName": "revPlay", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opening", - "columnName": "opening", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ending", - "columnName": "ending", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "speed", - "columnName": "speed", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "speedOverride", - "columnName": "speedOverride", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "scale", - "columnName": "scale", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "typeName", - "columnName": "typeName", - "affinity": "TEXT" - }, - { - "fieldPath": "area", - "columnName": "area", - "affinity": "TEXT" - }, - { - "fieldPath": "actor", - "columnName": "actor", - "affinity": "TEXT" - }, - { - "fieldPath": "director", - "columnName": "director", - "affinity": "TEXT" - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "TEXT" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "legacyKey", - "columnName": "legacyKey", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "tmdbSeasonNumber", - "columnName": "tmdbSeasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "tmdbEpisodeNumber", - "columnName": "tmdbEpisodeNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "player", - "columnName": "player", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "subtitleSource", - "columnName": "subtitleSource", - "affinity": "TEXT", - "defaultValue": "''" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "PlaybackDeleteTombstone", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `configKey` TEXT NOT NULL, `scope` TEXT NOT NULL, `historyKey` TEXT NOT NULL, `siteKey` TEXT NOT NULL, `vodId` TEXT NOT NULL, `mediaType` TEXT NOT NULL DEFAULT '', `tmdbId` INTEGER NOT NULL DEFAULT 0, `seasonNumber` INTEGER NOT NULL DEFAULT -1, `deletedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "configKey", - "columnName": "configKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "scope", - "columnName": "scope", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "historyKey", - "columnName": "historyKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "siteKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vodId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "deletedAt", - "columnName": "deletedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_PlaybackDeleteTombstone_deletedAt", - "unique": false, - "columnNames": [ - "deletedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_PlaybackDeleteTombstone_deletedAt` ON `${TABLE_NAME}` (`deletedAt`)" - } - ] - }, - { - "tableName": "TmdbSeasonProgress", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cid` INTEGER NOT NULL, `mediaType` TEXT NOT NULL, `tmdbId` INTEGER NOT NULL, `seasonNumber` INTEGER NOT NULL, `episodeNumber` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `sourceFlag` TEXT NOT NULL, `sourceEpisodeName` TEXT NOT NULL, `sourceEpisodeUrl` TEXT NOT NULL, `sourceHistoryKey` TEXT NOT NULL, `sourceBindingKey` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`cid`, `mediaType`, `tmdbId`, `seasonNumber`))", - "fields": [ - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "episodeNumber", - "columnName": "episodeNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sourceFlag", - "columnName": "sourceFlag", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeName", - "columnName": "sourceEpisodeName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeUrl", - "columnName": "sourceEpisodeUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceHistoryKey", - "columnName": "sourceHistoryKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceBindingKey", - "columnName": "sourceBindingKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "seasonNumber" - ] - }, - "indices": [ - { - "name": "index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt", - "unique": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "sourceHistoryKey", - "updatedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt` ON `${TABLE_NAME}` (`cid`, `mediaType`, `tmdbId`, `sourceHistoryKey`, `updatedAt`)" - }, - { - "name": "index_TmdbSeasonProgress_cid_sourceHistoryKey", - "unique": false, - "columnNames": [ - "cid", - "sourceHistoryKey" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_sourceHistoryKey` ON `${TABLE_NAME}` (`cid`, `sourceHistoryKey`)" - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b28bd3ade9e71166248e9ef1c1b72156')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/1.json b/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/1.json deleted file mode 100644 index ed151c9ec3..0000000000 --- a/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/1.json +++ /dev/null @@ -1,428 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 1, - "identityHash": "7e868fdbdbc43809b2189d43aa0f766b", - "entities": [ - { - "tableName": "following", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identity_key` TEXT NOT NULL, `series_key` TEXT NOT NULL, `cid` INTEGER NOT NULL, `site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, `vod_pic` TEXT NOT NULL, `media_type` TEXT NOT NULL, `tmdb_id` INTEGER NOT NULL, `tracked_season` INTEGER NOT NULL, `tracked_episode` INTEGER NOT NULL, `watched_season` INTEGER NOT NULL, `watched_episode` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `official_status` TEXT NOT NULL, `latest_released_season` INTEGER NOT NULL, `latest_released_episode` INTEGER NOT NULL, `season_total_episodes` INTEGER NOT NULL, `season_released_episodes` INTEGER NOT NULL, `series_total_episodes` INTEGER NOT NULL, `next_air_season` INTEGER NOT NULL, `next_air_episode` INTEGER NOT NULL, `next_air_at` INTEGER NOT NULL, `last_observed_episode` INTEGER NOT NULL, `read_watermark_episode` INTEGER NOT NULL, `last_notified_episode` INTEGER NOT NULL, `last_notified_at` INTEGER NOT NULL, `has_update` INTEGER NOT NULL, `unwatched_count` INTEGER NOT NULL, `notify_enabled` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `metadata_updated_at` INTEGER NOT NULL, `last_checked_at` INTEGER NOT NULL, `next_check_at` INTEGER NOT NULL, `failure_count` INTEGER NOT NULL, `last_error` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`identity_key`))", - "fields": [ - { - "fieldPath": "identityKey", - "columnName": "identity_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "seriesKey", - "columnName": "series_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "site_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vod_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodName", - "columnName": "vod_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vod_pic", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "media_type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdb_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trackedSeason", - "columnName": "tracked_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trackedEpisode", - "columnName": "tracked_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "watchedSeason", - "columnName": "watched_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "watchedEpisode", - "columnName": "watched_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "officialStatus", - "columnName": "official_status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "latestReleasedSeason", - "columnName": "latest_released_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "latestReleasedEpisode", - "columnName": "latest_released_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonTotalEpisodes", - "columnName": "season_total_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonReleasedEpisodes", - "columnName": "season_released_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seriesTotalEpisodes", - "columnName": "series_total_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirSeason", - "columnName": "next_air_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirEpisode", - "columnName": "next_air_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirAt", - "columnName": "next_air_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastObservedEpisode", - "columnName": "last_observed_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "readWatermarkEpisode", - "columnName": "read_watermark_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastNotifiedEpisode", - "columnName": "last_notified_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastNotifiedAt", - "columnName": "last_notified_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasUpdate", - "columnName": "has_update", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "unwatchedCount", - "columnName": "unwatched_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "notifyEnabled", - "columnName": "notify_enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "enabled", - "columnName": "enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "metadataUpdatedAt", - "columnName": "metadata_updated_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastCheckedAt", - "columnName": "last_checked_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextCheckAt", - "columnName": "next_check_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "failureCount", - "columnName": "failure_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastError", - "columnName": "last_error", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "created_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updated_at", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "identity_key" - ] - }, - "indices": [ - { - "name": "index_following_series_key", - "unique": false, - "columnNames": [ - "series_key" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_series_key` ON `${TABLE_NAME}` (`series_key`)" - }, - { - "name": "index_following_enabled_next_check_at", - "unique": false, - "columnNames": [ - "enabled", - "next_check_at" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_enabled_next_check_at` ON `${TABLE_NAME}` (`enabled`, `next_check_at`)" - }, - { - "name": "index_following_has_update_updated_at", - "unique": false, - "columnNames": [ - "has_update", - "updated_at" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_has_update_updated_at` ON `${TABLE_NAME}` (`has_update`, `updated_at`)" - }, - { - "name": "index_following_cid_site_key_vod_id", - "unique": false, - "columnNames": [ - "cid", - "site_key", - "vod_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_cid_site_key_vod_id` ON `${TABLE_NAME}` (`cid`, `site_key`, `vod_id`)" - }, - { - "name": "index_following_tmdb_id_media_type_tracked_season", - "unique": false, - "columnNames": [ - "tmdb_id", - "media_type", - "tracked_season" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_tmdb_id_media_type_tracked_season` ON `${TABLE_NAME}` (`tmdb_id`, `media_type`, `tracked_season`)" - } - ] - }, - { - "tableName": "following_source", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`following_key` TEXT NOT NULL, `cid` INTEGER NOT NULL, `site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, `vod_pic` TEXT NOT NULL, `vod_flag` TEXT NOT NULL, `playable_season` INTEGER NOT NULL, `playable_episode` INTEGER NOT NULL, `playable_count` INTEGER NOT NULL, `preferred` INTEGER NOT NULL, `last_probe_at` INTEGER NOT NULL, `last_error` TEXT NOT NULL, PRIMARY KEY(`following_key`, `cid`, `site_key`, `vod_id`))", - "fields": [ - { - "fieldPath": "followingKey", - "columnName": "following_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "site_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vod_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodName", - "columnName": "vod_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vod_pic", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodFlag", - "columnName": "vod_flag", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "playableSeason", - "columnName": "playable_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playableEpisode", - "columnName": "playable_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playableCount", - "columnName": "playable_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "preferred", - "columnName": "preferred", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastProbeAt", - "columnName": "last_probe_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastError", - "columnName": "last_error", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "following_key", - "cid", - "site_key", - "vod_id" - ] - }, - "indices": [ - { - "name": "index_following_source_following_key_preferred", - "unique": false, - "columnNames": [ - "following_key", - "preferred" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_source_following_key_preferred` ON `${TABLE_NAME}` (`following_key`, `preferred`)" - }, - { - "name": "index_following_source_cid_site_key_vod_id", - "unique": false, - "columnNames": [ - "cid", - "site_key", - "vod_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_source_cid_site_key_vod_id` ON `${TABLE_NAME}` (`cid`, `site_key`, `vod_id`)" - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e868fdbdbc43809b2189d43aa0f766b')" - ] - } -} \ No newline at end of file diff --git a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-6798.png b/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-6798.png deleted file mode 100644 index 032de0fe20..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-6798.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-7170.png b/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-7170.png deleted file mode 100644 index 5aca798fbf..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-7170.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-8170.png b/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-8170.png deleted file mode 100644 index cef0d9cd1e..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-8170.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced.ass b/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced.ass deleted file mode 100644 index 12e573c334..0000000000 --- a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced.ass +++ /dev/null @@ -1,15 +0,0 @@ -[Script Info] -ScriptType: v4.00+ -ScaledBorderAndShadow: yes -PlayResX: 1920 -PlayResY: 1080 -YCbCr Matrix: TV.601 - -[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Arimo,95,&H00008AFF,&H00FFFFFF,&H00000000,&H00666666,-1,0,0,0,100,100,0,0,1,6,0,8,40,40,60,1 -Style: Default Bas,Arial,95,&H00008AFF,&H00FFFFFF,&H00000000,&H00666666,-1,0,0,0,100,100,0,0,1,6,0,8,40,40,740,1 - -[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -Dialogue: 0,0:00:04.17,0:00:09.82,Default,,0,0,0,fx,{\k90\fad(300,200)}{\k42}So {\k42}we {\k43}can {\k46}find {\k37}our {\kf245}way diff --git a/app/src/androidTest/assets/exo-ass/official/Aileron-Regular.otf b/app/src/androidTest/assets/exo-ass/official/Aileron-Regular.otf deleted file mode 100644 index 4248f6ff3e..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/Aileron-Regular.otf and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/Arimo-Bold.ttf b/app/src/androidTest/assets/exo-ass/official/Arimo-Bold.ttf deleted file mode 100644 index 627f8676b8..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/Arimo-Bold.ttf and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/Arimo-OFL.txt b/app/src/androidTest/assets/exo-ass/official/Arimo-OFL.txt deleted file mode 100644 index dee6ac7d17..0000000000 --- a/app/src/androidTest/assets/exo-ass/official/Arimo-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2026 The Arimo Project Authors (https://github.com/googlefonts/arimo) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://openfontlicense.org - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/androidTest/assets/exo-ass/official/Arimo-Regular.ttf b/app/src/androidTest/assets/exo-ass/official/Arimo-Regular.ttf deleted file mode 100644 index 522cad58c7..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/Arimo-Regular.ttf and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/FONT-NOTICES.txt b/app/src/androidTest/assets/exo-ass/official/FONT-NOTICES.txt deleted file mode 100644 index 23822c12ea..0000000000 --- a/app/src/androidTest/assets/exo-ass/official/FONT-NOTICES.txt +++ /dev/null @@ -1,6 +0,0 @@ -Test-only fixed fonts from libass/libass-tests 10edd9ecd8054f2c4678d36b379d8372d0e573c6. -Aileron-Regular.otf name table copyright: No Rights Reserved. -Arimo-Bold.ttf and Arimo-Regular.ttf name table copyright: Copyright 2010 Google Inc. All Rights Reserved. -Both Arimo files explicitly declare SIL Open Font License 1.1 (name IDs 13/14). -Arimo-OFL.txt contains the standard OFL 1.1 text retrieved from google/fonts/ofl/arimo/OFL.txt on 2026-09-14; its current project copyright header is supplemental to the 2010 embedded notices above. -All file identities and source URLs are recorded in sources.json. These fonts are not shipped in production APKs. diff --git a/app/src/androidTest/assets/exo-ass/official/blur+t-1000.png b/app/src/androidTest/assets/exo-ass/official/blur+t-1000.png deleted file mode 100644 index dd98b8f84d..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/blur+t-1000.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/blur+t-1500.png b/app/src/androidTest/assets/exo-ass/official/blur+t-1500.png deleted file mode 100644 index ed427f6a40..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/blur+t-1500.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/blur+t-1900.png b/app/src/androidTest/assets/exo-ass/official/blur+t-1900.png deleted file mode 100644 index 48bbee14aa..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/blur+t-1900.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/blur+t.ass b/app/src/androidTest/assets/exo-ass/official/blur+t.ass deleted file mode 100644 index 6c3d5ada55..0000000000 --- a/app/src/androidTest/assets/exo-ass/official/blur+t.ass +++ /dev/null @@ -1,12 +0,0 @@ -[Script Info] -PlayResX: 800 -PlayResY: 600 -ScaledBorderAndShadow: yes - -[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Aileron,100,&H00000000,&H00000000,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,30,30,10,1 - -[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,{\q2\t(\blur0.125)}+ · ¶ {\t(\blur0.25)}+ · ¶ {\t(\blur0.5)}+ · ¶ \N\N {\t(\blur1)}+ · ¶ {\t(\blur2)}+ · ¶ {\t(\blur4)}+ · ¶ \N\N {\t(\blur8)}+ · ¶ {\t(\blur16)}+ · ¶ {\t(\blur32)}+ · ¶ diff --git a/app/src/androidTest/assets/exo-ass/official/sources.json b/app/src/androidTest/assets/exo-ass/official/sources.json deleted file mode 100644 index b4b01339fa..0000000000 --- a/app/src/androidTest/assets/exo-ass/official/sources.json +++ /dev/null @@ -1,57 +0,0 @@ -[ - { - "path": "regression/.fonts/Aileron-Regular.otf", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/.fonts/Aileron-Regular.otf", - "sha256": "2762f4fc2ebad8323264aea52ffa2260b86c9677493d3ce2dc4f34e5851d2aa2" - }, - { - "path": "regression/.fonts/Arimo-Bold.ttf", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/.fonts/Arimo-Bold.ttf", - "sha256": "0b5422b854f9a76ff56f894dc76d440eaca6489c119168f00cd176a5a8617013" - }, - { - "path": "regression/.fonts/Arimo-Regular.ttf", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/.fonts/Arimo-Regular.ttf", - "sha256": "e9aae1ebba96d3a4137e5b355040506d025ee690db24213fcad650200dbbbf42" - }, - { - "path": "regression/blurs/blur+t.ass", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/blurs/blur%2Bt.ass", - "sha256": "85e2085db7d8ec26ac264214a02211b62025fcafb42f96b125d8bf13cc5dc18f" - }, - { - "path": "regression/karaoke/357-k-and-kf-desynced.ass", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/karaoke/357-k-and-kf-desynced.ass", - "sha256": "534d2d14ebcab56046569007387488f1aae7b3649e2213247a1e8511c2c2ac89" - }, - { - "path": "regression/blurs/blur+t-1000.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/blurs/blur%2Bt-1000.png", - "sha256": "6a9b95a3806361c90a0f046f15dd3b0a85a36dbec7c93375bbd102c078eecc39" - }, - { - "path": "regression/blurs/blur+t-1500.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/blurs/blur%2Bt-1500.png", - "sha256": "76041ba11928ed1715c867610c8fc2b4a561180c7671f7cddd6795667ead33e4" - }, - { - "path": "regression/blurs/blur+t-1900.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/blurs/blur%2Bt-1900.png", - "sha256": "49622343cf17fe56bd1ede65e446b8aaddabcb285c5e3b39745afd2a12a46998" - }, - { - "path": "regression/karaoke/357-k-and-kf-desynced-6798.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/karaoke/357-k-and-kf-desynced-6798.png", - "sha256": "2aa7b22593900f717e500c840e0f5627d78f3f1680f9fcdc73873415c88041bf" - }, - { - "path": "regression/karaoke/357-k-and-kf-desynced-7170.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/karaoke/357-k-and-kf-desynced-7170.png", - "sha256": "9328d2259972374025b603723f6dcfc06f7fb1ceb4e8ac818196354779a864c1" - }, - { - "path": "regression/karaoke/357-k-and-kf-desynced-8170.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/karaoke/357-k-and-kf-desynced-8170.png", - "sha256": "57e38c4dbdcc9853496d0514724add7610a9cd22e0cabe5279131ffc44b36dbb" - } -] diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingActivityDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingActivityDeviceTest.java deleted file mode 100644 index 763d7b115a..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingActivityDeviceTest.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; -import android.view.View; -import android.widget.TextView; - -import androidx.recyclerview.widget.RecyclerView; -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.activity.FollowingActivity; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -@RunWith(AndroidJUnit4.class) -public class FollowingActivityDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private Context context; - private List identityKeys; - - @Before - public void setUp() { - context = ApplicationProvider.getApplicationContext(); - FollowingSettings.setEnabled(true); - String updatedIdentity = "tmdb:tv:900001:s1"; - String quietIdentity = "tmdb:tv:900002:s1"; - identityKeys = List.of(updatedIdentity, quietIdentity); - FollowingDatabase database = FollowingDatabase.get(); - database.getFollowingSourceDao().deleteAll(); - database.getFollowingDao().deleteAll(); - Following item = new Following(); - item.identityKey = updatedIdentity; - item.seriesKey = "tmdb:tv:900001"; - item.cid = 0; - item.siteKey = "test"; - item.vodId = "vod"; - item.vodName = "设备验证剧集"; - item.trackedSeason = 1; - item.latestReleasedSeason = 1; - item.latestReleasedEpisode = 5; - item.seasonReleasedEpisodes = 5; - item.readWatermarkEpisode = 1; - item.watchedEpisode = 2; - item.unwatchedCount = 3; - item.hasUpdate = true; - item.enabled = true; - item.nextCheckAt = Long.MAX_VALUE; - item.updatedAt = System.currentTimeMillis(); - FollowingSource source = new FollowingSource(); - source.followingKey = updatedIdentity; - source.siteKey = "test"; - source.vodId = "vod"; - source.preferred = true; - source.playableSeason = 1; - source.playableEpisode = 4; - source.playableCount = 4; - FollowingStore.saveNew(item, source); - - Following quiet = item.copy(); - quiet.identityKey = quietIdentity; - quiet.seriesKey = "tmdb:tv:900002"; - quiet.vodId = "vod2"; - quiet.vodName = "无更新剧集"; - quiet.watchedEpisode = 5; - quiet.readWatermarkEpisode = 5; - quiet.unwatchedCount = 0; - quiet.hasUpdate = false; - FollowingSource quietSource = source.copy(); - quietSource.followingKey = quietIdentity; - quietSource.vodId = "vod2"; - FollowingStore.saveNew(quiet, quietSource); - } - - @After - public void tearDown() { - for (String identityKey : identityKeys) FollowingStore.delete(identityKey); - } - - @Test - public void activityRendersStoredFollowingAndFiltersUpdates() { - try (ActivityScenario scenario = ActivityScenario.launch(FollowingActivity.class)) { - assertTrue(await(scenario, activity -> { - RecyclerView recycler = activity.findViewById(R.id.recycler); - return recycler != null && recycler.getAdapter() != null && recycler.getAdapter().getItemCount() == 2; - })); - - assertTrue(await(scenario, activity -> { - TextView summary = activity.findViewById(R.id.summary); - return summary != null && "追更 2 部 · 未读 0 部".contentEquals(summary.getText()); - })); - scenario.onActivity(activity -> { - TextView summary = activity.findViewById(R.id.summary); - assertEquals("追更 2 部 · 未读 0 部", summary.getText().toString()); - RecyclerView recycler = activity.findViewById(R.id.recycler); - RecyclerView.ViewHolder holder = recycler.findViewHolderForAdapterPosition(0); - assertNotNull(holder); - assertEquals(View.GONE, holder.itemView.findViewById(R.id.read).getVisibility()); - assertEquals(View.GONE, holder.itemView.findViewById(R.id.badge).getVisibility()); - activity.findViewById(R.id.filter).performClick(); - }); - - assertTrue(await(scenario, activity -> { - TextView filter = activity.findViewById(R.id.filter); - RecyclerView recycler = activity.findViewById(R.id.recycler); - return filter != null && "只看更新".contentEquals(filter.getText()) - && recycler != null && recycler.getAdapter() != null - && recycler.getAdapter().getItemCount() == 1; - })); - } - assertFalse(FollowingStore.find(identityKeys.get(0)).hasUpdate); - } - - private boolean await(ActivityScenario scenario, Check check) { - boolean[] result = new boolean[1]; - for (int i = 0; i < 30; i++) { - result[0] = false; - scenario.onActivity(activity -> result[0] = check.value(activity)); - if (result[0]) return true; - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - fail("interrupted while waiting for FollowingActivity"); - } - } - return false; - } - - private interface Check { - boolean value(FollowingActivity activity); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingBackupDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingBackupDeviceTest.java deleted file mode 100644 index 9b7160c1a3..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingBackupDeviceTest.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; - -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -@RunWith(AndroidJUnit4.class) -public class FollowingBackupDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private Context context; - private String identityKey; - - @Before - public void setUp() { - context = ApplicationProvider.getApplicationContext(); - identityKey = "tmdb:tv:992234:s1"; - FollowingStore.replaceAll(List.of(), List.of()); - } - - @After - public void tearDown() { - FollowingStore.replaceAll(List.of(), List.of()); - } - - @Test - public void realDatabaseRestoreAndMergeKeepIdentityAndWatermarks() { - Following local = following(); - local.readWatermarkEpisode = 3; - local.lastNotifiedEpisode = 3; - local.watchedEpisode = 2; - local.nextCheckAt = 999; - local.lastError = "local only"; - FollowingSource source = source(); - FollowingStore.saveNew(local, source); - - FollowingBackupCodec.Payload payload = FollowingBackupCodec.capture(); - assertEquals(1, payload.following().size()); - assertEquals(1, payload.sources().size()); - - Following remote = local.copy(); - remote.readWatermarkEpisode = 7; - remote.lastNotifiedEpisode = 6; - remote.watchedEpisode = 5; - remote.updatedAt = local.updatedAt + 1; - FollowingSource remoteSource = source.copy(); - remoteSource.playableEpisode = 8; - remoteSource.playableCount = 8; - - FollowingBackupCodec.merge(List.of(remote), List.of(remoteSource)); - - Following merged = FollowingStore.find(identityKey); - FollowingSource mergedSource = FollowingStore.preferredSource(identityKey); - assertNotNull(merged); - assertEquals(7, merged.readWatermarkEpisode); - assertEquals(6, merged.lastNotifiedEpisode); - assertEquals(5, merged.watchedEpisode); - assertEquals(999, merged.nextCheckAt); - assertEquals("local only", merged.lastError); - assertEquals(8, mergedSource.playableEpisode); - - FollowingBackupCodec.restoreFull(payload.following(), payload.sources()); - assertEquals(1, FollowingStore.list().size()); - assertEquals(3, FollowingStore.find(identityKey).readWatermarkEpisode); - assertEquals(1, FollowingStore.sources(identityKey).size()); - } - - private Following following() { - Following item = new Following(); - item.identityKey = identityKey; - item.seriesKey = "tmdb:tv:992234"; - item.cid = 1; - item.siteKey = "site"; - item.vodId = "vod"; - item.vodName = "备份测试"; - item.trackedSeason = 1; - item.latestReleasedSeason = 1; - item.latestReleasedEpisode = 8; - item.enabled = true; - item.createdAt = 100; - item.updatedAt = 100; - return item; - } - - private FollowingSource source() { - FollowingSource item = new FollowingSource(); - item.followingKey = identityKey; - item.cid = 1; - item.siteKey = "site"; - item.vodId = "vod"; - item.vodName = "备份测试"; - item.preferred = true; - item.playableEpisode = 5; - item.playableCount = 5; - return item; - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingCheckDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingCheckDeviceTest.java deleted file mode 100644 index e87b5689b9..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingCheckDeviceTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.view.View; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.activity.FollowingActivity; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingCheckDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - @Before - public void setUp() { - FollowingSettings.setEnabled(true); - FollowingStore.replaceAll(List.of(), List.of()); - } - - @After - public void tearDown() { - FollowingStore.replaceAll(List.of(), List.of()); - } - - @Test - public void checkAllStartedOnMainThreadDoesNotAccessRoomOnMainThread() throws Exception { - try (ActivityScenario scenario = ActivityScenario.launch(FollowingActivity.class)) { - scenario.onActivity(activity -> activity.findViewById(R.id.check).performClick()); - assertTrue(await(scenario, activity -> { - View check = activity.findViewById(R.id.check); - return check != null && check.isEnabled(); - })); - } - } - - private boolean await(ActivityScenario scenario, Check check) throws Exception { - for (int i = 0; i < 50; i++) { - AtomicBoolean result = new AtomicBoolean(); - scenario.onActivity(activity -> result.set(check.value(activity))); - if (result.get()) return true; - Thread.sleep(100); - } - return false; - } - - private interface Check { - boolean value(FollowingActivity activity); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingContinueDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingContinueDeviceTest.java deleted file mode 100644 index 51c99fa1a4..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingContinueDeviceTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.db.AppDatabase; - -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.After; -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -@RunWith(AndroidJUnit4.class) -public class FollowingContinueDeviceTest { - - private static final String HISTORY_KEY = "source-a@@@vod-a"; - - @After - public void tearDown() { - AppDatabase.get().getHistoryDao().delete(0, HISTORY_KEY); - } - - @Test - public void historyForFallsBackToTmdbProgressFromAnotherSource() { - AppDatabase.get().getHistoryDao().delete(0, HISTORY_KEY); - History history = new History(); - history.setKey(HISTORY_KEY); - history.setCid(0); - history.setVodName("跨源续播验证"); - history.setMediaType("tv"); - history.setTmdbId(910003); - history.setTmdbEpisodePosition(1, 4); - history.setPosition(8_000); - history.setDuration(24_000); - history.setCreateTime(100); - AppDatabase.get().getHistoryDao().insertOrUpdate(history); - - Following item = new Following(); - item.cid = 0; - item.mediaType = "tv"; - item.tmdbId = 910003; - item.trackedSeason = 1; - FollowingSource source = new FollowingSource(); - source.siteKey = "source-b"; - source.vodId = "vod-b"; - - History restored = FollowingStore.historyFor(item, source); - - assertNotNull(restored); - assertEquals(HISTORY_KEY, restored.getKey()); - assertEquals(4, restored.getTmdbEpisodeNumber()); - assertEquals(8_000, restored.getPosition()); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDatabaseTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDatabaseTest.java deleted file mode 100644 index e9ba88830b..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDatabaseTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; - -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.room.Room; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -@RunWith(AndroidJUnit4.class) -public class FollowingDatabaseTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private Context context; - private FollowingDatabase database; - - @Before - public void setUp() { - context = ApplicationProvider.getApplicationContext(); - database = Room.inMemoryDatabaseBuilder(context, FollowingDatabase.class).build(); - } - - @After - public void tearDown() { - if (database != null) database.close(); - } - - @Test - public void createsIndependentDatabaseAndStoresFollowAndSource() { - Following item = new Following(); - item.identityKey = "tmdb:tv:1:s1"; - item.seriesKey = "tmdb:tv:1"; - item.vodName = "Example"; - item.trackedSeason = 1; - item.enabled = true; - item.nextCheckAt = 10; - database.getFollowingDao().insertOrUpdate(item); - - FollowingSource source = new FollowingSource(); - source.followingKey = item.identityKey; - source.siteKey = "site"; - source.vodId = "vod"; - source.preferred = true; - database.getFollowingSourceDao().insertOrUpdate(source); - - assertNotNull(database.getFollowingDao().find(item.identityKey)); - assertEquals(item.identityKey, database.getFollowingDao().findDue(20, 5).get(0).identityKey); - assertEquals("vod", database.getFollowingSourceDao().findPreferred(item.identityKey).vodId); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeleteDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeleteDeviceTest.java deleted file mode 100644 index 373f8ea7d2..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeleteDeviceTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.fongmi.android.tv.following; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingDeleteDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private static final String IDENTITY = "tmdb:tv:993344:s1"; - - @Before - public void setUp() { - FollowingStore.replaceAll(List.of(), List.of()); - Following item = new Following(); - item.identityKey = IDENTITY; - item.seriesKey = "tmdb:tv:993344"; - item.vodName = "取消追更测试"; - item.trackedSeason = 1; - item.enabled = true; - FollowingSource source = new FollowingSource(); - source.followingKey = IDENTITY; - source.siteKey = "site"; - source.vodId = "vod"; - FollowingStore.saveNew(item, source); - } - - @After - public void tearDown() { - FollowingStore.replaceAll(List.of(), List.of()); - } - - @Test - public void deleteStartedOnMainThreadCompletesWithoutRoomMainThreadCrash() throws Exception { - CountDownLatch latch = new CountDownLatch(1); - AtomicReference failure = new AtomicReference<>(); - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> - FollowingPlaybackBridge.deleteAsync(IDENTITY, error -> { - failure.set(error); - latch.countDown(); - })); - - assertTrue(latch.await(5, TimeUnit.SECONDS)); - assertNull(failure.get()); - assertNull(FollowingStore.find(IDENTITY)); - assertTrue(FollowingStore.sources(IDENTITY).isEmpty()); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDetailDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDetailDeviceTest.java deleted file mode 100644 index 10866b0157..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDetailDeviceTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.fongmi.android.tv.following; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingDetailDeviceTest { - - @Test - public void databaseLookupCanStartOnMainThreadWithoutMainThreadRoomAccess() throws Exception { - CountDownLatch latch = new CountDownLatch(1); - AtomicBoolean mainThread = new AtomicBoolean(); - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> - FollowingPlaybackBridge.findAsync("missing:following:key", item -> { - mainThread.set(android.os.Looper.myLooper() == android.os.Looper.getMainLooper()); - latch.countDown(); - })); - - assertTrue(latch.await(5, TimeUnit.SECONDS)); - assertTrue("callback should return to main thread", mainThread.get()); - assertFalse(FollowingStore.database().getFollowingDao().findAll().stream() - .anyMatch(item -> "missing:following:key".equals(item.identityKey))); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeviceDataRule.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeviceDataRule.java deleted file mode 100644 index 40804f23c6..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeviceDataRule.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.fongmi.android.tv.following; - -import org.junit.rules.TestWatcher; -import org.junit.runner.Description; - -import java.util.ArrayList; -import java.util.List; - -/** Preserves the real device following database around tests that use it as a fixture store. */ -public final class FollowingDeviceDataRule extends TestWatcher { - - private Snapshot snapshot = Snapshot.empty(); - - @Override - protected void starting(Description description) { - snapshot = Snapshot.capture(); - } - - @Override - protected void finished(Description description) { - snapshot.restore(); - } - - private static final class Snapshot { - private final List following; - private final List sources; - - private Snapshot(List following, List sources) { - this.following = following; - this.sources = sources; - } - - static Snapshot empty() { - return new Snapshot(List.of(), List.of()); - } - - static Snapshot capture() { - List following = new ArrayList<>(); - for (Following item : FollowingStore.database().getFollowingDao().findAll()) { - if (item != null) following.add(item.copy()); - } - List sources = new ArrayList<>(); - for (FollowingSource source : FollowingStore.database().getFollowingSourceDao().findAll()) { - if (source != null) sources.add(source.copy()); - } - return new Snapshot(following, sources); - } - - void restore() { - FollowingStore.replaceAll(following, sources); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingMigrationDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingMigrationDeviceTest.java deleted file mode 100644 index 2ac308a527..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingMigrationDeviceTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.fongmi.android.tv.bean.TmdbItem; - -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.After; -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; - -@RunWith(AndroidJUnit4.class) -public class FollowingMigrationDeviceTest { - - private static final String SOURCE_KEY = "source:9:migrate:930001:s0"; - private static final String TARGET_KEY = "tmdb:tv:930001:s1"; - - @After - public void tearDown() { - FollowingStore.delete(SOURCE_KEY); - FollowingStore.delete(TARGET_KEY); - } - - @Test - public void sourceFallbackMigratesToMatchedTmdbIdentity() { - FollowingStore.delete(SOURCE_KEY); - FollowingStore.delete(TARGET_KEY); - Following source = new Following(); - source.identityKey = SOURCE_KEY; - source.seriesKey = "source:9:migrate:930001"; - source.cid = 9; - source.siteKey = "migrate"; - source.vodId = "930001"; - source.vodName = "迁移验证剧集"; - source.mediaType = "tv"; - source.trackedSeason = 0; - source.watchedEpisode = 1; - source.readWatermarkEpisode = 1; - source.enabled = true; - source.nextCheckAt = Long.MAX_VALUE; - FollowingSource binding = new FollowingSource(); - binding.followingKey = SOURCE_KEY; - binding.cid = source.cid; - binding.siteKey = source.siteKey; - binding.vodId = source.vodId; - binding.vodName = source.vodName; - binding.playableEpisode = 16; - binding.playableCount = 16; - binding.preferred = true; - FollowingStore.saveNew(source, binding); - - FollowingMetadataSnapshot snapshot = new FollowingMetadataSnapshot(); - snapshot.source = "tmdb"; - snapshot.status = FollowingMetadataSnapshot.RETURNING; - snapshot.latestReleasedSeason = 1; - snapshot.latestReleasedEpisode = 12; - snapshot.seasonTotalEpisodes = 18; - snapshot.seasonReleasedEpisodes = 12; - snapshot.seriesTotalEpisodes = 18; - snapshot.fetchedAt = System.currentTimeMillis(); - - Following migrated = FollowingStore.resolveTmdb( - new TmdbItem(930001, "tv", source.vodName, "", "", "", ""), 1, - source.cid, source.siteKey, source.vodId, snapshot); - - assertNotNull(migrated); - assertNull(FollowingStore.find(SOURCE_KEY)); - Following target = FollowingStore.find(TARGET_KEY); - assertNotNull(target); - assertEquals(930001, target.tmdbId); - assertEquals(1, target.trackedSeason); - assertEquals(12, target.latestReleasedEpisode); - assertEquals(FollowingMetadataSnapshot.RETURNING, target.officialStatus); - FollowingSource moved = FollowingStore.preferredSource(TARGET_KEY); - assertNotNull(moved); - assertEquals(1, moved.playableSeason); - assertEquals(16, moved.playableEpisode); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingNotifierDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingNotifierDeviceTest.java deleted file mode 100644 index de70d1eda3..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingNotifierDeviceTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.fongmi.android.tv.following; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.fongmi.android.tv.ui.activity.FollowingActivity; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; - -@RunWith(AndroidJUnit4.class) -public class FollowingNotifierDeviceTest { - - private boolean originalNotificationsEnabled; - - @Before - public void setUp() { - originalNotificationsEnabled = FollowingSettings.isNotificationsEnabled(); - FollowingSettings.setEnabled(true); - FollowingSettings.setNotificationsEnabled(true); - } - - @After - public void tearDown() { - FollowingSettings.setNotificationsEnabled(originalNotificationsEnabled); - } - - @Test - public void foregroundSuppressesNotificationWithoutAdvancingWatermark() { - Following item = new Following(); - item.identityKey = "tmdb:tv:910002:s1"; - item.vodName = "前台通知验证"; - item.trackedSeason = 1; - item.latestReleasedSeason = 1; - item.latestReleasedEpisode = 5; - item.readWatermarkEpisode = 4; - item.lastNotifiedEpisode = 2; - item.notifyEnabled = true; - item.enabled = true; - - try (ActivityScenario scenario = ActivityScenario.launch(FollowingActivity.class)) { - scenario.onActivity(activity -> { - assertFalse(FollowingNotifier.notifyUpdate(item, null)); - assertEquals(2, item.lastNotifiedEpisode); - }); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingProjectionDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingProjectionDeviceTest.java deleted file mode 100644 index ee85817ea9..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingProjectionDeviceTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.db.AppDatabase; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.After; -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingProjectionDeviceTest { - - private static final String IDENTITY_KEY = "tmdb:tv:910001:s1"; - private static final String HISTORY_KEY = "projection@@@item"; - - @After - public void tearDown() { - FollowingStore.delete(IDENTITY_KEY); - AppDatabase.get().getHistoryDao().delete(0, HISTORY_KEY); - } - - @Test - public void mainThreadProjectionQueuesWithoutMainThreadRoomAccess() throws Exception { - FollowingStore.delete(IDENTITY_KEY); - AppDatabase.get().getHistoryDao().delete(0, HISTORY_KEY); - - Following item = new Following(); - item.identityKey = IDENTITY_KEY; - item.seriesKey = "tmdb:tv:910001"; - item.siteKey = "projection"; - item.vodId = "item"; - item.mediaType = "tv"; - item.tmdbId = 910001; - item.trackedSeason = 1; - item.enabled = true; - item.nextCheckAt = Long.MAX_VALUE; - FollowingStore.saveNew(item, null); - - History history = new History(); - history.setKey(HISTORY_KEY); - history.setCid(0); - history.setVodName("投影验证剧集"); - history.setMediaType("tv"); - history.setTmdbId(910001); - history.setTmdbEpisodePosition(1, 3); - history.setPosition(12_000); - history.setDuration(24_000); - history.setCreateTime(System.currentTimeMillis()); - AppDatabase.get().getHistoryDao().insertOrUpdate(history); - - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> FollowingStore.project(history)); - - Following projected = awaitProjected(); - assertEquals(3, projected.watchedEpisode); - assertEquals(12_000, projected.position); - } - - private Following awaitProjected() throws Exception { - for (int i = 0; i < 50; i++) { - Following item = FollowingStore.find(IDENTITY_KEY); - if (item != null && item.watchedEpisode == 3) return item; - Thread.sleep(100); - } - throw new AssertionError("following projection did not complete"); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingSchedulerDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingSchedulerDeviceTest.java deleted file mode 100644 index 645cb0255d..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingSchedulerDeviceTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; - -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.work.WorkInfo; -import androidx.work.WorkManager; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingSchedulerDeviceTest { - - private Context context; - private boolean originalEnabled; - - @Before - public void setUp() { - context = ApplicationProvider.getApplicationContext(); - originalEnabled = FollowingSettings.isEnabled(); - FollowingSettings.setEnabled(true); - FollowingScheduler.cancelAll(context); - } - - @After - public void tearDown() { - FollowingScheduler.cancelAll(context); - FollowingSettings.setEnabled(originalEnabled); - } - - @Test - public void uniquePeriodicAndOneShotWorkAreEnqueuedAndCanceled() throws Exception { - FollowingScheduler.ensurePeriodic(context); - FollowingScheduler.enqueueDueNow(context); - - assertTrue(awaitState(FollowingScheduler.PERIODIC_WORK, false)); - assertTrue(awaitState(FollowingScheduler.ONE_SHOT_WORK, false)); - - FollowingSettings.setEnabled(false); - FollowingScheduler.cancelAll(context); - - assertTrue(awaitState(FollowingScheduler.PERIODIC_WORK, true)); - assertTrue(awaitState(FollowingScheduler.ONE_SHOT_WORK, true)); - } - - private boolean awaitState(String uniqueName, boolean canceled) throws Exception { - for (int i = 0; i < 30; i++) { - List infos = WorkManager.getInstance(context) - .getWorkInfosForUniqueWork(uniqueName) - .get(5, TimeUnit.SECONDS); - if (canceled && infos.isEmpty()) return true; - if (!infos.isEmpty() && infos.stream().allMatch(info -> canceled - ? info.getState() == WorkInfo.State.CANCELLED - : info.getState() == WorkInfo.State.ENQUEUED || info.getState() == WorkInfo.State.RUNNING)) { - return true; - } - Thread.sleep(100); - } - return false; - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingUpdateCoordinatorDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingUpdateCoordinatorDeviceTest.java deleted file mode 100644 index 9dd43c7c83..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingUpdateCoordinatorDeviceTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; - -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.IOException; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingUpdateCoordinatorDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private Context context; - private String identityKey; - - @Before - public void setUp() { - context = ApplicationProvider.getApplicationContext(); - FollowingSettings.setEnabled(true); - identityKey = "tmdb:tv:991234:s1"; - FollowingDatabase database = FollowingDatabase.get(); - database.getFollowingSourceDao().deleteAll(); - database.getFollowingDao().deleteAll(); - - Following item = new Following(); - item.identityKey = identityKey; - item.seriesKey = "tmdb:tv:991234"; - item.cid = 1; - item.siteKey = "test-site"; - item.vodId = "test-vod"; - item.vodName = "TMDB 降级测试"; - item.mediaType = "tv"; - item.tmdbId = 991234; - item.trackedSeason = 1; - item.latestReleasedSeason = 1; - item.latestReleasedEpisode = 6; - item.seasonReleasedEpisodes = 6; - item.officialStatus = FollowingMetadataSnapshot.RETURNING; - item.metadataUpdatedAt = 1_000; - item.enabled = true; - item.nextCheckAt = Long.MAX_VALUE; - item.createdAt = 1_000; - item.updatedAt = 1_000; - - FollowingSource source = new FollowingSource(); - source.followingKey = identityKey; - source.cid = item.cid; - source.siteKey = item.siteKey; - source.vodId = item.vodId; - source.vodName = item.vodName; - source.preferred = true; - FollowingStore.saveNew(item, source); - } - - @After - public void tearDown() { - FollowingScheduler.cancelNext(context, identityKey); - FollowingStore.delete(identityKey); - } - - @Test - public void tmdbFailureFallsBackToBoundSourceWithoutOverwritingOfficialState() { - AtomicInteger probes = new AtomicInteger(); - FollowingMetadataClient metadata = new FollowingMetadataClient() { - @Override - public FollowingMetadataSnapshot fetch(Following item, boolean refresh) throws Exception { - throw new IOException("TMDB unavailable"); - } - }; - FollowingSourceProbe probe = new FollowingSourceProbe() { - @Override - public FollowingSourceSnapshot probe(Following item, FollowingSource source) { - probes.incrementAndGet(); - source.playableSeason = item.trackedSeason; - source.playableEpisode = 8; - source.playableCount = 8; - source.lastProbeAt = System.currentTimeMillis(); - source.lastError = ""; - FollowingSourceSnapshot snapshot = new FollowingSourceSnapshot(); - snapshot.source = source; - snapshot.playableEpisode = 8; - snapshot.playableCount = 8; - snapshot.probedAt = source.lastProbeAt; - return snapshot; - } - }; - - Following item = FollowingStore.find(identityKey); - assertTrue(new FollowingUpdateCoordinator(metadata, probe).check(item, false)); - - Following stored = FollowingStore.find(identityKey); - FollowingSource source = FollowingStore.preferredSource(identityKey); - assertEquals(FollowingMetadataSnapshot.RETURNING, stored.officialStatus); - assertEquals(6, stored.latestReleasedEpisode); - assertEquals(0, stored.failureCount); - assertEquals("TMDB不可用,已回退原站", stored.lastError); - assertEquals(8, source.playableEpisode); - assertEquals(1, probes.get()); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/gitcloud/secure/GitCloudTokenStoreTest.java b/app/src/androidTest/java/com/fongmi/android/tv/gitcloud/secure/GitCloudTokenStoreTest.java deleted file mode 100644 index 3fc44c12dc..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/gitcloud/secure/GitCloudTokenStoreTest.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.fongmi.android.tv.gitcloud.secure; - -import android.content.Context; -import android.content.ContextWrapper; -import android.util.Base64; - -import androidx.test.platform.app.InstrumentationRegistry; - -import junit.framework.TestCase; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; -import java.io.OutputStream; -import java.security.Key; -import java.security.KeyStoreException; -import java.security.KeyStoreSpi; -import java.security.Provider; -import java.security.Security; -import java.security.cert.Certificate; -import java.util.Collections; -import java.util.Date; -import java.util.Enumeration; - -import javax.crypto.spec.SecretKeySpec; - -public class GitCloudTokenStoreTest extends TestCase { - private static final String KEYSTORE = "AndroidKeyStore"; - private static final String PREFIX = "local-v1:"; - private Provider original; - private int position; - private File directory; - private Context context; - - @Override - protected void setUp() throws Exception { - super.setUp(); - original = Security.getProvider(KEYSTORE); - Provider[] providers = Security.getProviders(); - for (int i = 0; i < providers.length; i++) if (providers[i] == original) position = i + 1; - Security.removeProvider(KEYSTORE); - Context target = InstrumentationRegistry.getInstrumentation().getTargetContext(); - directory = new File(target.getCacheDir(), "git-token-test-" + System.nanoTime()); - assertTrue(directory.mkdirs()); - context = new ContextWrapper(target) { - @Override public File getNoBackupFilesDir() { return directory; } - }; - } - - @Override - protected void tearDown() throws Exception { - try { - Security.removeProvider(KEYSTORE); - if (original != null) Security.insertProviderAt(original, position); - File[] files = directory == null ? null : directory.listFiles(); - if (files != null) for (File file : files) assertTrue(file.delete()); - if (directory != null) assertTrue(directory.delete()); - } finally { - super.tearDown(); - } - } - - public void testGithubAndCnbRoundTripWithoutKeystore() throws Exception { - String github = "test-only-github-token"; - String cnb = "test-only-cnb-token"; - String first = GitCloudTokenStore.encrypt(context, github); - String second = GitCloudTokenStore.encrypt(context, cnb); - assertTrue(first.startsWith(PREFIX)); - assertTrue(second.startsWith(PREFIX)); - assertEquals(github, GitCloudTokenStore.decrypt(context, first)); - assertEquals(cnb, GitCloudTokenStore.decrypt(context, second)); - assertFalse(first.equals(GitCloudTokenStore.encrypt(context, github))); - assertEquals(32, keyFile().length()); - } - - public void testLocalCiphertextRemainsReadableWhenKeystoreReturns() throws Exception { - String value = GitCloudTokenStore.encrypt(context, "test-only-token"); - registerExistingKey(); - assertEquals("test-only-token", GitCloudTokenStore.decrypt(context, value)); - } - - public void testAvailableKeystoreRemainsPreferred() throws Exception { - if (original != null) Security.insertProviderAt(original, position); - else registerExistingKey(); - String value = GitCloudTokenStore.encrypt(context, "existing-system-key-path"); - assertFalse(value.startsWith(PREFIX)); - assertEquals("existing-system-key-path", GitCloudTokenStore.decrypt(context, value)); - assertFalse(keyFile().exists()); - } - - public void testLegacyCiphertextFormatWithNistAes256GcmVector() throws Exception { - registerExistingKey(); - // NIST AES-256-GCM: zero key, 96-bit zero IV, one zero plaintext block. - byte[] body = hex("000000000000000000000000" - + "cea7403d4d606b6e074ec5d3baf39d18d0d1c8a799996bf0265b98b5d48ab919"); - String legacy = Base64.encodeToString(body, Base64.NO_WRAP); - assertEquals(new String(new char[16]), GitCloudTokenStore.decrypt(context, legacy)); - assertFalse(keyFile().exists()); - } - - public void testRegisteredButUnusableProviderDoesNotFallBack() throws Exception { - Security.addProvider(new Provider(KEYSTORE, 1, "unusable test provider") {{ - put("KeyStore.AndroidKeyStore", "missing.test.KeyStoreImplementation"); - }}); - try { - GitCloudTokenStore.encrypt(context, "test-only-token"); - fail("A registered but broken provider must not change key ownership"); - } catch (KeyStoreException expected) { - assertFalse(keyFile().exists()); - } - } - - public void testMissingLocalKeyIsNotRecreatedWhileReading() throws Exception { - String value = GitCloudTokenStore.encrypt(context, "test-only-token"); - assertTrue(keyFile().delete()); - try { - GitCloudTokenStore.decrypt(context, value); - fail("Missing key must fail without creating a replacement"); - } catch (KeyStoreException expected) { - assertFalse(keyFile().exists()); - } - } - - public void testDamagedLocalKeyIsNotOverwritten() throws Exception { - try (FileOutputStream output = new FileOutputStream(keyFile())) { output.write(7); } - try { - GitCloudTokenStore.encrypt(context, "test-only-token"); - fail("Damaged key must not be replaced"); - } catch (KeyStoreException expected) { - assertEquals(1, keyFile().length()); - } - } - - public void testTamperedCiphertextFailsAuthentication() throws Exception { - String value = GitCloudTokenStore.encrypt(context, "test-only-token"); - byte[] bytes = Base64.decode(value.substring(PREFIX.length()), Base64.DEFAULT); - bytes[bytes.length - 1] ^= 1; - try { - GitCloudTokenStore.decrypt(context, PREFIX + Base64.encodeToString(bytes, Base64.NO_WRAP)); - fail("GCM must reject a modified tag"); - } catch (javax.crypto.BadPaddingException expected) { - assertEquals(32, keyFile().length()); - } - } - - private File keyFile() { return new File(directory, "git_cloud_token.key"); } - - private void registerExistingKey() { - Security.addProvider(new Provider(KEYSTORE, 1, "existing key test provider") {{ - put("KeyStore.AndroidKeyStore", ExistingKeyStore.class.getName()); - }}); - } - - private static byte[] hex(String value) { - byte[] out = new byte[value.length() / 2]; - for (int i = 0; i < out.length; i++) out[i] = (byte) Integer.parseInt(value.substring(i * 2, i * 2 + 2), 16); - return out; - } - - public static final class ExistingKeyStore extends KeyStoreSpi { - @Override public Key engineGetKey(String alias, char[] password) { return new SecretKeySpec(new byte[32], "AES"); } - @Override public boolean engineContainsAlias(String alias) { return "webhtv_git_cloud_token".equals(alias); } - @Override public void engineLoad(InputStream stream, char[] password) { } - @Override public void engineStore(OutputStream stream, char[] password) { } - @Override public Certificate[] engineGetCertificateChain(String alias) { return null; } - @Override public Certificate engineGetCertificate(String alias) { return null; } - @Override public Date engineGetCreationDate(String alias) { return new Date(0); } - @Override public void engineSetKeyEntry(String alias, Key key, char[] password, Certificate[] chain) { throw new UnsupportedOperationException(); } - @Override public void engineSetKeyEntry(String alias, byte[] key, Certificate[] chain) { throw new UnsupportedOperationException(); } - @Override public void engineSetCertificateEntry(String alias, Certificate certificate) { throw new UnsupportedOperationException(); } - @Override public void engineDeleteEntry(String alias) { throw new UnsupportedOperationException(); } - @Override public Enumeration engineAliases() { return Collections.enumeration(Collections.singleton("webhtv_git_cloud_token")); } - @Override public int engineSize() { return 1; } - @Override public boolean engineIsKeyEntry(String alias) { return engineContainsAlias(alias); } - @Override public boolean engineIsCertificateEntry(String alias) { return false; } - @Override public String engineGetCertificateAlias(Certificate certificate) { return null; } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssAttachmentTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssAttachmentTest.java deleted file mode 100644 index c89ff89ec4..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssAttachmentTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import androidx.media3.common.C; -import androidx.media3.extractor.DefaultExtractorInput; -import androidx.media3.extractor.DiscardingTrackOutput; -import androidx.media3.extractor.ExtractorOutput; -import androidx.media3.extractor.SeekMap; -import androidx.media3.extractor.TrackOutput; -import androidx.test.platform.app.InstrumentationRegistry; -import junit.framework.TestCase; -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.concurrent.atomic.AtomicInteger; - -/** Real font bytes and production JNI/provider selection; no guessed substitute typeface. */ -public class AssAttachmentTest extends TestCase { - private byte[] font() throws Exception { - try (InputStream input = InstrumentationRegistry.getInstrumentation().getContext() - .getAssets().open("exo-ass/official/Aileron-Regular.otf")) { return input.readAllBytes(); } - } - - public void testUnorderedAttachmentDeduplicationAndClosedMedia() throws Exception { - AtomicInteger changes = new AtomicInteger(); - AssFontSet set = new AssFontSet(ignored -> changes.incrementAndGet()); - AssFontMatroskaExtractor extractor = new AssFontMatroskaExtractor( - androidx.media3.extractor.mkv.MatroskaExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA, false, set); - extractor.init(new ExtractorOutput() { - @Override public TrackOutput track(int id, int type) { return new DiscardingTrackOutput(); } - @Override public void endTracks() { } - @Override public void seekMap(SeekMap map) { } - }); - byte[] data = font(); - for (int pass = 0; pass < 2; pass++) { - extractor.startMasterElement(0x61A7, 0, data.length + 100); - ByteArrayInputStream bytes = new ByteArrayInputStream(data); - extractor.binaryElement(0x465C, data.length, - new DefaultExtractorInput(bytes::read, 0, data.length)); - extractor.stringElement(0x4660, "application/octet-stream"); - extractor.stringElement(0x466E, "renamed-container-attachment.otf"); - extractor.endMasterElement(0x61A7); - } - assertEquals(1, changes.get()); - assertEquals(1, set.snapshot().names().length); - assertTrue(Arrays.equals(data, set.snapshot().data()[0])); - assertFalse(set.canRead(AssFontSet.MAX_FONT_BYTES + 1)); - set.close(); - set.add("stale.otf", data); - assertEquals(0, set.snapshot().names().length); - assertEquals(1, changes.get()); - } - - private byte[] pixels(long handle) { - String script = "[Script Info]\nScriptType: v4.00+\nPlayResX: 640\nPlayResY: 360\n" - + "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" - + "Style: Default,Aileron,38,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,20,1\n" - + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - + "Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,Attachment faces ABC xyz\n"; - assertTrue(handle != 0); - assertTrue(AssNative.load(handle, script.getBytes(StandardCharsets.UTF_8))); - assertTrue(AssNative.testSurface(handle, 640, 360)); - assertTrue(AssNative.render(handle, 1000, 640, 360, 640, 360, 1, - C.COLOR_SPACE_BT709, C.COLOR_RANGE_LIMITED, true, new long[6]) > 0); - return AssNative.readPixels(handle); - } - - public void testAttachedFaceWinsOverSystemFallback() throws Exception { - AssNative.ensureLoaded(); - String[] names = {"unrelated-filename.otf"}; - byte[][] fonts = {font()}; - String config = AssFonts.prepare(InstrumentationRegistry.getInstrumentation().getTargetContext()); - long reference = 0, attached = 0, fallback = 0; - try { - reference = AssNative.createTestFonts(names, fonts); - byte[] expected = pixels(reference); - AssNative.destroy(reference); reference = 0; - attached = AssNative.create(config, names, fonts); - byte[] actual = pixels(attached); - AssNative.destroy(attached); attached = 0; - fallback = AssNative.create(config, new String[0], new byte[0][]); - byte[] substitute = pixels(fallback); - boolean visible = false; - for (byte channel : substitute) if (channel != 0) { visible = true; break; } - assertTrue("Unavailable script fonts still require a visible system fallback", visible); - assertTrue("Internal family must select the attached face even with a different filename", - Arrays.equals(expected, actual)); - assertFalse("System fallback must not masquerade as the script's original font", - Arrays.equals(expected, substitute)); - } finally { - if (reference != 0) AssNative.destroy(reference); - if (attached != 0) AssNative.destroy(attached); - if (fallback != 0) AssNative.destroy(fallback); - new File(config).delete(); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssNativeGoldenTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssNativeGoldenTest.java deleted file mode 100644 index a4c06b3639..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssNativeGoldenTest.java +++ /dev/null @@ -1,233 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import androidx.media3.common.C; -import androidx.test.platform.app.InstrumentationRegistry; -import junit.framework.TestCase; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; - -/** Fixed upstream PNGs + fixed fonts, with system font lookup explicitly disabled. */ -public class AssNativeGoldenTest extends TestCase { - private byte[] asset(String name) throws Exception { - try (InputStream input = InstrumentationRegistry.getInstrumentation().getContext().getAssets().open("exo-ass/official/" + name); - ByteArrayOutputStream output = new ByteArrayOutputStream()) { - byte[] buffer = new byte[8192]; int count; - while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count); - return output.toByteArray(); - } - } - - private long create() throws Exception { - AssNative.ensureLoaded(); - long handle = AssNative.createTestFonts(new String[]{"Aileron-Regular.otf", "Arimo-Bold.ttf", "Arimo-Regular.ttf"}, - new byte[][]{asset("Aileron-Regular.otf"), asset("Arimo-Bold.ttf"), asset("Arimo-Regular.ttf")}); - assertTrue("Native initialization with fixed fonts", handle != 0); - return handle; - } - - private void compare(long handle, String base, int time, int width, int height) throws Exception { - long[] stats = new long[6]; - assertTrue("A changed frame must be submitted", AssNative.render(handle, time, width, height, width, height, - 1, 2, 2, true, stats) > 0); - byte[] pixels = AssNative.readPixels(handle); - assertNotNull(pixels); - String name = base + "-" + time; - File directory = new File(InstrumentationRegistry.getInstrumentation().getTargetContext().getFilesDir(), "exo-ass-golden"); - assertTrue(directory.isDirectory() || directory.mkdirs()); - try (FileOutputStream output = new FileOutputStream(new File(directory, name + ".rgba"))) { output.write(pixels); } - byte[] png = asset(name + ".png"); - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inPremultiplied = false; - Bitmap reference = BitmapFactory.decodeByteArray(png, 0, png.length, options); - assertEquals(width, reference.getWidth()); assertEquals(height, reference.getHeight()); - int[] expected = new int[width * height]; - reference.getPixels(expected, 0, width, 0, 0, width, height); - reference.recycle(); - long error = 0, union = 0, bad = 0; - for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) { - int offset = ((height - 1 - y) * width + x) * 4; - int argb = expected[y * width + x]; - int a = argb >>> 24; - int actualA = pixels[offset + 3] & 255; - if (a == 0 && actualA == 0) continue; - union++; - int[] referencePixel = { ((argb >>> 16 & 255) * a + 127) / 255, - ((argb >>> 8 & 255) * a + 127) / 255, ((argb & 255) * a + 127) / 255, a }; - int maximum = 0; - for (int channel = 0; channel < 4; channel++) { - int difference = Math.abs(referencePixel[channel] - (pixels[offset + channel] & 255)); - error += difference; maximum = Math.max(maximum, difference); - } - if (maximum > 16) bad++; - } - assertTrue("Reference must contain a visible subtitle", union > 1000); - double mean = error / (union * 4.0); - double badRatio = bad / (double) union; - String report = name + " foregroundMae=" + mean + " badPixelRatio=" + badRatio - + " renderUs=" + stats[0] + " uploadUs=" + stats[1]; - android.util.Log.i("ExoAssGolden", report); - // Frozen before inspecting candidate pixels. Transparent background does not dilute error. - assertTrue(report, mean <= 1.5 && badRatio <= .02); - } - - public void testOfficialBlurTransformFrames() throws Exception { - long handle = create(); - try { - assertTrue(AssNative.load(handle, AssInput.normalize(asset("blur+t.ass")))); - assertTrue(AssNative.testSurface(handle, 800, 600)); - for (int time : new int[]{1000, 1500, 1900}) compare(handle, "blur+t", time, 800, 600); - } finally { AssNative.destroy(handle); } - } - - public void testHdrSdrRgbColorsAndVideoColorTransitions() throws Exception { - // A solid ASS drawing gives an independent color/alpha reference without font variance. - String script = "[Script Info]\nScriptType: v4.00+\nPlayResX: 64\nPlayResY: 64\n" - + "YCbCr Matrix: TV.601\n[V4+ Styles]\n" - + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, " - + "Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, " - + "Alignment, MarginL, MarginR, MarginV, Encoding\n" - + "Style: Default,Arimo,20,&H00C08040,&H00C08040,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n" - + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - + "Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,," - + "{\\an7\\pos(0,0)\\bord0\\shad0\\1a&H80&\\p1}m 0 0 l 64 0 64 64 0 64\n"; - long handle = create(); - long[] stats = new long[6]; - try { - assertTrue(AssNative.load(handle, AssInput.normalize(script.getBytes(StandardCharsets.UTF_8)))); - assertTrue(AssNative.testSurface(handle, 64, 64)); - assertTrue(AssNative.render(handle, 1000, 64, 64, 64, 64, 1, - AssNative.COLOR_SPACE_SDR_RGB, C.COLOR_RANGE_FULL, false, stats) > 0); - byte[] hdr = AssNative.readPixels(handle); - assertSdrRgbPixel(hdr); - assertTrue("Color-mode changes redraw even at a paused timestamp", - AssNative.render(handle, 1000, 64, 64, 64, 64, 1, - C.COLOR_SPACE_BT709, C.COLOR_RANGE_LIMITED, false, stats) > 0); - byte[] sdr = AssNative.readPixels(handle); - int pixel = (32 * 64 + 32) * 4; - assertTrue("SDR retains the historical TV.601 to TV.709 conversion", - Math.abs((hdr[pixel] & 255) - (sdr[pixel] & 255)) >= 2); - assertEquals(hdr[pixel + 3], sdr[pixel + 3]); - assertTrue(AssNative.render(handle, 1000, 64, 64, 64, 64, 1, - AssNative.COLOR_SPACE_SDR_RGB, C.COLOR_RANGE_FULL, false, stats) > 0); - assertSdrRgbPixel(AssNative.readPixels(handle)); - } finally { AssNative.destroy(handle); } - } - - private void assertSdrRgbPixel(byte[] pixels) { - assertNotNull(pixels); - int pixel = (32 * 64 + 32) * 4; - // ASS &H80C08040 is RGB(64,128,192), opacity 127/255, stored premultiplied. - int[] expected = {32, 64, 96, 127}; - for (int channel = 0; channel < 4; channel++) - assertTrue("SDR RGB channel " + channel + " actual=" + (pixels[pixel + channel] & 255), - Math.abs(expected[channel] - (pixels[pixel + channel] & 255)) <= 1); - } - - public void testOfficialKaraokeFrames() throws Exception { - long handle = create(); - try { - assertTrue(AssNative.load(handle, AssInput.normalize(asset("357-k-and-kf-desynced.ass")))); - assertTrue(AssNative.testSurface(handle, 1920, 1080)); - for (int time : new int[]{6798, 7170, 8170}) compare(handle, "357-k-and-kf-desynced", time, 1920, 1080); - } finally { AssNative.destroy(handle); } - } - - private record PacketScript(byte[] header, List packets) { } - - private static long scriptTimeMs(String value) { - String[] parts = value.trim().split("[:.]"); - return Long.parseLong(parts[0]) * 3_600_000 + Long.parseLong(parts[1]) * 60_000 - + Long.parseLong(parts[2]) * 1000 + Long.parseLong(parts[3]) * 10; - } - - private PacketScript packetize(String file) throws Exception { - String script = new String(AssInput.normalize(asset(file)), StandardCharsets.UTF_8); - StringBuilder header = new StringBuilder(); - AssPacketInput input = new AssPacketInput(); - int order = 0; - for (String line : script.split("\n")) { - if (!line.startsWith("Dialogue:")) { header.append(line).append('\n'); continue; } - String[] fields = line.substring("Dialogue:".length()).trim().split(",", 10); - long startMs = scriptTimeMs(fields[1]), duration = scriptTimeMs(fields[2]) - startMs; - String durationText = String.format(Locale.ROOT, "%d:%02d:%02d:%02d", - duration / 3_600_000, duration / 60_000 % 60, duration / 1000 % 60, duration / 10 % 100); - String sample = "Dialogue: 0:00:00:00," + durationText + "," + order++ + "," + fields[0] - + "," + String.join(",", Arrays.copyOfRange(fields, 3, 10)); - assertTrue(input.add(ByteBuffer.wrap(sample.getBytes(StandardCharsets.UTF_8)), - 1_000_000_000_000L + startMs * 1000, 1_000_000_000_000L)); - } - return new PacketScript(AssInput.normalizeHeader(header.toString().getBytes(StandardCharsets.UTF_8)), input.after(0)); - } - - private void loadPackets(long handle, PacketScript script) { - assertTrue(AssNative.loadHeader(handle, script.header())); - for (AssPacketInput.Packet packet : script.packets()) - assertTrue(AssNative.chunk(handle, packet.data(), packet.startMs(), packet.durationMs())); - } - - public void testPacketizedOfficialFramesDuplicatePrerollAndReplay() throws Exception { - long handle = create(); - try { - for (String base : new String[]{"blur+t", "357-k-and-kf-desynced"}) { - PacketScript script = packetize(base + ".ass"); - loadPackets(handle, script); - AssPacketInput.Packet first = script.packets().get(0); - assertTrue("Duplicate ReadOrder follows libass semantics", - AssNative.chunk(handle, first.data(), first.startMs(), first.durationMs())); - boolean blur = base.equals("blur+t"); - int width = blur ? 800 : 1920, height = blur ? 600 : 1080; - assertTrue(AssNative.testSurface(handle, width, height)); - int[] times = blur ? new int[]{1900, 1000, 1500} : new int[]{8170, 6798, 7170}; - for (int time : times) compare(handle, base, time, width, height); - // The same retained packets must reconstruct a fresh track after font changes. - loadPackets(handle, script); - compare(handle, base, times[1], width, height); - } - } finally { AssNative.destroy(handle); } - } - - public void testPacketNativeBoundsAndScriptModeIsolation() throws Exception { - long handle = create(); - try { - PacketScript script = packetize("blur+t.ass"); - AssPacketInput.Packet first = script.packets().get(0); - assertFalse(AssNative.chunk(handle, first.data(), first.startMs(), first.durationMs())); - assertTrue(AssNative.load(handle, AssInput.normalize(asset("blur+t.ass")))); - assertFalse("Chunk input must never mutate a full-script track", - AssNative.chunk(handle, first.data(), first.startMs(), first.durationMs())); - assertTrue(AssNative.loadHeader(handle, script.header())); - assertFalse(AssNative.chunk(handle, new byte[AssInput.MAX_INPUT_BYTES + 1], 0, 1)); - assertFalse(AssNative.chunk(handle, first.data(), Long.MAX_VALUE, 1)); - assertFalse(AssNative.chunk(handle, first.data(), 0, -1)); - assertFalse(AssNative.loadHeader(handle, new byte[AssInput.MAX_UTF8_BYTES + 1])); - assertFalse(AssNative.loadHeader(handle, AssInput.normalize(asset("blur+t.ass")))); - } finally { AssNative.destroy(handle); } - } - - public void testUnchangedContextRecreationAndEmptyFrameAreDistinct() throws Exception { - long handle = create(); - long[] stats = new long[6]; - try { - assertTrue(AssNative.load(handle, AssInput.normalize(asset("blur+t.ass")))); - assertFalse(AssNative.testSurface(handle, Integer.MAX_VALUE, 100)); - assertTrue(AssNative.testSurface(handle, 800, 600)); - assertTrue(AssNative.render(handle, 1000, 800, 600, 800, 600, 1, 2, 2, false, stats) > 0); - assertEquals(0, AssNative.render(handle, 1000, 800, 600, 800, 600, 1, 2, 2, false, stats)); - assertTrue(AssNative.setSurface(handle, null)); - assertTrue(AssNative.testSurface(handle, 800, 600)); - assertTrue(AssNative.render(handle, 1000, 800, 600, 800, 600, 1, 2, 2, false, stats) > 0); - assertEquals(2, AssNative.render(handle, 3000, 800, 600, 800, 600, 1, 2, 2, false, stats)); - byte[] pixels = AssNative.readPixels(handle); - for (byte pixel : pixels) assertEquals(0, pixel); - } finally { AssNative.destroy(handle); } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssPlaybackTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssPlaybackTest.java deleted file mode 100644 index 4a76d6ce05..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssPlaybackTest.java +++ /dev/null @@ -1,181 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.app.Instrumentation; -import android.content.Intent; -import android.os.SystemClock; -import android.os.ParcelFileDescriptor; -import android.view.View; -import androidx.test.platform.app.InstrumentationRegistry; -import junit.framework.TestCase; -import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicBoolean; -import java.io.FileInputStream; - -/** Real Exo engine, existing source/renderers factory, physical SurfaceView and async native worker. */ -public class AssPlaybackTest extends TestCase { - private Instrumentation instrumentation; - private AssPrototypeActivity activity; - - private void main(Runnable action) { instrumentation.runOnMainSync(action); } - - private void await(Callable condition, String description) throws Exception { - long deadline = SystemClock.elapsedRealtime() + 12000; - AtomicBoolean ready = new AtomicBoolean(); - while (SystemClock.elapsedRealtime() < deadline) { - main(() -> { - try { ready.set(condition.call()); } - catch (Exception error) { throw new IllegalStateException(error); } - }); - if (ready.get()) return; - SystemClock.sleep(40); - } - main(() -> android.util.Log.e("ExoAssTest", "timeout " + description + " " + activity.metrics())); - fail(description); - } - - @Override protected void setUp() throws Exception { - super.setUp(); - instrumentation = InstrumentationRegistry.getInstrumentation(); - } - - private void start(boolean enabled, boolean noSubtitle) throws Exception { - start(enabled, noSubtitle, false); - } - - private void start(boolean enabled, boolean noSubtitle, boolean embedded) throws Exception { - // Shell launch is the same path as the device harness, avoiding the - // runner's idle-queue wait while an animated player is being created. - Instrumentation.ActivityMonitor monitor = instrumentation.addMonitor( - AssPrototypeActivity.class.getName(), null, false); - try (ParcelFileDescriptor fd = instrumentation.getUiAutomation().executeShellCommand( - "am start -W -f 0x10008000 -n com.fongmi.android.tv/.player.exo.ass.AssPrototypeActivity" - + " --ez enabled " + enabled + " --ez no_subtitle " + noSubtitle - + " --ez embedded " + embedded); - FileInputStream input = new FileInputStream(fd.getFileDescriptor())) { - byte[] buffer = new byte[1024]; - while (input.read(buffer) != -1) { } - activity = (AssPrototypeActivity) monitor.waitForActivityWithTimeout(10000); - assertNotNull("Prototype Activity should launch", activity); - } finally { - instrumentation.removeMonitor(monitor); - } - } - - @Override protected void tearDown() throws Exception { - if (activity != null) main(activity::finish); - instrumentation.waitForIdleSync(); - super.tearDown(); - } - - public void testPauseDelaySurfaceFallbackSeekTracksAndRelease() throws Exception { - exerciseLifecycle(false); - } - - public void testContainerPacketsPauseSeekLateFontsTracksAndRelease() throws Exception { - exerciseLifecycle(true); - } - - private void exerciseLifecycle(boolean embedded) throws Exception { - start(true, false, embedded); - await(() -> activity.session != null && activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, - "ASS should become visible after a valid native submission"); - await(() -> activity.player.getCurrentPosition() >= 1200, "position after positive delay boundary"); - main(() -> { - assertEquals(View.INVISIBLE, activity.view.getSubtitleView().getVisibility()); - assertEquals("Current MKV's font must reach the selected ASS", 1, - activity.session.diagnostics().fontCount()); - if (embedded) { - assertEquals(0, activity.session.diagnostics().scripts()); - assertTrue(activity.session.diagnostics().packetCount() > 0); - assertTrue(activity.player.getCurrentMediaItem().localConfiguration.subtitleConfigurations.isEmpty()); - } - assertTrue(activity.player.getCurrentCues().cues.size() > 0); - activity.player.pause(); - }); - await(() -> !activity.player.isPlaying(), "pause"); - SystemClock.sleep(200); - long frozen = activity.session.diagnostics().timeMs(); - SystemClock.sleep(200); - assertEquals(frozen, activity.session.diagnostics().timeMs()); - main(() -> activity.player.setTextOffsetMs(500)); - await(() -> Math.abs(activity.session.diagnostics().timeMs() - (frozen - 500)) <= 40, "paused positive delay"); - if (embedded) { - java.lang.reflect.Field field = ExoAssSession.class.getDeclaredField("fonts"); - field.setAccessible(true); - AssFontSet fonts = (AssFontSet) field.get(activity.session); - byte[] font; - try (java.io.InputStream input = instrumentation.getContext().getAssets().open("exo-ass/official/Arimo-Regular.ttf")) { - font = input.readAllBytes(); - } - long frames = activity.session.diagnostics().frames(); - int packets = activity.session.diagnostics().packetCount(); - fonts.add("late-Arimo-Regular.ttf", font); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE - && activity.session.diagnostics().fontCount() == 2 - && activity.session.diagnostics().frames() > frames, "late font rebuild replays packets while paused"); - assertEquals(packets, activity.session.diagnostics().packetCount()); - } - long epoch = activity.session.diagnostics().surfaceEpoch(); - main(() -> activity.command("reattach", null)); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE - && activity.session.diagnostics().surfaceEpoch() > epoch, "paused Surface recreation"); - main(() -> activity.session.injectFailure("test")); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.FALLBACK - && activity.view.getSubtitleView().getVisibility() == View.VISIBLE, "recoverable fallback"); - main(() -> { - assertTrue("Current compatible Cue must survive fallback", !activity.player.getCurrentCues().cues.isEmpty()); - activity.session.setEnabled(false); - activity.session.setEnabled(true); - activity.player.play(); - }); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "runtime re-enable"); - long scripts = activity.session.diagnostics().scripts(); - int packets = activity.session.diagnostics().packetCount(); - for (int i = 0; i < 4; i++) { - long previous = activity.session.diagnostics().generation(); - final long position = i % 2 == 0 ? 6000 : 2000; - main(() -> activity.player.seekTo(position)); - await(() -> activity.session.diagnostics().generation() > previous - && activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "seek redraw"); - } - assertEquals("Seek reuses the complete script", scripts, activity.session.diagnostics().scripts()); - if (embedded) assertEquals("Duplicate preroll must not accumulate events", packets, - activity.session.diagnostics().packetCount()); - main(() -> activity.command("subtitle_off", null)); - await(() -> !activity.session.diagnostics().nativeAlive() - && activity.view.getSubtitleView().getVisibility() == View.VISIBLE, "track off releases native state"); - main(() -> activity.command("subtitle_on", null)); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "track reselect"); - main(() -> activity.command("texture", null)); - await(() -> activity.view.getSubtitleView().getVisibility() == View.VISIBLE, "TextureView remains compatible"); - main(() -> activity.command("surface", null)); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "SurfaceView resumes effect mode"); - ExoAssSession previous = activity.session; - main(() -> activity.command("rebuild", null)); - await(() -> previous.diagnostics().releaseComplete() - && !previous.diagnostics().workerAlive() - && activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "engine rebuild releases old native owner"); - ExoAssSession last = activity.session; - main(activity::finish); - await(() -> last.diagnostics().releaseComplete() && !last.diagnostics().nativeAlive() - && !last.diagnostics().workerAlive(), "final native and worker release"); - } - - public void testDisabledPrototypeDoesNotCreateNativeOrOverlay() throws Exception { - start(false, false); - await(() -> activity.player.isPlaying() && activity.player.getCurrentPosition() > 800, "compatible playback"); - main(() -> { - assertFalse(activity.session.diagnostics().nativeAlive()); - assertEquals(0, activity.session.diagnostics().frames()); - assertEquals(View.VISIBLE, activity.view.getSubtitleView().getVisibility()); - assertFalse(activity.player.getCurrentCues().cues.isEmpty()); - }); - } - - public void testNoSubtitleDoesNotCreateNativeOrWorkerFrames() throws Exception { - start(true, true); - await(() -> activity.player.isPlaying() && activity.player.getCurrentPosition() > 800, "playback without subtitles"); - assertFalse(activity.session.diagnostics().nativeAlive()); - assertEquals(0, activity.session.diagnostics().frames()); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssVideoPolicyTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssVideoPolicyTest.java deleted file mode 100644 index 511229f69c..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssVideoPolicyTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import androidx.media3.common.C; -import androidx.media3.common.ColorInfo; -import androidx.media3.common.DrmInitData; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; - -import junit.framework.TestCase; - -public class AssVideoPolicyTest extends TestCase { - private Format video(String mime, ColorInfo color) { - return new Format.Builder().setSampleMimeType(mime).setWidth(3840).setHeight(2160) - .setColorInfo(color).build(); - } - - private ColorInfo color(int space, int transfer, int range) { - return new ColorInfo.Builder().setColorSpace(space).setColorTransfer(transfer) - .setColorRange(range).build(); - } - - public void testHdrAndWideGamutKeepSubtitleRgb() { - for (int transfer : new int[]{C.COLOR_TRANSFER_ST2084, C.COLOR_TRANSFER_HLG, - C.COLOR_TRANSFER_SDR, Format.NO_VALUE}) { - Format format = video(MimeTypes.VIDEO_H265, - color(C.COLOR_SPACE_BT2020, transfer, C.COLOR_RANGE_LIMITED)); - assertTrue(AssVideoPolicy.supports(format)); - assertEquals(AssNative.COLOR_SPACE_SDR_RGB, AssVideoPolicy.colorSpace(format)); - assertEquals(C.COLOR_RANGE_FULL, AssVideoPolicy.colorRange(format)); - } - Format pq709 = video(MimeTypes.VIDEO_H265, - color(C.COLOR_SPACE_BT709, C.COLOR_TRANSFER_ST2084, C.COLOR_RANGE_LIMITED)); - assertTrue(AssVideoPolicy.supports(pq709)); - assertEquals(AssNative.COLOR_SPACE_SDR_RGB, AssVideoPolicy.colorSpace(pq709)); - } - - public void testDolbyVisionWithoutColorMetadataIsStillSdrSubtitleRgb() { - Format format = video(MimeTypes.VIDEO_DOLBY_VISION, null); - assertTrue(AssVideoPolicy.supports(format)); - assertEquals(AssNative.COLOR_SPACE_SDR_RGB, AssVideoPolicy.colorSpace(format)); - assertEquals(C.COLOR_RANGE_FULL, AssVideoPolicy.colorRange(format)); - } - - public void testSdrVideoRetainsDeclaredAndFallbackMatrices() { - Format sdr = video(MimeTypes.VIDEO_H264, - color(C.COLOR_SPACE_BT709, C.COLOR_TRANSFER_SDR, C.COLOR_RANGE_FULL)); - assertTrue(AssVideoPolicy.supports(sdr)); - assertEquals(C.COLOR_SPACE_BT709, AssVideoPolicy.colorSpace(sdr)); - assertEquals(C.COLOR_RANGE_FULL, AssVideoPolicy.colorRange(sdr)); - Format unspecified = video(MimeTypes.VIDEO_H264, null); - assertEquals(C.COLOR_SPACE_BT709, AssVideoPolicy.colorSpace(unspecified)); - assertEquals(C.COLOR_RANGE_LIMITED, AssVideoPolicy.colorRange(unspecified)); - assertEquals(C.COLOR_SPACE_BT601, - AssVideoPolicy.colorSpace(unspecified.buildUpon().setHeight(576).build())); - Format declared601 = video(MimeTypes.VIDEO_H264, - color(C.COLOR_SPACE_BT601, C.COLOR_TRANSFER_SDR, C.COLOR_RANGE_LIMITED)); - assertEquals(C.COLOR_SPACE_BT601, AssVideoPolicy.colorSpace(declared601)); - } - - public void testSecureRotatedAndUnknownTransferStillUseCompatiblePath() { - Format hdr = video(MimeTypes.VIDEO_H265, - color(C.COLOR_SPACE_BT2020, C.COLOR_TRANSFER_ST2084, C.COLOR_RANGE_LIMITED)); - assertFalse(AssVideoPolicy.supports(hdr.buildUpon().setWidth(0).build())); - assertFalse(AssVideoPolicy.supports(hdr.buildUpon().setRotationDegrees(90).build())); - assertFalse(AssVideoPolicy.supports(hdr.buildUpon().setCryptoType(C.CRYPTO_TYPE_FRAMEWORK).build())); - assertFalse(AssVideoPolicy.supports(hdr.buildUpon() - .setDrmInitData(new DrmInitData(new DrmInitData.SchemeData[0])).build())); - assertFalse(AssVideoPolicy.supports(hdr.buildUpon() - .setColorInfo(color(C.COLOR_SPACE_BT2020, C.COLOR_TRANSFER_LINEAR, C.COLOR_RANGE_FULL)).build())); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/TextRendererObserverTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/TextRendererObserverTest.java deleted file mode 100644 index c75a452531..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/TextRendererObserverTest.java +++ /dev/null @@ -1,193 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.os.SystemClock; -import junit.framework.TestCase; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.text.CueGroup; -import androidx.media3.common.util.Clock; -import androidx.media3.decoder.DecoderInputBuffer; -import androidx.media3.exoplayer.FormatHolder; -import androidx.media3.exoplayer.RendererConfiguration; -import androidx.media3.exoplayer.analytics.PlayerId; -import androidx.media3.exoplayer.source.MediaSource; -import androidx.media3.exoplayer.source.SampleStream; -import androidx.media3.exoplayer.text.TextOutput; -import androidx.media3.exoplayer.text.TextRenderer; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; - -/** Exercises the real shipped TextRenderer, decoder, Cue output and final-stream handling. */ -@SuppressWarnings("deprecation") -public class TextRendererObserverTest extends TestCase { - private static final long OFFSET = 1_000_000_000L; - private static final byte[] SCRIPT = ("[Script Info]\nPlayResX: 640\nPlayResY: 360\n" - + "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" - + "Style: Default,Arial,30,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1\n" - + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,kept,comma\n").getBytes(StandardCharsets.UTF_8); - private static final Format FORMAT = new Format.Builder().setSampleMimeType(MimeTypes.TEXT_SSA) - .setId(AssInput.EXTERNAL_ID_PREFIX + "test").build(); - - private static final class Output implements TextOutput { - CueGroup cues = CueGroup.EMPTY_TIME_ZERO; - @Override public void onCues(CueGroup cues) { this.cues = cues; } - } - - private static class Observer implements TextRenderer.Observer { - long generation, resets, sampleTime, position, textOffset; - int samples, disabled; - boolean ended, throwOnSample; - byte[] bytes; - TextRenderer.Stream reading, displaying; - @Override public void onStream(TextRenderer.Stream stream, long generation) { reading = stream; this.generation = generation; } - @Override public void onReset(TextRenderer.Stream stream, long generation, long position) { resets++; this.generation = generation; } - @Override public void onSample(TextRenderer.Stream stream, long generation, Format format, ByteBuffer data, long timeUs) { - samples++; bytes = AssInput.copy(data); sampleTime = timeUs; - if (throwOnSample) throw new IllegalStateException("fixture"); - } - @Override public void onClock(TextRenderer.Stream reading, TextRenderer.Stream displaying, long generation, - long position, long offset, boolean started, boolean ended) { - this.reading = reading; this.displaying = displaying; this.position = position; - textOffset = offset; this.ended = ended; - } - @Override public void onCues(TextRenderer.Stream stream, long generation, CueGroup group) { } - @Override public void onDisabled(long generation) { disabled++; } - } - - private static final class Input implements SampleStream { - int phase, actualReads, peeks; - @Override public boolean isReady() { return true; } - @Override public void maybeThrowError() { } - @Override public int skipData(long positionUs) { return 0; } - @Override public int readData(FormatHolder holder, DecoderInputBuffer buffer, int flags) { - if ((flags & FLAG_PEEK) != 0) peeks++; - if (phase == 0 || (flags & FLAG_REQUIRE_FORMAT) != 0) { - holder.format = FORMAT; phase = 1; return C.RESULT_FORMAT_READ; - } - buffer.clear(); - if (phase >= 2) { - buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); return C.RESULT_BUFFER_READ; - } - buffer.timeUs = 0; - buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME); - if ((flags & FLAG_OMIT_SAMPLE_DATA) == 0) { - buffer.ensureSpaceForWrite(SCRIPT.length); buffer.data.put(SCRIPT); - } - if ((flags & FLAG_PEEK) == 0) { phase = 2; actualReads++; } - return C.RESULT_BUFFER_READ; - } - } - - private TextRenderer renderer(Output output, Input input, Observer observer) throws Exception { - TextRenderer renderer = new TextRenderer(output, null); - if (observer != null) renderer.setObserver(observer); - renderer.init(0, PlayerId.UNSET, Clock.DEFAULT); - renderer.enable(RendererConfiguration.DEFAULT, new Format[]{FORMAT}, input, - OFFSET, false, true, OFFSET, OFFSET, new MediaSource.MediaPeriodId("period-a")); - renderer.start(); - return renderer; - } - - private void awaitCue(TextRenderer renderer, Output output, long position) throws Exception { - long deadline = SystemClock.elapsedRealtime() + 2000; - do { - renderer.render(position, SystemClock.elapsedRealtime() * 1000); - if (!output.cues.cues.isEmpty()) return; - SystemClock.sleep(5); - } while (SystemClock.elapsedRealtime() < deadline); - fail("Legacy Cue output did not arrive"); - } - - private void release(TextRenderer renderer) { - renderer.stop(); renderer.disable(); renderer.reset(); renderer.release(); - } - - public void testDefaultObserverOffPreservesCueAndSingleConsumption() throws Exception { - Output output = new Output(); Input input = new Input(); - TextRenderer renderer = renderer(output, input, null); - try { - awaitCue(renderer, output, OFFSET + 1_500_000); - assertEquals("kept,comma", output.cues.cues.get(0).text.toString()); - assertEquals(1, input.actualReads); - assertTrue(input.peeks > 0); - assertEquals(TextRenderer.class, renderer.getClass()); - } finally { release(renderer); } - } - - public void testPausedObserverDelayRedeliversCompatibleCue() throws Exception { - Output output = new Output(); Input input = new Input(); - TextRenderer renderer = renderer(output, input, new Observer()); - try { - awaitCue(renderer, output, OFFSET + 2_000_000); - renderer.stop(); - renderer.setTextOffsetMs(500); - assertEquals("kept,comma", output.cues.cues.get(0).text.toString()); - renderer.setTextOffsetMs(2500); - assertTrue("Moving before the cue's start must clear it", output.cues.cues.isEmpty()); - renderer.setTextOffsetMs(0); - assertEquals("Moving time backward must also reset the event index", "kept,comma", - output.cues.cues.get(0).text.toString()); - } finally { - if (renderer.getState() != androidx.media3.exoplayer.Renderer.STATE_STARTED) renderer.start(); - release(renderer); - } - } - - public void testObserverUsesSelectedBytesOffsetDelaySeekAndFinalEnd() throws Exception { - Output output = new Output(); Input input = new Input(); Observer observer = new Observer(); - TextRenderer renderer = renderer(output, input, observer); - try { - awaitCue(renderer, output, OFFSET + 1_500_000); - assertEquals(1, observer.samples); - assertTrue(Arrays.equals(SCRIPT, observer.bytes)); - assertEquals(OFFSET, observer.sampleTime); - assertEquals(OFFSET, observer.reading.offsetUs); - assertEquals("period-a", observer.reading.mediaPeriodId.periodUid); - renderer.setTextOffsetMs(500); - renderer.render(OFFSET + 2_000_000, 0); - assertEquals(1500, AssInput.timeMs(observer.position, observer.reading.offsetUs, observer.textOffset)); - assertEquals("kept,comma", output.cues.cues.get(0).text.toString()); - long generation = observer.generation; - input.phase = 1; - renderer.resetPosition(OFFSET + 1_500_000, false); - awaitCue(renderer, output, OFFSET + 1_500_000); - assertTrue(observer.generation > generation); - assertEquals(2, observer.samples); - renderer.setCurrentStreamFinal(); - renderer.setFinalStreamEndPositionUs(OFFSET + 4_000_000); - renderer.render(OFFSET + 4_000_000, 0); - assertTrue(renderer.isEnded()); - assertTrue(observer.ended); - } finally { release(renderer); } - assertEquals(1, observer.disabled); - } - - public void testThrowingObserverCannotBreakCompatibleDecoder() throws Exception { - Output output = new Output(); Input input = new Input(); Observer observer = new Observer(); - observer.throwOnSample = true; - TextRenderer renderer = renderer(output, input, observer); - try { - awaitCue(renderer, output, OFFSET + 1_500_000); - assertEquals("kept,comma", output.cues.cues.get(0).text.toString()); - assertEquals(1, observer.disabled); - } finally { release(renderer); } - } - - public void testReadAheadPeriodDoesNotRelabelDisplayedSubtitle() throws Exception { - Output output = new Output(); Input input = new Input(); Observer observer = new Observer(); - TextRenderer renderer = renderer(output, input, observer); - try { - awaitCue(renderer, output, OFFSET + 1_500_000); - long displaying = observer.displaying.sequence; - renderer.replaceStream(new Format[]{FORMAT}, new Input(), OFFSET + 5_000_000, - OFFSET + 5_000_000, new MediaSource.MediaPeriodId("period-b")); - renderer.render(OFFSET + 1_600_000, 0); - assertEquals("period-b", observer.reading.mediaPeriodId.periodUid); - assertEquals(displaying, observer.displaying.sequence); - assertEquals("kept,comma", output.cues.cues.get(0).text.toString()); - } finally { release(renderer); } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlaybackTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlaybackTest.java deleted file mode 100644 index 9184b6ed2c..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlaybackTest.java +++ /dev/null @@ -1,268 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.app.Instrumentation; -import android.graphics.Bitmap; -import android.os.ParcelFileDescriptor; -import android.os.SystemClock; -import android.text.Spanned; -import android.view.View; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MediaMetadata; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.Tracks; -import androidx.media3.common.text.Cue; -import androidx.media3.exoplayer.DecoderCounters; -import androidx.media3.exoplayer.Renderer; -import androidx.media3.ui.SubtitleView; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.bean.Sub; -import com.fongmi.android.tv.bean.Track; -import com.fongmi.android.tv.player.PlayerHelper; -import com.fongmi.android.tv.player.engine.PlaySpec; -import com.fongmi.android.tv.player.exo.ass.AssPrototypeActivity; -import com.fongmi.android.tv.player.exo.ass.ExoAssSession; - -import junit.framework.TestCase; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicBoolean; - -/** Existing real Exo engine/source factory, two selected streams and the physical ASS Surface. */ -public class DualSubtitlePlaybackTest extends TestCase { - private Instrumentation instrumentation; - private AssPrototypeActivity activity; - private ExoSubtitleSession subtitles; - - private void main(Runnable action) { - instrumentation.runOnMainSync(action); - } - - private void await(Callable condition, String message) throws Exception { - long deadline = SystemClock.elapsedRealtime() + 15000; - AtomicBoolean ready = new AtomicBoolean(); - while (SystemClock.elapsedRealtime() < deadline) { - main(() -> { - try { - assertNull("Playback error: " + activity.metrics(), activity.player.getPlayerError()); - ready.set(condition.call()); - } catch (Exception e) { throw new IllegalStateException(e); } - }); - if (ready.get()) return; - SystemClock.sleep(30); - } - main(() -> android.util.Log.e("ExoDualSubtitleTest", message + " " + activity.metrics())); - fail(message); - } - - @Override - protected void setUp() throws Exception { - super.setUp(); - instrumentation = InstrumentationRegistry.getInstrumentation(); - Instrumentation.ActivityMonitor monitor = instrumentation.addMonitor(AssPrototypeActivity.class.getName(), null, false); - try (ParcelFileDescriptor fd = instrumentation.getUiAutomation().executeShellCommand( - "am start -W -f 0x10008000 -n com.fongmi.android.tv/.player.exo.ass.AssPrototypeActivity --ez no_subtitle true"); - FileInputStream input = new FileInputStream(fd.getFileDescriptor())) { - byte[] buffer = new byte[1024]; - while (input.read(buffer) != -1) { } - activity = (AssPrototypeActivity) monitor.waitForActivityWithTimeout(10000); - assertNotNull(activity); - } finally { - instrumentation.removeMonitor(monitor); - } - } - - @Override - protected void tearDown() throws Exception { - if (activity != null) main(activity::finish); - instrumentation.waitForIdleSync(); - super.tearDown(); - } - - public void testExternalAssAndSecondaryTextLifecycle() throws Exception { - exercise(false); - } - - public void testEmbeddedAssAndExternalSecondaryLifecycle() throws Exception { - exercise(true); - } - - private void exercise(boolean embedded) throws Exception { - File directory = new File(instrumentation.getTargetContext().getCacheDir(), "dual-subtitle-fixture"); - assertTrue(directory.isDirectory() || directory.mkdirs()); - File srt = new File(directory, "secondary.srt"); - Files.write(srt.toPath(), ("1\n00:00:00,000 --> 00:00:04,000\nSECONDARY SRT ONE\n\n" - + "2\n00:00:04,000 --> 00:01:00,000\nSECONDARY SRT TWO\n").getBytes(StandardCharsets.UTF_8)); - File ass = new File(directory, "secondary.ass"); - Files.write(ass.toPath(), ("[Script Info]\nScriptType: v4.00+\nPlayResX: 960\nPlayResY: 540\n" - + "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" - + "Style: Default,Aileron,70,&H000000FF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,2,10,10,10,1\n" - + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - + "Dialogue: 0,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\\pos(480,510)\\frz20}SECONDARY ASS PLAIN\n").getBytes(StandardCharsets.UTF_8)); - PlaySpec spec = PlaySpec.from("E4-LIBASS-dual", "asset:///exo-ass/" - + (embedded ? "sample-embedded.mkv" : "sample-fonts.mkv"), Collections.emptyMap(), MediaMetadata.EMPTY); - spec.setSub(Sub.create("Secondary ASS", ass.toURI().toString(), "fr", MimeTypes.TEXT_SSA)); - spec.setSub(Sub.create("Secondary SRT", srt.toURI().toString(), "en", MimeTypes.APPLICATION_SUBRIP)); - if (!embedded) spec.setSub(Sub.create("Primary ASS", "asset:///exo-ass/animated.ass", "zh", MimeTypes.TEXT_SSA)); - main(() -> { - subtitles = activity.engine.getSubtitleSession(); - subtitles.attach(activity.view); - assertTrue(activity.engine.supportsSecondarySubtitle()); - assertNull("Disabled secondary must not allocate a view", secondaryView()); - activity.engine.start(spec, 1000, true); - }); - await(() -> find("Secondary SRT") != null && findPrimary() != null, "subtitle tracks extracted"); - main(() -> activity.engine.setTrack(List.of(track(findPrimary())))); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE - && activity.player.getCurrentPosition() >= 1200, "primary ASS rendered"); - DecoderCounters[] videoCounters = new DecoderCounters[1]; - main(() -> { - videoCounters[0] = activity.player.getVideoDecoderCounters(); - assertEquals(Renderer.STATE_DISABLED, secondaryRenderer().getState()); - activity.player.seekTo(1500); - activity.engine.setSecondarySubtitleTrack(track(find("Secondary SRT"))); - }); - await(() -> secondaryText().equals("SECONDARY SRT ONE"), "secondary SRT rendered beside ASS"); - main(() -> { - activity.player.pause(); - assertTrue(activity.engine.isPrimarySubtitleSelected(findPrimary())); - assertTrue(activity.engine.isSecondarySubtitleSelected(find("Secondary SRT"))); - assertFalse(activity.engine.isPrimarySubtitleSelected(find("Secondary SRT"))); - assertEquals(View.INVISIBLE, activity.view.getSubtitleView().getVisibility()); - assertEquals(View.VISIBLE, secondaryView().getVisibility()); - assertSame(activity.view.getSubtitleView().getParent(), secondaryView().getParent()); - assertEquals(1, activity.session.diagnostics().fontCount()); - assertFalse(secondaryCues().get(0).text instanceof Spanned); - }); - Bitmap screenshot = instrumentation.getUiAutomation().takeScreenshot(); - assertNotNull(screenshot); - try (FileOutputStream output = new FileOutputStream(new File(activity.getExternalFilesDir(null), - "exo-dual-" + (embedded ? "embedded" : "external") + ".png"))) { - screenshot.compress(Bitmap.CompressFormat.PNG, 100, output); - } finally { screenshot.recycle(); } - - main(() -> activity.player.setTextOffsetMs(5000)); - await(() -> secondaryCues().isEmpty(), "paused positive delay clears early secondary cue"); - main(() -> activity.player.setTextOffsetMs(0)); - await(() -> secondaryText().equals("SECONDARY SRT ONE"), "paused delay restoration redraws secondary"); - main(() -> { activity.player.seekTo(5500); activity.player.play(); }); - await(() -> secondaryText().equals("SECONDARY SRT TWO"), "forward seek subtitle time"); - main(() -> { activity.player.seekTo(1500); activity.player.play(); }); - await(() -> secondaryText().equals("SECONDARY SRT ONE"), "backward seek subtitle time"); - - main(() -> { - activity.player.pause(); - activity.engine.setTrack(List.of(Track.disabled(C.TRACK_TYPE_TEXT, "off"))); - }); - await(() -> !activity.session.diagnostics().nativeAlive(), "primary off releases only its ASS owner"); - main(() -> { - assertEquals("SECONDARY SRT ONE", secondaryText()); - assertTrue(activity.engine.isSecondarySubtitleSelected(find("Secondary SRT"))); - activity.engine.setTrack(List.of(track(findPrimary()))); - activity.player.play(); - }); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "primary ASS restored"); - main(() -> activity.engine.setSecondarySubtitleTrack(Track.disabled(C.TRACK_TYPE_TEXT, "off"))); - await(() -> secondaryRenderer().getState() == Renderer.STATE_DISABLED && secondaryCues().isEmpty(), "secondary independently disabled"); - main(() -> { - assertEquals(ExoAssSession.State.ACTIVE, activity.session.diagnostics().state()); - assertSame("Subtitle switching must not reinitialize video", videoCounters[0], activity.player.getVideoDecoderCounters()); - activity.engine.setSecondarySubtitleTrack(track(find("Secondary ASS"))); - }); - await(() -> secondaryText().equals("SECONDARY ASS PLAIN"), "secondary ASS follows MPV strip default"); - main(() -> { - activity.player.pause(); - assertFalse(secondaryCues().get(0).text instanceof Spanned); - assertEquals(Cue.ANCHOR_TYPE_START, secondaryCues().get(0).lineAnchor); - assertTrue(secondaryCues().get(0).line < 0.1f); - SubtitleView previous = secondaryView(); - subtitles.detach(); - assertNull(previous.getParent()); - subtitles.attach(activity.view); - assertNotSame(previous, secondaryView()); - assertEquals("SECONDARY ASS PLAIN", secondaryText()); - activity.command("reattach", null); - }); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "ASS and secondary survive host reattach"); - main(() -> { - android.util.Log.i("ExoDualSubtitleTest", "passed embedded=" + embedded + " " + activity.metrics()); - activity.engine.start(PlaySpec.from("different-source", "asset:///exo-ass/sample-fonts.mkv", - Collections.emptyMap(), MediaMetadata.EMPTY)); - assertTrue(secondaryCues().isEmpty()); - }); - await(() -> activity.player.getCurrentTracks().getGroups().stream().noneMatch(group -> group.getType() == C.TRACK_TYPE_TEXT), - "source change drops old subtitle selections"); - main(() -> { - ExoSubtitleSession old = subtitles; - SubtitleView oldView = secondaryView(); - activity.command("rebuild", null); - subtitles = activity.engine.getSubtitleSession(); - assertNotSame(old, subtitles); - assertNull(oldView.getParent()); - subtitles.attach(activity.view); - assertNull("New player starts without a secondary overlay", secondaryView()); - }); - } - - private Format find(String label) { - for (Tracks.Group group : activity.player.getCurrentTracks().getGroups()) { - if (group.getType() != C.TRACK_TYPE_TEXT) continue; - for (int i = 0; i < group.length; i++) if (label.equals(group.getTrackFormat(i).label)) return group.getTrackFormat(i); - } - return null; - } - - private Format findPrimary() { - for (Tracks.Group group : activity.player.getCurrentTracks().getGroups()) { - if (group.getType() != C.TRACK_TYPE_TEXT) continue; - for (int i = 0; i < group.length; i++) { - Format format = group.getTrackFormat(i); - if (MimeTypes.TEXT_SSA.equals(format.sampleMimeType) - && (format.label == null || !format.label.startsWith("Secondary"))) return format; - } - } - return null; - } - - private static Track track(Format format) { - if (format == null) throw new AssertionError("Missing fixture track"); - Track track = new Track(C.TRACK_TYPE_TEXT, format.label, PlayerHelper.describeFormat(format)).playerId(format.id); - track.setSelected(true); - return track; - } - - private Renderer secondaryRenderer() { - return activity.player.getRenderer(activity.player.getRendererCount() - 1); - } - - @SuppressWarnings("unchecked") - private List secondaryCues() { - return (List) field("cues"); - } - - private String secondaryText() { - List cues = secondaryCues(); - return cues.isEmpty() || cues.get(0).text == null ? "" : cues.get(0).text.toString(); - } - - private SubtitleView secondaryView() { - return (SubtitleView) field("view"); - } - - private Object field(String name) { - try { - java.lang.reflect.Field field = ExoSubtitleSession.class.getDeclaredField(name); - field.setAccessible(true); - return field.get(subtitles); - } catch (ReflectiveOperationException e) { throw new AssertionError(e); } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlayerTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlayerTest.java deleted file mode 100644 index cce4b9cd47..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlayerTest.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.content.Context; -import android.view.View; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MediaItem; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.Tracks; -import androidx.media3.common.text.Cue; -import androidx.media3.common.text.CueGroup; -import androidx.media3.datasource.ByteArrayDataSource; -import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.exoplayer.Renderer; -import androidx.media3.exoplayer.source.MediaSource; -import androidx.media3.exoplayer.source.MergingMediaSource; -import androidx.media3.exoplayer.source.SingleSampleMediaSource; -import androidx.media3.exoplayer.text.TextRenderer; -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; -import androidx.media3.ui.PlayerView; -import androidx.media3.ui.SubtitleView; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.bean.Track; - -import junit.framework.TestCase; - -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicBoolean; - -/** Real ExoPlayer/MediaPeriod integration, without relying on a device audio/video codec. */ -@SuppressWarnings("deprecation") -public class DualSubtitlePlayerTest extends TestCase { - private ExoPlayer player; - private ExoSubtitleSession session; - private PlayerView view; - private CueGroup primaryCues = CueGroup.EMPTY_TIME_ZERO; - - private static void main(Runnable action) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(action); - } - - protected void pumpMainLoop() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private void await(Callable condition, String message) throws Exception { - long deadline = System.nanoTime() + 5_000_000_000L; - AtomicBoolean ready = new AtomicBoolean(); - do { - pumpMainLoop(); - main(() -> { - assertNull("Player error", player.getPlayerError()); - try { ready.set(condition.call()); } - catch (Exception e) { throw new AssertionError(e); } - }); - if (ready.get()) return; - Thread.sleep(5); - } while (System.nanoTime() < deadline); - main(() -> fail(message + "; state=" + player.getPlaybackState() + " position=" + player.getCurrentPosition() - + " tracks=" + player.getCurrentTracks().getGroups().size() + " primary=" + text(primaryCues.cues) - + " secondary=" + text(secondaryCues()))); - } - - private MediaSource source(String id, String language, String text) { - byte[] bytes = ("1\n00:00:00,000 --> 00:00:04,000\n" + text + " ONE\n\n" - + "2\n00:00:04,000 --> 00:00:10,000\n" + text + " TWO\n").getBytes(StandardCharsets.UTF_8); - return new SingleSampleMediaSource.Factory(() -> new ByteArrayDataSource(bytes)) - .setTreatLoadErrorsAsEndOfStream(false).createMediaSource( - new MediaItem.SubtitleConfiguration.Builder(android.net.Uri.parse("memory:///" + id + ".srt")) - .setId(id).setLabel(id).setLanguage(language).setMimeType(MimeTypes.APPLICATION_SUBRIP) - .build(), 10_000_000); - } - - @Override protected void tearDown() throws Exception { - main(() -> { - if (view != null) view.setPlayer(null); - if (session != null) session.release(); - if (player != null) player.release(); - }); - super.tearDown(); - } - - public void testTwoRealMediaPeriodsSelectionsAndSiblingOverlayLifecycle() throws Exception { - main(() -> { - Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - session = new ExoSubtitleSession(); - DefaultTrackSelector original = new DefaultTrackSelector(context); - original.setParameters(original.buildUponParameters().setPreferredTextLanguage("en")); - player = new ExoPlayer.Builder(context) - .setTrackSelector(session.wrapTrackSelector(original)) - .setRenderersFactory(session.wrapRenderersFactory((handler, video, audio, output, metadata) -> - new Renderer[]{new TextRenderer(group -> { - primaryCues = group; - output.onCues(group); - }, handler.getLooper())})).build(); - view = new PlayerView(context); - view.setPlayer(player); - session.attach(view); - assertNull(secondaryView()); - player.setMediaSource(new MergingMediaSource(source("main", "en", "MAIN"), - source("second", "zh", "SECOND")), 1500); - player.prepare(); - player.play(); - }); - await(() -> text(primaryCues.cues).equals("MAIN ONE") && find("second") != null, "primary playback started"); - main(() -> { - assertEquals(Renderer.STATE_DISABLED, player.getRenderer(1).getState()); - assertTrue(session.isPrimarySelected(find("main"))); - Track track = new Track(C.TRACK_TYPE_TEXT, "second", null).playerId(find("second").id); - assertTrue(session.selectSecondary(player, track)); - }); - await(() -> text(secondaryCues()).equals("SECOND ONE"), "secondary selected through real merged media periods"); - main(() -> { - player.pause(); - assertTrue(session.isPrimarySelected(find("main"))); - assertTrue(session.isSecondarySelected(find("second"))); - assertEquals("MAIN ONE", text(primaryCues.cues)); - // This is the exact visibility operation performed when the existing ASS surface takes over. - view.getSubtitleView().setVisibility(View.INVISIBLE); - assertEquals(View.VISIBLE, secondaryView().getVisibility()); - assertSame(view.getSubtitleView().getParent(), secondaryView().getParent()); - assertFalse(secondaryView().isFocusable()); - SubtitleView old = secondaryView(); - session.detach(); - assertNull(old.getParent()); - session.attach(view); - assertNotSame(old, secondaryView()); - assertEquals("SECOND ONE", text(secondaryCues())); - player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build()); - }); - await(() -> player.getRenderer(0).getState() == Renderer.STATE_DISABLED, "primary independently disabled"); - main(() -> { - assertEquals("SECOND ONE", text(secondaryCues())); - assertTrue(session.isSecondarySelected(find("second"))); - player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false).build()); - player.seekTo(5500); - player.play(); - }); - await(() -> text(primaryCues.cues).equals("MAIN TWO") && text(secondaryCues()).equals("SECOND TWO"), - "both real renderers follow the same forward seek"); - main(() -> { player.seekTo(1500); player.play(); }); - await(() -> text(primaryCues.cues).equals("MAIN ONE") && text(secondaryCues()).equals("SECOND ONE"), - "both real renderers follow the same backward seek"); - main(() -> assertTrue(session.selectSecondary(player, Track.disabled(C.TRACK_TYPE_TEXT, "off")))); - await(() -> player.getRenderer(1).getState() == Renderer.STATE_DISABLED && secondaryCues().isEmpty(), - "secondary independently disabled"); - main(() -> { - assertEquals("MAIN ONE", text(primaryCues.cues)); - session.reset(); - player.stop(); - assertTrue(secondaryCues().isEmpty()); - }); - } - - private Format find(String id) { - for (Tracks.Group group : player.getCurrentTracks().getGroups()) { - // MergingMediaPeriod prefixes runtime ids with the child source index. - for (int i = 0; i < group.length; i++) if (id.equals(group.getTrackFormat(i).label)) return group.getTrackFormat(i); - } - return null; - } - - private Object field(String name) { - try { - java.lang.reflect.Field field = ExoSubtitleSession.class.getDeclaredField(name); - field.setAccessible(true); - return field.get(session); - } catch (ReflectiveOperationException e) { throw new AssertionError(e); } - } - - @SuppressWarnings("unchecked") private List secondaryCues() { return (List) field("cues"); } - private SubtitleView secondaryView() { return (SubtitleView) field("view"); } - private static String text(List cues) { return cues.isEmpty() ? "" : cues.get(0).text.toString(); } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleRendererTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleRendererTest.java deleted file mode 100644 index d12561f341..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleRendererTest.java +++ /dev/null @@ -1,239 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.os.Handler; -import android.os.Looper; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.TrackGroup; -import androidx.media3.common.TrackSelectionOverride; -import androidx.media3.common.text.Cue; -import androidx.media3.common.text.CueGroup; -import androidx.media3.common.util.Clock; -import androidx.media3.decoder.DecoderInputBuffer; -import androidx.media3.exoplayer.FormatHolder; -import androidx.media3.exoplayer.Renderer; -import androidx.media3.exoplayer.RendererConfiguration; -import androidx.media3.exoplayer.analytics.PlayerId; -import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; -import androidx.media3.exoplayer.source.SampleStream; -import androidx.media3.exoplayer.text.TextOutput; -import androidx.media3.exoplayer.text.TextRenderer; -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; -import androidx.test.platform.app.InstrumentationRegistry; - -import junit.framework.TestCase; - -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; - -/** Real shipped renderers/decoders with independent streams, one renderer clock and queued UI output. */ -@SuppressWarnings("deprecation") -public class DualSubtitleRendererTest extends TestCase { - private static final long OFFSET = 1_000_000_000L; - private static final Format PRIMARY = new Format.Builder().setId("primary") - .setSampleMimeType(MimeTypes.TEXT_SSA).build(); - private static final Format SECONDARY = new Format.Builder().setId("secondary") - .setSampleMimeType(MimeTypes.APPLICATION_SUBRIP).build(); - private static final TrackGroup SECONDARY_GROUP = new TrackGroup("secondary", SECONDARY); - private static final String ASS = "[Script Info]\nPlayResX: 640\nPlayResY: 360\n" - + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,PRIMARY ONE\n" - + "Dialogue: 0,0:00:05.00,0:00:10.00,Default,,0,0,0,,PRIMARY TWO\n"; - private static final String SRT = "1\n00:00:01,000 --> 00:00:03,000\nSECONDARY ONE\n\n" - + "2\n00:00:05,000 --> 00:00:10,000\nSECONDARY TWO\n"; - - private static final class Input implements SampleStream { - final Format format; - final byte[] data; - int phase, reads; - - Input(Format format, String text) { - this.format = format; - data = text.getBytes(StandardCharsets.UTF_8); - } - - @Override public boolean isReady() { return true; } - @Override public void maybeThrowError() { } - @Override public int skipData(long positionUs) { return 0; } - @Override - public int readData(FormatHolder holder, DecoderInputBuffer buffer, int flags) { - if (phase == 0 || (flags & FLAG_REQUIRE_FORMAT) != 0) { - holder.format = format; - phase = 1; - return C.RESULT_FORMAT_READ; - } - buffer.clear(); - if (phase > 1) { - buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); - return C.RESULT_BUFFER_READ; - } - buffer.timeUs = 0; - buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME); - if ((flags & FLAG_OMIT_SAMPLE_DATA) == 0) { - buffer.ensureSpaceForWrite(data.length); - buffer.data.put(data); - } - if ((flags & FLAG_PEEK) == 0) { phase = 2; reads++; } - return C.RESULT_BUFFER_READ; - } - } - - private static final class Output implements TextOutput, TextRenderer.Observer { - CueGroup cues = CueGroup.EMPTY_TIME_ZERO; - int samples; - @Override public void onCues(CueGroup cues) { this.cues = cues; } - @Override public void onStream(TextRenderer.Stream stream, long generation) { } - @Override public void onReset(TextRenderer.Stream stream, long generation, long position) { } - @Override public void onSample(TextRenderer.Stream stream, long generation, Format format, ByteBuffer data, long timeUs) { - assertEquals(PRIMARY, format); - samples++; - } - @Override public void onClock(TextRenderer.Stream reading, TextRenderer.Stream displaying, long generation, - long position, long offset, boolean started, boolean ended) { } - @Override public void onCues(TextRenderer.Stream stream, long generation, CueGroup cues) { } - @Override public void onDisabled(long generation) { } - } - - private final class Rig implements AutoCloseable { - final ExoSubtitleSession session = new ExoSubtitleSession(); - final DualSubtitleTrackSelector selector = (DualSubtitleTrackSelector) session.wrapTrackSelector( - new DefaultTrackSelector(InstrumentationRegistry.getInstrumentation().getTargetContext())); - final Output output = new Output(); - final TextRenderer primary = new TextRenderer(output, null); - final TextRenderer secondary; - final Input primaryInput = new Input(PRIMARY, ASS); - final Input secondaryInput = new Input(SECONDARY, SRT); - - Rig() { - primary.setObserver(output); - Renderer[] renderers = session.wrapRenderersFactory((handler, video, audio, text, metadata) -> - new Renderer[]{primary}).createRenderers(new Handler(Looper.getMainLooper()), null, null, output, null); - assertSame(primary, renderers[0]); - secondary = (TextRenderer) renderers[1]; - primary.init(0, PlayerId.UNSET, Clock.DEFAULT); - secondary.init(1, PlayerId.UNSET, Clock.DEFAULT); - } - - void start() throws Exception { - selector.setSecondaryOverride(new TrackSelectionOverride(SECONDARY_GROUP, 0)); - enable(primary, primaryInput, "a"); - enable(secondary, secondaryInput, "a"); - } - - void seek(long positionUs) throws Exception { - primaryInput.phase = secondaryInput.phase = 1; - primary.resetPosition(OFFSET + positionUs, false); - secondary.resetPosition(OFFSET + positionUs, false); - } - - @Override public void close() { - release(primary); - release(secondary); - main(session::release); - selector.release(); - } - } - - private static void enable(TextRenderer renderer, Input input, String period) throws Exception { - renderer.enable(RendererConfiguration.DEFAULT, new Format[]{input.format}, input, - OFFSET, false, true, OFFSET, OFFSET, new MediaPeriodId(period)); - renderer.start(); - } - - private static void release(TextRenderer renderer) { - if (renderer.getState() == Renderer.STATE_STARTED) renderer.stop(); - if (renderer.getState() == Renderer.STATE_ENABLED) renderer.disable(); - renderer.reset(); - renderer.release(); - } - - private static void main(Runnable action) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(action); - } - - @SuppressWarnings("unchecked") - private static List cues(ExoSubtitleSession session) { - AtomicReference> result = new AtomicReference<>(); - main(() -> { - try { - java.lang.reflect.Field field = ExoSubtitleSession.class.getDeclaredField("cues"); - field.setAccessible(true); - result.set((List) field.get(session)); - } catch (ReflectiveOperationException e) { throw new AssertionError(e); } - }); - return result.get(); - } - - private static String text(List cues) { - return cues.isEmpty() ? "" : cues.get(0).text.toString(); - } - - private static void await(Rig rig, long positionUs, String expectedPrimary, String expectedSecondary) throws Exception { - long deadline = System.nanoTime() + 2_000_000_000L; - do { - rig.primary.render(OFFSET + positionUs, 0); - rig.secondary.render(OFFSET + positionUs, 0); - if (expectedPrimary.equals(text(rig.output.cues.cues)) - && expectedSecondary.equals(text(cues(rig.session)))) return; - Thread.sleep(5); - } while (System.nanoTime() < deadline); - fail("Expected " + expectedPrimary + " / " + expectedSecondary + ", got " - + text(rig.output.cues.cues) + " / " + text(cues(rig.session))); - } - - public void testRealRenderersKeepOutputsIndependentAcrossPauseOffsetAndSeek() throws Exception { - try (Rig rig = new Rig()) { - assertEquals(Renderer.STATE_DISABLED, rig.secondary.getState()); - assertEquals(0, rig.secondaryInput.reads); - rig.start(); - await(rig, 1_500_000, "PRIMARY ONE", "SECONDARY ONE"); - assertEquals(1, rig.output.samples); - assertEquals(1, rig.secondaryInput.reads); - assertEquals(Cue.ANCHOR_TYPE_START, cues(rig.session).get(0).lineAnchor); - rig.primary.stop(); - rig.secondary.stop(); - rig.primary.setTextOffsetMs(2000); - rig.secondary.setTextOffsetMs(2000); - assertTrue(rig.output.cues.cues.isEmpty()); - assertTrue(cues(rig.session).isEmpty()); - rig.primary.setTextOffsetMs(0); - rig.secondary.setTextOffsetMs(0); - assertEquals("PRIMARY ONE", text(rig.output.cues.cues)); - assertEquals("SECONDARY ONE", text(cues(rig.session))); - rig.primary.start(); - rig.secondary.start(); - rig.seek(5_500_000); - await(rig, 5_500_000, "PRIMARY TWO", "SECONDARY TWO"); - rig.seek(1_500_000); - await(rig, 1_500_000, "PRIMARY ONE", "SECONDARY ONE"); - rig.secondary.stop(); - rig.secondary.disable(); - assertTrue(cues(rig.session).isEmpty()); - assertEquals("PRIMARY ONE", text(rig.output.cues.cues)); - } - } - - public void testSourceResetRejectsOldStreamEvenWhenTheNewFormatIsIdentical() throws Exception { - try (Rig rig = new Rig()) { - rig.start(); - await(rig, 1_500_000, "PRIMARY ONE", "SECONDARY ONE"); - main(() -> { - rig.session.reset(); - rig.selector.setSecondaryOverride(new TrackSelectionOverride(SECONDARY_GROUP, 0)); - }); - rig.secondary.setTextOffsetMs(100); - assertTrue("An old source must not repopulate the new session", cues(rig.session).isEmpty()); - rig.secondary.stop(); - rig.secondary.disable(); - enable(rig.secondary, new Input(SECONDARY, SRT), "b"); - await(rig, 1_500_000, "PRIMARY ONE", "SECONDARY ONE"); - main(rig.session::release); - rig.secondary.setTextOffsetMs(200); - assertTrue("Released sessions must ignore renderer output", cues(rig.session).isEmpty()); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleTrackSelectorTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleTrackSelectorTest.java deleted file mode 100644 index b73cce7760..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleTrackSelectorTest.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.content.Context; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.Timeline; -import androidx.media3.common.TrackGroup; -import androidx.media3.common.TrackSelectionOverride; -import androidx.media3.exoplayer.RendererCapabilities; -import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; -import androidx.media3.exoplayer.source.TrackGroupArray; -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; -import androidx.media3.exoplayer.trackselection.TrackSelectorResult; -import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter; -import androidx.test.platform.app.InstrumentationRegistry; - -import junit.framework.TestCase; - -import java.util.Arrays; - -/** Runs the shipped Media3 selector, including its parameter overrides and tunneling decisions. */ -public class DualSubtitleTrackSelectorTest extends TestCase { - private DefaultTrackSelector delegate; - private DualSubtitleTrackSelector selector; - private TrackGroup video, audio, primary, secondary; - private TrackGroupArray groups; - private RendererCapabilities[] capabilities; - private int invalidations; - - @Override - protected void setUp() throws Exception { - super.setUp(); - Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - video = new TrackGroup("video", new Format.Builder().setId("v").setSampleMimeType(MimeTypes.VIDEO_H264) - .setWidth(1280).setHeight(720).setFrameRate(24).build()); - audio = new TrackGroup("audio", new Format.Builder().setId("a").setSampleMimeType(MimeTypes.AUDIO_AAC) - .setChannelCount(2).setSampleRate(48000).build()); - primary = new TrackGroup("english", text("en", "en")); - secondary = new TrackGroup("chinese", text("zh", "zh")); - groups = new TrackGroupArray(video, audio, primary, secondary); - capabilities = new RendererCapabilities[]{new Capabilities(C.TRACK_TYPE_VIDEO), - new Capabilities(C.TRACK_TYPE_AUDIO), new Capabilities(C.TRACK_TYPE_TEXT), - new Capabilities(C.TRACK_TYPE_TEXT)}; - delegate = new DefaultTrackSelector(context); - delegate.setParameters(delegate.buildUponParameters() - .setConstrainAudioChannelCountToDeviceCapabilities(false).setTunnelingEnabled(true) - .setOverrideForType(new TrackSelectionOverride(primary, 0)).build()); - selector = new DualSubtitleTrackSelector(delegate); - selector.setSecondaryRendererIndex(3); - selector.init(() -> invalidations++, DefaultBandwidthMeter.getSingletonInstance(context)); - } - - @Override - protected void tearDown() throws Exception { - if (selector != null) selector.release(); - super.tearDown(); - } - - private static Format text(String id, String language) { - return new Format.Builder().setId(id).setLanguage(language).setSampleMimeType(MimeTypes.TEXT_VTT).build(); - } - - private TrackSelectorResult select() throws Exception { - return selector.selectTracks(capabilities, groups, new MediaPeriodId("period"), Timeline.EMPTY); - } - - private TrackSelectorResult activate() throws Exception { - TrackSelectorResult result = select(); - selector.onSelectionActivated(result.info); - return result; - } - - public void testDefaultOffPreservesOriginalSelectionsAndTunneling() throws Exception { - TrackSelectorResult original = delegate.selectTracks(Arrays.copyOf(capabilities, 3), groups, - new MediaPeriodId("period"), Timeline.EMPTY); - TrackSelectorResult result = activate(); - for (int i = 0; i < 3; i++) assertTrue("Original renderer " + i, result.isEquivalent(original, i)); - assertNull(result.selections[3]); - assertNull(result.rendererConfigurations[3]); - assertEquals(4, result.tracks.getGroups().size()); - assertTrue(selector.isPrimarySelected(primary.getFormat(0))); - assertFalse(selector.isSecondarySelected(secondary.getFormat(0))); - assertSame(delegate.getParameters(), selector.getParameters()); - assertSame(delegate.getRendererCapabilitiesListener(), selector.getRendererCapabilitiesListener()); - } - - public void testSecondaryHasIndependentSelectionAndOnlyPublishesWhenActivated() throws Exception { - TrackSelectorResult before = activate(); - assertTrue(selector.setSecondaryOverride(new TrackSelectionOverride(secondary, 0))); - assertEquals(1, invalidations); - TrackSelectorResult result = select(); - assertFalse("Read-ahead is not active playback", selector.isSecondarySelected(secondary.getFormat(0))); - for (int i = 0; i < 3; i++) assertTrue(result.isEquivalent(before, i)); - assertEquals(secondary, result.selections[3].getTrackGroup()); - assertEquals(1, result.selections[3].length()); - assertEquals(2, result.tracks.getGroups().stream() - .filter(group -> group.getType() == C.TRACK_TYPE_TEXT && group.isSelected()).count()); - selector.onSelectionActivated(result.info); - assertTrue(selector.isPrimarySelected(primary.getFormat(0))); - assertFalse(selector.isPrimarySelected(secondary.getFormat(0))); - assertTrue(selector.isSecondarySelected(secondary.getFormat(0))); - } - - public void testPrimaryAndSecondaryDisableIndependently() throws Exception { - TrackSelectorResult before = activate(); - selector.setSecondaryOverride(new TrackSelectionOverride(secondary, 0)); - activate(); - selector.setParameters(delegate.buildUponParameters().setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build()); - TrackSelectorResult primaryOff = activate(); - assertNull(primaryOff.selections[2]); - assertEquals(secondary, primaryOff.selections[3].getTrackGroup()); - assertFalse(selector.isPrimarySelected(primary.getFormat(0))); - assertTrue(selector.isSecondarySelected(secondary.getFormat(0))); - assertTrue(primaryOff.isEquivalent(before, 0)); - assertTrue(primaryOff.isEquivalent(before, 1)); - selector.setParameters(delegate.buildUponParameters().setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false).build()); - selector.setSecondaryOverride(null); - TrackSelectorResult secondaryOff = activate(); - assertEquals(primary, secondaryOff.selections[2].getTrackGroup()); - assertNull(secondaryOff.selections[3]); - assertTrue(selector.isPrimarySelected(primary.getFormat(0))); - } - - public void testDuplicateRoleRequestDoesNotDisturbEitherSelection() throws Exception { - activate(); - selector.setSecondaryOverride(new TrackSelectionOverride(secondary, 0)); - TrackSelectorResult before = activate(); - assertFalse(selector.setSecondaryOverride(new TrackSelectionOverride(primary, 0))); - assertTrue(activate().isEquivalent(before)); - } - - public void testTwoVariantsOfOneGroupCannotBindTheSameSampleQueueTwice() throws Exception { - TrackGroup variants = new TrackGroup("variants", text("en-1", "en"), text("en-2", "en")); - groups = new TrackGroupArray(video, audio, variants, secondary); - delegate.setParameters(delegate.buildUponParameters().setOverrideForType(new TrackSelectionOverride(variants, 0)).build()); - activate(); - assertFalse(selector.setSecondaryOverride(new TrackSelectionOverride(variants, 1))); - assertNull(activate().selections[3]); - } - - public void testResetRejectsReadAheadActivationAndOldOutputTokens() throws Exception { - activate(); - selector.setSecondaryOverride(new TrackSelectionOverride(secondary, 0)); - TrackSelectorResult old = select(); - DualSubtitleTrackSelector.Request token = selector.request(); - assertTrue(token.accepts(secondary.getFormat(0))); - selector.reset(); - selector.onSelectionActivated(old.info); - assertNotSame(token, selector.request()); - assertFalse(selector.isPrimarySelected(primary.getFormat(0))); - assertFalse(selector.isSecondarySelected(secondary.getFormat(0))); - assertNull(select().selections[3]); - } - - public void testUnavailableOrDrmSubtitleDoesNotAffectAudioVideo() throws Exception { - TrackSelectorResult before = activate(); - TrackGroup missing = new TrackGroup("missing", text("missing", "fr")); - selector.setSecondaryOverride(new TrackSelectionOverride(missing, 0)); - assertNull(select().selections[3]); - TrackGroup encrypted = new TrackGroup("encrypted", text("drm", "fr").buildUpon() - .setCryptoType(C.CRYPTO_TYPE_FRAMEWORK).build()); - groups = new TrackGroupArray(video, audio, primary, encrypted); - selector.setSecondaryOverride(new TrackSelectionOverride(encrypted, 0)); - TrackSelectorResult result = activate(); - assertNull(result.selections[3]); - assertTrue(result.isEquivalent(before, 0)); - assertTrue(result.isEquivalent(before, 1)); - } - - private static final class Capabilities implements RendererCapabilities { - private final int type; - Capabilities(int type) { this.type = type; } - @Override public String getName() { return "fixture-" + type; } - @Override public int getTrackType() { return type; } - @Override public int supportsMixedMimeTypeAdaptation() { return ADAPTIVE_NOT_SUPPORTED; } - @Override public int supportsFormat(Format format) { - if (MimeTypes.getTrackType(format.sampleMimeType) != type) return RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE); - return RendererCapabilities.create(format.cryptoType == C.CRYPTO_TYPE_NONE ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_DRM, - ADAPTIVE_NOT_SUPPORTED, type == C.TRACK_TYPE_TEXT ? TUNNELING_NOT_SUPPORTED : TUNNELING_SUPPORTED); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/SecondarySubtitleCuesTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/SecondarySubtitleCuesTest.java deleted file mode 100644 index 17132169ee..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/SecondarySubtitleCuesTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.graphics.Bitmap; -import android.graphics.Color; -import android.graphics.Typeface; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; - -import androidx.media3.common.text.Cue; - -import junit.framework.TestCase; - -import java.util.Collections; -import java.util.List; - -public class SecondarySubtitleCuesTest extends TestCase { - public void testTextIsStackedAtTopWithoutChangingPrimaryStylesOrBidiText() { - SpannableString text = new SpannableString("中文 English\nالعربية"); - text.setSpan(new ForegroundColorSpan(Color.RED), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - text.setSpan(new StyleSpan(Typeface.BOLD), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - Cue original = new Cue.Builder().setText(text).setLine(0.9f, Cue.LINE_TYPE_FRACTION) - .setLineAnchor(Cue.ANCHOR_TYPE_END).setTextSize(0.2f, Cue.TEXT_SIZE_TYPE_FRACTIONAL).build(); - List result = SecondarySubtitleCues.atTop(List.of(original, new Cue.Builder().setText("second line").build())); - assertEquals(1, result.size()); - Cue secondary = result.get(0); - assertEquals("中文 English\nالعربية\nsecond line", secondary.text.toString()); - assertFalse(secondary.text instanceof Spanned); - assertEquals(Cue.ANCHOR_TYPE_START, secondary.lineAnchor); - assertTrue(secondary.line < 0.1f); - assertEquals(Cue.DIMEN_UNSET, secondary.textSize, 0f); - assertTrue(original.text instanceof Spanned); - assertEquals(2, ((Spanned) original.text).getSpans(0, 2, Object.class).length); - assertEquals(0.9f, original.line, 0.0001f); - } - - public void testBitmapRegionsKeepPixelsAndRelativeLayoutWhenMovedToTop() { - Bitmap bitmap = Bitmap.createBitmap(8, 2, Bitmap.Config.ARGB_8888); - try { - Cue lower = new Cue.Builder().setBitmap(bitmap).setPosition(0.2f).setSize(0.25f).setBitmapHeight(0.1f) - .setLine(0.9f, Cue.LINE_TYPE_FRACTION).setLineAnchor(Cue.ANCHOR_TYPE_END).build(); - Cue upper = lower.buildUpon().setPosition(0.6f).setBitmapHeight(0.04f) - .setLine(0.85f, Cue.LINE_TYPE_FRACTION).setLineAnchor(Cue.ANCHOR_TYPE_START).build(); - List result = SecondarySubtitleCues.atTop(List.of(lower, upper)); - assertEquals(2, result.size()); - assertSame(bitmap, result.get(0).bitmap); - assertSame(bitmap, result.get(1).bitmap); - assertEquals(0.08f, result.get(0).line, 0.0001f); - assertEquals(0.13f, result.get(1).line, 0.0001f); - assertEquals(0.2f, result.get(0).position, 0.0001f); - assertEquals(0.6f, result.get(1).position, 0.0001f); - assertEquals(lower.size, result.get(0).size, 0f); - assertEquals(lower.bitmapHeight, result.get(0).bitmapHeight, 0f); - } finally { - bitmap.recycle(); - } - } - - public void testEmptyOutputClearsRatherThanCreatingAnEmptyTextBox() { - assertTrue(SecondarySubtitleCues.atTop(Collections.emptyList()).isEmpty()); - assertTrue(SecondarySubtitleCues.atTop(List.of(new Cue.Builder().setText("").build())).isEmpty()); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16KeylessSourceDetailDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16KeylessSourceDetailDeviceTest.java deleted file mode 100644 index 90bba2d8bd..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16KeylessSourceDetailDeviceTest.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.Intent; -import android.os.SystemClock; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.impl.Callback; -import com.fongmi.android.tv.setting.Setting; - -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -@RunWith(AndroidJUnit4.class) -public class C16KeylessSourceDetailDeviceTest { - - private static final String FIXTURE_URL = "http://127.0.0.1:18080/config.json"; - private String previousTmdbConfig; - private int previousDetailMode; - private String previousConfigUrl; - private int previousConfigId; - private boolean fixtureLoaded; - - @Before - public void loadKeylessFixture() throws Exception { - Assume.assumeTrue("C16 keyless fixture is not enabled", - "true".equals(InstrumentationRegistry.getArguments().getString("c16KeylessFixture"))); - previousTmdbConfig = Setting.getTmdbConfig(); - previousDetailMode = Setting.getDetailOpenMode(); - previousConfigUrl = Config.vod().getUrl(); - previousConfigId = Config.vod().getId(); - fixtureLoaded = true; - Setting.putTmdbConfig(""); - - Config fixture = Config.find(FIXTURE_URL, 0).url(FIXTURE_URL).name("C16 keyless fixture"); - reload(fixture, true); - assertFalse("fixture site did not load", VodConfig.get().getSite("c16_keyless_complete").isEmpty()); - } - - @After - public void restoreConfiguration() throws Exception { - if (!fixtureLoaded) return; - Setting.putTmdbConfig(previousTmdbConfig); - Setting.putDetailOpenMode(previousDetailMode); - if (previousConfigUrl == null || previousConfigUrl.isBlank()) return; - Config previous = Config.find(previousConfigUrl, 0); - if (previousConfigId > 0) previous.setId(previousConfigId); - reload(previous, false); - } - - @Test - public void completePayloadRendersStandaloneDetailWithoutKey() { - Setting.putDetailOpenMode(Setting.DETAIL_OPEN_ENHANCED); - assertTextEventually( - TmdbDetailActivity.class, - "c16_keyless_complete", - "C16 Keyless Complete", - List.of("Keyless Complete TMDB", "Embedded keyless overview", "Keyless Actor"), - true); - } - - @Test - public void completePayloadRendersNativeEnhancedDetailWithoutKey() { - Setting.putDetailOpenMode(Setting.DETAIL_OPEN_ORIGINAL_ENHANCED); - assertTextEventually( - VideoActivity.class, - "c16_keyless_complete", - "C16 Keyless Complete", - List.of("Keyless Complete TMDB", "Embedded keyless overview", "Keyless Actor"), - true); - } - - @Test - public void legacyPayloadFallsBackToNativeWithoutKeyPrompt() { - Setting.putDetailOpenMode(Setting.DETAIL_OPEN_ENHANCED); - assertTextEventually( - VideoActivity.class, - "c16_keyless_legacy", - "Fight Club", - List.of("Fight Club", "line-a", "正片"), - false); - } - - private static void reload(Config config, boolean required) throws Exception { - CountDownLatch latch = new CountDownLatch(1); - VodConfig.selectConfig(config.save(), new Callback() { - @Override - public void success() { - latch.countDown(); - } - - @Override - public void error(String msg) { - latch.countDown(); - } - }); - if (!latch.await(15, TimeUnit.SECONDS) && required) throw new AssertionError("fixture config timeout"); - } - - private static void assertTextEventually(Class activityClass, String key, String name, - List expectedTexts, boolean sourceTmdb) { - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, activityClass) - .putExtra("detail_mode", sourceTmdb ? Setting.getDetailOpenMode() : Setting.DETAIL_OPEN_ENHANCED) - .putExtra("fusion", false) - .putExtra("auto_play", false) - .putExtra("key", key) - .putExtra("id", key.replace('_', '-') + "-1") - .putExtra("name", name) - .putExtra("pic", "") - .putExtra("mark", ""); - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - long deadline = SystemClock.uptimeMillis() + 20_000L; - List texts = new ArrayList<>(); - while (SystemClock.uptimeMillis() < deadline) { - scenario.onActivity(activity -> { - texts.clear(); - collectText(activity.findViewById(android.R.id.content), texts); - }); - if (containsAll(texts, expectedTexts)) { - assertFalse("source-only page must not show a TMDB key prompt", contains(texts, "请先配置")); - return; - } - SystemClock.sleep(250L); - } - assertTrue("missing UI text for " + key + "; visible=" + texts, containsAll(texts, expectedTexts)); - } - } - - private static boolean containsAll(List texts, List expectedTexts) { - for (String expected : expectedTexts) if (!contains(texts, expected)) return false; - return true; - } - - private static boolean contains(List texts, String expected) { - return texts.stream().anyMatch(text -> text != null && text.contains(expected)); - } - - private static void collectText(View view, List texts) { - if (view == null || view.getVisibility() != View.VISIBLE) return; - if (view instanceof TextView textView) { - CharSequence text = textView.getText(); - if (text != null && !text.toString().isBlank()) texts.add(text.toString()); - } - if (!(view instanceof ViewGroup group)) return; - for (int i = 0; i < group.getChildCount(); i++) collectText(group.getChildAt(i), texts); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16SubscriptionCredentialDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16SubscriptionCredentialDeviceTest.java deleted file mode 100644 index 6fcb6bb015..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16SubscriptionCredentialDeviceTest.java +++ /dev/null @@ -1,230 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.Intent; -import android.os.SystemClock; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.api.SiteApi; -import com.fongmi.android.tv.api.config.SubscriptionTmdbCredentialStore; -import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.impl.Callback; -import com.fongmi.android.tv.setting.Setting; - -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.File; -import java.io.FileInputStream; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -@RunWith(AndroidJUnit4.class) -public class C16SubscriptionCredentialDeviceTest { - - private static final String FIXTURE_URL = "http://127.0.0.1:18081/config.json"; - private static final String NO_KEY_URL = "http://127.0.0.1:18081/no-key-config.json"; - private static final String INVALID_KEY_URL = "http://127.0.0.1:18081/invalid-key-config.json"; - private String previousTmdbConfig; - private int previousDetailMode; - private String previousConfigUrl; - private int previousConfigId; - private boolean fixtureLoaded; - - @Before - public void loadSubscriptionFixture() throws Exception { - Assume.assumeTrue("C16 subscription credential fixture is not enabled", - "true".equals(InstrumentationRegistry.getArguments().getString("c16SubscriptionCredentialFixture"))); - previousTmdbConfig = Setting.getTmdbConfig(); - previousDetailMode = Setting.getDetailOpenMode(); - previousConfigUrl = Config.vod().getUrl(); - previousConfigId = Config.vod().getId(); - fixtureLoaded = true; - Setting.putTmdbConfig(""); - Setting.putDetailOpenMode(Setting.DETAIL_OPEN_ENHANCED); - SubscriptionTmdbCredentialStore.clear(); - reload(Config.find(FIXTURE_URL, 0).url(FIXTURE_URL).name("C16 subscription credential fixture"), true); - assertFalse("fixture site did not load", VodConfig.get().getSite("c16_subscription_complete").isEmpty()); - assertTrue("subscription config root key was not accepted", - SubscriptionTmdbCredentialStore.snapshot(SubscriptionTmdbCredentialStore.currentScope()).isPresent()); - } - - @After - public void restoreConfiguration() throws Exception { - assertTestKeyNotPersisted(ApplicationProvider.getApplicationContext()); - SubscriptionTmdbCredentialStore.clear(); - if (!fixtureLoaded) return; - Setting.putTmdbConfig(previousTmdbConfig); - Setting.putDetailOpenMode(previousDetailMode); - if (previousConfigUrl == null || previousConfigUrl.isBlank()) return; - Config previous = Config.find(previousConfigUrl, 0); - if (previousConfigId > 0) previous.setId(previousConfigId); - reload(previous, true); - } - - @Test - public void subscriptionConfigRootKeyIsAcceptedAndRendersSourceData() throws Exception { - Result direct = SiteApi.detailContent("c16_subscription_complete", "c16-subscription-complete-1", true); - assertFalse(direct.getList().isEmpty()); - assertTrue("subscription config root key was not accepted", - SubscriptionTmdbCredentialStore.snapshot(SubscriptionTmdbCredentialStore.currentScope()).isPresent()); - assertDetailTextEventually("c16_subscription_complete", "C16 Subscription Complete", - List.of("Embedded subscription complete", "Subscription Complete Actor")); - } - - @Test - public void partialSourceUsesSubscriptionConfigRootKeyAndFallsBackWithoutBreakingUi() { - assertDetailTextEventually("c16_subscription_partial", "C16 Subscription Partial", - List.of("Source partial overview", "C16 Subscription Partial")); - } - - @Test - public void sameSubscriptionReusesKeyAndSwitchBackReacquiresFromConfig() throws Exception { - Result first = SiteApi.detailContent("c16_subscription_complete", "c16-subscription-complete-1", true); - assertFalse(first.getList().isEmpty()); - SubscriptionTmdbCredentialStore.Scope firstScope = SubscriptionTmdbCredentialStore.currentScope(); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(firstScope).isPresent()); - - reload(Config.find(FIXTURE_URL, 0).url(FIXTURE_URL).name("C16 subscription credential fixture"), true); - SubscriptionTmdbCredentialStore.Scope reloaded = SubscriptionTmdbCredentialStore.currentScope(); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(reloaded).isPresent()); - - reload(Config.find(NO_KEY_URL, 0).url(NO_KEY_URL).name("C16 no-key fixture"), true); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(SubscriptionTmdbCredentialStore.currentScope()).isEmpty()); - - reload(Config.find(FIXTURE_URL, 0).url(FIXTURE_URL).name("C16 subscription credential fixture"), true); - SubscriptionTmdbCredentialStore.Scope returned = SubscriptionTmdbCredentialStore.currentScope(); - assertNotEquals(firstScope.getEpoch(), returned.getEpoch()); - assertTrue("switching back did not reacquire the key from the new config response", - SubscriptionTmdbCredentialStore.snapshot(returned).isPresent()); - } - - @Test - public void userConfiguredCredentialWinsAndSubscriptionRootKeyIsNotStored() throws Exception { - Setting.putTmdbConfig("{\"apiKey\":\"user-key-0123456789abcdef\"}"); - SubscriptionTmdbCredentialStore.clear(); - reload(Config.find(FIXTURE_URL, 0).url(FIXTURE_URL).name("C16 subscription credential fixture"), true); - - Result result = SiteApi.detailContent("c16_subscription_complete", "c16-subscription-complete-1", true); - - assertFalse(result.getList().isEmpty()); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(SubscriptionTmdbCredentialStore.currentScope()).isEmpty()); - } - - @Test - public void invalidRootKeyDoesNotPopulateSubscriptionScope() throws Exception { - reload(Config.find(INVALID_KEY_URL, 0).url(INVALID_KEY_URL).name("C16 invalid key fixture"), true); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(SubscriptionTmdbCredentialStore.currentScope()).isEmpty()); - } - - private static void assertDetailTextEventually(String key, String name, List expectedTexts) { - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, TmdbDetailActivity.class) - .putExtra("detail_mode", Setting.DETAIL_OPEN_ENHANCED) - .putExtra("fusion", false) - .putExtra("auto_play", false) - .putExtra("key", key) - .putExtra("id", key.replace('_', '-') + "-1") - .putExtra("name", name) - .putExtra("pic", "") - .putExtra("mark", ""); - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - long deadline = SystemClock.uptimeMillis() + 20_000L; - List texts = new ArrayList<>(); - while (SystemClock.uptimeMillis() < deadline) { - scenario.onActivity(activity -> { - texts.clear(); - collectText(activity.findViewById(android.R.id.content), texts); - }); - if (containsAll(texts, expectedTexts)) { - assertFalse("temporary credential path must not show a TMDB key prompt", contains(texts, "请先配置")); - return; - } - SystemClock.sleep(250L); - } - assertTrue("missing UI text for " + key + "; visible=" + texts, containsAll(texts, expectedTexts)); - } - } - - private static void reload(Config config, boolean required) throws Exception { - CountDownLatch latch = new CountDownLatch(1); - VodConfig.selectConfig(config.save(), new Callback() { - @Override - public void success() { - latch.countDown(); - } - - @Override - public void error(String msg) { - latch.countDown(); - } - }); - if (!latch.await(15, TimeUnit.SECONDS) && required) throw new AssertionError("fixture config timeout"); - } - - private static void assertTestKeyNotPersisted(Context context) throws Exception { - List roots = new ArrayList<>(); - roots.add(context.getDataDir()); - File external = context.getExternalFilesDir(null); - if (external != null) roots.add(external); - for (File root : roots) scanForTestKey(root); - } - - private static void scanForTestKey(File file) throws Exception { - if (file == null || !file.exists()) return; - if (file.isDirectory()) { - File[] children = file.listFiles(); - if (children != null) for (File child : children) scanForTestKey(child); - return; - } - if (file.length() <= 0 || file.length() > 16L * 1024L * 1024L) return; - byte[] data = new byte[(int) file.length()]; - try (FileInputStream input = new FileInputStream(file)) { - int offset = 0; - while (offset < data.length) { - int read = input.read(data, offset, data.length - offset); - if (read < 0) break; - offset += read; - } - String text = new String(data, 0, offset, java.nio.charset.StandardCharsets.ISO_8859_1); - assertFalse("persistent test key found in " + file.getAbsolutePath(), text.contains("0123456789abcdef0123456789abcdef")); - } - } - - private static boolean containsAll(List texts, List expectedTexts) { - for (String expected : expectedTexts) if (!contains(texts, expected)) return false; - return true; - } - - private static boolean contains(List texts, String expected) { - return texts.stream().anyMatch(text -> text != null && text.contains(expected)); - } - - private static void collectText(View view, List texts) { - if (view == null || view.getVisibility() != View.VISIBLE) return; - if (view instanceof TextView textView) { - CharSequence text = textView.getText(); - if (text != null && !text.toString().isBlank()) texts.add(text.toString()); - } - if (!(view instanceof ViewGroup group)) return; - for (int i = 0; i < group.getChildCount(); i++) collectText(group.getChildAt(i), texts); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16TmdbSourceDetailDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16TmdbSourceDetailDeviceTest.java deleted file mode 100644 index 3f54e9bb13..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16TmdbSourceDetailDeviceTest.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.Intent; -import android.os.SystemClock; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.setting.Setting; - -import org.junit.Assume; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.ArrayList; -import java.util.List; - -@RunWith(AndroidJUnit4.class) -public class C16TmdbSourceDetailDeviceTest { - - @Before - public void loadFixtureSource() { - Assume.assumeTrue("C16 fixture source is not configured", isFixtureConfigured()); - VodConfig.get().ensureLoaded(); - long deadline = SystemClock.uptimeMillis() + 15_000L; - while (SystemClock.uptimeMillis() < deadline) { - if (!VodConfig.get().getSite("c16_t4_complete").isEmpty()) return; - SystemClock.sleep(250L); - } - assertTrue("fixture VOD config did not load", !VodConfig.get().getSite("c16_t4_complete").isEmpty()); - } - - private static boolean isFixtureConfigured() { - return "http://127.0.0.1:18080/config.json".equals(Config.vod().getUrl()); - } - - @Test - public void completeT4RendersEmbeddedCoreCreditsAndImages() { - assertDetailEventually( - "c16_t4_complete", - "c16-t4-complete-1", - "C16 Complete T4", - List.of("C16 Complete T4", "Embedded complete overview", "Embedded Actor") - ); - } - - @Test - public void partialT4KeepsSourceFieldsWhileTmdbFillsGaps() { - assertDetailEventually( - "c16_t4_partial", - "c16-t4-partial-1", - "C16 Partial T4", - List.of("C16 Partial T4", "Source partial overview must win") - ); - } - - @Test - public void legacySourceWithoutTmdbStillRendersOrdinaryDetail() { - assertDetailEventually( - "c16_legacy", - "c16-legacy-1", - "Fight Club", - List.of("Fight Club", "line-a", "正片") - ); - } - - @Test - public void completeT3RendersEmbeddedCoreCreditsAndImages() { - assertDetailEventually( - "c16_t3_complete", - "c16-t3-complete-1", - "C16 Complete T4", - List.of("C16 Complete T4", "Embedded complete overview", "Embedded Actor") - ); - } - - @Test - public void completeTvUsesEmbeddedSeasonEpisodeAndVideoCapabilities() { - assertDetailEventually( - "c16_t4_tv_complete", - "c16-t4-tv-1", - "C16 Complete TV", - List.of("C16 Complete TV", "Embedded complete TV overview", "Embedded Episode 1") - ); - } - - private static void assertDetailEventually(String key, String id, String name, List expectedTexts) { - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, TmdbDetailActivity.class) - .putExtra("detail_mode", Setting.DETAIL_OPEN_ENHANCED) - .putExtra("fusion", false) - .putExtra("auto_play", false) - .putExtra("key", key) - .putExtra("id", id) - .putExtra("name", name) - .putExtra("pic", "") - .putExtra("mark", ""); - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - long deadline = SystemClock.uptimeMillis() + 20_000L; - List texts = new ArrayList<>(); - while (SystemClock.uptimeMillis() < deadline) { - scenario.onActivity(activity -> { - texts.clear(); - collectText(activity.findViewById(android.R.id.content), texts); - }); - if (containsAll(texts, expectedTexts)) return; - SystemClock.sleep(250L); - } - assertTrue("missing UI text for " + key + "; visible=" + texts, containsAll(texts, expectedTexts)); - } - } - - private static boolean containsAll(List texts, List expectedTexts) { - for (String expected : expectedTexts) { - boolean found = texts.stream().anyMatch(text -> text != null && text.contains(expected)); - if (!found) return false; - } - return true; - } - - private static void collectText(View view, List texts) { - if (view == null || view.getVisibility() != View.VISIBLE) return; - if (view instanceof TextView textView) { - CharSequence text = textView.getText(); - if (text != null && !text.toString().isBlank()) texts.add(text.toString()); - } - if (!(view instanceof ViewGroup group)) return; - for (int i = 0; i < group.getChildCount(); i++) collectText(group.getChildAt(i), texts); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/LiveSourceFallbackInstrumentedTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/LiveSourceFallbackInstrumentedTest.java deleted file mode 100644 index fbcf301629..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/LiveSourceFallbackInstrumentedTest.java +++ /dev/null @@ -1,234 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import android.content.Context; -import android.content.Intent; -import android.os.SystemClock; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.api.config.LiveConfig; -import com.fongmi.android.tv.bean.Live; -import com.fongmi.android.tv.setting.LiveSetting; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; - -@RunWith(AndroidJUnit4.class) -public class LiveSourceFallbackInstrumentedTest { - - @Test - public void unparsedMiddleSourceDoesNotSkipToLastBeforeParseCompletes() throws Exception { - boolean originalFallback = LiveSetting.isSourceFallback(); - List originalLives = new ArrayList<>(LiveConfig.get().getLives()); - Live originalHome = LiveConfig.get().getHome(); - LiveSetting.putSourceFallback(true); - try (PlaylistServer server = new PlaylistServer()) { - Live first = new Live("instrumented-first", server.url()); - Live middle = new Live("instrumented-middle", server.url()); - Live last = new Live("instrumented-last", server.url()); - setCustomLives(List.of(first, middle, last), first); - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, LiveActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - waitForService(scenario); - waitUntil(() -> !first.getGroups().isEmpty(), 15_000); - - scenario.onActivity(activity -> activity.setLive(middle)); - - waitUntil(() -> !middle.getGroups().isEmpty(), 15_000); - assertFalse("the selected middle source should parse successfully", middle.getGroups().isEmpty()); - assertEquals("an unparsed selected source must not be skipped before its parse completes", - 1, LiveConfig.get().getHomeIndex()); - assertEquals("instrumented-middle", LiveConfig.get().getHome().getName()); - } finally { - restore(originalLives, originalHome, originalFallback); - } - } finally { - LiveSetting.putSourceFallback(originalFallback); - } - } - - @Test - public void invalidSourceAdvancesOnlyToNextValidSource() throws Exception { - boolean originalFallback = LiveSetting.isSourceFallback(); - List originalLives = new ArrayList<>(LiveConfig.get().getLives()); - Live originalHome = LiveConfig.get().getHome(); - LiveSetting.putSourceFallback(true); - try (PlaylistServer server = new PlaylistServer()) { - Live first = new Live("instrumented-first", server.url()); - Live invalid = new Live("instrumented-invalid", server.invalidUrl()); - Live nextValid = new Live("instrumented-next", server.url()); - Live last = new Live("instrumented-last", server.url()); - setCustomLives(List.of(first, invalid, nextValid, last), first); - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, LiveActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - waitForService(scenario); - waitUntil(() -> !first.getGroups().isEmpty(), 15_000); - - scenario.onActivity(activity -> activity.setLive(invalid)); - - waitUntil(() -> LiveConfig.get().getHomeIndex() == 2 && !nextValid.getGroups().isEmpty(), 20_000); - assertEquals("an invalid source must advance only to the next valid source", - 2, LiveConfig.get().getHomeIndex()); - assertEquals("instrumented-next", LiveConfig.get().getHome().getName()); - } finally { - restore(originalLives, originalHome, originalFallback); - } - } finally { - LiveSetting.putSourceFallback(originalFallback); - } - } - - private static void waitForService(ActivityScenario scenario) throws Exception { - long deadline = SystemClock.uptimeMillis() + 15_000; - while (SystemClock.uptimeMillis() < deadline) { - AtomicBoolean ready = new AtomicBoolean(false); - scenario.onActivity(activity -> ready.set(activity.isServiceReady())); - if (ready.get()) return; - SystemClock.sleep(100); - } - fail("LiveActivity playback service did not become ready"); - } - - private static void waitUntil(Check check, long timeoutMs) throws Exception { - long deadline = SystemClock.uptimeMillis() + timeoutMs; - while (SystemClock.uptimeMillis() < deadline) { - if (check.done()) return; - SystemClock.sleep(25); - } - fail("timed out waiting for the selected live source to parse"); - } - - private static void setCustomLives(List lives, Live home) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - LiveConfig.get().getLives().clear(); - LiveConfig.get().getLives().addAll(lives); - LiveConfig.get().setHome(home); - }); - } - - private static void restore(List lives, Live home, boolean fallback) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - LiveConfig.get().getLives().clear(); - LiveConfig.get().getLives().addAll(lives); - LiveConfig.get().setHome(home); - LiveSetting.putSourceFallback(fallback); - }); - } - - private interface Check { - boolean done(); - } - - private static final class PlaylistServer implements AutoCloseable { - - private final ServerSocket server; - private final Thread worker; - private final ExecutorService clients = Executors.newCachedThreadPool(); - - private PlaylistServer() throws IOException { - server = new ServerSocket(0, 20, InetAddress.getByName("127.0.0.1")); - worker = new Thread(this::serve, "live-source-instrumented-server"); - worker.setDaemon(true); - worker.start(); - } - - private String url() { - return "http://127.0.0.1:" + server.getLocalPort() + "/playlist.txt"; - } - - private String invalidUrl() { - return "http://127.0.0.1:" + server.getLocalPort() + "/invalid.txt"; - } - - private void serve() { - while (!server.isClosed()) { - try { - Socket client = server.accept(); - clients.execute(() -> handle(client)); - } catch (IOException error) { - if (!server.isClosed()) error.printStackTrace(); - } - } - } - - private void handle(Socket client) { - try (client) { - client.setSoTimeout(2_000); - String path = requestPath(client); - byte[] body = playlist().getBytes(StandardCharsets.UTF_8); - int status = 200; - String type = "text/plain; charset=utf-8"; - if (path != null && path.startsWith("/invalid")) { - status = 404; - body = "not a playlist".getBytes(StandardCharsets.UTF_8); - } else if (path != null && path.contains(".m3u8")) { - type = "application/vnd.apple.mpegurl"; - body = manifest().getBytes(StandardCharsets.UTF_8); - } - OutputStream output = client.getOutputStream(); - output.write(("HTTP/1.1 " + status + (status == 200 ? " OK" : " Not Found") + "\r\n" - + "Content-Type: " + type + "\r\n" - + "Content-Length: " + body.length + "\r\nConnection: close\r\n\r\n") - .getBytes(StandardCharsets.US_ASCII)); - output.write(body); - output.flush(); - } catch (IOException error) { - if (!server.isClosed()) error.printStackTrace(); - } - } - - private String requestPath(Socket client) throws IOException { - BufferedReader reader = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.US_ASCII)); - String request = reader.readLine(); - if (request == null) return null; - String[] parts = request.split(" "); - return parts.length > 1 ? parts[1] : null; - } - - private String playlist() { - return "#EXTM3U\n" - + "#EXTINF:-1,Instrumented middle source\n" - + "http://127.0.0.1:" + server.getLocalPort() + "/live.m3u8\n"; - } - - private String manifest() { - return "#EXTM3U\n" - + "#EXT-X-VERSION:3\n" - + "#EXT-X-TARGETDURATION:1\n" - + "#EXT-X-MEDIA-SEQUENCE:0\n" - + "#EXTINF:1.0,\n" - + "http://127.0.0.1:" + server.getLocalPort() + "/segment.ts\n" - + "#EXT-X-ENDLIST\n"; - } - - @Override - public void close() throws IOException { - server.close(); - clients.shutdownNow(); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayInstrumentedTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayInstrumentedTest.java deleted file mode 100644 index 15f39653ba..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayInstrumentedTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.Intent; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.setting.Setting; - -import org.junit.Test; -import org.junit.runner.RunWith; - -@RunWith(AndroidJUnit4.class) -public class TmdbDetailDirectPlayInstrumentedTest { - - @Test - public void directModeWaitsForExplicitPlayAction() { - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, TmdbDetailActivity.class) - .putExtra("detail_mode", Setting.DETAIL_OPEN_PLAYER) - .putExtra("fusion", false) - .putExtra("auto_play", false) - .putExtra("key", "") - .putExtra("id", "instrumented-direct-play") - .putExtra("name", "详情直放验收") - .putExtra("pic", "") - .putExtra("mark", ""); - - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - scenario.onActivity(activity -> { - assertEquals(TmdbDetailActivity.class, activity.getClass()); - assertNotNull(activity.findViewById(R.id.detailActions)); - assertEquals(android.view.View.VISIBLE, activity.findViewById(R.id.detailActions).getVisibility()); - assertNotNull(activity.findViewById(R.id.playerPanel)); - assertEquals(android.view.View.GONE, activity.findViewById(R.id.playerPanel).getVisibility()); - }); - } - } - - -} diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmpv.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmpv.so index c1b96a1e7c..664faecf56 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmpv.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmpv.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvcodec.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvcodec.so index f334ce034f..d2ce15e29a 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvcodec.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvcodec.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvdevice.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvdevice.so index 79aa34ad40..96c2cb6486 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvdevice.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvdevice.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvfilter.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvfilter.so index 3ec1196218..50816bffd7 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvfilter.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvfilter.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvformat.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvformat.so index 282693cdc5..f941a0ee20 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvformat.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvformat.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvutil.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvutil.so index 90526ff735..f593f46e77 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvutil.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvutil.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwresample.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwresample.so index 90ba1dbede..53c642c8e4 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwresample.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwresample.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwscale.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwscale.so index 395a8b8908..893846a48a 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwscale.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwscale.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libplayer.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libplayer.so index 022b663712..f5aa327417 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libplayer.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libplayer.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmpv.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmpv.so index fc42ead3c1..64701f8a77 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmpv.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmpv.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvcodec.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvcodec.so index 00bfc080c4..33177fa459 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvcodec.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvcodec.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvdevice.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvdevice.so index 368292cbb6..6ff21bbbff 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvdevice.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvdevice.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvfilter.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvfilter.so index a3aac78d39..6268b2daac 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvfilter.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvfilter.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvformat.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvformat.so index c1ad398321..57d5a0f2ac 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvformat.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvformat.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvutil.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvutil.so index c3a06f4e45..65559676fc 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvutil.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvutil.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwresample.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwresample.so index 80976a0502..4c3f8e3d3b 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwresample.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwresample.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwscale.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwscale.so index 16b2e6cc13..4b90858b94 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwscale.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwscale.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libplayer.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libplayer.so index 861492d7de..2a3435a7f8 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libplayer.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libplayer.so differ diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 8cc1826c81..0000000000 --- a/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/app/src/debug/assets/exo-ass/FIXTURE-NOTICES.txt b/app/src/debug/assets/exo-ass/FIXTURE-NOTICES.txt deleted file mode 100644 index 82634a2016..0000000000 --- a/app/src/debug/assets/exo-ass/FIXTURE-NOTICES.txt +++ /dev/null @@ -1,20 +0,0 @@ -sample-fonts.mkv contains a locally generated test pattern and tone (AVC + stereo PCM), -and Aileron-Regular.otf from libass/libass-tests at -10edd9ecd8054f2c4678d36b379d8372d0e573c6. -The font's name-table copyright is "No Rights Reserved". Its identity and source URL -are recorded in app/src/androidTest/assets/exo-ass/official/sources.json. -The PCM track avoids the connected device's existing AAC direct-output initialization -failure without changing production audio settings. animated.ass requests Aileron by -its internal family; its CJK line also exercises system fallback. -These generated media/font fixtures are included only in debug/test APKs. - -sample-embedded.mkv copies the first 8 seconds of sample-fonts.mkv and its Aileron -attachment, with embedded.ass muxed as a default Chinese ASS track. No external -subtitle configuration is needed. It exercises container packets, duplicate seek -preroll, and long events spanning video keyframes. -embedded.ass preserves animated.ass's four events and their layers, placing the -drawing event first so the legacy per-packet decoder's final Cue contains text. -The legacy SsaParser deliberately drops drawing-only Cues; this ordering gives -the fallback test a visible baseline without changing production parsing. -Reproduce with FFmpeg: -ffmpeg -i sample-fonts.mkv -i embedded.ass -map 0 -map 1:0 -c copy -t 8 -metadata:s:s:0 language=chi -disposition:s:0 default sample-embedded.mkv diff --git a/app/src/debug/assets/exo-ass/animated.ass b/app/src/debug/assets/exo-ass/animated.ass deleted file mode 100644 index b96df3699f..0000000000 --- a/app/src/debug/assets/exo-ass/animated.ass +++ /dev/null @@ -1,18 +0,0 @@ -[Script Info] -Title: WebHTV E4-LIBASS stage 1 fixture -ScriptType: v4.00+ -PlayResX: 960 -PlayResY: 540 -ScaledBorderAndShadow: yes -YCbCr Matrix: None - -[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Aileron,42,&H00FFFFFF,&H0000FFFF,&H00202020,&H90000000,0,0,0,0,100,100,0,0,1,2,1,2,20,20,30,1 - -[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -Dialogue: 0,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\move(100,100,800,100,0,8000)\c&H00FFFF&\t(0,8000,\frz25\fscx150)\blur1}ASS move + transform -Dialogue: 1,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\pos(480,250)\fnNoto Sans CJK SC\fs38}中文字体回退 · é · العربية -Dialogue: 2,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\pos(480,350)\kf150}Ka{\kf150}ra{\kf150}o{\kf150}ke {\kf300}timing -Dialogue: 3,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an7\pos(50,440)\p1\1c&H80FF80&\3c&HFFFFFF&\bord2}m 0 0 l 100 0 100 30 0 30 diff --git a/app/src/debug/assets/exo-ass/embedded.ass b/app/src/debug/assets/exo-ass/embedded.ass deleted file mode 100644 index 425bcf2846..0000000000 --- a/app/src/debug/assets/exo-ass/embedded.ass +++ /dev/null @@ -1,18 +0,0 @@ -[Script Info] -Title: WebHTV E4-LIBASS container fixture -ScriptType: v4.00+ -PlayResX: 960 -PlayResY: 540 -ScaledBorderAndShadow: yes -YCbCr Matrix: None - -[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Aileron,42,&H00FFFFFF,&H0000FFFF,&H00202020,&H90000000,0,0,0,0,100,100,0,0,1,2,1,2,20,20,30,1 - -[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -Dialogue: 3,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an7\pos(50,440)\p1\1c&H80FF80&\3c&HFFFFFF&\bord2}m 0 0 l 100 0 100 30 0 30 -Dialogue: 0,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\move(100,100,800,100,0,8000)\c&H00FFFF&\t(0,8000,\frz25\fscx150)\blur1}ASS move + transform -Dialogue: 1,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\pos(480,250)\fnNoto Sans CJK SC\fs38}中文字体回退 · é · العربية -Dialogue: 2,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\pos(480,350)\kf150}Ka{\kf150}ra{\kf150}o{\kf150}ke {\kf300}timing diff --git a/app/src/debug/assets/exo-ass/sample-embedded.mkv b/app/src/debug/assets/exo-ass/sample-embedded.mkv deleted file mode 100644 index d10e372479..0000000000 Binary files a/app/src/debug/assets/exo-ass/sample-embedded.mkv and /dev/null differ diff --git a/app/src/debug/assets/exo-ass/sample-fonts.mkv b/app/src/debug/assets/exo-ass/sample-fonts.mkv deleted file mode 100644 index 4448732e74..0000000000 Binary files a/app/src/debug/assets/exo-ass/sample-fonts.mkv and /dev/null differ diff --git a/app/src/debug/java/com/fongmi/android/tv/player/exo/ass/AssPrototypeActivity.java b/app/src/debug/java/com/fongmi/android/tv/player/exo/ass/AssPrototypeActivity.java deleted file mode 100644 index 5b0cc373be..0000000000 --- a/app/src/debug/java/com/fongmi/android/tv/player/exo/ass/AssPrototypeActivity.java +++ /dev/null @@ -1,242 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; -import android.os.Debug; -import android.os.Handler; -import android.os.SystemClock; -import android.view.View; -import android.view.WindowManager; -import android.widget.Button; -import android.widget.LinearLayout; - -import androidx.media3.common.MediaMetadata; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.PlaybackException; -import androidx.media3.common.Player; -import androidx.media3.exoplayer.DecoderCounters; -import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.exoplayer.analytics.AnalyticsListener; -import androidx.media3.ui.PlayerView; - -import com.fongmi.android.tv.bean.Sub; -import com.fongmi.android.tv.player.engine.ExoPlayerEngine; -import com.fongmi.android.tv.player.engine.PlaySpec; -import com.fongmi.android.tv.player.engine.PlayerEngine; -import com.fongmi.android.tv.player.exo.ExoUtil; - -import org.json.JSONObject; -import java.io.File; -import java.io.FileOutputStream; -import java.nio.charset.StandardCharsets; -import java.util.Collections; - -/** Debug-only playground and reproducible device fixture. No production settings are changed. */ -public final class AssPrototypeActivity extends Activity implements Player.Listener { - public ExoPlayerEngine engine; - public ExoPlayer player; - public PlayerView view; - public ExoAssSession session; - public long startupMs = -1, seekMs = -1; - public int underruns; - private long startedNs, seekNs; - private String videoUri, assUri; - private boolean enabled; - private boolean engineReleased, closing; - private String run; - private PlaySpec spec; - private PlaybackException error; - - @Override protected void onCreate(Bundle state) { - super.onCreate(state); - getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); - LinearLayout root = new LinearLayout(this); - root.setOrientation(LinearLayout.VERTICAL); - LinearLayout controls = new LinearLayout(this); - for (String[] action : new String[][]{{"暂停/播放", "pause_toggle"}, {"回到 2 秒", "seek"}, - {"延迟 +500ms", "delay"}, {"原样/兼容", "toggle"}, {"重建字幕层", "reattach"}}) { - Button button = new Button(this); - button.setText(action[0]); - button.setOnClickListener(v -> command(action[1], null)); - controls.addView(button, new LinearLayout.LayoutParams(0, -2, 1)); - } - root.addView(controls); - view = new PlayerView(this); - ExoUtil.setPlayerView(view); - view.setRender(0); - view.setUseController(false); - root.addView(view, new LinearLayout.LayoutParams(-1, 0, 1)); - setContentView(root); - open(getIntent()); - } - - private void open(Intent intent) { - enabled = intent.getBooleanExtra("enabled", true); - run = intent.getStringExtra("run"); - videoUri = intent.getStringExtra("video"); - assUri = intent.getStringExtra("subtitle"); - boolean embedded = intent.getBooleanExtra("embedded", false); - if (videoUri == null) videoUri = "asset:///exo-ass/" + (embedded ? "sample-embedded.mkv" : "sample-fonts.mkv"); - if (assUri == null) assUri = "asset:///exo-ass/animated.ass"; - engine = new ExoPlayerEngine(PlayerEngine.HARD, this); - bindPlayer(); - spec = PlaySpec.from("E4-LIBASS-prototype", videoUri, Collections.emptyMap(), MediaMetadata.EMPTY); - if (!embedded && !intent.getBooleanExtra("no_subtitle", false)) - spec.setSub(Sub.create("ASS prototype", assUri, "zh", MimeTypes.TEXT_SSA)); - startedNs = System.nanoTime(); - engine.start(spec); - } - - private void bindPlayer() { - player = (ExoPlayer) engine.getPlayer(); - player.addAnalyticsListener(new AnalyticsListener() { - @Override public void onAudioUnderrun(EventTime time, int size, long durationMs, long elapsedMs) { underruns++; } - }); - session = engine.getAssSession(); - if (session != null) { - session.setEnabled(enabled); - session.attach(view); - } - view.setPlayer(player); - } - - @Override protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - command(intent.getStringExtra("command"), intent); - } - - public void command(String command, Intent intent) { - if (command == null || engine == null) return; - switch (command) { - case "pause_toggle" -> player.setPlayWhenReady(!player.getPlayWhenReady()); - case "pause" -> player.pause(); - case "play" -> player.play(); - case "seek" -> { - seekNs = System.nanoTime(); - player.seekTo(intent == null ? 2000 : intent.getLongExtra("position", 2000)); - } - case "delay" -> player.setTextOffsetMs(intent == null ? 500 : intent.getLongExtra("offset", 500)); - case "toggle" -> { enabled = !enabled; if (session != null) session.setEnabled(enabled); } - case "fault" -> { if (session != null) session.injectFailure("surface"); } - case "detach" -> { if (session != null) session.detach(); } - case "attach" -> { if (session != null) session.attach(view); } - case "reattach" -> { if (session != null) { session.detach(); session.attach(view); } } - case "rebuild" -> { - long position = player.getCurrentPosition(); - view.setPlayer(null); - engine.rebuild(this); - bindPlayer(); - engine.start(spec, position, true); - } - case "subtitle_off" -> player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon() - .setTrackTypeDisabled(androidx.media3.common.C.TRACK_TYPE_TEXT, true).build()); - case "subtitle_on" -> player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon() - .setTrackTypeDisabled(androidx.media3.common.C.TRACK_TYPE_TEXT, false).build()); - case "texture" -> { if (session != null) session.detach(); view.setRender(1); if (session != null) session.attach(view); } - case "surface" -> { if (session != null) session.detach(); view.setRender(0); if (session != null) session.attach(view); } - case "dump" -> dumpMetrics(); - case "close" -> closeAfterRelease(); - default -> { } - } - } - - @Override public void onRenderedFirstFrame() { - if (startupMs < 0) startupMs = (System.nanoTime() - startedNs) / 1_000_000; - if (seekNs != 0) { seekMs = (System.nanoTime() - seekNs) / 1_000_000; seekNs = 0; } - } - - @Override public void onPlayerError(PlaybackException error) { this.error = error; } - - public JSONObject metrics() { - try { - JSONObject out = new JSONObject(); - out.put("run", run); out.put("enabled", enabled); - out.put("startupMs", startupMs); out.put("seekMs", seekMs); out.put("underruns", underruns); - out.put("positionMs", player.getCurrentPosition()); out.put("playing", player.isPlaying()); - Format video = player.getVideoFormat(); - if (video != null) { - out.put("videoMime", video.sampleMimeType); - out.put("videoWidth", video.width); out.put("videoHeight", video.height); - out.put("videoCodecs", video.codecs); - out.put("assSdrRgb", AssVideoPolicy.usesSdrRgb(video)); - if (video.colorInfo != null) { - out.put("videoColorSpace", video.colorInfo.colorSpace); - out.put("videoColorTransfer", video.colorInfo.colorTransfer); - out.put("videoColorRange", video.colorInfo.colorRange); - } - } - out.put("error", error == null ? "" : error.errorCode); - if (error != null) { - out.put("errorMessage", error.getMessage()); - out.put("errorCause", String.valueOf(error.getCause())); - } - DecoderCounters counters = player.getVideoDecoderCounters(); - if (counters != null) { - counters.ensureUpdated(); - out.put("renderedFrames", counters.renderedOutputBufferCount); - out.put("droppedFrames", counters.droppedBufferCount); - out.put("maxConsecutiveDropped", counters.maxConsecutiveDroppedBufferCount); - } - Debug.MemoryInfo memory = new Debug.MemoryInfo(); - Debug.getMemoryInfo(memory); - out.put("pssKb", memory.getTotalPss()); out.put("nativePssKb", memory.nativePss); - out.put("graphicsKb", memory.getMemoryStat("summary.graphics")); - if (session != null) { - ExoAssSession.Diagnostics d = session.diagnostics(); - out.put("assState", d.state()); out.put("failure", d.failure()); out.put("assFrames", d.frames()); - out.put("renderP95Us", d.renderP95Us()); out.put("uploadP95Us", d.uploadP95Us()); - out.put("swapP95Us", d.swapP95Us()); out.put("renderMaxUs", d.maxRenderUs()); - out.put("scriptCount", d.scripts()); out.put("staleFrames", d.staleFrames()); - out.put("assTimeMs", d.timeMs()); out.put("nativeAlive", d.nativeAlive()); - out.put("releaseComplete", d.releaseComplete()); out.put("workerAlive", d.workerAlive()); - out.put("workerTid", d.workerTid()); - out.put("generation", d.generation()); out.put("surfaceEpoch", d.surfaceEpoch()); - out.put("fontCount", d.fontCount()); out.put("fontBytes", d.fontBytes()); - out.put("packetCount", d.packetCount()); out.put("packetBytes", d.packetBytes()); - } - out.put("compatibleVisible", view.getSubtitleView().getVisibility() == View.VISIBLE); - out.put("cueCount", player.getCurrentCues().cues.size()); - return out; - } catch (Exception exception) { throw new IllegalStateException(exception); } - } - - private void dumpMetrics() { - try (FileOutputStream output = new FileOutputStream(new File(getFilesDir(), "exo-ass-metrics.jsonl"), true)) { - output.write((metrics() + "\n").getBytes(StandardCharsets.UTF_8)); - } catch (Exception exception) { throw new IllegalStateException(exception); } - } - - private void releaseEngine() { - if (engineReleased) return; - engineReleased = true; - view.setPlayer(null); - if (engine != null) engine.release(); - } - - private void closeAfterRelease() { - if (closing) return; - closing = true; - dumpMetrics(); - releaseEngine(); - Handler handler = new Handler(getMainLooper()); - long deadline = SystemClock.uptimeMillis() + 5000; - handler.post(new Runnable() { - @Override public void run() { - ExoAssSession.Diagnostics d = session == null ? null : session.diagnostics(); - if (d == null || d.releaseComplete() && !d.workerAlive() - || SystemClock.uptimeMillis() >= deadline) { - dumpMetrics(); - finish(); - } else handler.postDelayed(this, 25); - } - }); - } - - @Override protected void onDestroy() { - releaseEngine(); - super.onDestroy(); - } -} diff --git a/app/src/leanback/AndroidManifest.xml b/app/src/leanback/AndroidManifest.xml index 17d04304d6..4f0256732b 100644 --- a/app/src/leanback/AndroidManifest.xml +++ b/app/src/leanback/AndroidManifest.xml @@ -260,10 +260,6 @@ android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:screenOrientation="sensorLandscape" /> - { private final int resId; - private final String text; private int drawable; public static Func create(int resId) { @@ -21,7 +18,6 @@ public class Func implements Diffable { public Func(int resId) { this.resId = resId; this.setDrawable(); - this.text = resolveText(resId); } public int getResId() { @@ -33,32 +29,18 @@ public class Func implements Diffable { } public String getText() { - return text; + return ResUtil.getString(resId); } public void setDrawable() { if (resId == R.string.home_vod) this.drawable = R.drawable.ic_home_vod; else if (resId == R.string.home_live) this.drawable = R.drawable.ic_home_live; else if (resId == R.string.home_keep) this.drawable = R.drawable.ic_home_keep; - else if (resId == R.string.home_following) this.drawable = R.drawable.ic_home_following; else if (resId == R.string.home_push) this.drawable = R.drawable.ic_home_push; else if (resId == R.string.home_search) this.drawable = R.drawable.ic_home_search; else if (resId == R.string.home_setting) this.drawable = R.drawable.ic_home_setting; else if (resId == R.string.home_cast) this.drawable = R.drawable.ic_home_push; else if (resId == R.string.home_history_button) this.drawable = R.drawable.ic_setting_history; - else if (resId == R.string.home_custom_csp) this.drawable = R.drawable.ic_site_config; - } - - private static String resolveText(int resId) { - if (resId == R.string.home_following) { - int unread = FollowingPlaybackBridge.cachedUnreadCount(); - String text = ResUtil.getString(resId); - return unread > 0 ? text + " · " + unread : text; - } - if (resId != R.string.home_custom_csp) return ResUtil.getString(resId); - CustomCspSetting.Status status = CustomCspSetting.status(); - if (!status.available()) return ResUtil.getString(resId); - return ResUtil.getString(status.enabled() ? R.string.home_custom_csp_on : R.string.home_custom_csp_off); } @Override @@ -75,6 +57,6 @@ public class Func implements Diffable { @Override public boolean isSameContent(Func other) { - return equals(other) && getText().equals(other.getText()) && getDrawable() == other.getDrawable(); + return equals(other); } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/bean/HomeButton.java b/app/src/leanback/java/com/fongmi/android/tv/bean/HomeButton.java index 7c194f4303..905317d593 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/bean/HomeButton.java +++ b/app/src/leanback/java/com/fongmi/android/tv/bean/HomeButton.java @@ -6,7 +6,6 @@ import androidx.annotation.Nullable; import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.LiveConfig; -import com.fongmi.android.tv.following.FollowingSettings; import com.fongmi.android.tv.impl.Diffable; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.utils.ResUtil; @@ -21,7 +20,7 @@ public class HomeButton implements Diffable { private static final String KEY_BUTTON = "home_button"; private static final String KEY_SORTED = "home_button_sorted"; - private static final String ALL = "0,1,2,3,8,4,5,6,7,9"; + private static final String ALL = "0,1,2,3,4,5,6,7"; private static List buttons; private final int id; @@ -34,12 +33,10 @@ public class HomeButton implements Diffable { buttons.add(new HomeButton(1, R.string.home_live)); buttons.add(new HomeButton(2, R.string.home_search)); buttons.add(new HomeButton(3, R.string.home_keep)); - buttons.add(new HomeButton(8, R.string.home_following)); buttons.add(new HomeButton(4, R.string.home_push)); buttons.add(new HomeButton(5, R.string.home_cast)); buttons.add(new HomeButton(6, R.string.home_history_button)); buttons.add(new HomeButton(7, R.string.home_setting)); - buttons.add(new HomeButton(9, R.string.home_custom_csp)); return buttons; } @@ -51,7 +48,6 @@ public class HomeButton implements Diffable { List items = new ArrayList<>(); for (HomeButton button : getButtons()) { if (button.getResId() == R.string.home_live && !LiveConfig.hasUrl()) continue; - if (button.getResId() == R.string.home_following && !FollowingSettings.isEnabled()) continue; items.add(button); } return items; @@ -111,11 +107,9 @@ public class HomeButton implements Diffable { ids.add("1"); ids.add("2"); ids.add("3"); - ids.add("8"); ids.add("4"); if (!Setting.isHomeHistory()) ids.add("6"); ids.add("7"); - ids.add("9"); return TextUtils.join(",", ids); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CastActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CastActivity.java index 253eff7e96..95301ed9fd 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CastActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CastActivity.java @@ -32,7 +32,6 @@ import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.ui.custom.CustomKeyDownVod; import com.fongmi.android.tv.ui.custom.CustomSeekView; import com.fongmi.android.tv.ui.dialog.PlayerKernelDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.ui.dialog.SubtitleDialog; import com.fongmi.android.tv.ui.dialog.TrackDialog; import com.fongmi.android.tv.utils.Clock; @@ -191,11 +190,8 @@ public class CastActivity extends PlaybackActivity implements CustomKeyDownVod.L } private void onSpeed() { - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || player().isEmpty()) return; - mBinding.control.action.speed.setText(player().setSpeed(speed)); - PlayerSetting.putDefaultSpeed(player().getSpeed()); - }); + mBinding.control.action.speed.setText(player().addSpeed()); + PlayerSetting.putDefaultSpeed(player().getSpeed()); } private void onSpeedAdd() { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java index af3a695187..ae993f644e 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java @@ -47,7 +47,6 @@ import com.fongmi.android.tv.ui.adapter.SearchAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.custom.CustomScroller; import com.fongmi.android.tv.ui.dialog.SliderNumberDialog; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.SearchPageState; @@ -58,9 +57,7 @@ import com.google.android.material.textview.MaterialTextView; import com.google.gson.reflect.TypeToken; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; public class CollectActivity extends BaseActivity implements CollectAdapter.OnClickListener, SearchAdapter.OnClickListener, CustomScroller.Callback { @@ -77,8 +74,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private CollectAdapter mCollectAdapter; private SearchAdapter mSearchAdapter; private CustomScroller mScroller; - private Runnable mSearchFocusRequest; - private int mSearchFocusGeneration; private SiteViewModel mViewModel; private RecyclerView.OnScrollListener mImageScrollListener; private List mSites; @@ -92,10 +87,8 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private final List mPendingItems = new ArrayList<>(); private Runnable mApplyCollect; private String mPendingCollectSiteKey = ""; - private String mBoundSearchSiteKey = ""; private boolean mScrolling; private boolean mLeavingForPlayback; - private final Map mSearchPositions = new HashMap<>(); public static void start(Activity activity, String keyword) { start(activity, keyword, null, null); @@ -243,7 +236,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl mBinding.collect.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { @Override public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { - if (TouchOptimizationHelper.isTouchActive(parent)) return; scheduleCollect(position, 260); } }); @@ -254,15 +246,10 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl mBinding.collectHorizontal.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { @Override public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { - if (TouchOptimizationHelper.isTouchActive(parent)) return; scheduleCollect(position, 260); } }); setSearchLayout(); - mBinding.collect.setOnKeyListener((view, keyCode, event) -> { - if (event.getAction() != KeyEvent.ACTION_DOWN || keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) return false; - return focusFirstSearchResult(); - }); mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setItemAnimator(null); mBinding.recycler.setItemViewCacheSize(count * 3); @@ -271,13 +258,9 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl mBinding.recycler.addOnScrollListener(mImageScrollListener = new RecyclerView.OnScrollListener() { @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { - saveSearchPosition(getActiveSiteKey()); if (!canLoadImage()) return; - recyclerView.post(() -> { - if (!canLoadImage()) return; - ensureSearchRows(count, 2); - preloadNextRows(count); - }); + ensureSearchRows(count, 2); + preloadNextRows(count); } @Override @@ -320,7 +303,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private void setSearchProgress(SearchProgress progress) { if (progress == null) return; - mCollectAdapter.setProgress(progress.current(), progress.total()); mSearchCompleted = progress.total() > 0 && progress.current() >= progress.total(); if (mSearchCompleted && mCollectAdapter.getItemCount() > 0) updateEmptyState(mCollectAdapter.getActivated()); } @@ -391,7 +373,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl SliderNumberDialog.show(this, R.string.search_filter_similarity, mSimilarity, 0, 100, "%", R.string.search_filter_similarity_hint, value -> { mSimilarity = value; Setting.putSearchSimilarity(value); - mSearchPositions.clear(); updateFilterControls(); applyFilters(activeSiteKey); }); @@ -464,7 +445,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private void setFilterGroup(String group) { String activeSiteKey = getActiveSiteKey(); - mSearchPositions.clear(); mFilterGroup = Objects.toString(group, ""); updateFilterControls(); applyFilters(activeSiteKey); @@ -501,11 +481,12 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl Collect all = Collect.all(); all.setSelected("all".equals(activeSiteKey)); items.add(all); + boolean fixedOrder = Setting.getSearchResultSort() == 1; for (int i = 1; i < mAllCollectItems.size(); i++) { Collect raw = mAllCollectItems.get(i); if (!matchFilter(raw.getSite())) continue; List visible = SearchResultFilter.filter(raw.getList(), getKeyword(), mSimilarity); - if (!SearchSourceVisibility.shouldShow(!visible.isEmpty())) continue; + if (!SearchSourceVisibility.shouldShow(mSimilarity, fixedOrder, !visible.isEmpty())) continue; Collect item = new Collect(raw.getSite(), visible); item.setPage(mPaging.getPage(raw.getSite().getKey())); item.setSelected(raw.getSite().getKey().equals(activeSiteKey)); @@ -589,8 +570,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private void search() { mSearchCompleted = false; - mSearchPositions.clear(); - mBoundSearchSiteKey = ""; mPaging.clear(); removeApplyCollect(); mCollectAdapter.clear(); @@ -665,8 +644,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl } private void updateRecyclerLayout() { - mSearchPositions.clear(); - mBoundSearchSiteKey = ""; int count = getCount(); GridLayoutManager layoutManager = (GridLayoutManager) mBinding.recycler.getLayoutManager(); if (layoutManager != null) { @@ -778,65 +755,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl mPendingItems.clear(); } - private void focusSearchTarget(int position) { - int generation = ++mSearchFocusGeneration; - if (mSearchFocusRequest != null) App.removeCallbacks(mSearchFocusRequest); - mSearchFocusRequest = () -> { - if (generation != mSearchFocusGeneration || isFinishing() || isDestroyed()) return; - if (!canFocusSearchResult(position)) return; - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - View target = manager == null ? null : manager.findViewByPosition(position); - if (target != null) { - alignSearchResultCard(position, target); - requestSearchResultFocus(position, generation); - } else { - scrollToSearchResult(position); - mBinding.recycler.post(() -> { - if (generation != mSearchFocusGeneration || isFinishing() || isDestroyed()) return; - View laidOutTarget = mBinding.recycler.getLayoutManager() == null ? null : mBinding.recycler.getLayoutManager().findViewByPosition(position); - if (laidOutTarget != null) { - alignSearchResultCard(position, laidOutTarget); - requestSearchResultFocus(position, generation); - } - }); - } - }; - App.post(mSearchFocusRequest); - } - - private void requestSearchResultFocus(int position, int generation) { - mBinding.recycler.post(() -> { - if (generation != mSearchFocusGeneration || isFinishing() || isDestroyed()) return; - View target = mBinding.recycler.getLayoutManager() == null ? null : mBinding.recycler.getLayoutManager().findViewByPosition(position); - if (target != null) target.requestFocus(); - }); - } - - private boolean canFocusSearchResult(int position) { - return mBinding != null && mBinding.recycler != null && mSearchAdapter != null - && position >= 0 && position < mSearchAdapter.getItemCount(); - } - - private void scrollToSearchResult(int position) { - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - if (manager instanceof GridLayoutManager layoutManager) layoutManager.scrollToPosition(position); - } - - private void alignSearchResultCard(int position, View focusedView) { - if (focusedView == null || mBinding == null || mBinding.recycler.getHeight() <= 0) return; - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - if (!(manager instanceof GridLayoutManager layoutManager)) return; - int cardHeight = focusedView.getHeight(); - if (cardHeight <= 0) return; - int spanCount = Math.max(1, layoutManager.getSpanCount()); - int rowStart = Math.max(0, position - position % spanCount); - int padding = ResUtil.dp2px(8); - int bottom = mBinding.recycler.getHeight() - mBinding.recycler.getPaddingBottom() - padding; - int offset = Math.max(mBinding.recycler.getPaddingTop(), bottom - cardHeight); - mBinding.recycler.stopScroll(); - layoutManager.scrollToPositionWithOffset(rowStart, offset); - } - private void preloadNextRows(int count) { RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); if (!(manager instanceof GridLayoutManager layoutManager)) return; @@ -844,7 +762,7 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl int last = layoutManager.findLastVisibleItemPosition(); if (first < 0 || last < 0) return; int direction = last >= first ? last + 1 : first + 1; - ensureSearchItemsLoaded(direction, count * 2); + mSearchAdapter.ensureLoaded(direction, count * 2); } private void ensureSearchRows(int count, int rows) { @@ -852,15 +770,7 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl if (!(manager instanceof GridLayoutManager layoutManager)) return; int last = layoutManager.findLastVisibleItemPosition(); if (last < 0) return; - ensureSearchItemsLoaded(last + 1, count * rows); - } - - private void ensureSearchItemsLoaded(int position, int count) { - if (mBinding.recycler.isComputingLayout()) { - mBinding.recycler.post(() -> ensureSearchItemsLoaded(position, count)); - return; - } - mSearchAdapter.ensureLoaded(position, count); + mSearchAdapter.ensureLoaded(last + 1, count * rows); } @Override @@ -880,8 +790,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl if (position == 0 && keyCode == KeyEvent.KEYCODE_DPAD_LEFT) return true; return false; } - // 横排站点的左右键交给列表导航;只有竖排站点按右键进入搜索结果。 - if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) return focusFirstSearchResult(); // 在第一项按上键,跳转到切换按钮 if (position == 0 && keyCode == KeyEvent.KEYCODE_DPAD_UP) { mBinding.searchColumn.requestFocus(); @@ -892,12 +800,10 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private void scheduleCollect(int position, long delayMillis) { if (position < 0 || position >= mCollectAdapter.getItemCount()) return; - saveSearchPosition(getActiveSiteKey()); Collect item = mCollectAdapter.get(position); String siteKey = item.getSite().getKey(); boolean same = siteKey.equals(getActiveSiteKey()); mCollectAdapter.setSelected(position); - if (!same) mBoundSearchSiteKey = ""; restoreScroller(siteKey); mPendingItems.clear(); if (same && delayMillis > 0 && siteKey.equals(mPendingCollectSiteKey)) return; @@ -925,71 +831,14 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl } private void setSearchItemsLazy(List items) { - String siteKey = getActiveSiteKey(); - mBoundSearchSiteKey = siteKey; - SearchPosition saved = mSearchPositions.get(siteKey); - int position = saved == null ? 0 : saved.adapterPosition; - mSearchAdapter.setSource(items, Math.max(getCount() * 4, position + getCount())); + mBinding.recycler.scrollToPosition(0); + mSearchAdapter.setSource(items, getCount() * 4); mBinding.recycler.post(() -> { - if (!siteKey.equals(getActiveSiteKey())) return; - restoreSearchPosition(siteKey); ensureSearchRows(getCount(), 2); preloadNextRows(getCount()); }); } - private void saveSearchPosition(String siteKey) { - if (TextUtils.isEmpty(siteKey) || mBinding == null || mBinding.recycler == null) return; - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - if (!(manager instanceof GridLayoutManager layoutManager)) return; - int position = layoutManager.findFirstVisibleItemPosition(); - if (position == RecyclerView.NO_POSITION) return; - View first = layoutManager.findViewByPosition(position); - if (first == null) return; - int offset = first.getTop() - mBinding.recycler.getPaddingTop(); - mSearchPositions.put(siteKey, new SearchPosition(position, offset)); - } - - private void restoreSearchPosition(String siteKey) { - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - if (!(manager instanceof GridLayoutManager layoutManager)) return; - int itemCount = mSearchAdapter == null ? 0 : mSearchAdapter.getItemCount(); - if (itemCount <= 0) return; - SearchPosition saved = mSearchPositions.get(siteKey); - int position = saved == null ? 0 : Math.min(saved.adapterPosition, itemCount - 1); - int offset = saved == null ? 0 : saved.offset; - layoutManager.scrollToPositionWithOffset(Math.max(0, position), offset); - } - - private boolean focusFirstSearchResult() { - if (mSearchAdapter == null) return false; - String siteKey = getActiveSiteKey(); - if (!siteKey.equals(mBoundSearchSiteKey)) { - removeApplyCollect(); - Collect activated = mCollectAdapter.findActivated(siteKey); - if (activated == null) return false; - setSearchItemsLazy(new ArrayList<>(activated.getList())); - updateEmptyState(activated); - maybeLoadSelectedPage(); - } - if (mSearchAdapter.getItemCount() == 0) return false; - mBinding.recycler.post(() -> { - if (!siteKey.equals(mBoundSearchSiteKey) || !siteKey.equals(getActiveSiteKey())) return; - restoreSearchPosition(siteKey); - mBinding.recycler.post(() -> { - if (!siteKey.equals(mBoundSearchSiteKey) || !siteKey.equals(getActiveSiteKey())) return; - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - SearchPosition saved = mSearchPositions.get(siteKey); - int itemCount = mSearchAdapter == null ? 0 : mSearchAdapter.getItemCount(); - int position = saved == null || itemCount <= 0 ? 0 : Math.min(saved.adapterPosition, itemCount - 1); - View target = manager == null ? null : manager.findViewByPosition(position); - if (target != null) target.requestFocus(); - else mBinding.recycler.requestFocus(); - }); - }); - return true; - } - @Override public void onItemClick(Vod item) { if (!item.isFolder() && isHistoryResume()) { @@ -1040,7 +889,9 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl } private boolean focusFirstResult() { - return focusFirstSearchResult(); + if (mBinding.recycler.getChildCount() == 0) return false; + mBinding.recycler.getChildAt(0).requestFocus(); + return true; } private void focusSelectedCollect() { @@ -1056,12 +907,8 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl if (next + count >= mSearchAdapter.getItemCount()) flushPendingItems(); mSearchAdapter.ensureLoaded(next + 1, count * 3); boolean bottom = next >= mSearchAdapter.getItemCount(); - if (bottom) { - mScroller.checkMore(); - return true; - } - focusSearchTarget(next); - return true; + if (bottom) mScroller.checkMore(); + return bottom; } @Override @@ -1102,16 +949,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl if (canLoadImage()) Glide.with(this).resumeRequests(); } - private static final class SearchPosition { - private final int adapterPosition; - private final int offset; - - private SearchPosition(int adapterPosition, int offset) { - this.adapterPosition = Math.max(0, adapterPosition); - this.offset = offset; - } - } - @Override protected void onDestroy() { if (mBinding != null) { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java index 4af9cebbbe..0315e262f5 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java @@ -46,8 +46,6 @@ import com.fongmi.android.tv.event.CastEvent; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.event.ServerEvent; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingScheduler; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.impl.ConfigListener; import com.fongmi.android.tv.model.SiteViewModel; @@ -57,16 +55,13 @@ import com.fongmi.android.tv.service.DLNARendererService; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.AutoBackupPolicy; import com.fongmi.android.tv.setting.AppBranding; -import com.fongmi.android.tv.setting.CustomCspSetting; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.ui.adapter.BaseDiffCallback; import com.fongmi.android.tv.ui.adapter.TypeAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.ui.custom.CustomRowPresenter; import com.fongmi.android.tv.ui.custom.CustomSelector; import com.fongmi.android.tv.ui.custom.CustomTitleView; -import com.fongmi.android.tv.ui.dialog.CustomCspDialog; import com.fongmi.android.tv.ui.dialog.HistoryDialog; import com.fongmi.android.tv.ui.dialog.ExitConfirmDialog; import com.fongmi.android.tv.ui.dialog.HomeMenuDialog; @@ -78,7 +73,6 @@ import com.fongmi.android.tv.ui.presenter.HistoryPresenter; import com.fongmi.android.tv.ui.presenter.ProgressPresenter; import com.fongmi.android.tv.ui.presenter.VodPresenter; import com.fongmi.android.tv.utils.Clock; -import com.fongmi.android.tv.utils.CrashRestartMode; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.KeyUtil; import com.fongmi.android.tv.utils.Notify; @@ -137,9 +131,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List private boolean pendingOpenVod; // 手动点击"点播"后等待数据加载完成再进分类页 private boolean webConfirmKeyDown; private boolean webConfirmLongPress; - private boolean mTypeSelectionFromTouch; - private int focusGeneration; - private Runnable mPendingCategoryFocus; private final Runnable mTypeSwitch = this::switchType; private final Runnable mWebConfirmLongPress = this::triggerWebFocusedLongPress; private final Runnable mDelayedInitConfig = this::initConfig; @@ -218,14 +209,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List syncNativeContentInset(); syncWebOverlayLayout(); }); - mBinding.typeRecycler.setOnFocusChangeListener((view, hasFocus) -> { - if (!hasFocus) { - invalidatePendingFocusRequests(); - if (mFolder != null) mFolder.clearContentFocusRequest(); - } else if (isCategoryVisible() && mFolder != null) { - mFolder.scrollContentToTop(); - } - }); mBinding.recycler.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { @Override public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { @@ -245,8 +228,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List mSelectedTypeView = child.itemView; mSelectedTypeView.setSelected(true); if (parent.hasFocus()) updateToolbarVisibility(true); - mTypeSelectionFromTouch = TouchOptimizationHelper.isTouchActive(parent); - if (Setting.isHomeVodAutoLoad() && !mTypeSelectionFromTouch) scheduleTypeSwitch(position); + if (Setting.isHomeVodAutoLoad()) scheduleTypeSwitch(position); } }); } @@ -258,7 +240,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } private void resumeTypeSwitch() { - if (!Setting.isHomeVodAutoLoad() || mTypeSelectionFromTouch || mBinding.typeRecycler.getVisibility() != View.VISIBLE) return; + if (!Setting.isHomeVodAutoLoad() || mBinding.typeRecycler.getVisibility() != View.VISIBLE) return; int position = mBinding.typeRecycler.getSelectedPosition(); if (position >= 0) scheduleTypeSwitch(position); } @@ -275,13 +257,13 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List @Override public void onCategoryContentHorizontalEdge(Class item, int contentRow, boolean towardEnd) { if (!isCurrentCategory(item) || contentRow < 0) return; - mTypeSelectionFromTouch = false; item = getAdjacentCategory(item, towardEnd); if (item == null) return; mBinding.typeRecycler.removeCallbacks(mTypeSwitch); int position = mTypeAdapter.indexOf(item); mBinding.typeRecycler.setSelectedPosition(position); - focusCategoryButton(item); + if (contentRow == 0) focusFirstCard(item); + else focusCategoryButton(item); } private Class getAdjacentCategory(Class item, boolean towardEnd) { @@ -295,47 +277,17 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List private void focusFirstCard(Class item) { showCategoryContent(item); getSupportFragmentManager().executePendingTransactions(); - if (mFolder != null && isCurrentCategory(item)) mFolder.requestContentFocus(0, focusGeneration); + if (mFolder != null && isCurrentCategory(item)) mFolder.requestContentFocus(0); } private void focusCategoryButton(Class item) { - if (item == null || getSupportFragmentManager().isStateSaved()) return; + mBinding.typeRecycler.setVisibility(View.VISIBLE); + updateToolbarVisibility(true); + mBinding.typeRecycler.requestFocus(); showCategoryContent(item); - // Complete the switch before checking the new page; otherwise the posted callback can - // run while the target fragment is not added yet and drop the header/focus restoration. - getSupportFragmentManager().executePendingTransactions(); - final int generation = ++focusGeneration; - mPendingCategoryFocus = () -> { - if (generation != focusGeneration) return; - mPendingCategoryFocus = null; - if (isFinishing() || isDestroyed() || !isCurrentCategory(item) || mFolder == null) return; - int position = mTypeAdapter.indexOf(item); - if (position < 0 || mBinding.typeRecycler.getSelectedPosition() != position) return; - mBinding.typeRecycler.setVisibility(View.VISIBLE); - updateToolbarVisibility(true); - mBinding.typeRecycler.requestFocus(); - mFolder.scrollContentToTop(generation); - mBinding.typeRecycler.setSelectedPosition(position, holder -> { - if (generation != focusGeneration || !mBinding.typeRecycler.hasFocus()) return; - if (isCurrentCategory(item) && mBinding.typeRecycler.getSelectedPosition() == position) { - holder.itemView.requestFocus(); - } - }); - }; - mBinding.typeRecycler.post(mPendingCategoryFocus); - } - - private void invalidatePendingFocusRequests() { - focusGeneration++; - if (mBinding != null && mPendingCategoryFocus != null) { - mBinding.typeRecycler.removeCallbacks(mPendingCategoryFocus); - } - if (mBinding != null) mBinding.typeRecycler.removeCallbacks(mTypeSwitch); - mPendingCategoryFocus = null; } private void showHomeContent() { - invalidatePendingFocusRequests(); mCurrentType = null; mBinding.progressLayout.setVisibility(View.VISIBLE); mBinding.categoryContainer.setVisibility(View.GONE); @@ -351,14 +303,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } private void showCategoryContent(Class item, boolean toggleFilter) { - if (getSupportFragmentManager().isStateSaved()) { - invalidatePendingFocusRequests(); - return; - } - final int generation = ++focusGeneration; - if (mBinding != null && mPendingCategoryFocus != null) mBinding.typeRecycler.removeCallbacks(mPendingCategoryFocus); - mPendingCategoryFocus = null; - if (mFolder != null) mFolder.clearContentFocusRequest(); + if (getSupportFragmentManager().isStateSaved()) return; getSupportFragmentManager().executePendingTransactions(); if (item == null || item.isHome()) { showHomeContent(); @@ -394,8 +339,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List else transaction.add(R.id.categoryContainer, target, tag); target.setUserVisibleHint(true); transaction.runOnCommit(() -> { - if (generation != focusGeneration || target != mFolder || !isCurrentCategory(item)) return; - target.scrollContentToTop(generation); restoreTypeFocus(keepTypeFocus, item); if (toggleFilter && target == mFolder && isCurrentCategory(item)) updateFilter(item); }); @@ -421,7 +364,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } private void clearCategoryContent() { - invalidatePendingFocusRequests(); mBinding.typeRecycler.removeCallbacks(mTypeSwitch); mPendingTypePosition = -1; mCurrentType = null; @@ -590,11 +532,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } private void initConfig() { - if (CrashRestartMode.consume()) { - SpiderDebug.log("startup", "skip config load once after crash restart"); - showContent(); - return; - } SpiderDebug.log("startup", "config load start cost=%sms", System.currentTimeMillis() - App.time()); VodConfig.get().init().load(getCallback()); LiveConfig.get().init().load(); @@ -783,12 +720,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List mFuncAdapter.setItems(items, new BaseDiffCallback()); } - private void refreshFollowingCount() { - FollowingPlaybackBridge.refreshUnreadCountAsync(unread -> { - if (!isFinishing() && !isDestroyed()) setFunc(); - }); - } - private void getHistory() { getHistory(false); } @@ -956,21 +887,11 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } } else if (item.getResId() == R.string.home_live) LiveActivity.start(this); else if (item.getResId() == R.string.home_keep) KeepActivity.start(this); - else if (item.getResId() == R.string.home_following) FollowingActivity.start(this, null); else if (item.getResId() == R.string.home_push) PushActivity.start(this); else if (item.getResId() == R.string.home_search) SearchActivity.start(this); else if (item.getResId() == R.string.home_setting) SettingActivity.start(this); else if (item.getResId() == R.string.home_cast) PushActivity.start(this, 3); else if (item.getResId() == R.string.home_history_button) HistoryActivity.start(this); - else if (item.getResId() == R.string.home_custom_csp) openCustomCsp(); - } - - private void openCustomCsp() { - PermissionUtil.requestFile(this, granted -> { - if (isFinishing() || isDestroyed() || getSupportFragmentManager().isStateSaved()) return; - if (granted) CustomCspDialog.show(this, this::setFunc); - else Notify.show(R.string.setting_custom_csp_permission_required); - }); } @Override @@ -1031,7 +952,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List @Override public void onItemClick(Class item) { - mTypeSelectionFromTouch = false; if (item.isHome()) showHomeContent(); else if (isCurrentCategory(item)) updateFilter(item); else { @@ -1048,7 +968,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List @Override public void onItemClick(Vod item) { - if (item.isAction()) com.fongmi.android.tv.content.ActionCardHelper.handleAction(this, getHome().getKey(), item.getAction()); + if (item.isAction()) mViewModel.action(getHome().getKey(), item.getAction()); else if (getHome().isIndex()) CollectActivity.start(this, item.getName()); else VideoActivity.start(this, getHome().getKey(), item.getId(), item.getName(), item.getPic()); } @@ -1232,12 +1152,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } private boolean requestContentFocus() { - if (isCategoryVisible()) { - invalidatePendingFocusRequests(); - if (mFolder == null) return false; - mFolder.clearContentFocusRequest(); - return mFolder.requestContentFocus(); - } + if (isCategoryVisible()) return mFolder != null && mFolder.requestContentFocus(); if (mBinding.recycler.getVisibility() != View.VISIBLE || mBinding.recycler.getChildCount() == 0) return false; View child = mBinding.recycler.getFocusedChild(); if (child == null) child = mBinding.recycler.getChildAt(0); @@ -1250,18 +1165,13 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List mClock.start(); syncHomeSiteLock(); if (mWeb != null) mWeb.onResume(); - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); setFunc(); - refreshFollowingCount(); syncTypeItems(); resumeTypeSwitch(); } @Override protected void onPause() { - invalidatePendingFocusRequests(); - if (mFolder != null) mFolder.clearContentFocusRequest(); mBinding.typeRecycler.removeCallbacks(mTypeSwitch); cancelWebConfirmKey(); if (mWeb != null) mWeb.onPause(); @@ -1329,8 +1239,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List @Override protected void onDestroy() { - invalidatePendingFocusRequests(); - if (mFolder != null) mFolder.clearContentFocusRequest(); mBinding.typeRecycler.removeCallbacks(mTypeSwitch); cancelPendingStartupTasks(); if (mWeb != null) mWeb.destroy(); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java index 2de6257513..7f6036e621 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java @@ -46,12 +46,10 @@ import com.fongmi.android.tv.impl.LiveListener; import com.fongmi.android.tv.impl.PassListener; import com.fongmi.android.tv.model.LiveViewModel; import com.fongmi.android.tv.player.PlayerHelper; -import com.fongmi.android.tv.player.LiveSourceFallbackPolicy; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.Source; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.LiveSetting; -import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.ui.adapter.ChannelAdapter; import com.fongmi.android.tv.ui.adapter.EpgDataAdapter; @@ -61,7 +59,6 @@ import com.fongmi.android.tv.ui.custom.CustomLiveListView; import com.fongmi.android.tv.ui.custom.CustomSeekView; import com.fongmi.android.tv.ui.custom.PlayerOsdController; import com.fongmi.android.tv.ui.dialog.HistoryDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.ui.dialog.LiveDialog; import com.fongmi.android.tv.ui.dialog.PassDialog; import com.fongmi.android.tv.ui.dialog.PlayerKernelDialog; @@ -84,12 +81,6 @@ import java.util.List; public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnClickListener, ChannelAdapter.OnClickListener, EpgDataAdapter.OnClickListener, CustomKeyDownLive.Listener, CustomLiveListView.Callback, TrackDialog.Listener, PassListener, ConfigListener, LiveListener { private static final long PLAYBACK_END_RETRY_DELAY = 500; - private static final long LIVE_BUFFERING_TIMEOUT = 15000; - - @Override - protected boolean shouldAutoPlay() { - return true; - } private ActivityLiveBinding mBinding; private ChannelAdapter mChannelAdapter; @@ -113,12 +104,10 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private Runnable mR3; private Runnable mR4; private Runnable mEndRetry; - private Runnable mBufferingTimeout; private Clock mClock; private View mFocus2; private boolean playbackCatchup; private int count; - private boolean mFailedThisSession; public static void start(Context context) { context.startActivity(new Intent(context, LiveActivity.class).putExtra("empty", LiveConfig.isEmpty())); @@ -183,7 +172,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli @Override protected void initView(Bundle savedInstanceState) { super.initView(savedInstanceState); - applyPlaybackOverlay(); mClock = Clock.create(mBinding.widget.clock); mKeyDown = CustomKeyDownLive.create(this); mObserveEpg = this::setEpg; @@ -192,7 +180,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mR0 = this::setSelected; mR1 = this::hideControl; mR2 = this::setTraffic; - mBufferingTimeout = this::startFlow; mR3 = this::hideInfo; mR4 = this::hideUI; mEndRetry = this::checkNext; @@ -238,7 +225,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mBinding.control.action.player.setOnClickListener(view -> onPlayerKernel()); mBinding.control.action.player.setOnLongClickListener(view -> onPlayerKernelLong()); mBinding.control.action.decode.setOnClickListener(view -> onDecode()); - mBinding.control.action.playParams.setOnClickListener(view -> onPlayParams()); mBinding.control.action.speed.setOnLongClickListener(view -> onSpeedLong()); mBinding.video.setOnTouchListener((view, event) -> mKeyDown.onTouchEvent(event)); mBinding.group.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { @@ -266,10 +252,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mBinding.control.action.change.setSelected(LiveSetting.isChange()); } - private void applyPlaybackOverlay() { - mBinding.control.getRoot().setBackgroundResource(Setting.isPlaybackOverlayEnabled() ? R.drawable.shape_controller_scrim : R.color.transparent); - } - private void setDecode() { mBinding.control.action.decode.setText(player().getDecodeText()); } @@ -289,11 +271,15 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mViewModel.url().observeForever(mObserveUrl); mViewModel.xml().observe(this, this::setEpg); mViewModel.epg().observeForever(mObserveEpg); - mViewModel.live().observe(this, this::renderLive); + mViewModel.live().observe(this, live -> { + mViewModel.parseXml(live); + setGroup(live); + setWidth(live); + }); } private void checkLive() { - if (LiveConfig.isEmpty()) { + if (isEmpty()) { LiveConfig.get().init().load(getCallback()); } else { getLive(); @@ -319,16 +305,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli showProgress(); } - private void renderLive(Live live) { - if (live == null || live.getGroups().isEmpty()) { - if (LiveSetting.isSourceFallback()) startFlow(); - return; - } - mViewModel.parseXml(live); - setGroup(live); - setWidth(live); - } - private void setGroup(Live live) { List items = new ArrayList<>(); for (Group group : live.getGroups()) (group.isHidden() ? mHides : items).add(group); @@ -442,11 +418,8 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void onSpeed() { if (!player().isVod()) return; - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || !player().isVod()) return; - mBinding.control.action.speed.setText(player().setSpeed(speed)); - PlayerSetting.putDefaultSpeed(player().getSpeed()); - }); + mBinding.control.action.speed.setText(player().addSpeed()); + PlayerSetting.putDefaultSpeed(player().getSpeed()); } private void onSpeedAdd() { @@ -577,25 +550,14 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli setTrackVisible(); } - @Override - protected boolean onSourceHttpError(int statusCode, String msg) { - if (!LiveSetting.isSourceFallback()) return false; - onError(msg); - return true; - } - @Override protected void onError(String msg) { - App.removeCallbacks(mBufferingTimeout); Track.delete(player().getKey()); player().resetTrack(); player().reset(); player().stop(); - if (!mFailedThisSession) { - mFailedThisSession = true; - showError(msg); - startFlow(); - } + showError(msg); + startFlow(); } @Override @@ -622,13 +584,11 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli protected void onStateChanged(int state) { switch (state) { case Player.STATE_BUFFERING: - mFailedThisSession = false; showProgress(); break; case Player.STATE_READY: hideProgress(); player().reset(); - mFailedThisSession = false; break; case Player.STATE_ENDED: checkEnded(); @@ -639,7 +599,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli @Override protected void onPlayingChanged(boolean isPlaying) { - if (isPlaying) App.removeCallbacks(mBufferingTimeout); if (isPlaying || isPaused()) updatePlayControl(isPlaying); } @@ -701,10 +660,11 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli } private void showControl(View view) { - setPlayParamsState(); mBinding.control.getRoot().setVisibility(View.VISIBLE); - // 控制栏显示时统一由 PlayerOsdController 显示标题、分辨率和时间,避免旧栏重影 - mBinding.widget.top.setVisibility(View.GONE); + // OSD 启用时,不显示 widget.top(避免与 OSD 的 topLeft/topRight 重影) + if (!PlayerSetting.isOsdEnabled()) { + mBinding.widget.top.setVisibility(View.VISIBLE); + } if (mOsd != null) mOsd.setControlsVisible(true); App.post(view::requestFocus, 25); setR1Callback(); @@ -718,19 +678,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli App.removeCallbacks(mR1); } - private void onPlayParams() { - if (mOsd == null) return; - boolean visible = !mOsd.isDiagnosticsVisible(); - PlayerSetting.putOsdDiagnostics(visible); - mOsd.setDiagnosticsVisible(visible); - setPlayParamsState(); - hideControl(); - } - - private void setPlayParamsState() { - mBinding.control.action.playParams.setSelected(mOsd != null && mOsd.isDiagnosticsVisible()); - } - private void hideCenter() { mBinding.widget.action.setImageResource(R.drawable.ic_widget_play); mBinding.widget.center.setVisibility(View.GONE); @@ -884,8 +831,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void fetch(EpgData item) { if (mChannel == null) return; App.removeCallbacks(mEndRetry); - App.removeCallbacks(mBufferingTimeout); - App.post(mBufferingTimeout, LIVE_BUFFERING_TIMEOUT); playbackCatchup = true; mViewModel.getUrl(mChannel, item); player().clear(); @@ -896,8 +841,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void fetch() { if (mChannel == null) return; App.removeCallbacks(mEndRetry); - App.removeCallbacks(mBufferingTimeout); - App.post(mBufferingTimeout, LIVE_BUFFERING_TIMEOUT); playbackCatchup = false; LiveConfig.get().setKeep(mChannel); mViewModel.getUrl(mChannel); @@ -983,6 +926,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli @Override public void setLive(Live item) { + if (item.isSelected()) item.getGroups().clear(); LiveConfig.get().setHome(item); player().reset(); player().clear(); @@ -1046,16 +990,8 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli } private void startFlow() { - Live next = LiveSetting.isSourceFallback() ? LiveConfig.getNextHome() : null; - LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decide( - LiveSetting.isChange(), - LiveSetting.isSourceFallback(), - mChannel != null, - mChannel == null || mChannel.isLast(), - mChannel == null || mChannel.isOnly(), - next != null); - if (action == LiveSourceFallbackPolicy.Action.NEXT_LINE) nextLine(true); - else if (action == LiveSourceFallbackPolicy.Action.NEXT_SOURCE) setLive(next); + if (!LiveSetting.isChange()) return; + if (!mChannel.isLast()) nextLine(true); } private void prevChannel() { @@ -1236,7 +1172,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mOsd.setDiagnosticsVisible(PlayerSetting.isOsdDiagnostics()); mOsd.start(); } - setPlayParamsState(); } @Override @@ -1273,7 +1208,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli Source.get().exit(); App.removeCallbacks(mR0, mR1, mR2, mR3, mR4); App.removeCallbacks(mEndRetry); - App.removeCallbacks(mBufferingTimeout); if (mOsd != null) mOsd.release(); mViewModel.url().removeObserver(mObserveUrl); mViewModel.epg().removeObserver(mObserveEpg); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java index 3a018e109a..21c98c6bd7 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java @@ -24,10 +24,10 @@ import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.impl.ConfigListener; import com.fongmi.android.tv.impl.LiveListener; import com.fongmi.android.tv.impl.SiteListener; +import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.AboutDialog; -import com.fongmi.android.tv.ui.dialog.AppearanceDialog; import com.fongmi.android.tv.ui.dialog.ConfigDialog; import com.fongmi.android.tv.ui.dialog.DohDialog; import com.fongmi.android.tv.ui.dialog.HistoryDialog; @@ -39,6 +39,7 @@ import com.fongmi.android.tv.utils.AppVersion; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; +import com.fongmi.android.tv.utils.ResUtil; import com.github.catvod.bean.Doh; import com.github.catvod.net.OkHttp; @@ -51,6 +52,8 @@ import java.util.List; public class SettingActivity extends BaseActivity implements ConfigListener, SiteListener, LiveListener, DohDialog.Listener { private ActivitySettingBinding mBinding; + private String[] size; + private String[] language; public static void start(Activity activity) { activity.startActivity(new Intent(activity, SettingActivity.class)); @@ -89,6 +92,8 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit private void setOtherText() { mBinding.dohText.setText(getDohList()[getDohIndex()]); mBinding.incognitoText.setText(getSwitch(Setting.isIncognito())); + mBinding.languageText.setText((language = ResUtil.getStringArray(R.array.select_language))[Setting.getLanguageIndex()]); + mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[PlayerSetting.getSize()]); } private void setCacheText() { @@ -106,6 +111,8 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit mBinding.doh.setOnClickListener(this::setDoh); mBinding.live.setOnClickListener(this::onLive); mBinding.wall.setOnClickListener(this::onWall); + mBinding.size.setOnClickListener(this::setSize); + mBinding.language.setOnClickListener(this::setLanguage); mBinding.cache.setOnClickListener(this::onCache); mBinding.backup.setOnClickListener(this::onBackup); mBinding.enhance.setOnClickListener(this::onEnhance); @@ -115,7 +122,6 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit mBinding.player.setOnClickListener(this::onPlayer); mBinding.danmaku.setOnClickListener(this::onDanmaku); mBinding.subtitle.setOnClickListener(this::onSubtitle); - mBinding.appearance.setOnClickListener(this::onAppearance); mBinding.personal.setOnClickListener(this::onPersonal); mBinding.restore.setOnClickListener(this::onRestore); mBinding.version.setOnClickListener(this::onVersion); @@ -260,10 +266,6 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit SettingSubtitleActivity.start(this); } - private void onAppearance(View view) { - AppearanceDialog.show(this); - } - private void onPersonal(View view) { SettingPersonalActivity.start(this); } @@ -294,6 +296,19 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit mBinding.incognitoText.setText(getSwitch(Setting.isIncognito())); } + private void setSize(View view) { + int index = (PlayerSetting.getSize() + 1) % size.length; + mBinding.sizeText.setText(size[index]); + PlayerSetting.putSize(index); + RefreshEvent.size(); + } + + private void setLanguage(View view) { + int index = (Setting.getLanguageIndex() + 1) % language.length; + Setting.putLanguageIndex(index); + RefreshEvent.language(); + } + private void setDoh(View view) { DohDialog.create().index(getDohIndex()).show(this); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java index 201a9f48ca..ff9e4868bc 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java @@ -53,8 +53,6 @@ public class SettingAdActivity extends BaseActivity { private volatile int probeRuleCount; private final ActivityResultLauncher adAudioRulePicker = registerForActivityResult(new ActivityResultContracts.OpenDocument(), this::importAdAudioRules); - private final ActivityResultLauncher speechAdRulePicker = - registerForActivityResult(new ActivityResultContracts.OpenDocument(), this::importSpeechAdRules); public static void start(Activity activity) { activity.startActivity(new Intent(activity, SettingAdActivity.class)); @@ -91,8 +89,6 @@ public class SettingAdActivity extends BaseActivity { mBinding.probeRuleRefresh.setOnClickListener(this::refreshProbeRules); mBinding.speechAdEnabled.setOnClickListener(this::toggleSpeechAdEnabled); mBinding.speechAdKeywords.setOnClickListener(this::editSpeechAdKeywords); - mBinding.speechAdRules.setOnClickListener(this::manageSpeechAdRules); - mBinding.speechAdBuiltin.setOnClickListener(this::toggleSpeechAdBuiltin); mBinding.speechAdSkipSeconds.setOnClickListener(this::editSpeechAdSkipSeconds); mBinding.speechAdSkipMode.setOnClickListener(this::selectSpeechAdSkipMode); mBinding.autoSkipIntroOutro.setOnClickListener(this::setAutoSkipIntroOutro); @@ -130,11 +126,8 @@ public class SettingAdActivity extends BaseActivity { safeSet("probeRuleSource", mBinding.probeRuleSourceText, this::getProbeRuleSourceText); safeSet("probeRuleRefresh", mBinding.probeRuleRefreshText, this::getProbeRuleRefreshText); SpeechAdConfig speech = SpeechAdSetting.snapshot(); - SpeechAdSetting.RuleSnapshot speechRules = SpeechAdSetting.ruleSnapshot(); safeSet("speechAdEnabled", mBinding.speechAdEnabledText, () -> getSpeechAdEnabledText(speech)); safeSet("speechAdKeywords", mBinding.speechAdKeywordsText, () -> getString(R.string.speech_ad_keyword_count, speech.keywords().values().size())); - safeSet("speechAdRules", mBinding.speechAdRulesText, () -> getSpeechAdRulesText(speechRules)); - safeSet("speechAdBuiltin", mBinding.speechAdBuiltinText, () -> getSpeechAdBuiltinText(speechRules)); safeSet("speechAdSkipSeconds", mBinding.speechAdSkipSecondsText, () -> getString(R.string.speech_ad_skip_seconds_value, speech.skipSeconds())); safeSet("speechAdSkipMode", mBinding.speechAdSkipModeText, () -> speech.mode() == AdSkipPolicyController.Mode.AUTO ? getString(R.string.speech_ad_skip_mode_auto) : getString(R.string.speech_ad_skip_mode_prompt)); @@ -354,145 +347,6 @@ public class SettingAdActivity extends BaseActivity { setText(); } - private String getSpeechAdRulesText(SpeechAdSetting.RuleSnapshot snapshot) { - if (snapshot.hasError()) return getString(R.string.speech_ad_rules_error, snapshot.error()); - if (snapshot.rules().isEmpty()) return getString(R.string.speech_ad_rules_none); - return getString(R.string.speech_ad_rules_summary, - snapshot.rules().rules().size(), getSpeechAdRuleSourceText(snapshot)); - } - - private String getSpeechAdBuiltinText(SpeechAdSetting.RuleSnapshot snapshot) { - int count = Math.max(snapshot.builtinRules().rules().size(), SpeechAdSetting.builtinRuleCount()); - return getString(R.string.speech_ad_builtin_value, - getSwitch(snapshot.builtinEnabled()), count); - } - - private String getSpeechAdRuleSourceText(SpeechAdSetting.RuleSnapshot snapshot) { - boolean hasCustom = !snapshot.customRules().isEmpty(); - boolean hasBuiltin = snapshot.builtinEnabled() && !snapshot.builtinRules().isEmpty(); - if (hasCustom && hasBuiltin) return getString(R.string.speech_ad_rules_source_combined); - if (hasBuiltin) return getString(R.string.speech_ad_rules_source_builtin); - if (snapshot.source() == SpeechAdSetting.RuleSource.IMPORTED) { - return getString(R.string.speech_ad_rules_source_imported); - } - if (snapshot.source() == SpeechAdSetting.RuleSource.USER) { - return getString(R.string.speech_ad_rules_source_user); - } - return getString(R.string.speech_ad_rules_source_none); - } - - private void manageSpeechAdRules(View view) { - new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_rules_manage) - .setItems(new String[]{ - getString(R.string.speech_ad_rules_edit), - getString(R.string.speech_ad_rules_import), - getString(R.string.speech_ad_rules_clear), - getString(R.string.speech_ad_rules_view_builtin) - }, (dialog, which) -> { - if (which == 0) editSpeechAdRules(); - else if (which == 1) speechAdRulePicker.launch(new String[]{"text/plain", "text/*"}); - else if (which == 2) confirmClearSpeechAdRules(); - else showBuiltinSpeechAdRules(); - }) - .setNegativeButton(R.string.dialog_negative, null) - .show(); - } - - private void showBuiltinSpeechAdRules() { - try { - new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_rules_view_builtin) - .setMessage(SpeechAdSetting.builtinRulesText()) - .setPositiveButton(R.string.dialog_positive, null) - .show(); - } catch (RuntimeException error) { - Notify.show(getString(R.string.speech_ad_rules_error, SpeechAdSetting.safeError(error))); - } - } - - private void editSpeechAdRules() { - EditText input = new EditText(this); - input.setGravity(Gravity.TOP | Gravity.START); - input.setMinLines(8); - input.setMaxLines(16); - input.setSingleLine(false); - input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); - input.setText(SpeechAdSetting.customRulesText()); - input.setSelection(input.length()); - AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_rules_edit) - .setView(input) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, null) - .create(); - dialog.setOnShowListener(ignored -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(button -> { - try { - String text = input.getText().toString(); - if (text.trim().isEmpty()) SpeechAdSetting.clearRules(); - else SpeechAdSetting.setRulesText(text, SpeechAdSetting.RuleSource.USER); - Notify.show(R.string.speech_ad_rules_saved); - notifyAdAudioRuntime(); - setText(); - dialog.dismiss(); - } catch (RuntimeException error) { - input.setError(SpeechAdSetting.safeError(error)); - input.requestFocus(); - } - })); - dialog.show(); - LightDialog.apply(dialog); - } - - private void importSpeechAdRules(Uri uri) { - if (uri == null) return; - Task.execute(() -> { - boolean imported = false; - String message; - try { - SpeechAdSetting.importUri(getContentResolver(), uri); - imported = true; - message = getString(R.string.speech_ad_rules_imported, - SpeechAdSetting.ruleSnapshot().customRules().rules().size()); - } catch (Exception error) { - message = getString(R.string.speech_ad_rules_import_failed, - SpeechAdSetting.safeError(error)); - } - final boolean success = imported; - String result = message; - runOnUiThread(() -> { - if (success) notifyAdAudioRuntime(); - if (!canSetText()) return; - Notify.show(result); - setText(); - }); - }); - } - - private void confirmClearSpeechAdRules() { - new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_rules_clear) - .setMessage(R.string.speech_ad_rules_clear_confirm) - .setPositiveButton(R.string.dialog_positive, (dialog, which) -> { - SpeechAdSetting.clearRules(); - Notify.show(R.string.speech_ad_rules_clear_done); - notifyAdAudioRuntime(); - setText(); - }) - .setNegativeButton(R.string.dialog_negative, null) - .show(); - } - - private void toggleSpeechAdBuiltin(View view) { - try { - SpeechAdSetting.setBuiltinEnabled(!SpeechAdSetting.isBuiltinEnabled()); - notifyAdAudioRuntime(); - } catch (RuntimeException error) { - Notify.show(getString(R.string.speech_ad_rules_error, SpeechAdSetting.safeError(error))); - } - setText(); - } - private void editSpeechAdKeywords(View view) { SpeechAdConfig speech = SpeechAdSetting.snapshot(); EditText input = new EditText(this); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAppearanceActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAppearanceActivity.java deleted file mode 100644 index e1096b22be..0000000000 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAppearanceActivity.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; -import android.view.View; - -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.api.config.WallConfig; -import com.fongmi.android.tv.databinding.ActivitySettingAppearanceBinding; -import com.fongmi.android.tv.event.ConfigEvent; -import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.impl.Callback; -import com.fongmi.android.tv.setting.PlayerSetting; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.ui.base.BaseActivity; -import com.fongmi.android.tv.ui.dialog.ConfigDialog; -import com.fongmi.android.tv.ui.dialog.HistoryDialog; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.ResUtil; - -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; - -public class SettingAppearanceActivity extends BaseActivity { - - private ActivitySettingAppearanceBinding mBinding; - private String[] uiScale; - private String[] language; - private String[] size; - - public static void start(Activity activity) { - activity.startActivity(new Intent(activity, SettingAppearanceActivity.class)); - } - - @Override - protected ViewBinding getBinding() { - return mBinding = ActivitySettingAppearanceBinding.inflate(getLayoutInflater()); - } - - @Override - protected void initView(Bundle savedInstanceState) { - mBinding.uiScale.requestFocus(); - setText(); - } - - private void setText() { - mBinding.uiScaleText.setText((uiScale = ResUtil.getStringArray(R.array.select_ui_scale))[Setting.getUiScaleIndex()]); - mBinding.languageText.setText((language = ResUtil.getStringArray(R.array.select_language))[Setting.getLanguageIndex()]); - mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[getSizeIndex()]); - setWallText(); - } - - @Override - protected void initEvent() { - mBinding.uiScale.setOnClickListener(this::setUiScale); - mBinding.language.setOnClickListener(this::setLanguage); - mBinding.size.setOnClickListener(this::setSize); - mBinding.wall.setOnClickListener(this::onWall); - mBinding.wall.setOnLongClickListener(this::onWallEdit); - mBinding.wallHome.setOnClickListener(this::setWallDefault); - mBinding.wallRefresh.setOnClickListener(this::setWallRefresh); - mBinding.wallHistory.setOnClickListener(this::onWallHistory); - } - - private void setUiScale(View view) { - int index = (Setting.getUiScaleIndex() + 1) % uiScale.length; - Setting.putUiScaleIndex(index); - RefreshEvent.uiScale(); - } - - private void setLanguage(View view) { - int index = (Setting.getLanguageIndex() + 1) % language.length; - Setting.putLanguageIndex(index); - RefreshEvent.language(); - } - - private void setSize(View view) { - int index = (getSizeIndex() + 1) % size.length; - PlayerSetting.putSize(index); - RefreshEvent.size(); - setText(); - } - - private int getSizeIndex() { - return Math.max(0, Math.min(PlayerSetting.getSize(), size.length - 1)); - } - - private void onWall(View view) { - ConfigDialog.create().wall().show(this); - } - - private boolean onWallEdit(View view) { - ConfigDialog.create().wall().edit().show(this); - return true; - } - - private void setWallDefault(View view) { - Setting.putWall(Setting.nextDefaultWall()); - Setting.putWallType(0); - setWallText(); - ConfigEvent.wall(); - } - - private void setWallRefresh(View view) { - Setting.putWall(0); - WallConfig.get().load(getCallback()); - } - - private boolean onWallHistory(View view) { - HistoryDialog.create().wall().show(this); - return true; - } - - private Callback getCallback() { - return new Callback() { - @Override - public void start() { - Notify.progress(getActivity()); - } - - @Override - public void success() { - Notify.dismiss(); - setWallText(); - } - - @Override - public void error(String msg) { - Notify.dismiss(); - Notify.show(msg); - } - }; - } - - private void setWallText() { - mBinding.wallText.setText(Setting.getWallDesc(WallConfig.getDesc())); - } - - @Subscribe(threadMode = ThreadMode.MAIN) - public void onConfigEvent(ConfigEvent event) { - if (event.type() == ConfigEvent.Type.WALL) { - setWallText(); - } - } -} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPersonalActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPersonalActivity.java index 96a11ceed9..1fc41e3735 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPersonalActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPersonalActivity.java @@ -10,10 +10,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.HomeButton; import com.fongmi.android.tv.databinding.ActivitySettingPersonalBinding; -import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; import com.fongmi.android.tv.setting.AppBranding; import com.fongmi.android.tv.setting.AutoBackupPolicy; import com.fongmi.android.tv.setting.GroupRuleConfig; @@ -25,7 +22,6 @@ import com.fongmi.android.tv.ui.dialog.HomeButtonDialog; import com.fongmi.android.tv.ui.dialog.HomeMenuKeyDialog; import com.fongmi.android.tv.ui.dialog.SpeedSettingDialog; import com.fongmi.android.tv.ui.dialog.SliderNumberDialog; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; import com.fongmi.android.tv.utils.Util; @@ -42,7 +38,6 @@ public class SettingPersonalActivity extends BaseActivity { private String[] searchColumn; private String[] searchResultSort; private String[] globalHistoryMode; - private String[] interfaceFailoverMode; public static void start(Activity activity) { activity.startActivity(new Intent(activity, SettingPersonalActivity.class)); @@ -66,17 +61,14 @@ public class SettingPersonalActivity extends BaseActivity { @Override protected void initEvent() { mBinding.homeVodAutoLoad.setOnClickListener(this::setHomeVodAutoLoad); - mBinding.following.setOnClickListener(this::setFollowing); mBinding.homeSiteLock.setOnClickListener(this::setHomeSiteLock); mBinding.autoBackup.setOnClickListener(this::setAutoBackup); mBinding.homeButtons.setOnClickListener(this::onHomeButtons); mBinding.fullscreenMenuKey.setOnClickListener(this::setFullscreenMenuKey); mBinding.homeMenuKey.setOnClickListener(this::setHomeMenuKey); mBinding.playBackToDetail.setOnClickListener(this::setPlayBackToDetail); - mBinding.playbackOverlay.setOnClickListener(this::setPlaybackOverlay); mBinding.episodeHistory.setOnClickListener(this::setEpisodeHistory); mBinding.globalHistory.setOnClickListener(this::setGlobalHistory); - mBinding.interfaceFailover.setOnClickListener(this::setInterfaceFailover); mBinding.playSpeed.setOnClickListener(this::setPlaySpeed); mBinding.groupRule.setOnClickListener(this::setGroupRule); mBinding.homeHistory.setOnClickListener(this::setHomeHistory); @@ -86,7 +78,6 @@ public class SettingPersonalActivity extends BaseActivity { // mBinding.searchColumn.setOnClickListener(this::setSearchColumn); // 在搜索页面切换更方便 mBinding.appBranding.setOnClickListener(this::startAppBranding); mBinding.resetApp.setOnClickListener(this::showResetAppDialog); - mBinding.touchOptimization.setOnClickListener(this::setTouchOptimization); } @Override @@ -97,17 +88,14 @@ public class SettingPersonalActivity extends BaseActivity { private void setText() { mBinding.homeVodAutoLoadText.setText(getSwitch(Setting.isHomeVodAutoLoad())); - mBinding.followingText.setText(getSwitch(FollowingSettings.isEnabled())); mBinding.homeSiteLockText.setText(getSwitch(Setting.isHomeSiteLock())); mBinding.autoBackupText.setText(getSwitch(isAutoBackupEnabled())); mBinding.homeButtonsText.setText(getString(R.string.home_buttons_selected, HomeButton.getButtons().size(), HomeButton.all().size())); mBinding.fullscreenMenuKeyText.setText((fullscreenMenuKey = getResources().getStringArray(R.array.select_fullscreen_menu_key))[Setting.getFullscreenMenuKey()]); mBinding.homeMenuKeyText.setText((homeMenuKey = getResources().getStringArray(R.array.select_home_menu_key))[Setting.getHomeMenuKey()]); mBinding.playBackToDetailText.setText(getSwitch(Setting.isPlayBackToDetail())); - mBinding.playbackOverlayText.setText(getSwitch(Setting.isPlaybackOverlayEnabled())); mBinding.episodeHistoryText.setText(getSwitch(Setting.isEpisodeHistory())); mBinding.globalHistoryText.setText((globalHistoryMode = getResources().getStringArray(R.array.select_global_history_mode))[Setting.getGlobalHistoryMode()]); - mBinding.interfaceFailoverText.setText((interfaceFailoverMode = getResources().getStringArray(R.array.select_interface_failover_mode))[Setting.getInterfaceFailoverMode()]); mBinding.playSpeedText.setText(getSpeedText(PlayerSetting.getDefaultSpeed())); mBinding.groupRuleText.setText(getString(R.string.setting_group_rule_summary, GroupRuleConfig.enabledCount(), GroupRuleConfig.totalCount())); mBinding.homeHistoryText.setText(getSwitch(Setting.isHomeHistory())); @@ -116,7 +104,6 @@ public class SettingPersonalActivity extends BaseActivity { mBinding.searchResultSortText.setText((searchResultSort = getResources().getStringArray(R.array.select_search_result_sort))[Setting.getSearchResultSort()]); // mBinding.searchColumnText.setText(getSearchColumnText()); // 在搜索页面切换更方便 mBinding.appBrandingText.setText(AppBranding.getSummary(this)); - mBinding.touchOptimizationText.setText(getSwitch(Setting.isTouchOptimized())); } private String getSearchColumnText() { @@ -137,20 +124,6 @@ public class SettingPersonalActivity extends BaseActivity { setText(); } - private void setFollowing(View view) { - boolean enabled = !FollowingSettings.isEnabled(); - FollowingSettings.setEnabled(enabled); - if (enabled) { - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); - } else { - FollowingScheduler.cancelAll(this); - } - ConfigEvent.common(); - RefreshEvent.home(); - setText(); - } - private void setHomeSiteLock(View view) { Setting.putHomeSiteLock(!Setting.isHomeSiteLock()); setText(); @@ -189,11 +162,6 @@ public class SettingPersonalActivity extends BaseActivity { HomeMenuKeyDialog.show(this, this::setText); } - private void setPlaybackOverlay(View view) { - Setting.putPlaybackOverlayEnabled(!Setting.isPlaybackOverlayEnabled()); - setText(); - } - private void setPlayBackToDetail(View view) { Setting.putPlayBackToDetail(!Setting.isPlayBackToDetail()); setText(); @@ -210,11 +178,6 @@ public class SettingPersonalActivity extends BaseActivity { setText(); } - private void setInterfaceFailover(View view) { - Setting.putInterfaceFailoverMode((Setting.getInterfaceFailoverMode() + 1) % interfaceFailoverMode.length); - setText(); - } - private void setPlaySpeed(View view) { SpeedSettingDialog.show(this, R.string.setting_play_speed, PlayerSetting.getDefaultSpeed(), 0.5f, 5f, 0.25f, value -> { PlayerSetting.putDefaultSpeed(value); @@ -262,13 +225,6 @@ public class SettingPersonalActivity extends BaseActivity { AppBrandingActivity.start(this); } - private void setTouchOptimization(View view) { - boolean enabled = !Setting.isTouchOptimized(); - Setting.putTouchOptimized(enabled); - mBinding.touchOptimizationText.setText(getSwitch(enabled)); - TouchOptimizationHelper.sync(getWindow().getDecorView()); - } - private void resetApp() { if (!Util.resetApp()) Notify.show(R.string.reset_app_failed); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java index 174c36c4a2..04a6e842fa 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java @@ -17,7 +17,6 @@ import com.fongmi.android.tv.player.lut.LutSetting; import com.fongmi.android.tv.player.mpv.MpvConfigStore; import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; import com.fongmi.android.tv.setting.PlayerButtonSetting; -import com.fongmi.android.tv.setting.LiveSetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.PreloadSetting; import com.fongmi.android.tv.setting.Setting; @@ -87,7 +86,6 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B setPreloadText(); mBinding.autoPlayText.setText(getSwitch(PlayerSetting.isAutoPlay())); mBinding.autoChangeText.setText(getSwitch(PlayerSetting.isAutoChange())); - mBinding.liveSourceFallbackText.setText(getSwitch(LiveSetting.isSourceFallback())); mBinding.failureFallbackText.setText((failureFallback = ResUtil.getStringArray(R.array.select_player_failure_fallback))[PlayerSetting.getFailureFallback()]); mBinding.backgroundText.setText(getSwitch(PlayerSetting.isBackgroundOn())); mBinding.musicNotificationText.setText(getSwitch(PlayerSetting.isMusicNotification())); @@ -95,12 +93,14 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.audioDecodeText.setText(getSwitch(PlayerSetting.isAudioPrefer())); mBinding.audioPassThroughText.setText(getSwitch(PlayerSetting.isAudioPassThrough())); mBinding.videoDecodeText.setText(getSwitch(PlayerSetting.isVideoPrefer())); + mBinding.ffmpegModeText.setText(getFFmpegModeText()); mBinding.osdText.setText(getOsdText(osd = ResUtil.getStringArray(R.array.select_player_osd))); mBinding.kernelText.setText((kernel = ResUtil.getStringArray(R.array.select_player_kernel))[PlayerSetting.getPlayer()]); mpvRender = ResUtil.getStringArray(R.array.select_mpv_render); mBinding.scaleText.setText((scale = ResUtil.getStringArray(R.array.select_scale))[PlayerSetting.getScale()]); mBinding.lutText.setText(LutSetting.getSummary()); setMpvRows(); + setFfmpegModeVisibility(); mBinding.renderText.setText((render = ResUtil.getStringArray(R.array.select_render))[PlayerSetting.getRender()]); mBinding.captionText.setText((caption = ResUtil.getStringArray(R.array.select_caption))[PlayerSetting.isCaption() ? 1 : 0]); hidePerformanceRows(); @@ -114,10 +114,6 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.scale.setOnClickListener(this::setScale); mBinding.lut.setOnClickListener(this::onLut); mBinding.mpvConfig.setOnClickListener(view -> MpvConfigDialog.show(this, () -> mBinding.mpvConfigText.setText(MpvConfigStore.summary()))); - mBinding.blurayMenu.setOnClickListener(view -> { - PlayerSetting.putBlurayMenu(!PlayerSetting.isBlurayMenu()); - mBinding.blurayMenuText.setText(getSwitch(PlayerSetting.isBlurayMenu())); - }); mBinding.mpvRender.setOnClickListener(this::setMpvRender); mBinding.osd.setOnClickListener(this::onOsd); mBinding.playerButtons.setOnClickListener(view -> PlayerButtonConfigDialog.show(this, this::setPlayerButtonsText)); @@ -134,10 +130,6 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.preloadPause.setOnClickListener(this::setPreloadPause); mBinding.autoPlay.setOnClickListener(this::setAutoPlay); mBinding.autoChange.setOnClickListener(this::setAutoChange); - mBinding.liveSourceFallback.setOnClickListener(view -> { - LiveSetting.putSourceFallback(!LiveSetting.isSourceFallback()); - mBinding.liveSourceFallbackText.setText(getSwitch(LiveSetting.isSourceFallback())); - }); mBinding.failureFallback.setOnClickListener(this::setFailureFallback); mBinding.render.setOnClickListener(this::setRender); mBinding.tunnel.setOnClickListener(this::setTunnel); @@ -150,6 +142,7 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.audioDecode.setOnClickListener(this::setAudioDecode); mBinding.audioPassThrough.setOnClickListener(this::setAudioPassThrough); mBinding.videoDecode.setOnClickListener(this::setVideoDecode); + mBinding.ffmpegMode.setOnClickListener(this::setFfmpegMode); } private void setVisible() { @@ -178,6 +171,7 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.kernelText.setText(kernel[index]); PlayerSetting.putPlayer(index); setMpvRows(); + setFfmpegModeVisibility(); setPerformanceText(); }); } @@ -196,13 +190,16 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B private void setMpvRows() { boolean visible = PlayerSetting.getPlayer() == PlayerSetting.MPV; mBinding.mpvConfig.setVisibility(visible ? View.VISIBLE : View.GONE); - mBinding.blurayMenu.setVisibility(visible ? View.VISIBLE : View.GONE); mBinding.mpvRender.setVisibility(visible ? View.VISIBLE : View.GONE); mBinding.mpvConfigText.setText(MpvConfigStore.summary()); - mBinding.blurayMenuText.setText(getSwitch(PlayerSetting.isBlurayMenu())); mBinding.mpvRenderText.setText(getMpvRenderText()); } + private void setFfmpegModeVisibility() { + boolean visible = PlayerSetting.getPlayer() == PlayerSetting.EXO; + mBinding.ffmpegMode.setVisibility(visible ? View.VISIBLE : View.GONE); + } + private void setMpvRender(View view) { int index = (PlayerSetting.getMpvRender() + 1) % mpvRender.length; PlayerSetting.putMpvRender(index); @@ -487,6 +484,21 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B setPerformanceText(); } + private void setFfmpegMode(View view) { + int mode = (PlayerSetting.getFFmpegMode() + 1) % 4; + PlayerSetting.putFFmpegMode(mode); + mBinding.ffmpegModeText.setText(getFFmpegModeText()); + } + + private String getFFmpegModeText() { + return switch (PlayerSetting.getFFmpegMode()) { + case PlayerSetting.FFMPEG_MODE_OFFICIAL -> "Official"; + case PlayerSetting.FFMPEG_MODE_SIMPLE -> "Simple"; + case PlayerSetting.FFMPEG_MODE_AUTO -> "自动"; + default -> "NextLib"; + }; + } + private void onBackground(View view) { PlayerSetting.putBackground(PlayerSetting.isBackgroundOn() ? 0 : 1); mBinding.backgroundText.setText(getSwitch(PlayerSetting.isBackgroundOn())); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingSubtitleActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingSubtitleActivity.java index e8e8ce1510..a8b501a72c 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingSubtitleActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingSubtitleActivity.java @@ -11,7 +11,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.ActivitySettingSubtitleBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.subtitle.provider.SubtitleProviderRegistry; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.SubtitleSettingsDialog; import com.fongmi.android.tv.utils.ResUtil; @@ -42,14 +41,14 @@ public class SettingSubtitleActivity extends BaseActivity { subtitleLanguageValues = ResUtil.getStringArray(R.array.select_subtitle_language_value); mBinding.subtitleAutoMatchText.setText(getSwitch(Setting.isSubtitleAutoMatchEnabled())); mBinding.subtitleLanguageText.setText(getSubtitleLanguageText()); - mBinding.subtitleAssrtTokenText.setText(getSubtitleSourceText()); + mBinding.subtitleAssrtTokenText.setText(getSubtitleAssrtTokenText()); } @Override protected void initEvent() { mBinding.subtitleAutoMatch.setOnClickListener(this::setSubtitleAutoMatch); mBinding.subtitleLanguage.setOnClickListener(this::onSubtitleLanguage); - mBinding.subtitleAssrtToken.setOnClickListener(this::onSubtitleSource); + mBinding.subtitleAssrtToken.setOnClickListener(this::onSubtitleAssrtToken); } private void setSubtitleAutoMatch(View view) { @@ -64,8 +63,11 @@ public class SettingSubtitleActivity extends BaseActivity { }); } - private void onSubtitleSource(View view) { - SubtitleSettingsDialog.showSourceSummary(this, SubtitleProviderRegistry.get().providers(), () -> mBinding.subtitleAssrtTokenText.setText(getSubtitleSourceText())); + private void onSubtitleAssrtToken(View view) { + SubtitleSettingsDialog.showAssrtToken(this, Setting.getSubtitleAssrtToken(), value -> { + Setting.putSubtitleAssrtToken(value); + mBinding.subtitleAssrtTokenText.setText(getSubtitleAssrtTokenText()); + }); } private String getSubtitleLanguageText() { @@ -79,7 +81,7 @@ public class SettingSubtitleActivity extends BaseActivity { return 0; } - private String getSubtitleSourceText() { - return getString(R.string.player_subtitle_source_count, SubtitleProviderRegistry.get().providerNames().size()); + private String getSubtitleAssrtTokenText() { + return getString(TextUtils.isEmpty(Setting.getSubtitleAssrtToken()) ? R.string.setting_unconfigured : R.string.setting_configured); } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingTmdbActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingTmdbActivity.java index e9d49aa1ae..e8fec7eb72 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingTmdbActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingTmdbActivity.java @@ -15,6 +15,7 @@ import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.LightDialog; import com.fongmi.android.tv.ui.dialog.TmdbSourceDialog; +import com.fongmi.android.tv.utils.Notify; import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class SettingTmdbActivity extends BaseActivity { @@ -94,6 +95,15 @@ public class SettingTmdbActivity extends BaseActivity { private void setDetailOpenMode(View view) { AlertDialog alert = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog).setTitle(R.string.setting_detail_open_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailOpenModes(), getDetailOpenModeIndex(), (dialog, which) -> { int mode = DETAIL_OPEN_MODES[which]; + if (Setting.isTmdbMode(mode) && !Setting.isTmdbReady()) { + dialog.dismiss(); + Notify.show(R.string.detail_tmdb_need_key); + TmdbSourceDialog.create(this).onDismiss(() -> { + if (Setting.isTmdbReady()) Setting.putDetailOpenMode(mode); + setText(); + }).show(); + return; + } Setting.putDetailOpenMode(mode); setText(); dialog.dismiss(); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java index a40a3670cb..6f30d99523 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java @@ -2,10 +2,8 @@ package com.fongmi.android.tv.ui.activity; import android.annotation.SuppressLint; import android.app.Activity; -import android.content.Context; import android.content.Intent; import android.graphics.Rect; -import android.media.AudioManager; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; @@ -33,11 +31,9 @@ import androidx.leanback.widget.VerticalGridView; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.C; -import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; import androidx.media3.common.VideoSize; -import androidx.media3.mpvplayer.MpvPlayer; import androidx.media3.ui.PlayerView; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -50,8 +46,11 @@ import com.fongmi.android.tv.api.DanmakuApi; import com.fongmi.android.tv.api.SiteApi; import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.AdDetectionRequest; -import com.fongmi.android.tv.bean.AdDetectionResult; +import com.fongmi.android.tv.ad.feedback.AdFeedbackController; +import com.fongmi.android.tv.ad.feedback.AdFeedbackHostAdapter; +import com.fongmi.android.tv.ad.feedback.AdFeedbackSession; +import com.fongmi.android.tv.ad.feedback.AdRulePlanApplier; +import com.fongmi.android.tv.ui.dialog.AdFeedbackDialog; import com.fongmi.android.tv.bean.AiConfig; import com.fongmi.android.tv.bean.Danmaku; import com.fongmi.android.tv.bean.Episode; @@ -66,8 +65,6 @@ import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.bean.Sub; import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbSourcePayload; import com.fongmi.android.tv.bean.TmdbVideo; import com.fongmi.android.tv.bean.Track; import com.fongmi.android.tv.bean.UserAdRule; @@ -81,17 +78,12 @@ import com.fongmi.android.tv.model.SiteViewModel; import com.fongmi.android.tv.model.SearchProgress; import com.fongmi.android.tv.playback.PlaybackEventCollector; import com.fongmi.android.tv.playback.HistoryResumePayload; -import com.fongmi.android.tv.playback.SubtitleRestoreCoordinator; import com.fongmi.android.tv.player.IntroSkipKinds; import com.fongmi.android.tv.player.IntroSkipPlayback; -import com.fongmi.android.tv.player.PlaybackAutoContext; -import com.fongmi.android.tv.player.PlaybackExperimentPolicy; -import com.fongmi.android.tv.player.PlaybackResourceClassifier; import com.fongmi.android.tv.player.PlayerHelper; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.lut.LutPreset; import com.fongmi.android.tv.player.lut.LutStore; -import com.fongmi.android.tv.service.AiAdDetectionService; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.service.IntroSkipService; import com.fongmi.android.tv.service.OmdbService; @@ -99,22 +91,13 @@ import com.fongmi.android.tv.service.PersonalRecommendationService; import com.fongmi.android.tv.setting.DanmakuSetting; import com.fongmi.android.tv.setting.PlayerButtonSetting; import com.fongmi.android.tv.setting.MultiThreadProxySetting; -import com.fongmi.android.tv.setting.PlaybackExperimentSetting; -import com.fongmi.android.tv.setting.PreloadSetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.setting.SiteHealthStore; -import com.fongmi.android.tv.setting.DetailRuntimeModePolicy; import com.fongmi.android.tv.setting.TmdbSitePolicy; -import com.fongmi.android.tv.setting.TmdbSourceState; import com.fongmi.android.tv.title.MediaTitleLearningExample; import com.fongmi.android.tv.title.MediaTitleRequest; import com.fongmi.android.tv.subtitle.SubtitlePlaybackSession; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingSource; import com.fongmi.android.tv.ui.activity.SearchActivity; import com.fongmi.android.tv.ui.adapter.ArrayAdapter; import com.fongmi.android.tv.ui.adapter.BackdropAdapter; @@ -131,7 +114,6 @@ import com.fongmi.android.tv.ui.custom.CustomSeekView; import com.fongmi.android.tv.ui.custom.PlayerOsdController; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.ui.dialog.CodecCapabilityDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.ui.dialog.ContentDialog; import com.fongmi.android.tv.ui.dialog.AdRuleEditDialog; import com.fongmi.android.tv.ui.dialog.DanmakuDialog; @@ -150,23 +132,15 @@ import com.fongmi.android.tv.ui.helper.EpisodeRangePolicy; import com.fongmi.android.tv.ui.helper.EpisodeSeasonPolicy; import com.fongmi.android.tv.ui.helper.SourceEpisodeSeasonCache; import com.fongmi.android.tv.ui.helper.PlayerControlFocusHelper; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.ui.helper.TmdbEpisodeGridPolicy; import com.fongmi.android.tv.ui.helper.TmdbNavigation; -import com.fongmi.android.tv.ui.helper.TmdbBundle; -import com.fongmi.android.tv.ui.helper.TmdbSourceAdapter; -import com.fongmi.android.tv.ui.helper.TmdbSourceAvailability; -import com.fongmi.android.tv.ui.helper.TmdbSourcePayloadParser; import com.fongmi.android.tv.ui.helper.TmdbVideoPlayback; import com.fongmi.android.tv.ui.helper.VodEventGuard; import com.fongmi.android.tv.ui.player.VodPlayerChrome; import com.fongmi.android.tv.ui.player.VodPlayerUiController; import com.fongmi.android.tv.ui.player.VodPlayerUiHost; -import com.fongmi.android.tv.ui.player.ShortDramaQueueCoordinator; -import com.fongmi.android.tv.player.engine.PlaySpec; import com.fongmi.android.tv.utils.ActivityLaunch; import com.fongmi.android.tv.utils.AudioUtil; -import com.fongmi.android.tv.utils.BrightnessPolicy; import com.fongmi.android.tv.utils.Clock; import com.fongmi.android.tv.utils.EpisodeHistoryTitleResolver; import com.fongmi.android.tv.utils.EpisodeTitleFormatter; @@ -238,9 +212,7 @@ import com.fongmi.android.tv.player.lyrics.LyricsRequest; import com.fongmi.android.tv.player.lyrics.LyricsRepository; import com.fongmi.android.tv.player.lyrics.LyricsResult; import com.fongmi.android.tv.player.lut.LutSetting; -import com.fongmi.android.tv.player.mpv.MpvConfigStore; import com.fongmi.android.tv.setting.LyricsSetting; -import com.fongmi.android.tv.ui.custom.FlagSelectionListener; import com.fongmi.android.tv.ui.custom.AudioPlayerBackgroundDrawable; import com.fongmi.android.tv.ui.custom.KaraokeResultView; import com.fongmi.android.tv.ui.dialog.CastDialog; @@ -336,8 +308,6 @@ private Runnable mAudioRefreshLyricsRunnable; private Runnable mApplyAudioBackgroundRunnable; private Runnable mHideAudioFocusRunnable; private long mInitialPlaybackPosition = C.TIME_UNSET; -private int runtimeDetailMode = Setting.getDetailOpenMode(); -private boolean runtimeSourceOnly; private static final int SHORT_DRAMA_SCALE = 0; // 0=原始(适合TV), 4=裁剪(适合手机) private static final int TMDB_DETAIL_LOAD_TIMEOUT = 15000; @@ -357,7 +327,6 @@ private boolean runtimeSourceOnly; private static final String EXTRA_RESUME_HISTORY_CID = "resume_history_cid"; private static final String EXTRA_RESUME_HISTORY_KEY = "resume_history_key"; private static final String EXTRA_TMDB_VOD_CACHE_KEY = "tmdb_vod_cache_key"; - private static final String EXTRA_DETAIL_RUNTIME_MODE = "detail_runtime_mode"; private static final String EXTRA_IMMERSIVE_AUDIO_CACHE_KEY = "immersive_audio_cache_key"; private static final String EXTRA_SEARCH_KEYWORD = "search_keyword"; private static final java.util.concurrent.ConcurrentHashMap IMMERSIVE_AUDIO_LAUNCHES = new java.util.concurrent.ConcurrentHashMap<>(); @@ -378,29 +347,13 @@ private boolean runtimeSourceOnly; private PartAdapter mPartAdapter; private BackdropAdapter mBackdropAdapter; private Map mActionButtons; - private final List mCustomActionViews = new ArrayList<>(); - private HorizontalScrollView mCustomPortraitButtons; - private LinearLayout mCustomPortraitButtonRow; private QuickSearchDialog mQuickSearchDialog; private VodPlayerUiController mPlayerUi; private PlayerOsdController mOsd; private final IntroSkipPlayback mIntroSkipPlayback = new IntroSkipPlayback(); private androidx.appcompat.app.AlertDialog mIntroSkipConfirmDialog; private final SubtitlePlaybackSession subtitlePlaybackSession = new SubtitlePlaybackSession(this); - private static final int TV_TOUCH_NONE = 0; - private static final int TV_TOUCH_HORIZONTAL = 1; - private static final int TV_TOUCH_VERTICAL = 2; - private static final long TV_TOUCH_SEEK_SCALE = 50L; - private CustomKeyDownVod mKeyDown; - private AudioManager mTvAudioManager; - private int mTvTouchAxis; - private boolean mTvTouchMulti; - private float mTvTouchDownX; - private float mTvTouchDownY; - private float mTvTouchBright; - private float mTvTouchVolume; - private long mTvTouchSeek; private SiteViewModel mViewModel; private List mBroken; private History mHistory; @@ -413,8 +366,6 @@ private boolean runtimeSourceOnly; private boolean detailRequested; private boolean detailHealthRecorded; private boolean playHealthRecorded; - private int followingUiGeneration; - private boolean followingActionPending; private SpaceItemDecoration episodeGridDecoration; private boolean episodeGridMode; private Runnable mR1; @@ -443,7 +394,11 @@ private boolean runtimeSourceOnly; private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.recommendationExecutor()); private final Task.Scope mTmdbRatingTasks = new Task.Scope(Task.recommendationExecutor()); private String mTmdbRatingContextKey = ""; - private int mAdFeedbackGeneration; + private AdFeedbackController mAdFeedback; + private AdFeedbackHostAdapter mAdFeedbackHost; + private AdFeedbackDialog mAdFeedbackDialog; + /** 标记模式下已打的起点,-1 表示未进入标记模式。 */ + private long mAdMarkStartMs = -1; // TMDB 模式相关字段 private com.fongmi.android.tv.ui.helper.TmdbUIAdapter mTmdbUIAdapter; @@ -493,16 +448,6 @@ private boolean runtimeSourceOnly; private String playerContentFlag = ""; private String playerContentEpisode = ""; private Result mAppliedPlayerResult; - private final ShortDramaQueueCoordinator mShortDramaQueue = new ShortDramaQueueCoordinator(); - private PlaySpec mShortDramaQueuedSpec; - private Result mShortDramaQueuedResult; - private Episode mShortDramaQueuedEpisode; - private boolean mShortDramaQueueSessionActive; - private boolean mShortDramaQueueFallbackUsed; - private boolean mShortDramaEndedFallbackScheduled; - private long mShortDramaTransitionPositionMs = C.TIME_UNSET; - private long mShortDramaTransitionDurationMs = C.TIME_UNSET; - private final Runnable mShortDramaEndedFallback = this::fallbackShortDramaQueued; private AudioPlaybackResolver.Resolved mImmersiveAudioResolved; private boolean mImmersiveAudioRequested; private String mContextWallUrl; @@ -516,10 +461,6 @@ private boolean runtimeSourceOnly; private boolean playerKernelSwitchRefreshing; private int playerKernelSwitchRequestId; private int mPendingPlayerKernel = PlayerSetting.NONE; - private MpvPlayer mDiscMenuPlayer; - private final Runnable mDiscMenuStateListener = this::updateDiscMenuButton; - private MpvPlayer mCustomButtonPlayer; - private final Runnable mCustomButtonStateListener = this::updateCustomButtonStates; private final ActivityResultLauncher mLutDir = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null || result.getData().getData() == null) return; @@ -640,25 +581,7 @@ private boolean runtimeSourceOnly; private static boolean shouldOpenLegacyTmdbDetail(String key, String id, boolean cast) { int mode = Setting.getDetailOpenMode(); - return canOpenLegacyTmdbDetail(key, id, cast) && Setting.isTmdbDetailModeConfigured() && Setting.isStandaloneTmdbDetailMode(mode); - } - - public static void startDirectResolved(Activity activity, Vod vod) { - if (activity == null || vod == null) return; - Intent intent = new Intent(activity, VideoActivity.class); - intent.putExtra("detail_mode", Setting.DETAIL_OPEN_DIRECT); - intent.putExtra(EXTRA_DETAIL_RUNTIME_MODE, Setting.DETAIL_OPEN_DIRECT); - intent.putExtra("scan", false); - intent.putExtra("collect", false); - intent.putExtra("cast", false); - intent.putExtra("key", vod.getSiteKey()); - intent.putExtra("id", vod.getId()); - intent.putExtra("name", vod.getName()); - intent.putExtra("pic", vod.getPic()); - intent.putExtra("mark", vod.getRemarks()); - intent.putExtra("content", vod.getContent()); - putDetailVodCache(intent, vod); - activity.startActivity(intent); + return canOpenLegacyTmdbDetail(key, id, cast) && Setting.isTmdbDetailPage() && Setting.isStandaloneTmdbDetailMode(mode); } public static void start(Activity activity, String url) { @@ -1124,7 +1047,6 @@ private boolean runtimeSourceOnly; ? getString(R.string.detail_episode_season_context, season) : getString(R.string.detail_episode)); boolean selectable = isTmdbSourceEnabled() - && !runtimeSourceOnly && mTmdbUIAdapter != null && mTmdbUIAdapter.getTmdbItem() != null && mTmdbUIAdapter.getTmdbItem().isTv() @@ -1234,43 +1156,14 @@ private boolean runtimeSourceOnly; return getIntent().getBooleanExtra("tmdbMode", false); } - private void initializeRuntimeDetailMode() { - runtimeDetailMode = getIntent().getIntExtra(EXTRA_DETAIL_RUNTIME_MODE, Setting.getDetailOpenMode()); - runtimeSourceOnly = false; - } - - private void applyRuntimeDetailMode(int mode) { - runtimeDetailMode = Setting.isTmdbMode(mode) || mode == Setting.DETAIL_OPEN_DIRECT ? mode : Setting.DETAIL_OPEN_DIRECT; - } - - private int runtimeDetailMode() { - return runtimeDetailMode; - } - - private int policyConfiguredMode() { - int override = getIntent().getIntExtra(EXTRA_DETAIL_RUNTIME_MODE, Integer.MIN_VALUE); - return override == Setting.DETAIL_OPEN_DIRECT ? Setting.DETAIL_OPEN_DIRECT : Setting.getDetailOpenMode(); - } - - private boolean isRuntimeFusionMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_FUSION || getIntent().getBooleanExtra("fusion", false); - } - - private boolean isRuntimeOriginalEnhancedMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_ORIGINAL_ENHANCED; - } - - private boolean isRuntimeDirectMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_DIRECT; - } - private boolean shouldUseUpstreamNativeEpisodeModule() { - return isRuntimeDirectMode() && !isTmdbMode(); + return Setting.isDirectDetailPage() && !isTmdbMode(); } private boolean isTmdbSourceEnabled() { if (isTmdbMode()) return true; - if (!Setting.isTmdbMode(runtimeDetailMode())) return false; + if (!Setting.isTmdbMode(Setting.getDetailOpenMode())) return false; + if (!Setting.isTmdbEnabled()) return false; return TmdbSitePolicy.isEnabled(getKey(), getId()); } @@ -1387,7 +1280,6 @@ private boolean runtimeSourceOnly; @Override protected void onNewIntent(Intent intent) { - invalidateShortDramaQueue("new-intent"); String oldKey = getKey(); String oldId = getId(); super.onNewIntent(intent); @@ -1408,7 +1300,6 @@ private boolean runtimeSourceOnly; resetPlaybackOwnership(); if (mViewModel != null) mViewModel.cancelPlayerContent(); invalidatePlayerContent(); - mAppliedPlayerResult = null; playerKernelSwitchRequestId++; setAudioStageVisible(false); restoreImmersiveAudioRequest(); @@ -1420,14 +1311,12 @@ private boolean runtimeSourceOnly; protected void initView(Bundle savedInstanceState) { long start = System.currentTimeMillis(); SpiderDebug.log("video-flow", "initView start sinceLaunch=%dms key=%s id=%s", getLaunchCost(start), getKey(), getId()); - initializeRuntimeDetailMode(); restoreImmersiveAudioRequest(); mTmdbDetailTimeout = this::showTmdbDetailFallback; mTmdbEpisodeTimeout = this::showTmdbEpisodeFallback; initTmdbMode(); super.initView(savedInstanceState); SpiderDebug.log("video-flow", "initView after playback cost=%dms", System.currentTimeMillis() - start); - applyPlaybackOverlay(); mFrameParams = mBinding.video.getLayoutParams(); mPlayerUi = new VodPlayerUiController(new VodPlayerUiHost() { @Override @@ -1445,7 +1334,6 @@ private boolean runtimeSourceOnly; mPiP = mPlayerUi.pip(); setupAudioStageOverlay(); mKeyDown = CustomKeyDownVod.create(this); - mTvAudioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); mObserveDetail = this::setDetail; mObservePlayer = this::setPlayer; mObserveSearch = this::setSearch; @@ -1460,7 +1348,6 @@ private boolean runtimeSourceOnly; mApplyAudioBackgroundRunnable = this::applyAudioBackground; mHideAudioFocusRunnable = this::hideAudioStageFocusHighlight; SpiderDebug.log("video-flow", "initView state ready cost=%dms", System.currentTimeMillis() - start); - prepareInitialDetailShell(); checkCast(); SpiderDebug.log("video-flow", "initView preview ready cost=%dms", System.currentTimeMillis() - start); setRecyclerView(); @@ -1469,30 +1356,16 @@ private boolean runtimeSourceOnly; setVideoView(); SpiderDebug.log("video-flow", "initView video view ready cost=%dms", System.currentTimeMillis() - start); setViewModel(); + // 初始化:隐藏换源按钮 + mBinding.change1.setVisibility(View.GONE); checkId(); SpiderDebug.log("video-flow", "initView end cost=%dms sinceLaunch=%dms", System.currentTimeMillis() - start, getLaunchCost(System.currentTimeMillis())); } - private void applyPlaybackOverlay() { - mBinding.control.getRoot().setBackgroundResource(Setting.isPlaybackOverlayEnabled() ? R.drawable.shape_controller_scrim : R.color.transparent); - } - - /** - * 在首帧揭开预览前先确定详情模式,避免原生增强异步详情回来前短暂显示普通详情按钮。 - * 未配置好 TMDB 时仍保留普通详情入口。 - */ - private void prepareInitialDetailShell() { - boolean tmdbDetail = shouldLoadTmdbDetail(); - boolean enhancedDetail = tmdbDetail && (isRuntimeOriginalEnhancedMode() || isIntentTmdbPlayback()); - setOriginalEnhancedActionVisibility(enhancedDetail); - if (enhancedDetail && shouldUseTmdbLayout()) suppressTmdbNativeTextFields(); - } - @Override @SuppressLint("ClickableViewAccessibility") protected void initEvent() { mBinding.keep.setOnClickListener(view -> onKeep()); - mBinding.following.setOnClickListener(view -> onFollowing()); mBinding.searchDetail.setOnClickListener(view -> onSearch()); mBinding.searchDetail.setOnLongClickListener(view -> { onGlobalSearch(); @@ -1556,16 +1429,8 @@ private boolean runtimeSourceOnly; mBinding.control.action.danmaku.setOnClickListener(guarded(this::onDanmaku)); mBinding.control.action.danmaku.setOnLongClickListener(view -> onDanmakuToggle()); mBinding.control.action.adFeedback.setOnClickListener(view -> onAdFeedback()); + mBinding.control.action.adFeedback.setOnLongClickListener(view -> onAdFeedbackLongPress()); mBinding.control.action.opening.setOnClickListener(guarded(this::onOpening)); - mBinding.control.action.discMenu.setOnClickListener(view -> { - hideControl(); - openDiscMenu(); - }); - mBinding.control.action.discMenu.setOnLongClickListener(view -> { - hideControl(); - showDiscMenuControls(); - return true; - }); if (mBinding.control.action.immersiveAudio != null) mBinding.control.action.immersiveAudio.setOnClickListener(view -> toggleImmersiveAudioMode()); if (mBinding.control.action.cast != null) mBinding.control.action.cast.setOnClickListener(view -> onCast()); if (mBinding.control.action.timer != null) mBinding.control.action.timer.setOnClickListener(view -> onTimer()); @@ -1591,8 +1456,13 @@ private boolean runtimeSourceOnly; mBinding.control.action.ending.setOnLongClickListener(view -> onEndingReset()); mBinding.control.action.opening.setOnLongClickListener(view -> onOpeningReset()); setActionFocusScroll(); - mBinding.video.setOnTouchListener(this::onVideoTouch); - mBinding.flag.addOnChildViewHolderSelectedListener(new FlagSelectionListener(mBinding.flag, mFlagAdapter, this)); + mBinding.video.setOnTouchListener((view, event) -> mKeyDown.onTouchEvent(event)); + mBinding.flag.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { + @Override + public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { + if (mFlagAdapter.getItemCount() > 0) onItemClick(mFlagAdapter.get(position)); + } + }); mBinding.episode.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { @Override public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { @@ -1617,12 +1487,8 @@ private boolean runtimeSourceOnly; .setTitle(R.string.intro_skip_confirm_title) .setMessage(IntroSkipKinds.confirmMessage(segment)) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> mIntroSkipPlayback.declineConfirmation(segment)) + .setNegativeButton(android.R.string.cancel, null) .show(); - mIntroSkipConfirmDialog.setOnDismissListener(dialog -> { - mIntroSkipPlayback.cancelConfirmation(segment); - if (mIntroSkipConfirmDialog == dialog) mIntroSkipConfirmDialog = null; - }); return true; }); mIntroSkipPlayback.setSkipNoticeListener(IntroSkipKinds::notifySkipped); @@ -1649,6 +1515,79 @@ private boolean runtimeSourceOnly; } } + private void setRecyclerView() { + mBinding.flag.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.flag.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.flag.setAdapter(mFlagAdapter = new FlagAdapter(this)); + mBinding.episode.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.episode.setVerticalSpacing(ResUtil.dp2px(8)); + mBinding.episode.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.episode.setAdapter(mEpisodeAdapter = new EpisodeAdapter(this, this::onEpisodeLongClick)); + mEpisodeAdapter.setColumn(1); // 横向滚动,固定1列 + int episodeGridSpan = getEpisodeGridSpanCount(); + mBinding.episodeGrid.setItemAnimator(null); + mBinding.episodeGrid.setLayoutManager(new GridLayoutManager(this, episodeGridSpan)); + mBinding.episodeGrid.setAdapter(mEpisodeGridAdapter = new EpisodeAdapter(this, this::onEpisodeLongClick)); + mEpisodeGridAdapter.setGridMode(true); + mEpisodeGridAdapter.setVerticalGridMode(true); + mEpisodeGridAdapter.setColumn(episodeGridSpan); + mBinding.quality.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.quality.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.quality.setAdapter(mQualityAdapter = new QualityAdapter(this)); + mBinding.array.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.array.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.array.setAdapter(mArrayAdapter = new ArrayAdapter(this)); + mArrayAdapter.setOnKeyListener((view, keyCode, event) -> onArrayKey(event)); + mBinding.array.setOnKeyListener((view, keyCode, event) -> onArrayKey(event)); + mBinding.part.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.part.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.part.setAdapter(mPartAdapter = new PartAdapter(item -> initSearch(item, false))); + mBinding.quick.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.quick.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.quick.setAdapter(mQuickAdapter = new QuickAdapter(this)); + mBinding.control.parse.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.control.parse.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.control.parse.setAdapter(mParseAdapter = new ParseAdapter(this)); + mParseAdapter.addAll(VodConfig.get().getParses()); + // TMDB 相关 GridView 初始化 + setupTmdbGridViews(); + } + + private void setupTmdbGridViews() { + mBinding.tmdbCast.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbCast.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbPhotos.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbPhotos.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbCrew.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbCrew.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbRelatedVideos.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbRelatedVideos.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbPersonalTmdbRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbPersonalTmdbRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbPersonalDoubanRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbPersonalDoubanRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbPersonalAiRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbPersonalAiRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + attachRecommendationLazyLoader(mBinding.tmdbRecommendations, RecommendationRow.RECOMMENDATIONS); + attachRecommendationLazyLoader(mBinding.tmdbPersonalTmdbRecommendations, RecommendationRow.PERSONAL_TMDB); + attachRecommendationLazyLoader(mBinding.tmdbPersonalDoubanRecommendations, RecommendationRow.PERSONAL_DOUBAN); + } + + private void setVideoView() { + mBinding.control.action.danmaku.setVisibility(DanmakuSetting.isLoad() ? View.VISIBLE : View.GONE); + mBinding.control.action.adFeedback.setVisibility(isAdFeedbackEnabled() ? View.VISIBLE : View.GONE); + applyDanmakuState(); + mBinding.control.action.reset.setText(ResUtil.getStringArray(R.array.select_reset)[Setting.getReset()]); + setupActionButtons(); + setPlayer(); + } + + private void setPlayer() { + mBinding.control.action.player.setText(service() == null ? ResUtil.getStringArray(R.array.select_player)[PlayerSetting.getActivePlayer()] : player().getPlayerText()); + } + private void setupActionButtons() { mActionButtons = new HashMap<>(); addActionButton(PlayerButtonSetting.NEXT, mBinding.control.action.next); @@ -1682,176 +1621,17 @@ private boolean runtimeSourceOnly; addActionButton(PlayerButtonSetting.TIMER, mBinding.control.action.timer); addActionButton(PlayerButtonSetting.REPEAT, mBinding.control.action.repeat); PlayerButtonSetting.applyOrder(mBinding.control.action.container, mActionButtons); - setupCustomActionButtons(); - updatePanDiagnosticAction(); - updateDiscMenuButton(); + applyActionButtonVisibility(); } private void addActionButton(String id, View view) { mActionButtons.put(id, view); } - private void setRecyclerView() { - mBinding.flag.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.flag.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.flag.setAdapter(mFlagAdapter = new FlagAdapter(this)); - mBinding.episode.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.episode.setVerticalSpacing(ResUtil.dp2px(8)); - mBinding.episode.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.episode.setAdapter(mEpisodeAdapter = new EpisodeAdapter(this, this::onEpisodeLongClick)); - mEpisodeAdapter.setColumn(1); // 横向滚动,固定1列 - int episodeGridSpan = getEpisodeGridSpanCount(); - mBinding.episodeGrid.setItemAnimator(null); - mBinding.episodeGrid.setLayoutManager(new GridLayoutManager(this, episodeGridSpan)); - mBinding.episodeGrid.setAdapter(mEpisodeGridAdapter = new EpisodeAdapter(this, this::onEpisodeLongClick)); - mEpisodeGridAdapter.setGridMode(true); - mEpisodeGridAdapter.setVerticalGridMode(true); - mEpisodeGridAdapter.setColumn(episodeGridSpan); - mBinding.quality.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.quality.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.quality.setAdapter(mQualityAdapter = new QualityAdapter(this)); - mBinding.array.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.array.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.array.setAdapter(mArrayAdapter = new ArrayAdapter(this)); - mArrayAdapter.setOnKeyListener((view, keyCode, event) -> onArrayKey(event)); - mBinding.array.setOnKeyListener((view, keyCode, event) -> onArrayKey(event)); - mBinding.array.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { - @Override - public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { - if (child != null) selectEpisodeSegment(position, false); - } - }); - mBinding.part.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.part.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.part.setAdapter(mPartAdapter = new PartAdapter(item -> initSearch(item, false))); - mBinding.quick.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.quick.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.quick.setAdapter(mQuickAdapter = new QuickAdapter(this)); - mBinding.control.parse.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.control.parse.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.control.parse.setAdapter(mParseAdapter = new ParseAdapter(this)); - mParseAdapter.addAll(VodConfig.get().getParses()); - setupTmdbGridViews(); - } - - private void setupTmdbGridViews() { - mBinding.tmdbCast.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbCast.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbPhotos.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbPhotos.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbCrew.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbCrew.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbRelatedVideos.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbRelatedVideos.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbPersonalTmdbRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbPersonalTmdbRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbPersonalDoubanRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbPersonalDoubanRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbPersonalAiRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbPersonalAiRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - attachRecommendationLazyLoader(mBinding.tmdbRecommendations, RecommendationRow.RECOMMENDATIONS); - attachRecommendationLazyLoader(mBinding.tmdbPersonalTmdbRecommendations, RecommendationRow.PERSONAL_TMDB); - attachRecommendationLazyLoader(mBinding.tmdbPersonalDoubanRecommendations, RecommendationRow.PERSONAL_DOUBAN); - } - - private void setVideoView() { - mBinding.control.action.danmaku.setVisibility(DanmakuSetting.isLoad() ? View.VISIBLE : View.GONE); - mBinding.control.action.adFeedback.setVisibility(isAdFeedbackEnabled() ? View.VISIBLE : View.GONE); - applyDanmakuState(); - mBinding.control.action.reset.setText(ResUtil.getStringArray(R.array.select_reset)[Setting.getReset()]); - setupActionButtons(); - setPlayerButton(); - } - - private void setupCustomActionButtons() { - ensureCustomButtonContainers(); - for (View view : mCustomActionViews) { - ViewParent parent = view.getParent(); - if (parent instanceof ViewGroup) ((ViewGroup) parent).removeView(view); - } - mCustomActionViews.clear(); - mCustomPortraitButtonRow.removeAllViews(); - List buttons = MpvConfigStore.customButtons(); - for (int index = 0; index < buttons.size(); index++) { - MpvConfigStore.CustomButton button = buttons.get(index); - if (!button.isButtonVisible()) continue; - TextView view = new TextView(this); - view.setTextSize(13); - view.setTextColor(Color.WHITE); - view.setGravity(Gravity.CENTER); - view.setMinHeight(ResUtil.dp2px(40)); - view.setMinWidth(ResUtil.dp2px(56)); - view.setPadding(ResUtil.dp2px(10), ResUtil.dp2px(4), ResUtil.dp2px(10), ResUtil.dp2px(4)); - view.setBackgroundResource(R.drawable.selector_control_sheet_button); - view.setText(button.title); - view.setSingleLine(true); - view.setMaxWidth(ResUtil.dp2px(144)); - view.setEllipsize(TextUtils.TruncateAt.END); - view.setContentDescription(button.title); - view.setTag(button.id); - view.setOnClickListener(item -> { - player().sendMpvCustomButton(button.id, false); - setR1Callback(); - }); - view.setOnLongClickListener(item -> { - player().sendMpvCustomButton(button.id, true); - setR1Callback(); - return true; - }); - view.setFocusable(true); - view.setFocusableInTouchMode(true); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ResUtil.dp2px(40)); - params.setMargins(ResUtil.dp2px(4), ResUtil.dp2px(2), ResUtil.dp2px(4), ResUtil.dp2px(2)); - mCustomPortraitButtonRow.addView(view, params); - mCustomActionViews.add(view); - } - updateCustomButtonVisibility(); - } - - private void updateCustomButtonStates() { - MpvPlayer mpv = service() != null && isOwner() - && player().getPlayer() instanceof MpvPlayer active ? active : null; - if (mCustomButtonPlayer != mpv) { - if (mCustomButtonPlayer != null) mCustomButtonPlayer.removeCustomButtonStateListener(mCustomButtonStateListener); - mCustomButtonPlayer = mpv; - if (mpv != null) mpv.addCustomButtonStateListener(mCustomButtonStateListener); - } - for (View view : mCustomActionViews) { - view.setSelected(mpv != null && mpv.isCustomButtonActive((String) view.getTag())); - } - } - - private void ensureCustomButtonContainers() { - if (mCustomPortraitButtons != null) return; - mCustomPortraitButtons = new HorizontalScrollView(this); - mCustomPortraitButtons.setHorizontalScrollBarEnabled(false); - mCustomPortraitButtons.setFillViewport(false); - mCustomPortraitButtons.setClipToPadding(false); - mCustomPortraitButtons.setOverScrollMode(View.OVER_SCROLL_NEVER); - mCustomPortraitButtonRow = new LinearLayout(this); - mCustomPortraitButtonRow.setGravity(Gravity.CENTER_VERTICAL); - mCustomPortraitButtonRow.setOrientation(LinearLayout.HORIZONTAL); - mCustomPortraitButtonRow.setClipChildren(false); - mCustomPortraitButtons.addView(mCustomPortraitButtonRow, new HorizontalScrollView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.setMargins(0, 0, 0, ResUtil.dp2px(8)); - ((ViewGroup) mBinding.control.getRoot()).addView(mCustomPortraitButtons, 0, params); - } - - private void updateCustomButtonVisibility() { - updateCustomButtonStates(); - boolean visible = service() != null && player().isMpv() && isVisible(mBinding.control.getRoot()); - if (mCustomPortraitButtons != null) mCustomPortraitButtons.setVisibility(visible ? View.VISIBLE : View.GONE); - } - private void applyActionButtonVisibility() { - updateCustomButtonVisibility(); mBinding.control.action.cast.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE); updateImmersiveAudioAction(); updatePanDiagnosticAction(); - updateDiscMenuButton(); if (mActionButtons != null) PlayerButtonSetting.applyVisibility(mActionButtons); } @@ -1860,24 +1640,6 @@ private boolean runtimeSourceOnly; mBinding.control.action.panDiagnostic.setVisibility(isFullscreen() && canRunPanDiagnostic() ? View.VISIBLE : View.GONE); } - private void setPlayerButton() { - mBinding.control.action.player.setText(service() == null ? ResUtil.getStringArray(R.array.select_player)[PlayerSetting.getActivePlayer()] : player().getPlayerText()); - } - - private void updateDiscMenuButton() { - updateCustomButtonStates(); - MpvPlayer mpv = service() != null && isOwner() - && player().getPlayer() instanceof MpvPlayer active ? active : null; - if (mDiscMenuPlayer != mpv) { - if (mDiscMenuPlayer != null) mDiscMenuPlayer.removeDiscMenuStateListener(mDiscMenuStateListener); - mDiscMenuPlayer = mpv; - if (mpv != null) mpv.addDiscMenuStateListener(mDiscMenuStateListener); - } - boolean available = mpv != null && mpv.isDiscMenuAvailable(); - if (!available && mBinding.control.action.discMenu.hasFocus()) mBinding.control.action.playParams.requestFocus(); - mBinding.control.action.discMenu.setVisibility(available ? View.VISIBLE : View.GONE); - } - private boolean canRunPanDiagnostic() { if (service() == null || player().isEmpty()) return false; try { @@ -2118,6 +1880,8 @@ private boolean runtimeSourceOnly; } playerStartTime = System.currentTimeMillis(); beginPlayHealth(); + // 快速 TMDB 起播绕过 getPlayer,仍需作废上一条播放的反馈状态 + resetAdFeedback(); prepareFastTmdbPlaybackHistory(item, flag, episode); SpiderDebug.log("video-flow", "fast tmdb playback start cost=%dms key=%s flag=%s episode=%s url=%s", System.currentTimeMillis() - start, getKey(), flag.getFlag(), episode.getName(), episode.getUrl()); beginPlayerContentRequest(getKey(), flag.getFlag(), episode.getUrl()); @@ -2277,7 +2041,7 @@ private boolean runtimeSourceOnly; private String cachedTmdbLanguage() { try { - return com.fongmi.android.tv.bean.TmdbConfig.effectiveCurrent().getLanguage(); + return com.fongmi.android.tv.bean.TmdbConfig.objectFrom(Setting.getTmdbConfig()).getLanguage(); } catch (Throwable e) { return ""; } @@ -2299,7 +2063,7 @@ private boolean runtimeSourceOnly; if (path.startsWith("http://") || path.startsWith("https://")) return path; String base = ""; try { - com.fongmi.android.tv.bean.TmdbConfig config = com.fongmi.android.tv.bean.TmdbConfig.effectiveCurrent(); + com.fongmi.android.tv.bean.TmdbConfig config = com.fongmi.android.tv.bean.TmdbConfig.objectFrom(Setting.getTmdbConfig()); base = backdrop ? config.getBackdropBase() : config.getImageBase(); } catch (Throwable ignored) { } @@ -2438,8 +2202,6 @@ private boolean runtimeSourceOnly; player().clear(); } updateNavigationKey(); - // singleTop 切换条目时也要先按新 Intent 准备详情壳,不能沿用旧条目的操作按钮。 - prepareInitialDetailShell(); mBinding.progressLayout.showProgress(); } @@ -2588,25 +2350,13 @@ private boolean runtimeSourceOnly; App.removeCallbacks(mTmdbEpisodeTimeout); item.checkPic(getPic()); item.checkName(getName()); + boolean loadTmdbDetail = shouldLoadTmdbDetail(); item.checkContent(getContent()); applyIntentTmdbVodRemark(item); - TmdbConfig tmdbConfig = TmdbConfig.effectiveCurrent(); - TmdbSourcePayload sourcePayload = TmdbSourcePayloadParser.parse(item.getTmdb()); - TmdbBundle sourceBundle = TmdbSourceAdapter.toBundle(sourcePayload, item, tmdbConfig); - TmdbSourceState sourceState = TmdbSourceAvailability.classify(item, sourcePayload, sourceBundle); - DetailRuntimeModePolicy.Decision decision = DetailRuntimeModePolicy.resolve(new DetailRuntimeModePolicy.Input( - policyConfiguredMode(), - tmdbConfig.isReady(), - TmdbSitePolicy.isEnabled(tmdbConfig, getKey(), getId()), - sourceState - )); - applyRuntimeDetailMode(decision.runtimeMode()); - runtimeSourceOnly = decision.sourceOnly(); - boolean loadTmdbDetail = shouldLoadTmdbDetail(); - setOriginalEnhancedActionVisibility(loadTmdbDetail && (isRuntimeOriginalEnhancedMode() || isIntentTmdbPlayback())); + setOriginalEnhancedActionVisibility(loadTmdbDetail && (Setting.isOriginalEnhancedDetailPage() || isIntentTmdbPlayback())); if (isIntentTmdbPlayback()) com.fongmi.android.tv.utils.TmdbEpisodeSorter.sort(item); applyTmdbEpisodeTitles(item); - setTmdbRematchVisible(loadTmdbDetail && !runtimeSourceOnly); + setTmdbRematchVisible(loadTmdbDetail); // 非 TMDB:立即揭开,全部内容一次性出现;TMDB:继续停在 loading,等富集完成再揭开 if (!loadTmdbDetail) mBinding.progressLayout.showContent(); mBinding.name.setText(item.getName()); @@ -2625,25 +2375,20 @@ private boolean runtimeSourceOnly; else if (loadTmdbDetail) revealShellWhileTmdbLoads(); // TMDB 增强:自动匹配并增强 Vod - if (mTmdbUIAdapter != null && (mTmdbUIAdapter.isReady() || runtimeSourceOnly)) { + if (mTmdbUIAdapter != null && mTmdbUIAdapter.isReady()) { mTmdbUIAdapter.setActiveFlag(getFlag()); - if (sourceState == TmdbSourceState.RENDERABLE && sourceBundle != null) { - SpiderDebug.log("tmdb-tv", "source-first load vodTitle=%s tmdbId=%d media=%s sourceOnly=%s", item.getName(), sourceBundle.item().getTmdbId(), sourceBundle.item().getMediaType(), runtimeSourceOnly); - mTmdbUIAdapter.loadSource(sourceBundle, item, sourcePayload); + com.fongmi.android.tv.bean.TmdbItem tmdbItem = getTmdbItem(); + if (tmdbItem != null) { + SpiderDebug.log("tmdb-tv", "direct load vodTitle=%s tmdbTitle=%s tmdbId=%d media=%s cache=%s", item.getName(), tmdbItem.getTitle(), tmdbItem.getTmdbId(), tmdbItem.getMediaType(), mFastTmdbDetailCache != null); + mTmdbUIAdapter.load(tmdbItem, item, mFastTmdbDetailCache); } else { - com.fongmi.android.tv.bean.TmdbItem tmdbItem = getTmdbItem(); - if (tmdbItem != null) { - SpiderDebug.log("tmdb-tv", "direct load vodTitle=%s tmdbTitle=%s tmdbId=%d media=%s cache=%s", item.getName(), tmdbItem.getTitle(), tmdbItem.getTmdbId(), tmdbItem.getMediaType(), mFastTmdbDetailCache != null); - mTmdbUIAdapter.load(tmdbItem, item, mFastTmdbDetailCache); - } else { - mTmdbUIAdapter.autoMatch(item.getName(), item, getSearchKeyword()); - } + mTmdbUIAdapter.autoMatch(item.getName(), item, getSearchKeyword()); } } } private boolean shouldLoadTmdbDetail() { - return mTmdbUIAdapter != null && (mTmdbUIAdapter.isReady() || runtimeSourceOnly); + return mTmdbUIAdapter != null && mTmdbUIAdapter.isReady(); } private boolean shouldShowTmdbLoadingOverlay() { @@ -2655,8 +2400,7 @@ private boolean runtimeSourceOnly; * 不再先整页转圈、揭开后再转一次,避免同一次进入出现两层「加载中」。 */ private boolean shouldRevealShellWhileLoading() { - // 影视原生与原生增强一样由播放器窗口表达加载态,避免进场后整页再转一次。 - return isRuntimeOriginalEnhancedMode() || isRuntimeDirectMode(); + return Setting.isOriginalEnhancedDetailPage(); } private void setOriginalEnhancedActionVisibility(boolean hide) { @@ -2664,16 +2408,14 @@ private boolean runtimeSourceOnly; mBinding.change1.setVisibility(hide ? View.VISIBLE : View.GONE); mBinding.searchDetail.setVisibility(hide ? View.GONE : View.VISIBLE); mBinding.keep.setNextFocusLeftId(hide ? R.id.change1 : R.id.searchDetail); - mBinding.following.setNextFocusLeftId(R.id.keep); } private void setTmdbRematchVisible(boolean visible) { mBinding.tmdbRematch.setVisibility(visible ? View.VISIBLE : View.GONE); mBinding.searchDetail.setNextFocusRightId(R.id.keep); mBinding.change1.setNextFocusRightId(R.id.keep); - mBinding.keep.setNextFocusRightId(R.id.following); - mBinding.following.setNextFocusRightId(visible ? R.id.tmdbRematch : View.NO_ID); - mBinding.tmdbRematch.setNextFocusLeftId(R.id.following); + mBinding.keep.setNextFocusRightId(visible ? R.id.tmdbRematch : View.NO_ID); + mBinding.tmdbRematch.setNextFocusLeftId(R.id.keep); mBinding.tmdbRematch.setNextFocusRightId(View.NO_ID); } @@ -2931,6 +2673,7 @@ private boolean runtimeSourceOnly; } private void getPlayer(Flag flag, Episode episode) { + resetAdFeedback(); mBinding.widget.title.setText(getPlaybackControlTitle(episode)); playerStartTime = System.currentTimeMillis(); beginPlayHealth(); @@ -2987,7 +2730,6 @@ private boolean runtimeSourceOnly; mInitialPlaybackPosition = resolveInitialPlaybackPosition(); SpiderDebug.log("video-flow", "startPlayer dispatch initialPosition=%d music=%s ijk=%s", mInitialPlaybackPosition, isMusicLike(), service() != null && player().isIjk()); long start = System.currentTimeMillis(); - if (SubtitleRestoreCoordinator.restore(mHistory, player(), result)) syncHistory(); startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata(), mInitialPlaybackPosition); SpiderDebug.log("video-flow", "startPlayer return cost=%dms sincePlayerStart=%dms", System.currentTimeMillis() - start, System.currentTimeMillis() - playerStartTime); subtitlePlaybackSession.onPlaybackStarted(this, result); @@ -2997,8 +2739,6 @@ private boolean runtimeSourceOnly; .rawTitle(mHistory.getVodName()) .rawRemarks(mHistory.getVodRemarks()) .episodeName(getEpisode().getName()) - .tmdbId(danmakuTmdbId()) - .tmdbSeasonNumber(danmakuTmdbSeasonNumber()) .source(MediaTitleLearningExample.SOURCE_DANMAKU_AUTO) .allowAi(true) .build(), danmaku -> { @@ -3017,26 +2757,12 @@ private boolean runtimeSourceOnly; return handled; } - private int danmakuTmdbId() { - TmdbItem item = getMatchedTmdbItem(); - return item == null ? 0 : item.getTmdbId(); - } - - private int danmakuTmdbSeasonNumber() { - TmdbItem item = getMatchedTmdbItem(); - if (item == null || !item.isTv()) return 0; - Episode episode = getEpisode(); - TmdbEpisode tmdbEpisode = episode == null ? null : episode.getTmdbEpisode(); - return tmdbEpisode == null ? 0 : tmdbEpisode.getSeasonNumber(); - } - private boolean canApplyPlayerResult() { if (mFlagAdapter != null && mFlagAdapter.getItemCount() > 0 && mEpisodeAdapter != null && mEpisodeAdapter.getItemCount() > 0) return true; return mFastPlaybackFlag != null && mFastPlaybackEpisode != null && mHistory != null; } private void beginPlayerContentRequest(String key, String flag, String episode) { - if (mViewModel != null) mViewModel.cancelPlayerContent(); mPendingPlayer = null; invalidatePlayerContent(); playerContentKey = key; @@ -3044,289 +2770,6 @@ private boolean runtimeSourceOnly; playerContentEpisode = episode; } - private boolean isShortDramaSource() { - Site site = getSite(); - return Setting.isShortDramaSiteEnabled(site == null ? getKey() : site.getKey(), - site == null ? "" : site.getName()); - } - - private boolean isShortDramaQueueEligible(Result result) { - if (!PlaybackExperimentSetting.isAllowed(PlaybackExperimentPolicy.Action.EXO_SHORT_DRAMA_QUEUE)) return false; - if (!isShortDramaSource() || service() == null || player() == null || !player().isExo() - || !player().supportsPlaylistQueue() || !PreloadSetting.isPreload(PlayerSetting.EXO)) return false; - if (MultiThreadProxySetting.get().enabled() || player().isRepeatOne() || mHistory == null - || mHistory.getOpening() > 0 || mHistory.getEnding() > 0 - || pendingResumeSeekMs != C.TIME_UNSET || tmdbHistoryResumePending - || isAudioOnly() || shouldUseImmersiveAudio()) return false; - Flag flag = getFlag(); - if (flag == null || flag.getEpisodes().size() < 2 || result == null || result.hasMsg() - || result.getRealUrl().isEmpty() || result.getDrm() != null - || result.needParse() || result.shouldUseParse()) return false; - String url = result.getRealUrl(); - if (com.fongmi.android.tv.player.exo.MediaSourceFactory.isConcatenatingUrl(url)) return false; - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest( - url, null, result.getFormat()); - if (classification.streamKind() == PlaybackAutoContext.StreamKind.LIVE - || classification.streamKind() == PlaybackAutoContext.StreamKind.LOW_LATENCY_LIVE) return false; - return classification.protocol() == PlaybackAutoContext.Protocol.HLS - || classification.protocol() == PlaybackAutoContext.Protocol.PROGRESSIVE_HTTP; - } - - private ShortDramaQueueCoordinator.Episode shortDramaEpisode(Episode episode, int index) { - return new ShortDramaQueueCoordinator.Episode("", episode == null ? "" : episode.getName(), index); - } - - private ShortDramaQueueCoordinator.MediaKind shortDramaMediaKind(String url, String format) { - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest(url, null, format); - return classification.protocol() == PlaybackAutoContext.Protocol.HLS - ? ShortDramaQueueCoordinator.MediaKind.HLS - : ShortDramaQueueCoordinator.MediaKind.PROGRESSIVE_MP4; - } - - private boolean hasShortDramaStoredPosition(Episode episode) { - if (episode == null || skipEpisodePositionCache()) return false; - EpisodePositionCache.EpisodePosition cached = EpisodePositionCache.get().get( - getKey(), getId(), getFlag().getFlag(), episodePositionCacheName(episode, currentSourceSeasonNumber())); - return cached != null && cached.position > 0; - } - - private MediaMetadata buildShortDramaMetadata(Episode episode) { - String title = mHistory == null ? getName() : mHistory.getVodName(); - String name = episode == null ? "" : episode.getName(); - return PlayerManager.buildMetadata(title, name.isEmpty() || title.equals(name) ? "" : name, - mHistory == null ? "" : mHistory.getVodPic()); - } - - private void beginShortDramaQueue() { - Flag flag = getFlag(); - List items = flag == null ? List.of() : flag.getEpisodes(); - Episode current = getEpisode(); - if (flag == null || current == null || items.size() < 2 || !isShortDramaQueueEligible(mAppliedPlayerResult)) return; - int currentIndex = getSelectedEpisodePosition(items); - List descriptors = new ArrayList<>(items.size()); - for (int i = 0; i < items.size(); i++) descriptors.add(shortDramaEpisode(items.get(i), i)); - ShortDramaQueueCoordinator.Session session = new ShortDramaQueueCoordinator.Session( - getId(), getKey(), flag.getFlag(), getEpisodePlayFlag(flag, current), descriptors, - currentIndex, mHistory.isRevPlay()); - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.begin(session); - mShortDramaQueueFallbackUsed = false; - mShortDramaQueueSessionActive = true; - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - resolveShortDramaNext(snapshot); - } - - private void resolveShortDramaNext(ShortDramaQueueCoordinator.Snapshot snapshot) { - if (snapshot == null || snapshot.session() == null || snapshot.queued() != null || mViewModel == null) return; - ShortDramaQueueCoordinator.ResolveRequest request = snapshot.nextRequest(); - if (request == null) return; - int index = request.episode().index(); - List items = getFlag() == null ? List.of() : getFlag().getEpisodes(); - if (index < 0 || index >= items.size()) return; - Episode target = items.get(index); - if (hasShortDramaStoredPosition(target)) return; - String key = snapshot.session().siteKey(); - String playFlag = snapshot.session().playFlag(); - long generation = request.generation(); - mViewModel.playerContentIsolated(key, playFlag, target.getUrl(), player().getPlayerType(), - result -> enqueueShortDramaResult(generation, request.episode(), target, result), - error -> { - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "resolve failed generation=%d type=%s", generation, error == null ? "unknown" : error.getClass().getSimpleName()); - }); - } - - private void enqueueShortDramaResult(long generation, - ShortDramaQueueCoordinator.Episode descriptor, - Episode target, - Result result) { - if (isFinishing() || isDestroyed() || target == null || !isShortDramaQueueEligible(mAppliedPlayerResult)) return; - if (result == null || result.hasMsg() || result.getRealUrl().isEmpty() || result.getDrm() != null - || result.needParse() || result.shouldUseParse()) return; - String url = result.getRealUrl(); - boolean concatenated = com.fongmi.android.tv.player.exo.MediaSourceFactory.isConcatenatingUrl(url); - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest( - url, null, result.getFormat()); - boolean live = classification.streamKind() == PlaybackAutoContext.StreamKind.LIVE - || classification.streamKind() == PlaybackAutoContext.StreamKind.LOW_LATENCY_LIVE; - boolean hls = classification.protocol() == PlaybackAutoContext.Protocol.HLS; - boolean progressive = classification.protocol() == PlaybackAutoContext.Protocol.PROGRESSIVE_HTTP; - ShortDramaQueueCoordinator.ResolvedItem resolved = new ShortDramaQueueCoordinator.ResolvedItem( - url, new HashMap<>(result.getHeader()), result.getFormat(), - hls ? ShortDramaQueueCoordinator.MediaKind.HLS : progressive ? ShortDramaQueueCoordinator.MediaKind.PROGRESSIVE_MP4 : null, - false, result.needParse() || result.shouldUseParse(), live, false, concatenated, false); - ShortDramaQueueCoordinator.QueueItem item = mShortDramaQueue.acceptResolved(generation, descriptor, resolved); - if (item == null || !resolved.isEligible()) return; - PlaySpec spec = PlaySpec.from(result, activePlaybackKey(), buildShortDramaMetadata(target)).checkUa(); - spec.setHeaders(new HashMap<>(result.getHeader())); - if (!player().appendPlaylistItem(spec, item.mediaId())) { - invalidateShortDramaQueue("append-failed"); - return; - } - mShortDramaQueuedSpec = spec; - mShortDramaQueuedResult = result; - mShortDramaQueuedEpisode = target; - player().setPlaylistPreloadDurationMs(5_000L); - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "append generation=%d index=%d kind=%s", generation, target.getIndex(), resolved.kind()); - } - - private void saveShortDramaEpisodeProgress(Episode episode) { - saveShortDramaEpisodeProgress(episode, - mHistory == null ? C.TIME_UNSET : mHistory.getPosition(), - mHistory == null ? C.TIME_UNSET : mHistory.getDuration()); - } - - private void saveShortDramaEpisodeProgress(Episode episode, long position, long duration) { - if (episode == null || mHistory == null || service() == null || !isOwner() || tmdbHistoryResumePending) return; - String cacheName = episodePositionCacheName(episode, currentSourceSeasonNumber()); - if (!TextUtils.isEmpty(cacheName) && !skipEpisodePositionCache()) { - long savedPosition = position > 0 ? position : mHistory.getPosition(); - long savedDuration = duration > 0 ? duration : mHistory.getDuration(); - EpisodePositionCache.get().put(getKey(), getId(), getFlag().getFlag(), cacheName, - savedPosition, savedDuration); - } - } - - private void updateHistoryAfterShortDramaTransition(Episode item) { - if (mHistory == null || item == null || getFlag() == null) return; - Episode historyEpisode = withSourceSeasonEpisodeIdentity(item); - EpisodePositionCache.EpisodePosition cached = skipEpisodePositionCache() ? null : EpisodePositionCache.get().get( - getKey(), getId(), getFlag().getFlag(), episodePositionCacheName(item, currentSourceSeasonNumber())); - if (cached != null) { - mHistory.setPosition(cached.position); - mHistory.setDuration(cached.duration); - } else { - mHistory.setPosition(C.TIME_UNSET); - mHistory.setDuration(C.TIME_UNSET); - } - setHistoryFlag(getFlag()); - mHistory.setVodRemarks(getHistoryEpisodeName(item)); - mHistory.setEpisodeUrl(item.getUrl()); - if (historyEpisode.getTmdbEpisode() != null) mHistory.setTmdbEpisodePosition(historyEpisode); - PlaybackEventCollector.get().updateHistory(mHistory); - } - - private void commitShortDramaTransition(ShortDramaQueueCoordinator.Transition transition, - Episode current, - Result result, - PlaySpec spec) { - Episode previous = transition.previous() == null ? null : getEpisodeByIndex(transition.previous().index()); - if (previous != null) saveShortDramaEpisodeProgress(previous, - mShortDramaTransitionPositionMs, mShortDramaTransitionDurationMs); - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - Flag flag = getFlag(); - if (flag == null || current == null || result == null || spec == null) { - fallbackShortDramaQueued(); - return; - } - mFlagAdapter.toggle(current); - setEpisodeAdapter(flag.getEpisodes()); - applyAudioQueueMetadata(current); - mBinding.widget.title.setText(getPlaybackControlTitle(current)); - mBinding.widget.title.setSelected(true); - mInlineLyrics = getEpisodeInlineLyrics(current); - applyPlaybackArtwork(current); - clearLyrics(); - clearKaraokeState(); - if (result.hasDesc()) { - setText(mBinding.content, 0, result.getDesc()); - setPlaybackLyrics(result.getDesc()); - } - mAppliedPlayerResult = result; - mQualityAdapter.addAll(result); - mQualityAdapter.setPosition(mQualityAdapter.getPosition()); - setUseParse(false); - setQualityVisible(result.getUrl().isMulti()); - updateHistoryAfterShortDramaTransition(current); - player().setMetadata(buildShortDramaMetadata(current)); - List siteDanmakus = result.getDanmaku(); - player().setDanmaku(siteDanmakus.isEmpty() ? Danmaku.empty() : siteDanmakus.get(0)); - subtitlePlaybackSession.onPlaybackStarted(this, result); - DanmakuApi.cancel(); - if (DanmakuApi.canAutoSearch(siteDanmakus)) searchShortDramaDanmaku(current, siteDanmakus); - loadTmdbRelatedVideosForCurrentEpisode(); - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaEndedFallbackScheduled = false; - App.removeCallbacks(mShortDramaEndedFallback); - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.snapshot(); - resolveShortDramaNext(snapshot); - } - - private void searchShortDramaDanmaku(Episode episode, List siteDanmakus) { - String episodeUrl = episode == null ? "" : episode.getUrl(); - DanmakuApi.search(MediaTitleRequest.builder() - .siteKey(getKey()).vodId(getId()).rawTitle(mHistory.getVodName()) - .rawRemarks(mHistory.getVodRemarks()).episodeName(episode == null ? "" : episode.getName()) - .tmdbId(danmakuTmdbId()).tmdbSeasonNumber(danmakuTmdbSeasonNumber()) - .source(MediaTitleLearningExample.SOURCE_DANMAKU_AUTO).allowAi(true).build(), danmaku -> { - Episode active = getEpisode(); - if (player() == null || active == null || !TextUtils.equals(active.getUrl(), episodeUrl)) return; - if (DanmakuSetting.isSpiderFirst() && !siteDanmakus.isEmpty()) player().addDanmaku(danmaku); - else player().setDanmaku(danmaku); - }); - } - - private Episode getEpisodeByIndex(int index) { - Flag flag = getFlag(); - if (flag == null || index < 0 || index >= flag.getEpisodes().size()) return null; - return flag.getEpisodes().get(index); - } - - private void onShortDramaMediaItemTransition(androidx.media3.common.MediaItem mediaItem, int reason) { - if (mediaItem == null || reason != Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) return; - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.snapshot(); - ShortDramaQueueCoordinator.Transition transition = mShortDramaQueue.onMediaItemTransition( - snapshot.generation(), mediaItem.mediaId, ShortDramaQueueCoordinator.TransitionReason.AUTO); - if (transition == null) return; - PlaySpec spec = mShortDramaQueuedSpec; - Result result = mShortDramaQueuedResult; - Episode current = mShortDramaQueuedEpisode; - // Commit changes the manager's current PlaySpec, so close the previous - // episode's playback event before switching to the queued item. - if (service() != null) PlaybackEventCollector.get().onStop(player()); - if (spec == null || result == null || current == null || !player().commitPlaylistTransition(spec)) { - fallbackShortDramaQueued(); - return; - } - commitShortDramaTransition(transition, current, result, spec); - } - - private void invalidateShortDramaQueue(String reason) { - App.removeCallbacks(mShortDramaEndedFallback); - mShortDramaEndedFallbackScheduled = false; - if (mViewModel != null) mViewModel.cancelPlayerContentIsolated(); - if (player() != null && player().isExo()) player().removePlaylistItemsAfterCurrent(); - mShortDramaQueue.invalidate(); - mShortDramaQueueSessionActive = false; - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "invalidate reason=%s", reason); - } - - private void fallbackShortDramaQueued() { - if (mShortDramaQueueFallbackUsed || mShortDramaQueuedEpisode == null) return; - mShortDramaQueueFallbackUsed = true; - Episode target = mShortDramaQueuedEpisode; - saveShortDramaEpisodeProgress(getEpisode()); - invalidateShortDramaQueue("queue-fallback"); - if (!isFinishing() && !isDestroyed() && target != null) onItemClick(target); - } - - private void scheduleShortDramaEndedFallback() { - if (mShortDramaEndedFallbackScheduled) return; - mShortDramaEndedFallbackScheduled = true; - App.post(mShortDramaEndedFallback, 1_500L); - } - - private void invalidatePlayerContent() { playerContentGeneration++; playerContentRequestId++; @@ -3384,7 +2827,6 @@ private boolean runtimeSourceOnly; private void beginPlayHealth() { playHealthKey = getKey(); - SiteHealthStore.recordPlayAttempt(playHealthKey); playHealthRecorded = false; } @@ -3396,8 +2838,6 @@ private boolean runtimeSourceOnly; @Override public void onItemClick(Flag item) { - if (isFinishing() || isDestroyed()) return; - invalidateShortDramaQueue("line-switch"); if (mFlagAdapter.getItemCount() == 0 || item == null) return; int position = mFlagAdapter.indexOf(item); Flag resolved = mFlagAdapter.get(position < 0 ? 0 : position); @@ -3419,16 +2859,10 @@ private boolean runtimeSourceOnly; } private void setEpisodeAdapter(List items) { - setEpisodeAdapter(items, true, true); + setEpisodeAdapter(items, true); } private void setEpisodeAdapter(List items, boolean scrollToCurrent) { - mBinding.control.action.episodes.setVisibility(items.size() < 2 ? View.GONE : View.VISIBLE); - applyActionButtonVisibility(); - setEpisodeAdapter(items, scrollToCurrent, true); - } - - private void setEpisodeAdapter(List items, boolean scrollToCurrent, boolean updateEpisodeChrome) { updateEpisodeSeasonContext(); boolean isEmpty = items.isEmpty(); boolean hasMultiple = items.size() > 1; @@ -3460,12 +2894,10 @@ private boolean runtimeSourceOnly; // useTmdbCards=false 被隐藏,用户无法切回列表。 if (useTmdbCards && hasMultiple) episodeGridMode = Setting.getTmdbEpisodeGridMode(); if (!useTmdbCards || !hasMultiple) episodeGridMode = false; - if (updateEpisodeChrome) { - mBinding.episodeHeader.setVisibility(showTmdbEpisodeChrome && !isEmpty ? View.VISIBLE : View.GONE); - mBinding.episodeReverse.setVisibility(showTmdbEpisodeChrome && hasMultiple ? View.VISIBLE : View.GONE); - mBinding.episodeViewMode.setVisibility(showTmdbEpisodeChrome && hasMultiple && useTmdbCards ? View.VISIBLE : View.GONE); - mBinding.episodeFileName.setVisibility(showTmdbEpisodeChrome && hasMultiple ? View.VISIBLE : View.GONE); - } + mBinding.episodeHeader.setVisibility(showTmdbEpisodeChrome && !isEmpty ? View.VISIBLE : View.GONE); + mBinding.episodeReverse.setVisibility(showTmdbEpisodeChrome && hasMultiple ? View.VISIBLE : View.GONE); + mBinding.episodeViewMode.setVisibility(showTmdbEpisodeChrome && hasMultiple && useTmdbCards ? View.VISIBLE : View.GONE); + mBinding.episodeFileName.setVisibility(showTmdbEpisodeChrome && hasMultiple ? View.VISIBLE : View.GONE); updateEpisodeFallbackStillUrl(); mEpisodeAdapter.setUseTmdbCard(useTmdbCards); mEpisodeGridAdapter.setUseTmdbCard(useTmdbCards); @@ -3579,7 +3011,6 @@ private boolean runtimeSourceOnly; @Override public void onItemClick(Episode item) { if (shouldEnterFullscreen(item)) return; - invalidateShortDramaQueue("manual-episode"); selectEpisode(item, true); } @@ -3648,6 +3079,8 @@ private boolean runtimeSourceOnly; updateActionQuality(result); return; } + // 换画质同集换 URL:切片结构与在途归因都属于上一画质 + resetAdFeedback(); mQualityAdapter.setPosition(position); updateActionQuality(result); onItemClick(result); @@ -3655,20 +3088,17 @@ private boolean runtimeSourceOnly; @Override public void onItemClick(Result result) { - invalidateShortDramaQueue("quality"); + // 画质列表直点也会换 URL,且不经 changeQuality + resetAdFeedback(); updateActionQuality(result); beginPlayHealth(); - // 切清晰度也会重建 spec,字幕列表跟着重置,所以这里同样要恢复一次。 - if (SubtitleRestoreCoordinator.restore(mHistory, player(), result)) syncHistory(); startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata()); subtitlePlaybackSession.onPlaybackStarted(this, result); } private void reverseEpisode(boolean scroll) { - invalidateShortDramaQueue("reverse-sort"); mFlagAdapter.reverse(); - // 倒序只改变列表顺序;保留已经显示的工具栏,避免一次重绑期间 TMDB 状态短暂未就绪时闪退。 - setEpisodeAdapter(getFlag().getEpisodes(), scroll, false); + setEpisodeAdapter(getFlag().getEpisodes(), scroll); if (scroll) scrollToCurrentEpisode(); else scrollToFirstEpisode(); } @@ -3684,8 +3114,7 @@ private boolean runtimeSourceOnly; if (mBinding.episodeFileName.getVisibility() != View.VISIBLE) return; boolean showScraped = !Setting.getTmdbEpisodeShowScrapedName(); Setting.putTmdbEpisodeShowScrapedName(showScraped); - // 原文件名只改变列表标题;工具栏可见性不应随这次重绑重新计算。 - setEpisodeAdapter(getFlag().getEpisodes(), true, false); + setEpisodeAdapter(getFlag().getEpisodes(), true); } private void applyEpisodeViewMode(boolean scrollToCurrent) { @@ -3881,7 +3310,6 @@ private boolean runtimeSourceOnly; @Override public void onItemClick(Parse item) { - invalidateShortDramaQueue("parse"); setParse(item); onRefresh(); } @@ -4321,7 +3749,6 @@ private boolean runtimeSourceOnly; } private void onRepeat() { - invalidateShortDramaQueue("repeat"); player().setRepeatOne(!player().isRepeatOne()); mBinding.control.action.repeat.setSelected(player().isRepeatOne()); } @@ -4331,7 +3758,6 @@ private boolean runtimeSourceOnly; } private void onMultiThreadProxySaved(boolean applyNow) { - invalidateShortDramaQueue("multi-thread-proxy"); setPlayParamsState(); if (applyNow && player() != null && !player().isEmpty()) player().reloadCurrentMediaItem(); } @@ -4537,12 +3963,9 @@ private boolean runtimeSourceOnly; } private void onSpeed() { - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || player().isEmpty()) return; - mBinding.control.action.speed.setText(player().setSpeed(speed)); - saveUserSpeed(); - setR1Callback(); - }); + mBinding.control.action.speed.setText(player().addSpeed()); + saveUserSpeed(); + setR1Callback(); } private void onSpeedAdd() { @@ -4580,7 +4003,6 @@ private boolean runtimeSourceOnly; } private void onRefresh() { - invalidateShortDramaQueue("refresh"); saveHistory(); if (mViewModel != null) mViewModel.cancelPlayerContent(); invalidatePlayerContent(); @@ -4682,7 +4104,6 @@ private boolean runtimeSourceOnly; } private void onPlayerKernel() { - invalidateShortDramaQueue("kernel"); if (playerKernelSwitchRefreshing) return; PlayerKernelDialog.show(this, player().getPlayerType(), this::switchPlayerKernel, this::onExternalPlayer); } @@ -4749,6 +4170,8 @@ private boolean runtimeSourceOnly; || !isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return; playerKernelSwitchRefreshing = false; if (!canApplyPlayerContentRequest(requestId, generation, key, flag, episode)) return; + // 换内核会重新解析出可能不同的 URL,与换画质同理 + resetAdFeedback(); if (result == null || result.hasMsg() || result.getRealUrl().isEmpty()) { Notify.show(result != null && result.hasMsg() ? result.getMsg() : getString(R.string.error_play_url)); } else { @@ -4760,7 +4183,6 @@ private boolean runtimeSourceOnly; } private void onDecode() { - invalidateShortDramaQueue("decode"); mClock.setCallback(null); player().toggleDecode(); setDecode(); @@ -4782,7 +4204,7 @@ private boolean runtimeSourceOnly; } private void onDanmaku() { - DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).tmdb(danmakuTmdbId(), danmakuTmdbSeasonNumber()).show(this); + DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).show(this); hideControl(); } @@ -4792,7 +4214,7 @@ private boolean runtimeSourceOnly; return; } if (!isAdFeedbackEnabled()) { - Notify.show(R.string.ad_feedback_ai_disabled); + Notify.show(R.string.ad_interval_invalid); return; } hideControl(); @@ -4800,15 +4222,10 @@ private boolean runtimeSourceOnly; } private boolean isAdFeedbackEnabled() { - // 功能开关 + 仅支持解析的格式(HLS/m3u8)才可反馈,因为去广分析依赖切片列表 - return Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection() && isAdFeedbackSupportedFormat(); - } - - private boolean isAdFeedbackSupportedFormat() { - if (player() == null) return false; - String url = player().getUrl(); - if (TextUtils.isEmpty(url)) return false; - return PlaybackResourceClassifier.isHlsUrl(url); + // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。 + // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。 + if (player() == null || TextUtils.isEmpty(player().getUrl())) return false; + return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0; } private void setAdFeedbackVisible() { @@ -4816,76 +4233,173 @@ private boolean runtimeSourceOnly; applyActionButtonVisibility(); } + private AdFeedbackController adFeedback() { + if (mAdFeedback == null) { + mAdFeedbackHost = new AdFeedbackHostAdapter( + new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi()); + mAdFeedback = new AdFeedbackController(mAdFeedbackHost); + } + return mAdFeedback; + } + + /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */ + private void resetAdFeedback() { + mAdMarkStartMs = -1; + if (mAdFeedback != null) mAdFeedback.invalidate(); + if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence(); + // 作废后归因回调不再刷新界面,对话框会永久停在「分析中」 + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); + mAdFeedbackDialog = null; + } + + /** + * 起播后记录本站域名,供域名信誉通道建立基线。 + * + *

必须强制初始化适配器:它原本只在用户首次按下「有广告」时懒创建, + * 若这里跳过未初始化的情况,首次反馈时基线仍为空,域名通道会因 + * 「无基线不断言」而弃权 —— 整条通道等于没接。 + */ + private void recordAdFeedbackHost() { + // 关掉去广告或播直播时不建基线:这些域名会挤掉 LRU 里真正有用的条目 + if (!isAdFeedbackEnabled()) return; + adFeedback(); + mAdFeedbackHost.recordPlaybackHost(); + } + + /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */ private void submitAdFeedback() { - AdDetectionRequest request = buildAdDetectionRequest(); - if (request == null) { - Notify.show(R.string.ad_feedback_no_url); + AdFeedbackController controller = adFeedback(); + AdFeedbackSession session; + if (mAdMarkStartMs >= 0) { + // 先取位置再清标记:播放器已释放时保留标记,用户可重试而不必重新打点 + long end = mAdFeedbackHost.safePositionMs(); + if (end < 0) { + Notify.show(R.string.ad_interval_invalid); + return; + } + long start = mAdMarkStartMs; + mAdMarkStartMs = -1; + session = controller.onMarkedInterval(start, end); + } else { + session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence()); + } + if (session == null) { + Notify.show(R.string.ad_interval_invalid); return; } - // 记录 AI 反馈统计 - AdBlockStatsStore.recordFeedback(request.getSiteKey()); - - Notify.show(R.string.ad_feedback_analyzing); - int generation = ++mAdFeedbackGeneration; - AiConfig config = AiConfig.objectFrom(Setting.getAiConfig()); - Task.execute(() -> { - enrichRequestWithM3u8Evidence(request); - AdDetectionResult result = new AiAdDetectionService(config).analyze(request); - runOnUiThread(() -> { - if (isFinishing() || isDestroyed() || generation != mAdFeedbackGeneration) return; - onAdDetectionResult(request, result); - }); - }); + // 统计放在成交之后,未成立的反馈不计入 + AdBlockStatsStore.recordFeedback(getKey()); } - private AdDetectionRequest buildAdDetectionRequest() { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return null; - String url = player().getUrl(); - Uri uri = Uri.parse(url); - AdDetectionRequest request = new AdDetectionRequest(); + /** 长按:打起点进入标记模式,再次长按取消。 */ + private boolean onAdFeedbackLongPress() { + if (!isAdFeedbackEnabled()) return false; + if (mAdMarkStartMs >= 0) { + mAdMarkStartMs = -1; + Notify.show(R.string.ad_interval_mark_cancelled); + } else { + mAdMarkStartMs = player().getPosition(); + Notify.show(R.string.ad_interval_mark_start); + } + return true; + } + + private void showAdFeedbackSession(AdFeedbackSession session) { + if (isFinishing() || isDestroyed()) return; + if (mAdFeedbackDialog == null) mAdFeedbackDialog = new AdFeedbackDialog(this, this::applyAdRulePlan); + mAdFeedbackDialog.show(session); + } + + private void applyAdRulePlan(com.fongmi.android.tv.ad.feedback.AdAttribution plan) { Site site = getSite(); - request.setSiteKey(site == null ? getKey() : site.getKey()); - request.setSiteName(site == null ? "" : site.getName()); - request.setVodName(mHistory == null ? getName() : mHistory.getVodName()); - request.setFlagName(getFlag() == null ? "" : getFlag().getFlag()); - request.setEpisodeName(getEpisode() == null ? "" : getEpisode().getName()); - request.setUrlHost(uri.getHost()); - request.setUrlPath(uri.getPath()); - return request; - } - - private void enrichRequestWithM3u8Evidence(AdDetectionRequest request) { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return; - String url = player().getUrl(); - if (!url.contains(".m3u8")) return; - try { - java.util.Map headers = player().getHeaders(); - com.fongmi.android.tv.bean.M3u8Evidence evidence = com.fongmi.android.tv.utils.M3u8Parser.parse(url, headers); - request.setEvidence(evidence); - } catch (Exception e) { - // Ignore parsing failures - } - } - - private void onAdDetectionResult(AdDetectionRequest request, AdDetectionResult result) { - // 记录 AI 分析结果统计 - AdBlockStatsStore.recordAiAnalysis(result != null && !result.isError()); - - if (result == null || result.isError()) { - Notify.show(result == null ? getString(R.string.ad_feedback_failed) : result.getErrorMessage()); - return; - } - if (result.isEmpty()) { - Notify.show(R.string.ad_feedback_no_ad); - return; - } - com.fongmi.android.tv.ui.dialog.AdRulePreviewDialog.create(result).show(this, confirmedResult -> { - UserAdRule rule = UserAdRule.fromAiResult(confirmedResult, request.getSiteKey()); - com.fongmi.android.tv.api.config.UserAdRuleStore.add(rule); - Notify.show(R.string.ad_feedback_saved); + AdRulePlanApplier.Outcome outcome = AdRulePlanApplier.apply( + plan, site == null ? getKey() : site.getKey()); + Notify.show(switch (outcome) { + case APPLIED -> R.string.ad_interval_rule_saved; + case SKIPPED -> R.string.ad_interval_rule_skipped; + case FAILED -> R.string.ad_interval_rule_failed; }); } + private final class AdFeedbackPlayback implements AdFeedbackHostAdapter.Playback { + @Override + public long positionMs() { + return player() == null ? 0 : player().getPosition(); + } + + @Override + public long durationMs() { + return player() == null ? 0 : player().getDuration(); + } + + @Override + public String playUrl() { + return player() == null ? "" : player().getUrl(); + } + + @Override + public java.util.Map headers() { + return player() == null ? java.util.Map.of() : player().getHeaders(); + } + + @Override + public boolean hls() { + return player() != null + && com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(player().getUrl()); + } + + @Override + public boolean skipInterval(long startMs, long endMs, String feedbackId) { + return player() != null && player().skipUserAdInterval(startMs, endMs, feedbackId); + } + } + + private final class AdFeedbackMetadata implements AdFeedbackHostAdapter.Metadata { + @Override + public String siteKey() { + Site site = getSite(); + return site == null ? getKey() : site.getKey(); + } + + @Override + public String siteName() { + Site site = getSite(); + return site == null ? "" : site.getName(); + } + + @Override + public String vodName() { + return mHistory == null ? getName() : mHistory.getVodName(); + } + + @Override + public String flagName() { + return getFlag() == null ? "" : getFlag().getFlag(); + } + + @Override + public String episodeName() { + return getEpisode() == null ? "" : getEpisode().getName(); + } + } + + private final class AdFeedbackUi implements AdFeedbackHostAdapter.Ui { + @Override + public void runBackground(Runnable task) { + Task.execute(task); + } + + @Override + public void runOnUi(Runnable task) { + runOnUiThread(task); + } + + @Override + public void showSession(AdFeedbackSession session) { + showAdFeedbackSession(session); + } + } + private boolean onDanmakuToggle() { DanmakuSetting.putShow(!DanmakuSetting.isShow()); applyDanmakuState(); @@ -4964,7 +4478,6 @@ private boolean runtimeSourceOnly; showTopInfo(); setPlayParamsState(); mBinding.control.getRoot().setVisibility(View.VISIBLE); - updateCustomButtonVisibility(); if (mOsd != null) mOsd.setControlsVisible(true); PlayerControlFocusHelper.ensureFocus(mBinding.control.getRoot(), view); setR1Callback(); @@ -4972,7 +4485,6 @@ private boolean runtimeSourceOnly; private void hideControl() { mBinding.control.getRoot().setVisibility(View.GONE); - updateCustomButtonVisibility(); if (mOsd != null) mOsd.setControlsVisible(false); if (player().isPlaying()) mBinding.widget.top.setVisibility(View.GONE); App.removeCallbacks(mR1); @@ -5330,7 +4842,7 @@ private boolean runtimeSourceOnly; mHistory.setCreateTime(System.currentTimeMillis()); } if (exit && service() != null) PlaybackEventCollector.get().onStop(player()); - if (!canSavePlaybackHistory(mHistory)) return; + if (!mHistory.canSave()) return; History history = mHistory.copy(); Task.execute(() -> { if (history.getDuration() > 0) history.merge().save(); @@ -5374,8 +4886,7 @@ private boolean runtimeSourceOnly; } if (!sameEpisode && !tmdbHistoryResumePending) { - // updatePlaybackHistoryPosition() 刚把旧集进度写回 History;切换新集前必须先覆盖, - // 否则新集没有独立缓存时会继承旧集进度并从错误位置开始播放。 + // 从缓存中恢复新集的播放位置 EpisodePositionCache.EpisodePosition cached = skipEpisodePositionCache() ? null : EpisodePositionCache.get().get( getKey(), getId(), @@ -5421,65 +4932,6 @@ private boolean runtimeSourceOnly; keep.setVodPic(mHistory.getVodPic()); keep.save(); } - updateFollowingState(); - } - - private void onFollowing() { - if (!FollowingSettings.isEnabled()) { - Notify.show(R.string.following_enabled_hint); - return; - } - if (mHistory == null || followingActionPending || !FollowingPlaybackBridge.isEligible(mHistory)) return; - String identityKey = FollowingPlaybackBridge.identityKey(mHistory, currentSourceSeasonNumber()); - if (TextUtils.isEmpty(identityKey)) return; - followingActionPending = true; - mBinding.following.setEnabled(false); - FollowingPlaybackBridge.findAsync(identityKey, existing -> { - if (isFinishing() || isDestroyed()) return; - if (existing != null) { - followingActionPending = false; - mBinding.following.setEnabled(true); - FollowingActivity.start(this, existing.identityKey); - return; - } - Following item = FollowingPlaybackBridge.build(mHistory, currentSourceSeasonNumber()); - FollowingSource source = FollowingPlaybackBridge.source(item, mHistory); - FollowingPlaybackBridge.addAsync(item, source, (saved, error) -> { - followingActionPending = false; - if (isFinishing() || isDestroyed()) return; - if (error != null) { - mBinding.following.setEnabled(true); - Notify.show(error.getMessage()); - return; - } - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); - updateFollowingState(); - Notify.show(R.string.following_added); - }); - }); - } - - private void updateFollowingState() { - boolean eligible = FollowingSettings.isEnabled() && FollowingPlaybackBridge.isEligible(mHistory); - int generation = ++followingUiGeneration; - if (!eligible) { - applyFollowingButtonState(false, false); - return; - } - String identityKey = FollowingPlaybackBridge.identityKey(mHistory, currentSourceSeasonNumber()); - applyFollowingButtonState(true, false); - FollowingPlaybackBridge.findAsync(identityKey, item -> { - if (generation != followingUiGeneration || isFinishing() || isDestroyed()) return; - applyFollowingButtonState(true, item != null); - }); - } - - private void applyFollowingButtonState(boolean eligible, boolean followed) { - mBinding.following.setVisibility(eligible ? View.VISIBLE : View.GONE); - mBinding.following.setEnabled(true); - mBinding.following.setSelected(followed); - mBinding.following.setText(followed ? R.string.following_added : R.string.following_add); } private void updateVod(Vod item) { @@ -5745,11 +5197,6 @@ private boolean runtimeSourceOnly; refreshControlDialog(); } - @Override - protected void onSubtitleSelected(Sub sub) { - if (SubtitleRestoreCoordinator.remember(mHistory, sub)) syncHistory(); - } - @Override protected void onTitlesChanged() { setTitleVisible(); @@ -5758,7 +5205,6 @@ private boolean runtimeSourceOnly; @Override protected void onError(String msg) { - invalidateShortDramaQueue("error"); recordPlayHealth(false, msg); subtitlePlaybackSession.stop(this); Track.delete(player().getKey()); @@ -5774,7 +5220,6 @@ private boolean runtimeSourceOnly; @Override protected void onReload(String msg) { - recordPlayHealth(false, msg); if (PlayerManager.RELOAD_LUT_WARMUP.equals(msg)) { if (SpiderDebug.isEnabled()) SpiderDebug.log("lut-ui", "auto refresh after lut warmup playback failure key=%s episode=%s", getKey(), getEpisode() == null ? null : getEpisode().getName()); onRefresh(); @@ -5790,11 +5235,6 @@ private boolean runtimeSourceOnly; if (result != null) setPlayer(result); } - @Override - protected void onFirstFrameRendered() { - recordPlayHealth(true, ""); - } - @Override protected void onStateChanged(int state) { switch (state) { @@ -5803,6 +5243,7 @@ private boolean runtimeSourceOnly; break; case Player.STATE_READY: mKaraokeResultShown = false; + recordPlayHealth(true, ""); showPlaybackContent(); boolean pendingResumeSeekApplied = applyPendingResumeSeek(); refreshLyrics(); @@ -5811,37 +5252,14 @@ private boolean runtimeSourceOnly; requestIntroSkipPlan(); if (!pendingResumeSeekApplied) applyAutoIntroSkip(); setAdFeedbackVisible(); // 播放地址确定后按格式刷新"有广告"按钮 - if (!mShortDramaQueueSessionActive) beginShortDramaQueue(); + recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线 break; case Player.STATE_ENDED: - if (mShortDramaQueue.shouldSuppressLegacyAutoAdvance()) { - scheduleShortDramaEndedFallback(); - break; - } checkEnded(true); break; } } - @Override - public void onMediaItemTransition(@Nullable MediaItem mediaItem, int reason) { - super.onMediaItemTransition(mediaItem, reason); - onShortDramaMediaItemTransition(mediaItem, reason); - } - - @Override - protected void onPlayerPositionDiscontinuity(Player.PositionInfo oldPosition, - Player.PositionInfo newPosition, - int reason) { - if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION - && mShortDramaQueue.shouldSuppressLegacyAutoAdvance()) { - mShortDramaTransitionPositionMs = oldPosition == null - ? C.TIME_UNSET : Math.max(0L, oldPosition.positionMs); - mShortDramaTransitionDurationMs = player() == null - ? C.TIME_UNSET : player().getDuration(); - } - } - @Override protected void onPlayingChanged(boolean isPlaying) { syncKaraokePosition(); @@ -5911,10 +5329,6 @@ private boolean runtimeSourceOnly; if (!isOwner() || mHistory == null) return; long position, duration; mHistory.setCreateTime(time); - if (hasDiscNavigationTimeline()) { - if (canSavePlaybackHistory(mHistory) && mHistory.canSync()) syncHistory(); - return; - } updatePlaybackHistoryPosition(); syncKaraokePosition(); if (mLyrics != null) mLyrics.update(player()); @@ -5932,7 +5346,6 @@ private boolean runtimeSourceOnly; private void updatePlaybackHistoryPosition() { if (mHistory == null || tmdbHistoryResumePending) return; - if (hasDiscNavigationTimeline()) return; long position = player().getPosition(); long duration = player().getDuration(); if (position > 0) mHistory.setPosition(position); @@ -6394,7 +5807,6 @@ private boolean runtimeSourceOnly; mBinding.shortDisplay.setNextFocusDownId(target); mBinding.searchDetail.setNextFocusDownId(target); mBinding.keep.setNextFocusDownId(target); - mBinding.following.setNextFocusDownId(target); mBinding.change1.setNextFocusDownId(target); mBinding.tmdbRematch.setNextFocusDownId(target); } @@ -6638,10 +6050,6 @@ private boolean runtimeSourceOnly; hideTmdbRatingChips(label, container); return; } - if (runtimeSourceOnly) { - renderTmdbRatingChips(label, container, buildTmdbRatingChips()); - return; - } TmdbItem ratingItem = mTmdbUIAdapter.getTmdbItem(); String ratingContextKey = ratingItem == null ? "" : ratingItem.getMediaType() + "|" + ratingItem.getTmdbId(); @@ -6722,7 +6130,6 @@ private boolean runtimeSourceOnly; } private void showManualTmdbSeasonDialog() { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isLoaded() || mTmdbUIAdapter.getTmdbItem() == null || !mTmdbUIAdapter.getTmdbItem().isTv()) { Notify.show(R.string.detail_tmdb_empty); return; @@ -6878,7 +6285,6 @@ private boolean runtimeSourceOnly; } } private void showManualTmdbMatchDialog() { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isReady()) { Notify.show(R.string.detail_tmdb_need_key); return; @@ -6921,7 +6327,6 @@ private boolean runtimeSourceOnly; } private void searchTmdb(String keyword, TmdbSearchDialog dialog) { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isReady()) return; dialog.loading(); int generation = ++mTmdbDialogGeneration; @@ -6943,7 +6348,6 @@ private boolean runtimeSourceOnly; } private void applyManualTmdb(TmdbItem item) { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || mVod == null || item == null) return; mTmdbDialogGeneration++; // 手动重新匹配是从已揭示的详情页触发的,细粒度事件(VOD_CORE/推荐/个性/集数标题) @@ -7383,18 +6787,13 @@ private boolean runtimeSourceOnly; } private void onIntroSkipPlanLoaded() { - if (isFinishing() || isDestroyed() || player() == null || player().isReleased() || !isOwner()) return; - // 查询可能在 EXO 首次 prepare/恢复定位期间提前返回。此时 seek 会与起播定位、 - // 去广告或播放器内部准备竞争,造成重复加载,严重时停在黑屏。统一等 READY 后再应用。 - if (player().getPlaybackState() != Player.STATE_READY) return; setOpeningEndingText(); applyAutoIntroSkip(); preloadAdjacentIntroSkipPlans(); } private boolean applyAutoIntroSkip() { - if (!Setting.isIntroSkipEnabled() || isFinishing() || isDestroyed() - || player() == null || player().isReleased() || !isOwner()) return false; + if (!Setting.isIntroSkipEnabled() || player() == null) return false; // notify=true:片尾无处可跳(末集/电影)时至少要有提示,不能静默无反应 return mIntroSkipPlayback.apply(player(), () -> advanceEpisode(true)); } @@ -7454,9 +6853,8 @@ private boolean runtimeSourceOnly; @Subscribe(threadMode = ThreadMode.MAIN) public void onConfigEvent(ConfigEvent event) { - if (isRedirect() || !event.isVod()) return; - if (mTmdbUIAdapter != null) mTmdbUIAdapter.invalidateSubscription(); - if (mParseAdapter != null) mParseAdapter.addAll(VodConfig.get().getParses()); + if (isRedirect() || !event.isVod() || mParseAdapter == null) return; + mParseAdapter.addAll(VodConfig.get().getParses()); } /** @@ -7483,14 +6881,13 @@ private boolean runtimeSourceOnly; } private void setPosition() { - if (mHistory == null || hasDiscMenu()) return; - long position = resolveInitialPlaybackPosition(); - if (position <= 0) return; - if (mInitialPlaybackPosition == position) { - SpiderDebug.log("video-flow", "skip duplicate restore seek position=%d key=%s", position, getHistoryKey()); + pendingResumeSeekMs = C.TIME_UNSET; + if (mHistory == null) { + tmdbHistoryResumePending = false; mInitialPlaybackPosition = C.TIME_UNSET; return; } + long position = resolveInitialPlaybackPosition(); if (position == C.TIME_UNSET || position <= 0) { tmdbHistoryResumePending = false; mInitialPlaybackPosition = C.TIME_UNSET; @@ -7730,7 +7127,8 @@ private boolean runtimeSourceOnly; mTmdbUIAdapter = new com.fongmi.android.tv.ui.helper.TmdbUIAdapter(this); if (!mTmdbUIAdapter.isReady()) { - SpiderDebug.log("TMDB 增强等待源内嵌数据或 API Key"); + SpiderDebug.log("TMDB 增强已启用,但配置未就绪(需要 API Key)"); + return; } mTmdbUIAdapter.setPersonalAiUpdateListener(this::refreshTmdbPersonalAiRecommendations); com.fongmi.android.tv.bean.TmdbItem tmdbItem = getTmdbItem(); @@ -7760,10 +7158,6 @@ private boolean runtimeSourceOnly; @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); - if (!hasFocus) { - cancelTvTouch(); - mKeyDown.releaseSpeed(); - } if (!hasFocus || mDialogReturnFocus == null) return; View target = mDialogReturnFocus; mDialogReturnFocus = null; @@ -7840,22 +7234,9 @@ private boolean runtimeSourceOnly; return false; } - @Override - protected boolean dispatchDiscMenuKey(KeyEvent event) { - // Let TV controls own DPAD/ENTER while visible, including the shared - // PlaybackActivity dispatch performed by super.dispatchKeyEvent(). - if (isVisible(mBinding.control.getRoot()) || isVisible(mBinding.lutQuick)) return false; - return super.dispatchDiscMenuKey(event); - } - @Override public boolean dispatchKeyEvent(KeyEvent event) { - if (dispatchDiscMenuKey(event)) return true; if (KeyUtil.isActionUp(event) && KeyUtil.isBackKey(event) && mBinding.lutQuick.hideIfVisible()) return true; - if (mKeyDown.isChangingSpeed() && KeyUtil.isActionUp(event)) { - mKeyDown.releaseSpeed(); - if (isVisible(mBinding.lutQuick)) return dispatchLutQuickKey(event); - } if (isVisible(mBinding.lutQuick)) return dispatchLutQuickKey(event); if (isFullscreen() && KeyUtil.isMenuKey(event)) { if (Setting.getFullscreenMenuKey() == 1) onEpisodes(); @@ -8084,138 +7465,6 @@ private boolean runtimeSourceOnly; return false; } - private boolean onVideoTouch(View view, MotionEvent event) { - if (dispatchDiscMenuTouch(event)) return true; - if (!isFullscreen()) return false; - if (!Setting.isTouchOptimized()) return mKeyDown.onTouchEvent(event); - boolean handled = mKeyDown.onTouchEvent(event); - handleTvTouch(event); - return handled || event.getActionMasked() != MotionEvent.ACTION_CANCEL; - } - - private void handleTvTouch(MotionEvent event) { - if (event == null) return; - int action = event.getActionMasked(); - if (action == MotionEvent.ACTION_DOWN) { - mTvTouchAxis = TV_TOUCH_NONE; - mTvTouchMulti = false; - mTvTouchDownX = event.getX(); - mTvTouchDownY = event.getY(); - mTvTouchBright = Util.getBrightness(this); - mTvTouchVolume = mTvAudioManager == null ? 0 : mTvAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC); - mTvTouchSeek = 0; - return; - } - if (action == MotionEvent.ACTION_POINTER_DOWN) { - mTvTouchMulti = true; - mTvTouchAxis = TV_TOUCH_NONE; - hideTvTouchWidgets(); - return; - } - if (mTvTouchMulti) { - if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) resetTvTouch(); - return; - } - if (action == MotionEvent.ACTION_MOVE) { - if (event.getPointerCount() != 1) { - mTvTouchMulti = true; - mTvTouchAxis = TV_TOUCH_NONE; - hideTvTouchWidgets(); - return; - } - float dx = event.getX() - mTvTouchDownX; - float dy = mTvTouchDownY - event.getY(); - if (mTvTouchAxis == TV_TOUCH_NONE) { - float slop = ResUtil.dp2px(20); - if (Math.hypot(dx, dy) < slop) return; - mTvTouchAxis = Math.abs(dx) >= Math.abs(dy) ? TV_TOUCH_HORIZONTAL : TV_TOUCH_VERTICAL; - } - if (mTvTouchAxis == TV_TOUCH_HORIZONTAL) { - mKeyDown.releaseSpeed(); - if (player() == null) return; - mTvTouchSeek = (long) (dx * TV_TOUCH_SEEK_SCALE); - onSeeking(mTvTouchSeek); - } else if (mTvTouchDownX <= Math.max(1, mBinding.video.getWidth()) / 2f) { - mKeyDown.releaseSpeed(); - onBright((int) (applyTvBrightness(dy) * 100)); - } else { - onVolume(applyTvVolume(dy)); - } - return; - } - if (action == MotionEvent.ACTION_UP) { - boolean seeking = mTvTouchAxis == TV_TOUCH_HORIZONTAL; - if (seeking && player() != null) onSeekEnd(mTvTouchSeek); - mKeyDown.releaseSpeed(); - hideTvTouchWidgets(); - if (seeking) hideCenter(); - resetTvTouch(); - } else if (action == MotionEvent.ACTION_CANCEL) { - boolean seeking = mTvTouchAxis == TV_TOUCH_HORIZONTAL; - mKeyDown.releaseSpeed(); - hideTvTouchWidgets(); - if (seeking) hideCenter(); - resetTvTouch(); - } - } - - private float applyTvBrightness(float deltaY) { - int height = Math.max(mBinding.video.getMeasuredHeight(), 1); - float brightness = BrightnessPolicy.scroll(mTvTouchBright, deltaY, height); - WindowManager.LayoutParams attributes = getWindow().getAttributes(); - if (attributes.screenBrightness != brightness) { - attributes.screenBrightness = brightness; - getWindow().setAttributes(attributes); - } - return brightness; - } - - private int applyTvVolume(float deltaY) { - if (mTvAudioManager == null) return 0; - int max = mTvAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC); - if (max <= 0) return 0; - float value = Math.max(0, Math.min(max, mTvTouchVolume + deltaY * 2.0f / Math.max(mBinding.video.getMeasuredHeight(), 1) * max)); - int volume = (int) value; - mTvAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0); - return (int) (volume * 100f / max); - } - - private void resetTvTouch() { - mTvTouchAxis = TV_TOUCH_NONE; - mTvTouchMulti = false; - mTvTouchSeek = 0; - } - - private void cancelTvTouch() { - boolean seeking = mTvTouchAxis == TV_TOUCH_HORIZONTAL; - mKeyDown.releaseSpeed(); - hideTvTouchWidgets(); - if (seeking) hideCenter(); - resetTvTouch(); - } - - private void hideTvTouchWidgets() { - mBinding.widget.bright.setVisibility(View.GONE); - mBinding.widget.volume.setVisibility(View.GONE); - if (isVisible(mBinding.widget.center)) hideCenter(); - } - - public void onBright(int progress) { - mBinding.widget.bright.setVisibility(View.VISIBLE); - mBinding.widget.brightProgress.setProgress(progress); - if (progress < 35) mBinding.widget.brightIcon.setImageResource(R.drawable.ic_widget_bright_low); - else if (progress < 70) mBinding.widget.brightIcon.setImageResource(R.drawable.ic_widget_bright_medium); - else mBinding.widget.brightIcon.setImageResource(R.drawable.ic_widget_bright_high); - } - - public void onVolume(int progress) { - mBinding.widget.volume.setVisibility(View.VISIBLE); - mBinding.widget.volumeProgress.setProgress(progress); - if (progress < 35) mBinding.widget.volumeIcon.setImageResource(R.drawable.ic_widget_volume_low); - else if (progress < 70) mBinding.widget.volumeIcon.setImageResource(R.drawable.ic_widget_volume_medium); - else mBinding.widget.volumeIcon.setImageResource(R.drawable.ic_widget_volume_high); - } - @Override public void onSingleTap() { if (isFullscreen()) onToggle(); @@ -8243,9 +7492,7 @@ private boolean runtimeSourceOnly; @Override protected void onStop() { - cancelTvTouch(); super.onStop(); - mKeyDown.releaseSpeed(); mPlayerUi.onStop(); if (mKaraoke != null) mKaraoke.clear(); stopAudioCoverRotation(); @@ -8283,7 +7530,6 @@ private boolean runtimeSourceOnly; protected void markPlaybackExiting() { if (isPlaybackExiting()) return; super.markPlaybackExiting(); - mIntroSkipPlayback.reset(); cancelAiSeasonAnalysis(false); mPersonalRecommendationGeneration++; mPersonalRecommendationTasks.close(); @@ -8293,18 +7539,8 @@ private boolean runtimeSourceOnly; } @Override protected void onDestroy() { - invalidateShortDramaQueue("destroy"); - cancelTvTouch(); - mIntroSkipPlayback.reset(); + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); cancelAiSeasonAnalysis(false); - if (mCustomButtonPlayer != null) { - mCustomButtonPlayer.removeCustomButtonStateListener(mCustomButtonStateListener); - mCustomButtonPlayer = null; - } - if (mDiscMenuPlayer != null) { - mDiscMenuPlayer.removeDiscMenuStateListener(mDiscMenuStateListener); - mDiscMenuPlayer = null; - } mLyricsSearchSeq++; mLyricsRefreshSeq++; dismissLyricsResultDialog(); @@ -11250,7 +10486,6 @@ private void showAudioSheet(BottomSheetDialog dialog, boolean draggable, boolean applyAudioSheetWindowGlass(dialog); hideSystemBarsForAudioSheet(dialog); focusAudioSheetContent(dialog); - syncAudioDialog(dialog); } private void showCompactPlaybackSheet(BottomSheetDialog dialog) { @@ -11297,7 +10532,6 @@ private void showLyricsSearchSheetDialog(BottomSheetDialog dialog) { applyAudioSheetWindowGlass(dialog); hideSystemBarsForAudioSheet(dialog); focusAudioSheetContent(dialog); - syncAudioDialog(dialog); Window window = dialog.getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); @@ -11336,7 +10570,6 @@ private void showAudioDrawerSheet(BottomSheetDialog dialog, boolean atStart) { applyAudioSheetWindowGlass(dialog); hideSystemBarsForAudioSheet(dialog); focusAudioSheetContent(dialog); - syncAudioDialog(dialog); } private void showAudioQueueDrawerDialog(Dialog dialog) { @@ -11361,11 +10594,6 @@ private void showAudioQueueDrawerDialog(Dialog dialog) { window.clearFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND); hideSystemBarsForAudioDialog(dialog); focusAudioQueueSelectedItem(); - syncAudioDialog(dialog); - } - -private void syncAudioDialog(Dialog dialog) { - TouchOptimizationHelper.sync(dialog); } private void focusAudioSheetContent(BottomSheetDialog dialog) { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java index 47420980f2..bece071062 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java @@ -34,14 +34,11 @@ import org.greenrobot.eventbus.ThreadMode; import java.util.Optional; -public class VodActivity extends BaseActivity implements TypeAdapter.OnClickListener, FolderFragment.FilterHost, FolderFragment.CategoryEdgeHost { +public class VodActivity extends BaseActivity implements TypeAdapter.OnClickListener, FolderFragment.FilterHost { private ActivityVodBinding mBinding; private TypeAdapter mAdapter; private View mOldView; - private boolean mPendingCategoryFocus; - private int focusGeneration; - private Runnable mPendingCategoryFocusRunnable; public static void start(Activity activity, Result result) { start(activity, VodConfig.get().getHome().getKey(), result); @@ -113,33 +110,10 @@ public class VodActivity extends BaseActivity implements TypeAdapter.OnClickList @Override protected void initEvent() { - mBinding.recycler.setOnFocusChangeListener((view, hasFocus) -> { - if (!hasFocus) invalidatePendingFocusRequests(); - }); mBinding.pager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { @Override public void onPageSelected(int position) { mBinding.recycler.setSelectedPosition(position); - if (mPendingCategoryFocus) { - mPendingCategoryFocus = false; - final int generation = focusGeneration; - mPendingCategoryFocusRunnable = () -> { - if (generation != focusGeneration) return; - mPendingCategoryFocusRunnable = null; - if (isFinishing() || isDestroyed() || mBinding.pager.getCurrentItem() != position) return; - // A newly loaded page may have no cards yet; the host must reveal its header. - mBinding.recycler.setVisibility(View.VISIBLE); - mBinding.recycler.requestFocus(); - getFragment().scrollContentToTop(generation); - mBinding.recycler.setSelectedPosition(position, holder -> { - if (generation != focusGeneration || !mBinding.recycler.hasFocus()) return; - if (mBinding.pager.getCurrentItem() == position && mBinding.recycler.getSelectedPosition() == position) holder.itemView.requestFocus(); - }); - }; - mBinding.recycler.post(mPendingCategoryFocusRunnable); - return; - } - invalidatePendingFocusRequests(); mBinding.recycler.requestFocus(); } }); @@ -208,7 +182,6 @@ public class VodActivity extends BaseActivity implements TypeAdapter.OnClickList @Override public void onItemClick(Class item) { - invalidatePendingFocusRequests(); updateFilter(item); } @@ -217,17 +190,6 @@ public class VodActivity extends BaseActivity implements TypeAdapter.OnClickList getFragment().onRefresh(); } - @Override - public void onCategoryContentHorizontalEdge(Class item, int contentRow, boolean towardEnd) { - int position = mAdapter.indexOf(item); - int target = position + (towardEnd ? 1 : -1); - if (position != mBinding.pager.getCurrentItem() || contentRow < 0 || target < 0 || target >= mAdapter.getItemCount()) return; - App.removeCallbacks(mRunnable); - invalidatePendingFocusRequests(); - mPendingCategoryFocus = true; - mBinding.pager.setCurrentItem(target); - } - @Override public boolean dispatchKeyEvent(KeyEvent event) { if (KeyUtil.isMenuKey(event)) updateFilter(); @@ -236,42 +198,17 @@ public class VodActivity extends BaseActivity implements TypeAdapter.OnClickList } private boolean requestContentFocus() { - invalidatePendingFocusRequests(); FolderFragment fragment = getFragment(); return fragment != null && fragment.requestContentFocus(); } - private int invalidatePendingFocusRequests() { - focusGeneration++; - App.removeCallbacks(mRunnable); - if (mBinding != null && mPendingCategoryFocusRunnable != null) { - mBinding.recycler.removeCallbacks(mPendingCategoryFocusRunnable); - } - mPendingCategoryFocusRunnable = null; - mPendingCategoryFocus = false; - return focusGeneration; - } - @Override protected void onBackInvoked() { - invalidatePendingFocusRequests(); if (isFilterVisible()) updateFilter(); else if (getFragment().canBack()) getFragment().goBack(); else super.onBackInvoked(); } - @Override - protected void onPause() { - invalidatePendingFocusRequests(); - super.onPause(); - } - - @Override - protected void onDestroy() { - invalidatePendingFocusRequests(); - super.onDestroy(); - } - class PageAdapter extends FragmentStatePagerAdapter { public PageAdapter(@NonNull FragmentManager fm) { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java index 471822c663..ad1e2ca338 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java @@ -29,7 +29,6 @@ public class AdRuleAdapter extends RecyclerView.Adapter payloads) { - if (!payloads.isEmpty() && payloads.get(payloads.size() - 1) instanceof Boolean enabled) { - RuleItem item = mItems.get(position); - holder.binding.toggle.setChecked(enabled); - holder.binding.toggle.setContentDescription(item.getName()); - if (item.getType() == RuleType.HLS_RULE) { - holder.binding.summary.setText(item.getSummary(holder.itemView.getContext())); - } - return; - } - super.onBindViewHolder(holder, position, payloads); - } - public static class ViewHolder extends RecyclerView.ViewHolder { private final AdapterAdRuleBinding binding; diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/CollectAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/CollectAdapter.java index a8a56681d0..79a2c0ea6d 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/CollectAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/CollectAdapter.java @@ -21,8 +21,6 @@ public class CollectAdapter extends RecyclerView.Adapter mItems; - private int progressCurrent; - private int progressTotal; public CollectAdapter(OnClickListener listener) { this.listener = listener; @@ -33,9 +31,7 @@ public class CollectAdapter extends RecyclerView.Adapter 0 && !mItems.isEmpty()) notifyChanged(0); - } - public int findCollectIndex(String siteKey) { for (int i = 0; i < mItems.size(); i++) { if (mItems.get(i).getSite().getKey().equals(siteKey)) return i; @@ -164,10 +154,7 @@ public class CollectAdapter extends RecyclerView.Adapter= 0 && listener.onCollectKey(adapterPosition, keyCode, event); }); - String name = item.getSite().getDisplayName(); - holder.binding.text.setText("all".equals(item.getSite().getKey()) && progressTotal > 0 - ? name + " " + progressCurrent + "/" + progressTotal - : name); + holder.binding.text.setText(item.getSite().getDisplayName()); holder.binding.text.setSelected(holder.binding.text.hasFocus() || item.isSelected()); holder.binding.text.setOnFocusChangeListener((v, hasFocus) -> holder.binding.text.setSelected(hasFocus || item.isSelected())); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java index 5dc7ae9fed..7b7bf36e38 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java @@ -9,7 +9,6 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.setting.InterfaceOrderStore; import com.fongmi.android.tv.databinding.AdapterConfigBinding; import java.util.List; @@ -28,8 +27,6 @@ public class ConfigAdapter extends RecyclerView.Adapter TextUtils.equals(item.getUrl(), currentUrl)); return this; @@ -54,20 +51,10 @@ public class ConfigAdapter extends RecyclerView.Adapter= mItems.size() || to >= mItems.size() || from == to) return false; - Config item = mItems.remove(from); - mItems.add(to, item); - notifyItemMoved(from, to); - if (mItems.size() > 0) InterfaceOrderStore.saveVodConfigs(mItems); - return true; - } - @Override public int getItemCount() { return mItems.size(); @@ -84,7 +71,6 @@ public class ConfigAdapter extends RecyclerView.Adapter listener.onTextClick(item)); - holder.binding.text.setOnLongClickListener(v -> listener.onTextLongClick(holder)); holder.binding.delete.setVisibility(readOnly ? View.GONE : View.VISIBLE); holder.binding.delete.setOnClickListener(v -> listener.onDeleteClick(item)); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java index af2b97b695..f00838c36e 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java @@ -89,7 +89,7 @@ public class HistoryAdapter extends BaseDiffAdapter { - - private final OnClickListener listener; - private final int selected; - private final int[] mItems; - - public ThemeAdapter(OnClickListener listener, int[] items, int selected) { - this.listener = listener; - this.selected = selected; - this.mItems = items; - } - - public interface OnClickListener { - - void onItemClick(int color); - } - - @Override - public int getItemCount() { - return mItems.length; - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(AdapterThemeBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - int color = mItems[position]; - holder.binding.circle.setBackground(getCircle(color)); - holder.binding.getRoot().setOnClickListener(v -> listener.onItemClick(color)); - holder.binding.check.setVisibility(selected == color ? View.VISIBLE : View.INVISIBLE); - holder.binding.close.setVisibility(selected != color && color == -1 ? View.VISIBLE : View.GONE); - } - - private GradientDrawable getCircle(int color) { - GradientDrawable circle = new GradientDrawable(); - circle.setShape(GradientDrawable.OVAL); - if (color == 0) circle.setColor(Setting.getWallColor()); - else if (color == -1) circle.setColor(MDColor.GREY_500); - else circle.setColor(color); - return circle; - } - - public class ViewHolder extends RecyclerView.ViewHolder { - - private final AdapterThemeBinding binding; - - ViewHolder(@NonNull AdapterThemeBinding binding) { - super(binding.getRoot()); - this.binding = binding; - } - } -} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java index d10c7a7667..ba289aafb9 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java @@ -8,13 +8,9 @@ import android.content.res.Resources; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; -import android.view.Window; import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.FragmentActivity; import androidx.recyclerview.widget.RecyclerView; @@ -25,21 +21,16 @@ import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.server.process.ApkUrlPush; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.ui.custom.CustomWallView; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.Util; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; -import me.jessyan.autosize.AutoSizeConfig; import me.jessyan.autosize.AutoSizeCompat; public abstract class BaseActivity extends AppCompatActivity { - private static final int DESIGN_WIDTH_IN_DP = 960; - private static final int DESIGN_HEIGHT_IN_DP = 540; - protected abstract ViewBinding getBinding(); @Override @@ -50,7 +41,6 @@ public abstract class BaseActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - registerFragmentLifecycleCallbacks(); setContentView(getBinding().getRoot()); EventBus.getDefault().register(this); initView(savedInstanceState); @@ -62,8 +52,8 @@ public abstract class BaseActivity extends AppCompatActivity { @Override public void setContentView(View view) { super.setContentView(view); - if (customWall()) addCustomWall(); - TouchOptimizationHelper.sync(getWindow().getDecorView()); + if (!customWall()) return; + addCustomWall(); } private void addCustomWall() { @@ -140,8 +130,6 @@ public abstract class BaseActivity extends AppCompatActivity { private Resources hackResources(Resources resources) { try { - // Keep the TV AutoSize pipeline and express the user scale through its design size. - applyUiScale(); AutoSizeCompat.autoConvertDensityOfGlobal(resources); return resources; } catch (Exception ignored) { @@ -149,16 +137,9 @@ public abstract class BaseActivity extends AppCompatActivity { } } - private void applyUiScale() { - AutoSizeConfig config = AutoSizeConfig.getInstance(); - float factor = Setting.getUiScaleFactor(Setting.getUiScale()); - config.setDesignWidthInDp(Math.round(DESIGN_WIDTH_IN_DP / factor)); - config.setDesignHeightInDp(Math.round(DESIGN_HEIGHT_IN_DP / factor)); - } - @Subscribe(threadMode = ThreadMode.MAIN) public void onSubscribe(Object o) { - if (o instanceof RefreshEvent event && (event.getType() == RefreshEvent.Type.LANGUAGE || event.getType() == RefreshEvent.Type.UI_SCALE || event.getType() == RefreshEvent.Type.THEME)) recreate(); + if (o instanceof RefreshEvent event && event.getType() == RefreshEvent.Type.LANGUAGE) recreate(); } @Override @@ -185,28 +166,10 @@ public abstract class BaseActivity extends AppCompatActivity { @Override protected void onResume() { super.onResume(); - TouchOptimizationHelper.sync(getWindow().getDecorView()); Updater.create().resume(this); ApkUrlPush.get().resume(this); } - private void registerFragmentLifecycleCallbacks() { - getSupportFragmentManager().registerFragmentLifecycleCallbacks(new FragmentManager.FragmentLifecycleCallbacks() { - @Override - public void onFragmentViewCreated(@NonNull FragmentManager fragmentManager, @NonNull Fragment fragment, @NonNull View view, Bundle savedInstanceState) { - TouchOptimizationHelper.sync(view); - } - - @Override - public void onFragmentStarted(@NonNull FragmentManager fragmentManager, @NonNull Fragment fragment) { - if (!(fragment instanceof DialogFragment dialog) || dialog.getDialog() == null) return; - Window window = dialog.getDialog().getWindow(); - if (window == null) return; - window.getDecorView().post(() -> TouchOptimizationHelper.sync(window.getDecorView())); - } - }, true); - } - @Override protected void onDestroy() { EventBus.getDefault().unregister(this); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownVod.java b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownVod.java index 248d3cd925..fd8bd06ccc 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownVod.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownVod.java @@ -33,16 +33,6 @@ public class CustomKeyDownVod extends GestureDetector.SimpleOnGestureListener { return detector.onTouchEvent(e); } - public boolean isChangingSpeed() { - return changeSpeed; - } - - public void releaseSpeed() { - if (!changeSpeed) return; - changeSpeed = false; - listener.onSpeedEnd(); - } - public void setFull(boolean full) { this.full = full; } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/FlagSelectionListener.java b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/FlagSelectionListener.java deleted file mode 100644 index 21c9f73900..0000000000 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/FlagSelectionListener.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.fongmi.android.tv.ui.custom; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.leanback.widget.HorizontalGridView; -import androidx.leanback.widget.OnChildViewHolderSelectedListener; -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.bean.Flag; -import com.fongmi.android.tv.ui.adapter.FlagAdapter; - -public final class FlagSelectionListener extends OnChildViewHolderSelectedListener implements Runnable { - - private final HorizontalGridView view; - private final FlagAdapter adapter; - private final FlagAdapter.OnClickListener listener; - private Flag pendingItem; - - public FlagSelectionListener(HorizontalGridView view, FlagAdapter adapter, FlagAdapter.OnClickListener listener) { - this.view = view; - this.adapter = adapter; - this.listener = listener; - } - - @Override - public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { - view.removeCallbacks(this); - pendingItem = position >= 0 && position < adapter.getItemCount() ? adapter.get(position) : null; - // Leanback may dispatch selection inside layout. The whole route switch can - // refresh this grid through focus changes and seamless episode selection. - if (pendingItem != null) view.post(this); - } - - @Override - public void run() { - if (!view.isAttachedToWindow() || view.getAdapter() != adapter) { - pendingItem = null; - return; - } - if (view.isComputingLayout()) { - view.postOnAnimation(this); - return; - } - Flag item = pendingItem; - pendingItem = null; - int position = view.getSelectedPosition(); - // A newer selection or a refreshed route list makes the queued item stale. - if (item != null && position >= 0 && position < adapter.getItemCount() && adapter.get(position) == item) { - listener.onItemClick(item); - } - } -} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java index c7e51ce8ba..0494960252 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java @@ -3,15 +3,12 @@ package com.fongmi.android.tv.ui.dialog; import android.app.Activity; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; -import android.text.Editable; -import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.Gravity; import android.view.Window; import android.view.WindowManager; -import android.widget.EditText; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; @@ -19,28 +16,18 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.AdBlockStatsStore; -import com.fongmi.android.tv.bean.AdBlockLog; -import com.fongmi.android.tv.bean.AdBlockLogFilter; import com.fongmi.android.tv.bean.AdBlockStats; import com.fongmi.android.tv.bean.RuleHitRecord; -import com.fongmi.android.tv.databinding.AdapterAdBlockLogBinding; import com.fongmi.android.tv.databinding.DialogAdBlockStatsBinding; import com.fongmi.android.tv.impl.Callback; -import com.fongmi.android.tv.utils.AdBlockTimeFormatter; -import com.fongmi.android.tv.widget.AdBlockChartView; -import com.google.android.material.button.MaterialButton; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Date; import java.util.Comparator; -import java.util.EnumMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; /** @@ -51,13 +38,6 @@ public class AdBlockStatsDialog { private final DialogAdBlockStatsBinding binding; private final AlertDialog dialog; private final Activity activity; - private final EnumMap logFilters = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final EnumMap> logSelections = - new EnumMap<>(AdBlockLogFilter.Column.class); - private List currentLogs = new ArrayList<>(); - private BlockLogAdapter logAdapter; - private boolean logFiltersBound; public static AdBlockStatsDialog create(Activity activity) { return new AdBlockStatsDialog(activity); @@ -76,151 +56,26 @@ public class AdBlockStatsDialog { loadStats(); dialog.show(); configureWindow(); - configureTabFocus(); } private void configureWindow() { Window window = dialog.getWindow(); if (window == null) return; - int horizontalMargin = ResUtil.dp2px(24); - int verticalMargin = ResUtil.dp2px(24); - int width = Math.max(1, ResUtil.getScreenWidth(activity) - horizontalMargin * 2); - int height = Math.max(1, ResUtil.getScreenHeight(activity) - verticalMargin * 2); + int width = Math.min(Math.round(ResUtil.getScreenWidth(activity) * 0.72f), ResUtil.dp2px(720)); + int height = Math.min(Math.round(ResUtil.getScreenHeight(activity) * 0.82f), ResUtil.dp2px(680)); WindowManager.LayoutParams params = window.getAttributes(); - params.width = width; - params.height = height; + params.width = Math.max(width, ResUtil.dp2px(420)); + params.height = Math.max(height, ResUtil.dp2px(360)); params.gravity = Gravity.CENTER; window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); window.setAttributes(params); window.setLayout(params.width, params.height); - binding.getRoot().setMinimumHeight(height); } private void initView() { binding.reset.setOnClickListener(v -> onReset()); binding.close.setOnClickListener(v -> dialog.dismiss()); - if (binding.statsTabs.getTabCount() == 0) { - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_overview)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_site_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_rule_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_pipeline_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_log)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_chart)); - binding.statsTabs.addOnTabSelectedListener(new com.google.android.material.tabs.TabLayout.OnTabSelectedListener() { - @Override public void onTabSelected(com.google.android.material.tabs.TabLayout.Tab tab) { showPage(tab.getPosition()); } - @Override public void onTabUnselected(com.google.android.material.tabs.TabLayout.Tab tab) {} - @Override public void onTabReselected(com.google.android.material.tabs.TabLayout.Tab tab) {} - }); - } - if (!logFiltersBound) { - bindLogFilters(); - logFiltersBound = true; - } - showPage(binding.statsTabs.getSelectedTabPosition()); - } - - private void configureTabFocus() { - if (binding.statsTabs.getChildCount() == 0) return; - View strip = binding.statsTabs.getChildAt(0); - if (!(strip instanceof ViewGroup tabStrip)) return; - for (int i = 0; i < tabStrip.getChildCount(); i++) { - View tabView = tabStrip.getChildAt(i); - tabView.setFocusable(true); - tabView.setFocusableInTouchMode(true); - tabView.setBackgroundResource(R.drawable.selector_mpv_tab_focus); - tabView.setOnFocusChangeListener((view, hasFocus) -> { - if (!hasFocus) return; - int position = tabStrip.indexOfChild(view); - com.google.android.material.tabs.TabLayout.Tab tab = binding.statsTabs.getTabAt(position); - if (tab != null) tab.select(); - }); - } - int position = binding.statsTabs.getSelectedTabPosition(); - if (position < 0 || position >= tabStrip.getChildCount()) position = 0; - if (tabStrip.getChildCount() > 0) { - View selectedTab = tabStrip.getChildAt(position); - selectedTab.post(selectedTab::requestFocus); - } - } - - private void bindLogFilters() { - bindCategoricalFilter(binding.siteFilter, AdBlockLogFilter.Column.SITE_NAME, R.string.ad_log_site_name); - bindCategoricalFilter(binding.siteDomainFilter, AdBlockLogFilter.Column.SITE_DOMAIN, R.string.ad_log_site_domain); - bindCategoricalFilter(binding.vodFilter, AdBlockLogFilter.Column.VOD_NAME, R.string.ad_log_playback_context); - bindCategoricalFilter(binding.lineFilter, AdBlockLogFilter.Column.LINE_NAME, R.string.ad_log_line_name); - bindCategoricalFilter(binding.episodeFilter, AdBlockLogFilter.Column.EPISODE_NAME, R.string.ad_log_episode_name); - bindCategoricalFilter(binding.ruleFilter, AdBlockLogFilter.Column.RULE, R.string.ad_log_rule_domain); - bindCategoricalFilter(binding.adDomainFilter, AdBlockLogFilter.Column.AD_DOMAIN, R.string.ad_log_ad_domain); - bindCategoricalFilter(binding.pipelineFilter, AdBlockLogFilter.Column.PIPELINE, R.string.ad_log_pipeline); - bindTextFilter(binding.blockedAtFilter, AdBlockLogFilter.Column.BLOCKED_AT); - bindTextFilter(binding.segmentStartFilter, AdBlockLogFilter.Column.SEGMENT_START); - bindTextFilter(binding.segmentEndFilter, AdBlockLogFilter.Column.SEGMENT_END); - bindTextFilter(binding.segmentDurationFilter, AdBlockLogFilter.Column.SEGMENT_DURATION); - } - - private void bindCategoricalFilter(MaterialButton button, AdBlockLogFilter.Column column, int title) { - button.setOnClickListener(v -> showCategoricalFilter(button, column, title)); - updateCategoricalButton(button, column); - } - - private void showCategoricalFilter(MaterialButton button, AdBlockLogFilter.Column column, int title) { - Set selected = logSelections.get(column); - AdBlockLogFilterDialog.show(activity, activity.getString(title), currentLogs, - selected == null ? java.util.Set.of() : new java.util.LinkedHashSet<>(selected), - log -> displayValue(log, column), values -> { - if (values == null || values.isEmpty()) logSelections.remove(column); - else logSelections.put(column, new java.util.LinkedHashSet<>(values)); - updateCategoricalButton(button, column); - if (logAdapter != null) { - logAdapter.setSelection(column, values); - updateLogEmptyState(); - } - }); - } - - private void updateCategoricalButton(MaterialButton button, AdBlockLogFilter.Column column) { - Set selected = logSelections.get(column); - if (selected == null || selected.isEmpty()) { - button.setText(R.string.ad_log_filter_hint); - } else if (selected.size() == 1) { - button.setText(selected.iterator().next()); - } else { - button.setText(activity.getString(R.string.ad_log_filter_selected, selected.size())); - } - button.setEnabled(!currentLogs.isEmpty()); - } - - private void bindTextFilter(EditText input, AdBlockLogFilter.Column column) { - input.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - - @Override - public void afterTextChanged(Editable s) { - String query = s == null ? "" : s.toString(); - if (query.trim().isEmpty()) logFilters.remove(column); - else logFilters.put(column, query); - if (logAdapter != null) { - logAdapter.setFilter(column, query); - updateLogEmptyState(); - } - } - }); - } - - private void showPage(int position) { - binding.overviewPage.setVisibility(position == 0 ? View.VISIBLE : View.GONE); - binding.sitePage.setVisibility(position == 1 ? View.VISIBLE : View.GONE); - binding.rulePage.setVisibility(position == 2 ? View.VISIBLE : View.GONE); - binding.pipelinePage.setVisibility(position == 3 ? View.VISIBLE : View.GONE); - binding.logPage.setVisibility(position == 4 ? View.VISIBLE : View.GONE); - binding.chartPage.setVisibility(position == 5 ? View.VISIBLE : View.GONE); } private void loadStats() { @@ -237,29 +92,18 @@ public class AdBlockStatsDialog { binding.aiSuccessRate.setText("0%"); } - // 站点维度统计概览与排行使用同一份快照 + // 站点拦截排行 List siteRank = buildSiteRank(stats); - binding.siteCount.setText(String.valueOf(stats.getSiteBlocked().size())); - if (siteRank.isEmpty()) { - binding.topSite.setText("-"); - binding.topSiteShare.setText("0%"); - } else { - SiteRankItem top = siteRank.get(0); - binding.topSite.setText(top.getDisplayName()); - double share = stats.getTotalBlocked() > 0 ? top.getCount() * 100.0 / stats.getTotalBlocked() : 0.0; - binding.topSiteShare.setText(String.format(Locale.getDefault(), "%.1f%%", share)); - } - - // 站点、规则和播放链路默认显示汇总项,点击汇总项展开对应的逐条日志。 if (siteRank.isEmpty()) { binding.siteRankEmpty.setVisibility(View.VISIBLE); binding.siteRankRecycler.setVisibility(View.GONE); } else { binding.siteRankEmpty.setVisibility(View.GONE); binding.siteRankRecycler.setVisibility(View.VISIBLE); - binding.siteRankRecycler.setAdapter(new GroupedLogAdapter(siteRank, stats, GroupType.SOURCE)); + binding.siteRankRecycler.setAdapter(new SiteRankAdapter(siteRank)); } + // 规则命中排行 List ruleRank = AdBlockStatsStore.getTopRules(10); if (ruleRank.isEmpty()) { binding.ruleRankEmpty.setVisibility(View.VISIBLE); @@ -267,126 +111,24 @@ public class AdBlockStatsDialog { } else { binding.ruleRankEmpty.setVisibility(View.GONE); binding.ruleRankRecycler.setVisibility(View.VISIBLE); - binding.ruleRankRecycler.setAdapter(new RuleGroupedLogAdapter(ruleRank, stats)); + binding.ruleRankRecycler.setAdapter(new RuleRankAdapter(ruleRank)); } - - List pipelineRank = buildPipelineRank(stats); - if (pipelineRank.isEmpty()) { - binding.pipelineRankEmpty.setVisibility(View.VISIBLE); - binding.pipelineRankRecycler.setVisibility(View.GONE); - } else { - binding.pipelineRankEmpty.setVisibility(View.GONE); - binding.pipelineRankRecycler.setVisibility(View.VISIBLE); - binding.pipelineRankRecycler.setAdapter(new GroupedLogAdapter(pipelineRank, stats, GroupType.PIPELINE)); - } - - List logs = stats.getBlockLogs(); - currentLogs = new ArrayList<>(logs); - updateAllCategoricalButtons(); - logAdapter = new BlockLogAdapter(logs, logFilters, logSelections, this::filterValues); - binding.logRecycler.setAdapter(logAdapter); - updateLogEmptyState(); - - List chartItems = buildSiteRank(stats); - binding.chartEmpty.setVisibility(chartItems.isEmpty() ? View.VISIBLE : View.GONE); - binding.chartView.setVisibility(chartItems.isEmpty() ? View.GONE : View.VISIBLE); - List chartEntries = chartItems.stream() - .map(item -> new AdBlockChartView.Entry(item.getDisplayName(), item.getCount())) - .collect(Collectors.toList()); - binding.chartView.setEntries(chartEntries); - } - - private void updateAllCategoricalButtons() { - updateCategoricalButton(binding.siteFilter, AdBlockLogFilter.Column.SITE_NAME); - updateCategoricalButton(binding.siteDomainFilter, AdBlockLogFilter.Column.SITE_DOMAIN); - updateCategoricalButton(binding.vodFilter, AdBlockLogFilter.Column.VOD_NAME); - updateCategoricalButton(binding.lineFilter, AdBlockLogFilter.Column.LINE_NAME); - updateCategoricalButton(binding.episodeFilter, AdBlockLogFilter.Column.EPISODE_NAME); - updateCategoricalButton(binding.ruleFilter, AdBlockLogFilter.Column.RULE); - updateCategoricalButton(binding.adDomainFilter, AdBlockLogFilter.Column.AD_DOMAIN); - updateCategoricalButton(binding.pipelineFilter, AdBlockLogFilter.Column.PIPELINE); - } - - private Map filterValues(AdBlockLog item) { - EnumMap values = new EnumMap<>(AdBlockLogFilter.Column.class); - for (AdBlockLogFilter.Column column : AdBlockLogFilter.Column.values()) { - values.put(column, displayValue(item, column)); - } - return values; - } - - private String displayValue(AdBlockLog item, AdBlockLogFilter.Column column) { - String unknown = activity.getString(R.string.ad_log_unknown); - switch (column) { - case SITE_NAME: return value(item.getSiteName(), unknown); - case SITE_DOMAIN: return value(item.getSiteDomain(), unknown); - case VOD_NAME: return value(item.getVodName(), unknown); - case LINE_NAME: return value(item.getLineName(), unknown); - case EPISODE_NAME: return value(item.getEpisodeName(), unknown); - case RULE: return value(AdBlockStatsStore.getRuleDisplayName(item.getRuleId()), unknown); - case AD_DOMAIN: return value(item.getAdDomain(), unknown); - case BLOCKED_AT: return item.getBlockedAt() > 0 - ? new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(new Date(item.getBlockedAt())) : unknown; - case SEGMENT_START: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentStartSeconds()) : unknown; - case SEGMENT_END: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentEndSeconds()) : unknown; - case SEGMENT_DURATION: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentDurationSeconds()) : unknown; - case PIPELINE: return value(item.getPipelineName(), unknown); - default: return unknown; - } - } - - private static String value(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; - } - - private void updateLogEmptyState() { - boolean noLogs = logAdapter == null || logAdapter.getSourceItemCount() == 0; - boolean noMatches = !noLogs && logAdapter.getItemCount() == 0; - binding.logEmpty.setText(noLogs ? R.string.ad_stats_empty : R.string.ad_stats_filter_empty); - binding.logEmpty.setVisibility(noLogs || noMatches ? View.VISIBLE : View.GONE); - binding.logTableScroll.setVisibility(noLogs ? View.GONE : View.VISIBLE); } private List buildSiteRank(AdBlockStats stats) { - Map displayNames = new java.util.HashMap<>(); - for (AdBlockLog log : stats.getBlockLogs()) { - String name = log.getSiteName(); - if (name == null || name.isBlank()) continue; - if (!log.getSiteKey().isBlank()) displayNames.putIfAbsent(log.getSiteKey(), name); - displayNames.putIfAbsent(log.getSourceName(), name); - } return stats.getSiteBlocked().entrySet().stream() - .map(entry -> new SiteRankItem(entry.getKey(), - displayNames.getOrDefault(entry.getKey(), entry.getKey()), entry.getValue())) + .map(entry -> new SiteRankItem(entry.getKey(), entry.getValue())) .sorted(Comparator.comparingLong(SiteRankItem::getCount).reversed()) .limit(10) .collect(Collectors.toList()); } - private List buildPipelineRank(AdBlockStats stats) { - return stats.getPipelineCounts().entrySet().stream() - .map(entry -> new SiteRankItem(entry.getKey(), entry.getValue())) - .sorted(Comparator.comparingLong(SiteRankItem::getCount).reversed()) - .collect(Collectors.toList()); - } - - private void clearTextFilters() { - binding.blockedAtFilter.setText(""); - binding.segmentStartFilter.setText(""); - binding.segmentEndFilter.setText(""); - binding.segmentDurationFilter.setText(""); - updateAllCategoricalButtons(); - } - private void onReset() { new MaterialAlertDialogBuilder(activity) .setTitle(R.string.ad_stats_reset) .setMessage(R.string.ad_stats_reset_confirm) .setPositiveButton(android.R.string.ok, (d, which) -> { AdBlockStatsStore.reset(); - logFilters.clear(); - logSelections.clear(); - clearTextFilters(); loadStats(); }) .setNegativeButton(android.R.string.cancel, null) @@ -396,16 +138,10 @@ public class AdBlockStatsDialog { // 站点排行项 private static class SiteRankItem { private final String siteKey; - private final String displayName; private final long count; public SiteRankItem(String siteKey, long count) { - this(siteKey, siteKey, count); - } - - public SiteRankItem(String siteKey, String displayName, long count) { this.siteKey = siteKey; - this.displayName = displayName; this.count = count; } @@ -413,232 +149,11 @@ public class AdBlockStatsDialog { return siteKey; } - public String getDisplayName() { - return displayName; - } - public long getCount() { return count; } } - private static void restoreItemFocus(RecyclerView recycler, int requestedPosition, int itemCount) { - if (recycler == null || itemCount <= 0) return; - int target = Math.min(Math.max(0, requestedPosition), itemCount - 1); - recycler.scrollToPosition(target); - recycler.post(() -> { - RecyclerView.ViewHolder targetHolder = recycler.findViewHolderForAdapterPosition(target); - if (targetHolder != null) targetHolder.itemView.requestFocus(); - }); - } - - private enum GroupType { SOURCE, PIPELINE } - - private static class GroupedLogAdapter extends RecyclerView.Adapter { - private final List groups; - private final AdBlockStats stats; - private final GroupType type; - private final java.util.Set expanded = new java.util.HashSet<>(); - private final List visibleItems = new ArrayList<>(); - - GroupedLogAdapter(List groups, AdBlockStats stats, GroupType type) { - this.groups = groups; - this.stats = stats; - this.type = type; - rebuild(); - } - - private void rebuild() { - visibleItems.clear(); - for (SiteRankItem group : groups) { - visibleItems.add(group); - if (expanded.contains(group.getSiteKey())) { - visibleItems.addAll(type == GroupType.SOURCE - ? stats.getBlockLogsBySite(group.getSiteKey()) - : stats.getBlockLogsByPipeline(group.getSiteKey())); - } - } - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - Object visible = visibleItems.get(position); - if (visible instanceof SiteRankItem) { - SiteRankItem group = (SiteRankItem) visible; - boolean isExpanded = expanded.contains(group.getSiteKey()); - holder.binding.name.setText((isExpanded ? "▼ " : "▶ ") + group.getDisplayName()); - holder.binding.source.setVisibility(View.GONE); - holder.binding.count.setText(String.valueOf(group.getCount())); - holder.itemView.setOnClickListener(view -> { - int focusedPosition = holder.getBindingAdapterPosition(); - RecyclerView recycler = (RecyclerView) view.getParent(); - if (!expanded.add(group.getSiteKey())) expanded.remove(group.getSiteKey()); - rebuild(); - notifyDataSetChanged(); - restoreItemFocus(recycler, focusedPosition, getItemCount()); - }); - } else { - bindLog(holder, (AdBlockLog) visible); - } - } - - private void bindLog(ViewHolder holder, AdBlockLog log) { - holder.binding.name.setText(" " + log.getAdDomain()); - holder.binding.source.setText(log.getSourceName() + " · " + log.getPipelineName() + " · " + log.getRuleId()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", log.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(view -> view.requestFocus()); - } - - @Override - public int getItemCount() { - return visibleItems.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private static class RuleGroupedLogAdapter extends RecyclerView.Adapter { - private final List groups; - private final AdBlockStats stats; - private final java.util.Set expanded = new java.util.HashSet<>(); - private final List visibleItems = new ArrayList<>(); - - RuleGroupedLogAdapter(List groups, AdBlockStats stats) { - this.groups = groups; - this.stats = stats; - rebuild(); - } - - private void rebuild() { - visibleItems.clear(); - for (RuleHitRecord group : groups) { - visibleItems.add(group); - if (expanded.contains(group.getRuleId())) visibleItems.addAll(stats.getBlockLogsByRule(group.getRuleId())); - } - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - Object visible = visibleItems.get(position); - if (visible instanceof RuleHitRecord) { - RuleHitRecord group = (RuleHitRecord) visible; - boolean isExpanded = expanded.contains(group.getRuleId()); - String name = "hls.legacy-fallback".equals(group.getRuleId()) ? "内部兜底规则" : group.getRuleName(); - if (name == null || name.isEmpty()) name = group.getRuleId(); - holder.binding.name.setText((isExpanded ? "▼ " : "▶ ") + name); - holder.binding.source.setText(group.getRuleSource()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.valueOf(group.getHitCount())); - holder.itemView.setOnClickListener(view -> { - int focusedPosition = holder.getBindingAdapterPosition(); - RecyclerView recycler = (RecyclerView) view.getParent(); - if (!expanded.add(group.getRuleId())) expanded.remove(group.getRuleId()); - rebuild(); - notifyDataSetChanged(); - restoreItemFocus(recycler, focusedPosition, getItemCount()); - }); - } else { - AdBlockLog log = (AdBlockLog) visible; - holder.binding.name.setText(" " + log.getAdDomain()); - holder.binding.source.setText(log.getSourceName() + " · " + log.getPipelineName()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", log.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(view -> view.requestFocus()); - } - } - - @Override - public int getItemCount() { - return visibleItems.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private static class ExpandableLogAdapter extends RecyclerView.Adapter { - private final List items; - private boolean expanded; - - ExpandableLogAdapter(List items) { - this.items = items; - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - holder.itemView.setFocusable(true); - holder.itemView.setFocusableInTouchMode(true); - holder.itemView.setOnClickListener(view -> view.requestFocus()); - AdBlockLog item = items.get(position); - holder.binding.name.setText(item.getAdDomain()); - holder.binding.source.setText(item.getSourceName() + " · " + item.getPipelineName() + " · " + item.getRuleId()); - holder.binding.source.setVisibility(expanded ? View.VISIBLE : View.GONE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", item.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(view -> { - expanded = !expanded; - notifyDataSetChanged(); - }); - } - - @Override - public int getItemCount() { - return items.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private List getBlockLogsBySource(AdBlockStats stats, String source) { - return stats.getBlockLogsBySource(source); - } - - private List getBlockLogsByRule(AdBlockStats stats, String rule) { - return stats.getBlockLogsByRule(rule); - } - - private List getBlockLogsByPipeline(AdBlockStats stats, String pipeline) { - return stats.getBlockLogsByPipeline(pipeline); - } - // 站点排行适配器 private static class SiteRankAdapter extends RecyclerView.Adapter { private final List items; @@ -656,7 +171,7 @@ public class AdBlockStatsDialog { @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { SiteRankItem item = items.get(position); - holder.binding.name.setText(item.getDisplayName()); + holder.binding.name.setText(item.getSiteKey()); holder.binding.count.setText(String.valueOf(item.getCount())); holder.binding.source.setVisibility(View.GONE); } @@ -676,106 +191,6 @@ public class AdBlockStatsDialog { } } - private static class BlockLogAdapter extends RecyclerView.Adapter { - private final List sourceItems; - private List items; - private final EnumMap filters = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final EnumMap> selections = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final AdBlockLogFilter.ValueProvider valueProvider; - private final SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()); - - BlockLogAdapter(List items, Map initialFilters, - Map> initialSelections, - AdBlockLogFilter.ValueProvider valueProvider) { - this.sourceItems = items == null ? new ArrayList<>() : new ArrayList<>(items); - if (initialFilters != null) filters.putAll(initialFilters); - if (initialSelections != null) { - for (Map.Entry> entry : initialSelections.entrySet()) { - if (entry.getValue() != null && !entry.getValue().isEmpty()) { - selections.put(entry.getKey(), new java.util.LinkedHashSet<>(entry.getValue())); - } - } - } - this.valueProvider = valueProvider; - applyFilters(); - } - - void setFilter(AdBlockLogFilter.Column column, String query) { - if (query == null || query.trim().isEmpty()) filters.remove(column); - else filters.put(column, query); - applyFilters(); - notifyDataSetChanged(); - } - - void setSelection(AdBlockLogFilter.Column column, Set values) { - if (values == null || values.isEmpty()) selections.remove(column); - else selections.put(column, new java.util.LinkedHashSet<>(values)); - applyFilters(); - notifyDataSetChanged(); - } - - int getSourceItemCount() { - return sourceItems.size(); - } - - private void applyFilters() { - items = AdBlockLogFilter.filter(sourceItems, filters, selections, valueProvider); - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - AdapterAdBlockLogBinding binding = AdapterAdBlockLogBinding.inflate( - LayoutInflater.from(parent.getContext()), parent, false); - return new ViewHolder(binding); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - AdBlockLog item = items.get(position); - String unknown = holder.itemView.getContext().getString(R.string.ad_log_unknown); - holder.binding.siteName.setText(value(item.getSiteName(), unknown)); - holder.binding.siteDomain.setText(value(item.getSiteDomain(), unknown)); - holder.binding.playbackContext.setText(value(item.getVodName(), unknown)); - holder.binding.lineName.setText(value(item.getLineName(), unknown)); - holder.binding.episodeName.setText(value(item.getEpisodeName(), unknown)); - String rule = AdBlockStatsStore.getRuleDisplayName(item.getRuleId()); - String domain = value(item.getAdDomain(), unknown); - holder.binding.ruleDomain.setText(value(rule, unknown)); - holder.binding.adDomain.setText(domain); - holder.binding.blockedAt.setText(item.getBlockedAt() > 0 - ? timeFormat.format(new Date(item.getBlockedAt())) : unknown); - holder.binding.segmentStart.setText(item.hasSegmentTiming() ? seconds(item.getSegmentStartSeconds()) : unknown); - holder.binding.segmentEnd.setText(item.hasSegmentTiming() ? seconds(item.getSegmentEndSeconds()) : unknown); - holder.binding.segmentDuration.setText(item.hasSegmentTiming() ? seconds(item.getSegmentDurationSeconds()) : unknown); - holder.binding.pipeline.setText(value(item.getPipelineName(), unknown)); - } - - private static String seconds(double value) { - return AdBlockTimeFormatter.formatSeconds(value); - } - - private static String value(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; - } - - @Override - public int getItemCount() { - return items.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final AdapterAdBlockLogBinding binding; - - ViewHolder(AdapterAdBlockLogBinding binding) { - super(binding.getRoot()); - this.binding = binding; - } - } - } - // 规则排行适配器 private static class RuleRankAdapter extends RecyclerView.Adapter { private final List items; diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java index 97e216ddb3..8dd315086e 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java @@ -1,6 +1,5 @@ package com.fongmi.android.tv.ui.dialog; -import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; import android.graphics.Color; @@ -11,8 +10,6 @@ import android.view.KeyEvent; import android.view.View; import android.view.Window; import android.view.WindowManager; -import android.widget.LinearLayout; -import android.widget.TextView; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; @@ -35,7 +32,6 @@ import com.fongmi.android.tv.ui.adapter.AdRuleAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.button.MaterialButton; import java.util.ArrayList; import java.util.List; @@ -89,37 +85,15 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter binding.add.setOnClickListener(v -> onAddManual()); binding.stats.setOnClickListener(v -> onStats()); binding.importCandidates.setOnClickListener(v -> onImportCandidates()); - binding.enableImported.setOnClickListener(v -> onEnableImported()); - binding.disableImported.setOnClickListener(v -> onDisableImported()); binding.add.setOnKeyListener((v, keyCode, event) -> moveFocus(event, keyCode, KeyEvent.KEYCODE_DPAD_DOWN, binding.stats)); binding.stats.setOnKeyListener((v, keyCode, event) -> { if (moveFocus(event, keyCode, KeyEvent.KEYCODE_DPAD_UP, binding.add)) return true; if (event.getAction() != KeyEvent.ACTION_DOWN || keyCode != KeyEvent.KEYCODE_DPAD_DOWN) return false; if (binding.importCandidates.getVisibility() == View.VISIBLE) return requestFocus(binding.importCandidates); - if (binding.importBulkActions.getVisibility() == View.VISIBLE) return requestFocus(binding.enableImported); return focusFirstRule(); }); binding.importCandidates.setOnKeyListener((v, keyCode, event) -> { if (moveFocus(event, keyCode, KeyEvent.KEYCODE_DPAD_UP, binding.stats)) return true; - if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { - if (binding.importBulkActions.getVisibility() == View.VISIBLE) return requestFocus(binding.enableImported); - return focusFirstRule(); - } - return false; - }); - binding.enableImported.setOnKeyListener((v, keyCode, event) -> { - if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_UP) { - return requestFocus(binding.importCandidates.getVisibility() == View.VISIBLE ? binding.importCandidates : binding.stats); - } - if (moveFocus(event, keyCode, KeyEvent.KEYCODE_DPAD_RIGHT, binding.disableImported)) return true; - if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_DOWN) return focusFirstRule(); - return false; - }); - binding.disableImported.setOnKeyListener((v, keyCode, event) -> { - if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_UP) { - return requestFocus(binding.importCandidates.getVisibility() == View.VISIBLE ? binding.importCandidates : binding.stats); - } - if (moveFocus(event, keyCode, KeyEvent.KEYCODE_DPAD_LEFT, binding.enableImported)) return true; if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_DOWN) return focusFirstRule(); return false; }); @@ -147,25 +121,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter return focused; } - private void restoreRuleFocus(int position) { - if (position < 0) return; - binding.recycler.scrollToPosition(position); - binding.recycler.post(() -> { - View row = binding.recycler.getLayoutManager() == null - ? null : binding.recycler.getLayoutManager().findViewByPosition(position); - View target = row == null ? null : row.findViewById(R.id.toggle); - if (target == null) { - binding.recycler.post(() -> { - View retryRow = binding.recycler.getLayoutManager() == null - ? null : binding.recycler.getLayoutManager().findViewByPosition(position); - requestFocus(retryRow == null ? null : retryRow.findViewById(R.id.toggle)); - }); - } else { - requestFocus(target); - } - }); - } - private void loadData() { List items = new ArrayList<>(); @@ -198,8 +153,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter int pending = ImportedAdRuleCandidateStore.pending().size(); binding.importCandidates.setVisibility(pending == 0 ? View.GONE : View.VISIBLE); binding.importCandidates.setText(getString(R.string.ad_rule_import_candidates, pending)); - boolean hasImported = userRules.stream().anyMatch(rule -> UserAdRule.isInterfaceSource(rule.getSource())); - binding.importBulkActions.setVisibility(hasImported ? View.VISIBLE : View.GONE); } private void onAddManual() { @@ -210,25 +163,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter AdBlockStatsDialog.create((FragmentActivity) requireActivity()).show(); } - private void onEnableImported() { - if (UserAdRuleStore.setInterfaceRulesEnabled(true) == 0) return; - loadData(); - if (callback != null) callback.onRuleChanged(); - } - - private void onDisableImported() { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.ad_rule_disable_imported) - .setMessage(R.string.ad_rule_disable_imported_confirm) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - UserAdRuleStore.setInterfaceRulesEnabled(false); - loadData(); - if (callback != null) callback.onRuleChanged(); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - private void onImportCandidates() { List candidates = ImportedAdRuleCandidateStore.pending(); if (candidates.isEmpty()) return; @@ -241,95 +175,19 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter .toArray(String[]::new); boolean[] selected = new boolean[candidates.size()]; for (int i = 0; i < selected.length; i++) selected[i] = ImportedAdRuleCandidate.RISK_LOW.equals(candidates.get(i).getRiskLevel()); - final AlertDialog[] dialogRef = new AlertDialog[1]; - View toolbar = createCandidateToolbar( - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 0), - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 1), - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 2), - () -> ignoreCandidates(dialogRef[0], candidates)); - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.ad_rule_import_title) - .setCustomTitle(toolbar) .setMultiChoiceItems(labels, selected, (dialog, which, checked) -> selected[which] = checked) - .setPositiveButton(R.string.ad_rule_import_enable_action, (dialog, which) -> importSelectedCandidates(candidates, selected, true)) - .setNeutralButton(R.string.ad_rule_import_action, (dialog, which) -> importSelectedCandidates(candidates, selected, false)) - .setNegativeButton(android.R.string.cancel, null); - dialogRef[0] = builder.create(); - View finalToolbar = toolbar; - dialogRef[0].setOnShowListener(dialog -> { - if (finalToolbar instanceof LinearLayout root && root.getChildCount() > 1) requestFocus(root.getChildAt(1)); - }); - dialogRef[0].show(); - } - - private void ignoreCandidates(Dialog dialog, List candidates) { - List ids = new ArrayList<>(); - for (ImportedAdRuleCandidate candidate : candidates) ids.add(candidate.getId()); - ImportedAdRuleCandidateStore.ignoreCandidates(ids); - if (dialog != null) dialog.dismiss(); - loadData(); - } - - private void importSelectedCandidates(List candidates, boolean[] selected, boolean enabled) { - List ids = new ArrayList<>(); - for (int i = 0; i < candidates.size(); i++) if (selected[i]) ids.add(candidates.get(i).getId()); - if (!ids.isEmpty()) ImportedAdRuleCandidateStore.importCandidates(ids, enabled); - onRuleEdited(); - } - - private View createCandidateToolbar(Runnable selectLowRisk, Runnable selectAll, Runnable invert, Runnable ignoreAll) { - LinearLayout root = new LinearLayout(requireContext()); - root.setOrientation(LinearLayout.VERTICAL); - root.setPadding(ResUtil.dp2px(24), ResUtil.dp2px(8), ResUtil.dp2px(24), 0); - TextView title = new TextView(requireContext()); - title.setText(R.string.ad_rule_import_title); - title.setTextSize(20); - root.addView(title, new LinearLayout.LayoutParams(-1, -2)); - MaterialButton lowRisk = candidateActionButton(R.string.ad_rule_select_low_risk); - lowRisk.setOnClickListener(v -> selectLowRisk.run()); - MaterialButton all = candidateActionButton(R.string.ad_rule_select_all); - all.setOnClickListener(v -> selectAll.run()); - MaterialButton invertButton = candidateActionButton(R.string.ad_rule_invert_selection); - invertButton.setOnClickListener(v -> invert.run()); - MaterialButton ignore = candidateActionButton(R.string.ad_rule_ignore_all); - ignore.setOnClickListener(v -> ignoreAll.run()); - root.addView(lowRisk, new LinearLayout.LayoutParams(-1, -2)); - LinearLayout row = new LinearLayout(requireContext()); - row.setOrientation(LinearLayout.HORIZONTAL); - LinearLayout.LayoutParams first = new LinearLayout.LayoutParams(0, -2, 1f); - first.setMargins(0, ResUtil.dp2px(4), ResUtil.dp2px(2), 0); - row.addView(all, first); - LinearLayout.LayoutParams middle = new LinearLayout.LayoutParams(0, -2, 1f); - middle.setMargins(ResUtil.dp2px(2), ResUtil.dp2px(4), ResUtil.dp2px(2), 0); - row.addView(invertButton, middle); - LinearLayout.LayoutParams last = new LinearLayout.LayoutParams(0, -2, 1f); - last.setMargins(ResUtil.dp2px(2), ResUtil.dp2px(4), 0, 0); - row.addView(ignore, last); - root.addView(row); - return root; - } - - private MaterialButton candidateActionButton(int textRes) { - MaterialButton button = new MaterialButton(requireContext()); - button.setText(textRes); - button.setAllCaps(false); - button.setTextSize(12); - button.setMinHeight(ResUtil.dp2px(40)); - return button; - } - - private void updateCandidateSelection(Dialog dialog, List candidates, - boolean[] selected, int mode) { - if (mode == 0) { - for (int i = 0; i < selected.length; i++) selected[i] = ImportedAdRuleCandidate.RISK_LOW.equals(candidates.get(i).getRiskLevel()); - } else if (mode == 1) { - for (int i = 0; i < selected.length; i++) selected[i] = true; - } else { - for (int i = 0; i < selected.length; i++) selected[i] = !selected[i]; - } - if (dialog instanceof AlertDialog alert && alert.getListView() != null) { - for (int i = 0; i < selected.length; i++) alert.getListView().setItemChecked(i, selected[i]); - } + .setPositiveButton(R.string.ad_rule_import_action, (dialog, which) -> { + for (int i = 0; i < candidates.size(); i++) if (selected[i]) ImportedAdRuleCandidateStore.importCandidate(candidates.get(i).getId()); + onRuleEdited(); + }) + .setNeutralButton(R.string.ad_rule_ignore_all, (dialog, which) -> { + for (ImportedAdRuleCandidate candidate : candidates) ImportedAdRuleCandidateStore.ignore(candidate.getId()); + loadData(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); } private void onRuleEdited() { @@ -364,20 +222,14 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter } private void showHlsRuleDetail(HlsRuleConfig.Entry item) { - HlsRuleConfig.Entry current = HlsRuleConfig.getEntries().stream() - .filter(entry -> item.key().equals(entry.key())) - .findFirst().orElse(item); - String name = current.name().isBlank() ? current.id() : current.name(); - String status = current.valid() - ? getString(current.enabled() ? R.string.ad_rule_hls_enabled : R.string.ad_rule_hls_disabled) - : getString(R.string.ad_rule_hls_invalid, current.error()); - String details = HlsRuleConfig.LEGACY_FALLBACK_KEY.equals(current.id()) - ? "具体判断规则\n" + HlsRuleConfig.LEGACY_FALLBACK_DETAIL - : getString(R.string.ad_rule_detail_json) + "\n" + current.detail(); - String content = getString(R.string.ad_rule_hls_builtin_summary, current.version(), status) - + "\n" + getString(R.string.ad_rule_detail_id, current.id()) - + "\n" + getString(R.string.ad_rule_detail_source, current.source()) - + "\n\n" + details; + String name = item.name().isBlank() ? item.id() : item.name(); + String status = item.valid() + ? getString(item.enabled() ? R.string.ad_rule_hls_enabled : R.string.ad_rule_hls_disabled) + : getString(R.string.ad_rule_hls_invalid, item.error()); + String content = getString(R.string.ad_rule_hls_builtin_summary, item.version(), status) + + "\n" + getString(R.string.ad_rule_detail_id, item.id()) + + "\n" + getString(R.string.ad_rule_detail_source, item.source()) + + "\n\n" + getString(R.string.ad_rule_detail_json) + "\n" + item.detail(); showTextDetail(name, content, null); } @@ -425,7 +277,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter .setTitle(name) .setMessage(messageRes) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) - .setNegativeButton(android.R.string.cancel, null) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> loadData()) .show(); } @@ -433,7 +285,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter public void onUserToggleClick(UserAdRule item, boolean enabled) { if (!enabled) { int message = UserAdRule.SOURCE_AI.equals(item.getSource()) ? R.string.ad_rule_ai_disable_confirm - : UserAdRule.isInterfaceSource(item.getSource()) ? R.string.ad_rule_imported_disable_confirm + : item.getSource() != null && item.getSource().startsWith("interface_") ? R.string.ad_rule_imported_disable_confirm : R.string.ad_rule_manual_disable_confirm; confirmDisable(item.getName(), message, () -> setUserEnabled(item, false)); } else { @@ -444,9 +296,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void setUserEnabled(UserAdRule item, boolean enabled) { item.setEnabled(enabled); UserAdRuleStore.update(item); - int position = adapter.positionOfUserRule(item); - adapter.refreshUserEnabled(item); - restoreRuleFocus(position); + loadData(); if (callback != null) callback.onRuleChanged(); } @@ -461,9 +311,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void setDefaultEnabled(String ruleId, boolean enabled) { DisabledDefaultRuleStore.setDisabled(ruleId, !enabled); - int position = adapter.positionOfDefaultRule(ruleId); - adapter.refreshDefaultEnabled(ruleId, enabled); - restoreRuleFocus(position); + loadData(); if (callback != null) callback.onRuleChanged(); } @@ -479,9 +327,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void setHlsEnabled(String key, boolean enabled) { HlsRuleStateStore.setEnabled(key, enabled); - int position = adapter.positionOfHlsRule(key); - adapter.refreshHlsEnabled(key, enabled); - restoreRuleFocus(position); + loadData(); if (callback != null) callback.onRuleChanged(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java deleted file mode 100644 index 871cf5584d..0000000000 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Dialog; -import android.graphics.Color; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.setting.PlayerSetting; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.utils.ResUtil; -import com.google.android.material.textview.MaterialTextView; - -public final class AppearanceDialog extends DialogFragment implements ThemeDialog.Listener { - - private String[] uiScales; - private String[] languages; - private String[] imageSizes; - private MaterialTextView uiScaleValue; - private MaterialTextView themeValue; - private MaterialTextView imageSizeValue; - private MaterialTextView languageValue; - - public static void show(Fragment fragment) { - new AppearanceDialog().show(fragment.getChildFragmentManager(), AppearanceDialog.class.getSimpleName()); - } - - public static void show(FragmentActivity activity) { - new AppearanceDialog().show(activity.getSupportFragmentManager(), AppearanceDialog.class.getSimpleName()); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - uiScales = ResUtil.getStringArray(R.array.select_ui_scale); - languages = ResUtil.getStringArray(R.array.select_language); - imageSizes = ResUtil.getStringArray(R.array.select_size); - return LightDialog.create(requireContext(), getString(R.string.setting_appearance), createContent(), getString(R.string.dialog_close), null, null, null); - } - - private View createContent() { - LinearLayout content = new LinearLayout(requireContext()); - content.setOrientation(LinearLayout.VERTICAL); - uiScaleValue = addRow(content, R.string.setting_ui_scale, uiScales[Setting.getUiScaleIndex()], this::chooseUiScale); - themeValue = addRow(content, R.string.setting_theme_color, getThemeText(), view -> ThemeDialog.show(this)); - imageSizeValue = addRow(content, R.string.setting_size, imageSizes[PlayerSetting.getSize()], this::chooseImageSize); - languageValue = addRow(content, R.string.setting_language, languages[Setting.getLanguageIndex()], this::chooseLanguage); - return content; - } - - private MaterialTextView addRow(LinearLayout content, int titleRes, String value, View.OnClickListener listener) { - LinearLayout row = new LinearLayout(requireContext()); - row.setOrientation(LinearLayout.HORIZONTAL); - row.setGravity(Gravity.CENTER_VERTICAL); - row.setBackgroundResource(R.drawable.selector_git_cloud_card); - row.setClickable(true); - row.setFocusable(true); - row.setPadding(dp(16), 0, dp(16), 0); - row.setOnClickListener(listener); - - MaterialTextView title = new MaterialTextView(requireContext()); - title.setText(titleRes); - title.setTextColor(Color.parseColor("#202124")); - title.setTextSize(15); - title.setSingleLine(true); - row.addView(title, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); - - MaterialTextView summary = new MaterialTextView(requireContext()); - summary.setText(value); - summary.setTextColor(Color.parseColor("#5F6368")); - summary.setTextSize(14); - summary.setGravity(Gravity.END); - summary.setSingleLine(true); - summary.setEllipsize(TextUtils.TruncateAt.END); - LinearLayout.LayoutParams summaryParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1); - summaryParams.leftMargin = dp(12); - row.addView(summary, summaryParams); - - LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(58)); - rowParams.bottomMargin = dp(8); - content.addView(row, rowParams); - return summary; - } - - private void chooseUiScale(View view) { - ChoiceDialog.showSingle(this, R.string.setting_ui_scale, uiScales, Setting.getUiScaleIndex(), which -> { - if (which == Setting.getUiScaleIndex()) return; - uiScaleValue.setText(uiScales[which]); - Setting.putUiScaleIndex(which); - dismissAllowingStateLoss(); - requireActivity().recreate(); - }); - } - - private void chooseImageSize(View view) { - ChoiceDialog.showSingle(this, R.string.setting_size, imageSizes, PlayerSetting.getSize(), which -> { - imageSizeValue.setText(imageSizes[which]); - PlayerSetting.putSize(which); - RefreshEvent.size(); - }); - } - - private void chooseLanguage(View view) { - ChoiceDialog.showSingle(this, R.string.setting_language, languages, Setting.getLanguageIndex(), which -> { - if (which == Setting.getLanguageIndex()) return; - languageValue.setText(languages[which]); - Setting.putLanguageIndex(which); - dismissAllowingStateLoss(); - RefreshEvent.language(); - }); - } - - @Override - public void setTheme(int color) { - themeValue.setText(themeText(color)); - Setting.putThemeColor(color); - dismissAllowingStateLoss(); - RefreshEvent.theme(); - } - - private String getThemeText() { - return themeText(Setting.getThemeColor()); - } - - private String themeText(int color) { - if (color == -1) return getString(R.string.setting_off); - return getString(color == 0 ? R.string.setting_auto : R.string.setting_custom); - } - - private int dp(int value) { - return ResUtil.dp2px(value); - } -} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java index e5d7b3a83a..b90ac7db8d 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java @@ -57,14 +57,7 @@ public class EpisodeDetailDialog { java.util.List preloadedPhotos, java.util.List preloadedGuests, android.content.DialogInterface.OnDismissListener dismissListener) { - show(activity, episode, null, site, preloadedPhotos, preloadedGuests, dismissListener); - } - - public static void show(FragmentActivity activity, Episode episode, TmdbEpisode boundTmdbEpisode, Site site, - java.util.List preloadedPhotos, - java.util.List preloadedGuests, - android.content.DialogInterface.OnDismissListener dismissListener) { - TmdbEpisode tmdbEpisode = boundTmdbEpisode != null ? boundTmdbEpisode : episode.getTmdbEpisode(); + TmdbEpisode tmdbEpisode = episode.getTmdbEpisode(); if (tmdbEpisode == null) { // 电影没有分集对象,尝试从宿主获取影片级数据 if (activity instanceof com.fongmi.android.tv.ui.host.TmdbDetailHost) { @@ -359,7 +352,7 @@ public class EpisodeDetailDialog { try { // 调用TMDB API获取剧集图片 TmdbService service = new TmdbService(); - TmdbConfig config = TmdbConfig.effectiveCurrent(); + TmdbConfig config = TmdbConfig.objectFrom(Setting.getTmdbConfig()); android.util.Log.d("EpisodeDetail", "开始请求TMDB API..."); @@ -396,13 +389,10 @@ public class EpisodeDetailDialog { guestsLabel.setVisibility(View.VISIBLE); guestsGrid.setVisibility(View.VISIBLE); guestsGrid.setHorizontalSpacing(ResUtil.dp2px(12)); - boolean cinema = Setting.isTmdbCinemaStyle(); - guestsGrid.setRowHeight(ResUtil.dp2px(cinema ? 90 : 154)); - if (cinema) resizeGuestGrid(guestsGrid); + guestsGrid.setRowHeight(ResUtil.dp2px(154)); TmdbPersonAdapter guestAdapter = new TmdbPersonAdapter(person -> TmdbPersonDialog.show(activity, person, site)); guestAdapter.setLight(light); - guestAdapter.setCinema(cinema); guestAdapter.setItems(guests); guestsGrid.setAdapter(guestAdapter); } @@ -455,13 +445,10 @@ public class EpisodeDetailDialog { guestsLabel.setVisibility(View.VISIBLE); guestsGrid.setVisibility(View.VISIBLE); guestsGrid.setHorizontalSpacing(ResUtil.dp2px(12)); - boolean cinema = Setting.isTmdbCinemaStyle(); - guestsGrid.setRowHeight(ResUtil.dp2px(cinema ? 90 : 154)); - if (cinema) resizeGuestGrid(guestsGrid); + guestsGrid.setRowHeight(ResUtil.dp2px(154)); TmdbPersonAdapter guestAdapter = new TmdbPersonAdapter(person -> TmdbPersonDialog.show(activity, person, null)); guestAdapter.setLight(light); - guestAdapter.setCinema(cinema); guestAdapter.setItems(guests); guestsGrid.setAdapter(guestAdapter); } else { @@ -470,12 +457,6 @@ public class EpisodeDetailDialog { } } - private static void resizeGuestGrid(androidx.leanback.widget.HorizontalGridView guestsGrid) { - ViewGroup.LayoutParams params = guestsGrid.getLayoutParams(); - params.height = ResUtil.dp2px(104); - guestsGrid.setLayoutParams(params); - } - /** * 设置 ScrollView 按键监听,处理遥控器上下键滚动 * 策略:ScrollView 持有焦点时,按下滚动;网格可见且有数据时,转移焦点到网格 @@ -496,15 +477,29 @@ public class EpisodeDetailDialog { if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_DOWN) { // 从大海报往下:跳到第一个有数据的网格 - if (isSameOrDescendantOf(focus, stillCard)) { - if (requestGridFocus(photosGrid) || requestGridFocus(guestsGrid)) return true; + if (focus == stillCard || (focus != null && focus.getParent() == stillCard)) { + if (photosGrid != null && photosGrid.getVisibility() == View.VISIBLE + && photosGrid.getAdapter() != null && photosGrid.getAdapter().getItemCount() > 0) { + photosGrid.requestFocus(); + return true; + } + if (guestsGrid != null && guestsGrid.getVisibility() == View.VISIBLE + && guestsGrid.getAdapter() != null && guestsGrid.getAdapter().getItemCount() > 0) { + guestsGrid.requestFocus(); + return true; + } } } if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_UP) { // 客串卡片先回到剧照,剧照再回到大海报;焦点实际在网格 item 上,需要检查父级关系 if (focus != null && (focus == guestsGrid || isDescendantOf(focus, guestsGrid))) { - if (!requestGridFocus(photosGrid)) stillCard.requestFocus(); + if (photosGrid != null && photosGrid.getVisibility() == View.VISIBLE + && photosGrid.getAdapter() != null && photosGrid.getAdapter().getItemCount() > 0) { + photosGrid.requestFocus(); + } else { + stillCard.requestFocus(); + } return true; } if (focus != null && (focus == photosGrid || isDescendantOf(focus, photosGrid))) { @@ -517,25 +512,6 @@ public class EpisodeDetailDialog { }); } - /** - * 将焦点落到横向网格的实际卡片,而不是只把焦点留在网格容器上。 - * RecyclerView/HorizontalGridView 只有在 holder 回调里请求子项焦点时, - * 才能可靠地触发 ScrollView 的垂直滚动和后续遥控导航。 - */ - private static boolean requestGridFocus(androidx.leanback.widget.HorizontalGridView grid) { - if (grid == null || grid.getVisibility() != View.VISIBLE - || grid.getAdapter() == null || grid.getAdapter().getItemCount() == 0) { - return false; - } - int position = Math.max(0, grid.getSelectedPosition()); - grid.setSelectedPosition(position, holder -> holder.itemView.requestFocus()); - return true; - } - - private static boolean isSameOrDescendantOf(View view, View ancestor) { - return view != null && (view == ancestor || isDescendantOf(view, ancestor)); - } - /** * 检查 view 是否是 ancestor 的子孙视图 */ diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java index dcce4cabef..d7e17a788b 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java @@ -1,12 +1,8 @@ package com.fongmi.android.tv.ui.dialog; import androidx.fragment.app.FragmentActivity; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.LiveConfig; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.api.config.WallConfig; @@ -24,7 +20,6 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl private ConfigListener listener; private boolean readOnly; private int type; - private ItemTouchHelper sortTouchHelper; public static HistoryDialog create() { return new HistoryDialog(); @@ -77,18 +72,6 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl binding.recycler.setHasFixedSize(false); binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 16)); binding.recycler.setAdapter(adapter.readOnly(readOnly).addAll(type, getConfig())); - if (type == 0 && !readOnly) attachSortHelper(); - } - - private void attachSortHelper() { - sortTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { - @Override public boolean isLongPressDragEnabled() { return false; } - @Override public boolean onMove(@NonNull RecyclerView view, @NonNull RecyclerView.ViewHolder from, @NonNull RecyclerView.ViewHolder to) { - return adapter.drag(from.getBindingAdapterPosition(), to.getBindingAdapterPosition()); - } - @Override public void onSwiped(@NonNull RecyclerView.ViewHolder holder, int direction) { } - }); - sortTouchHelper.attachToRecyclerView(binding.recycler); } private Config getConfig() { @@ -102,30 +85,13 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl @Override public void onTextClick(Config item) { - if (listener == null) return; - listener.setConfig(item); + if (listener != null) listener.setConfig(item); dismiss(); } - @Override - public boolean onTextLongClick(ConfigAdapter.ViewHolder holder) { - if (type != 0 || readOnly || sortTouchHelper == null) return false; - sortTouchHelper.startDrag(holder); - return true; - } - @Override public void onDeleteClick(Config item) { - androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.config_delete_title) - .setMessage(getString(R.string.config_delete_message, item.getDesc())) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, (target, which) -> { - if (adapter.remove(item) == 0) dismiss(); - }) - .create(); - dialog.setOnShowListener(target -> dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE).requestFocus()); - dialog.show(); + if (adapter.remove(item) == 0) dismiss(); } @Override diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java index 2b9d4f1517..3f23685298 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java @@ -34,7 +34,6 @@ import com.fongmi.android.tv.impl.SiteListener; import com.fongmi.android.tv.setting.SiteGroupOrderStore; import com.fongmi.android.tv.ui.adapter.SiteAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.KeyUtil; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; @@ -145,7 +144,6 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi directDialog.show(); log("show call end cost=%sms total=%sms", cost(showDialogStart), cost()); applyWindow(directDialog.getWindow()); - TouchOptimizationHelper.sync(directDialog); log("window applied total=%sms", cost()); } @@ -687,3 +685,4 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi return -1; } } + diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java deleted file mode 100644 index 39297af16d..0000000000 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import androidx.fragment.app.Fragment; -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.databinding.DialogThemeBinding; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.ui.adapter.ThemeAdapter; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -public class ThemeDialog extends BaseAlertDialog implements ThemeAdapter.OnClickListener { - - private static final int[] COLORS = {-1, 0, 0xFF6750A4, 0xFF3949AB, 0xFF1E88E5, 0xFF00ACC1, 0xFF00897B, 0xFF43A047, 0xFF7CB342, 0xFFFB8C00, 0xFFE53935, 0xFFD81B60, 0xFF8E24AA, 0xFF6D4C41,}; - private DialogThemeBinding binding; - - public static void show(Fragment fragment) { - new ThemeDialog().show(fragment.getChildFragmentManager(), null); - } - - @Override - protected ViewBinding getBinding() { - return binding = DialogThemeBinding.inflate(getLayoutInflater()); - } - - @Override - protected MaterialAlertDialogBuilder getBuilder() { - return builder().setTitle(R.string.setting_theme_color).setView(getBinding().getRoot()); - } - - @Override - protected void initView() { - binding.recycler.setAdapter(new ThemeAdapter(this, COLORS, Setting.getThemeColor())); - } - - @Override - public void onItemClick(int color) { - ((Listener) requireParentFragment()).setTheme(color); - dismiss(); - } - - public interface Listener { - - void setTheme(int color); - } -} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/FolderFragment.java b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/FolderFragment.java index f4178d3b4f..026702ded0 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/FolderFragment.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/FolderFragment.java @@ -41,9 +41,6 @@ public class FolderFragment extends BaseFragment { private FragmentFolderBinding mBinding; private Boolean pendingFilterVisible; private Integer pendingContentRow; - private boolean pendingScrollToTop; - private int focusGeneration; - private int scrollGeneration; private Class mType; public static FolderFragment newInstance(String key, Class type) { @@ -101,7 +98,6 @@ public class FolderFragment extends BaseFragment { FragmentTransaction transaction = getChildFragmentManager().beginTransaction().replace(R.id.container, TypeFragment.newInstance(getKey(), mType.getTypeId(), mType.getStyle(), getExtend(), mType.isFolder(), getHistoryResumeCid(), getHistoryResumeKey(), getHistoryResumeTargetCid())); transaction.runOnCommit(this::applyPendingFilter); transaction.runOnCommit(this::applyPendingContentFocus); - transaction.runOnCommit(this::applyPendingScrollToTop); transaction.commit(); } @@ -153,12 +149,7 @@ public class FolderFragment extends BaseFragment { } public void requestContentFocus(int contentRow) { - requestContentFocus(contentRow, ++focusGeneration); - } - - public void requestContentFocus(int contentRow, int generation) { pendingContentRow = Math.max(0, contentRow); - focusGeneration = generation; applyPendingContentFocus(); } @@ -166,33 +157,12 @@ public class FolderFragment extends BaseFragment { if (pendingContentRow == null) return; TypeFragment child = getChild(); if (child == null) return; - child.requestContentFocus(pendingContentRow, focusGeneration); + child.requestContentFocus(pendingContentRow); pendingContentRow = null; } - public void scrollContentToTop() { - scrollContentToTop(++scrollGeneration); - } - - public void scrollContentToTop(int generation) { - pendingScrollToTop = true; - scrollGeneration = generation; - applyPendingScrollToTop(); - } - - private void applyPendingScrollToTop() { - if (!pendingScrollToTop) return; - TypeFragment child = getChild(); - if (child == null) return; - child.scrollContentToTop(scrollGeneration); - pendingScrollToTop = false; - } - public void clearContentFocusRequest() { pendingContentRow = null; - pendingScrollToTop = false; - focusGeneration++; - scrollGeneration++; TypeFragment child = getChild(); if (child != null) child.clearContentFocusRequest(); } @@ -212,15 +182,6 @@ public class FolderFragment extends BaseFragment { @Override public void setUserVisibleHint(boolean isVisibleToUser) { super.setUserVisibleHint(isVisibleToUser); - if (mBinding != null && !isVisibleToUser) { - clearContentFocusRequest(); - Optional.ofNullable(getChild()).ifPresent(f -> f.setUserVisibleHint(false)); - } - } - - @Override - public void onDestroyView() { - clearContentFocusRequest(); - super.onDestroyView(); + if (mBinding != null && !isVisibleToUser) Optional.ofNullable(getChild()).ifPresent(f -> f.setUserVisibleHint(false)); } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java index a586acdf1a..3b05bcc624 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java @@ -59,7 +59,6 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac private List mFilters; private Integer pendingContentRow; private int contentFocusGeneration; - private int scrollGeneration; private boolean headerVisible; private boolean filterVisible; @@ -285,7 +284,7 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac @Override public void onItemClick(Vod item) { if (item.isAction()) { - com.fongmi.android.tv.content.ActionCardHelper.handleAction(requireActivity(), getKey(), item.getAction()); + mViewModel.action(getKey(), item.getAction()); } else if (item.isFolder()) { getParent().openFolder(item.getId(), mExtends); headerVisible = mBinding.recycler.isHeaderVisible(); @@ -319,9 +318,6 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac public void onHiddenChanged(boolean hidden) { super.onHiddenChanged(hidden); if (hidden) { - scrollGeneration++; - contentFocusGeneration++; - pendingContentRow = null; mBinding.recycler.showHeader(); } else { if (headerVisible) mBinding.recycler.showHeader(); @@ -348,35 +344,15 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac return mBinding.recycler.requestFocus(); } - public void scrollContentToTop() { - scrollContentToTop(++scrollGeneration); - } - - public void scrollContentToTop(int generation) { - if (mBinding == null || mAdapter == null) return; - int target = filterVisible ? mFilters.size() : 0; - scrollGeneration = generation; - mBinding.recycler.post(() -> { - if (generation != scrollGeneration || mBinding == null || mAdapter == null || mAdapter.size() <= target || !isVisible() || getParentFragment() == null || !getParentFragment().isVisible()) return; - mBinding.recycler.showHeader(); - mBinding.recycler.scrollToPosition(target); - }); - } - public void requestContentFocus(int contentRow) { - requestContentFocus(contentRow, contentFocusGeneration + 1); - } - - public void requestContentFocus(int contentRow, int generation) { pendingContentRow = Math.max(0, contentRow); - contentFocusGeneration = generation; + contentFocusGeneration++; applyPendingContentFocus(); } public void clearContentFocusRequest() { pendingContentRow = null; contentFocusGeneration++; - scrollGeneration++; } private void applyPendingContentFocus() { @@ -406,12 +382,4 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac holder.itemView.requestFocus(); } - @Override - public void onDestroyView() { - pendingContentRow = null; - contentFocusGeneration++; - scrollGeneration++; - super.onDestroyView(); - } - } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HistoryPresenter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HistoryPresenter.java index e5c73f0640..ca4f704f91 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HistoryPresenter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HistoryPresenter.java @@ -81,7 +81,7 @@ public class HistoryPresenter extends Presenter { holder.binding.playback.setVisibility(!delete && item.hasPlaybackTime() ? View.VISIBLE : View.GONE); setProgress(holder.binding, item); holder.binding.delete.setVisibility(!delete ? View.GONE : View.VISIBLE); - holder.binding.remark.setVisibility(same ? View.INVISIBLE : View.VISIBLE); + holder.binding.remark.setVisibility(delete || same ? View.INVISIBLE : View.VISIBLE); ImgUtil.load(item.getVodName(), item.getVodPic(), holder.binding.image); } diff --git a/app/src/leanback/res/drawable/ic_action_check.xml b/app/src/leanback/res/drawable/ic_action_check.xml deleted file mode 100644 index c7681a5e86..0000000000 --- a/app/src/leanback/res/drawable/ic_action_check.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/app/src/leanback/res/drawable/ic_action_close.xml b/app/src/leanback/res/drawable/ic_action_close.xml deleted file mode 100644 index 5ddf18632f..0000000000 --- a/app/src/leanback/res/drawable/ic_action_close.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/app/src/leanback/res/drawable/ic_banner.png b/app/src/leanback/res/drawable/ic_banner.png index 29924fe231..670e5edf00 100644 Binary files a/app/src/leanback/res/drawable/ic_banner.png and b/app/src/leanback/res/drawable/ic_banner.png differ diff --git a/app/src/leanback/res/drawable/selector_ad_stats_item.xml b/app/src/leanback/res/drawable/selector_ad_stats_item.xml deleted file mode 100644 index 4be2706b17..0000000000 --- a/app/src/leanback/res/drawable/selector_ad_stats_item.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/leanback/res/drawable/selector_control_sheet_button.xml b/app/src/leanback/res/drawable/selector_control_sheet_button.xml index 0d9e987373..b131534d57 100644 --- a/app/src/leanback/res/drawable/selector_control_sheet_button.xml +++ b/app/src/leanback/res/drawable/selector_control_sheet_button.xml @@ -24,10 +24,10 @@ - + + android:width="1dp" + android:color="#66FFFFFF" /> diff --git a/app/src/leanback/res/drawable/shape_ad_stats_content.xml b/app/src/leanback/res/drawable/shape_ad_stats_content.xml deleted file mode 100644 index 4d8e0fecea..0000000000 --- a/app/src/leanback/res/drawable/shape_ad_stats_content.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/leanback/res/drawable/shape_ad_stats_tabs.xml b/app/src/leanback/res/drawable/shape_ad_stats_tabs.xml deleted file mode 100644 index a14b725498..0000000000 --- a/app/src/leanback/res/drawable/shape_ad_stats_tabs.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/app/src/leanback/res/drawable/shape_config_history_dialog.xml b/app/src/leanback/res/drawable/shape_config_history_dialog.xml index 658271618c..0ce7998154 100644 --- a/app/src/leanback/res/drawable/shape_config_history_dialog.xml +++ b/app/src/leanback/res/drawable/shape_config_history_dialog.xml @@ -4,6 +4,6 @@ - + diff --git a/app/src/leanback/res/drawable/shape_episode_dialog_panel.xml b/app/src/leanback/res/drawable/shape_episode_dialog_panel.xml index 6ccf1e1a21..c8db5a4467 100644 --- a/app/src/leanback/res/drawable/shape_episode_dialog_panel.xml +++ b/app/src/leanback/res/drawable/shape_episode_dialog_panel.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/leanback/res/drawable/shape_exit_confirm_dialog.xml b/app/src/leanback/res/drawable/shape_exit_confirm_dialog.xml index 257b3379b4..9962ac58b0 100644 --- a/app/src/leanback/res/drawable/shape_exit_confirm_dialog.xml +++ b/app/src/leanback/res/drawable/shape_exit_confirm_dialog.xml @@ -4,7 +4,7 @@ - + - + diff --git a/app/src/leanback/res/drawable/shape_site_item_focused.xml b/app/src/leanback/res/drawable/shape_site_item_focused.xml index 047ec41f5f..8ab7f327ef 100644 --- a/app/src/leanback/res/drawable/shape_site_item_focused.xml +++ b/app/src/leanback/res/drawable/shape_site_item_focused.xml @@ -4,7 +4,7 @@ - + - + - + - - + android:orientation="horizontal"> - - - - - - + + + + + + - - diff --git a/app/src/leanback/res/layout/activity_setting_ad.xml b/app/src/leanback/res/layout/activity_setting_ad.xml index 5ace24b005..2c62702d0e 100644 --- a/app/src/leanback/res/layout/activity_setting_ad.xml +++ b/app/src/leanback/res/layout/activity_setting_ad.xml @@ -329,67 +329,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/leanback/res/layout/activity_setting_personal.xml b/app/src/leanback/res/layout/activity_setting_personal.xml index 4e30b97708..08d4c50985 100644 --- a/app/src/leanback/res/layout/activity_setting_personal.xml +++ b/app/src/leanback/res/layout/activity_setting_personal.xml @@ -68,35 +68,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/leanback/res/layout/activity_setting_player.xml b/app/src/leanback/res/layout/activity_setting_player.xml index d1db67db5c..be253d28d2 100644 --- a/app/src/leanback/res/layout/activity_setting_player.xml +++ b/app/src/leanback/res/layout/activity_setting_player.xml @@ -196,37 +196,8 @@ - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/app/src/leanback/res/layout/activity_video.xml b/app/src/leanback/res/layout/activity_video.xml index 34d7201a8a..aa089799ca 100644 --- a/app/src/leanback/res/layout/activity_video.xml +++ b/app/src/leanback/res/layout/activity_video.xml @@ -730,7 +730,6 @@ android:layout_marginEnd="16dp" android:background="@drawable/selector_video_item" android:drawablePadding="6dp" - android:visibility="gone" android:focusable="true" android:focusableInTouchMode="true" android:gravity="center" @@ -823,28 +822,6 @@ android:textSize="16sp" app:drawableStartCompat="@drawable/ic_detail_keep_off" /> - - @@ -1369,7 +1346,8 @@ android:layout_marginBottom="12dp" android:clipChildren="false" android:clipToPadding="false" - android:paddingStart="24dp" android:paddingEnd="24dp" + android:paddingStart="24dp" + android:paddingEnd="24dp" android:visibility="gone" /> - - - - - - - - - - - - - - - diff --git a/app/src/leanback/res/layout/adapter_ad_stats_item.xml b/app/src/leanback/res/layout/adapter_ad_stats_item.xml index a247461efd..b78158553c 100644 --- a/app/src/leanback/res/layout/adapter_ad_stats_item.xml +++ b/app/src/leanback/res/layout/adapter_ad_stats_item.xml @@ -2,16 +2,14 @@ + android:paddingBottom="8dp"> + android:textSize="14sp" /> - - - - - - - - - diff --git a/app/src/leanback/res/layout/dialog_ad_block_stats.xml b/app/src/leanback/res/layout/dialog_ad_block_stats.xml index e3065c3af6..66f936e7fa 100644 --- a/app/src/leanback/res/layout/dialog_ad_block_stats.xml +++ b/app/src/leanback/res/layout/dialog_ad_block_stats.xml @@ -1,72 +1,54 @@ - + android:fillViewport="true"> - + android:orientation="vertical" + android:padding="24dp"> - - - - - + android:layout_height="wrap_content" + android:text="@string/ad_block_stats" + android:textColor="@color/dialog_outlined_button_text" + android:textSize="17sp" + android:textStyle="bold" /> + + + + android:padding="16dp"> + android:orientation="vertical"> + + + android:textSize="12sp" /> + + android:orientation="vertical"> + + + android:textSize="12sp" /> + + android:orientation="vertical"> + + + android:textSize="12sp" /> + + - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="44dp" + android:layout_marginEnd="12dp" + android:minWidth="112dp" + android:text="@string/ad_stats_reset" + android:textSize="14sp" /> + + + - - - - - - - + + diff --git a/app/src/leanback/res/layout/dialog_ad_rule_manage.xml b/app/src/leanback/res/layout/dialog_ad_rule_manage.xml index c68dfffc48..cade28fdf5 100644 --- a/app/src/leanback/res/layout/dialog_ad_rule_manage.xml +++ b/app/src/leanback/res/layout/dialog_ad_rule_manage.xml @@ -39,35 +39,6 @@ tools:text="待审核接口规则(2)" tools:visibility="visible" /> - - - - - - - - diff --git a/app/src/leanback/res/layout/dialog_theme.xml b/app/src/leanback/res/layout/dialog_theme.xml deleted file mode 100644 index 173d12d1c1..0000000000 --- a/app/src/leanback/res/layout/dialog_theme.xml +++ /dev/null @@ -1,11 +0,0 @@ - - diff --git a/app/src/leanback/res/layout/view_control_cast.xml b/app/src/leanback/res/layout/view_control_cast.xml index c031bf46eb..0c8043f7e6 100644 --- a/app/src/leanback/res/layout/view_control_cast.xml +++ b/app/src/leanback/res/layout/view_control_cast.xml @@ -4,7 +4,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" - android:background="@color/transparent" + android:background="@drawable/shape_controller_scrim" android:orientation="vertical" android:paddingStart="24dp" android:paddingTop="24dp" diff --git a/app/src/leanback/res/layout/view_control_live.xml b/app/src/leanback/res/layout/view_control_live.xml index b5e5ae2d7d..2900b2bfd9 100644 --- a/app/src/leanback/res/layout/view_control_live.xml +++ b/app/src/leanback/res/layout/view_control_live.xml @@ -4,7 +4,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" - android:background="@color/transparent" + android:background="@drawable/shape_controller_scrim" android:orientation="vertical" android:paddingStart="24dp" android:paddingTop="24dp" diff --git a/app/src/leanback/res/layout/view_control_live_action.xml b/app/src/leanback/res/layout/view_control_live_action.xml index 3dc0348104..cff1739e81 100644 --- a/app/src/leanback/res/layout/view_control_live_action.xml +++ b/app/src/leanback/res/layout/view_control_live_action.xml @@ -49,12 +49,6 @@ android:layout_marginEnd="8dp" android:text="@string/play_decode" /> - - - - @@ -140,60 +139,4 @@ android:src="@drawable/ic_widget_forward" android:visibility="gone" /> - - - - - - - - - - - - - - - - diff --git a/app/src/leanback/res/values-zh-rCN/strings.xml b/app/src/leanback/res/values-zh-rCN/strings.xml index f0acf06eb8..542ad4a341 100644 --- a/app/src/leanback/res/values-zh-rCN/strings.xml +++ b/app/src/leanback/res/values-zh-rCN/strings.xml @@ -9,9 +9,6 @@ 推送 投屏 设置 - 站点注入 - 站点注入:开 - 站点注入:关 倍速 缩放 长标题列数 diff --git a/app/src/leanback/res/values-zh-rTW/strings.xml b/app/src/leanback/res/values-zh-rTW/strings.xml index 3b431bac0b..f57ba8eeb1 100644 --- a/app/src/leanback/res/values-zh-rTW/strings.xml +++ b/app/src/leanback/res/values-zh-rTW/strings.xml @@ -9,9 +9,6 @@ 推送 投屏 設定 - 站點注入 - 站點注入:開 - 站點注入:關 倍速 縮放 長標題列數 diff --git a/app/src/leanback/res/values/strings.xml b/app/src/leanback/res/values/strings.xml index cde8c2c6d1..a68415a13e 100644 --- a/app/src/leanback/res/values/strings.xml +++ b/app/src/leanback/res/values/strings.xml @@ -9,9 +9,6 @@ Push Cast Setting - Site injection - Site injection: On - Site injection: Off Speed Scale Long title columns diff --git a/app/src/leanback/res/values/styles.xml b/app/src/leanback/res/values/styles.xml index 217c08c6e7..3a6c5f39b4 100644 --- a/app/src/leanback/res/values/styles.xml +++ b/app/src/leanback/res/values/styles.xml @@ -2,8 +2,7 @@ - - - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d04325f4d2..02de062278 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -74,11 +74,6 @@ android:name="android.max_aspect" android:value="2.5" /> - - - - - - diff --git a/app/src/main/assets/favicon.ico b/app/src/main/assets/favicon.ico index 85465d3bb9..21bc2e34c3 100644 Binary files a/app/src/main/assets/favicon.ico and b/app/src/main/assets/favicon.ico differ diff --git a/app/src/main/assets/lab_template.json b/app/src/main/assets/lab_template.json index d4a3e7df55..d55f2cff91 100644 --- a/app/src/main/assets/lab_template.json +++ b/app/src/main/assets/lab_template.json @@ -1309,15 +1309,15 @@ { "id": "php_version", "name": "查看版本", - "command": "proot -0 -r {envRootPath}/rootfs -b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -w /root --kill-on-exit /usr/bin/env -i HOME=/root TERM=xterm-256color LANG=C.UTF-8 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /bin/bash --login -c \"php8.3 -v\"", + "command": "proot -0 -r {envRootPath}/rootfs -b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -w /root --kill-on-exit /usr/bin/env -i HOME=/root TERM=xterm-256color LANG=C.UTF-8 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /bin/bash --login -c \"php -v\"", "auto_execute": false, "show_output": true }, { "id": "php_serve", "name": "启动内置服务", - "description": "php8.3 -S 监听指定端口,站点根目录在 rootfs 内", - "command": "proot -0 -r {envRootPath}/rootfs -b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -w /root --kill-on-exit /usr/bin/env -i HOME=/root TERM=xterm-256color LANG=C.UTF-8 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /bin/bash --login -c \"cd /root && php8.3 -S 0.0.0.0:{port}\"", + "description": "php -S 监听指定端口,站点根目录在 rootfs 内", + "command": "proot -0 -r {envRootPath}/rootfs -b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -w /root --kill-on-exit /usr/bin/env -i HOME=/root TERM=xterm-256color LANG=C.UTF-8 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /bin/bash --login -c \"cd /root && php -S 0.0.0.0:{port}\"", "auto_execute": false, "show_output": true, "variables": [ diff --git a/app/src/main/assets/reader.html b/app/src/main/assets/reader.html index b947bf38f2..7daa25659b 100644 --- a/app/src/main/assets/reader.html +++ b/app/src/main/assets/reader.html @@ -5,1743 +5,370 @@ 阅读器 + + + - - + +
- -
-
+ +
- -
-
- -
阅读
- - - + +
+
+
▶️
+
📋
+
🎨
+
+
+
+ + + -
- - -
- - - 设置自动阅读 ▷ - - -
- - -
-
- -
-
-
上一章
-
1/1
-
下一章
-
-
- 背景 -
- 更多 › -
-
- 翻页 -
-
无动画
-
平移
-
覆盖
-
仿真
-
上下
-
-
-
-
▶ 自动阅读
-
🔊 朗读
-
⚙ 更多设置
-
-
- - -
-
✕
-
更多设置
-
- 亮度 - - 100% -
-
- 字号 - - 18 - -
-
- 段距 - - 8 - -
-
- 行距 - - 1.9 - -
-
- 字距 - - 0.0 - -
-
- 字重 -
-
细
-
常规
-
微粗
-
中粗
-
-
-
- 字体 - - -
-
- - -
-
✕
-
语音朗读
-
点击播放开始朗读
-
-
系统
-
腾讯
-
微软
-
-
- - - -
-
- 语速 - - 1.0x -
-
- 跟随 -
开
-
-
-
停止朗读
-
-
- - -
-
✕
-
自动阅读
-
- 模式 -
-
上下滚屏
-
左右翻页
-
-
-
- 速度 - - 5 -
-
-
退出自动阅读
-
-
- - -
- -
- -
0/0
- -
-
- - - - -
-
- - -
- -
-
浏览设置
-
翻页方向
-
-
← 左右 →
-
↑ 上下 ↓
-
-
分界图高度
-
-
全屏
-
半屏
-
-
- - -
-
-
-
目录
-
书签
-
详情
-
-
-
-
- - - -
-
-
-
-
暂无书签
长按正文段落可添加书签
-
-
-
-
- -
-
-
-
-
-
简介
-
-
-
-
-
正在切换章节...
+ + diff --git a/app/src/main/assets/rules/hls_rules.json b/app/src/main/assets/rules/hls_rules.json index 4fe7882d4b..89dfab758d 100644 --- a/app/src/main/assets/rules/hls_rules.json +++ b/app/src/main/assets/rules/hls_rules.json @@ -2,5 +2,39 @@ "schemaVersion": 2, "packageId": "webhtv-builtin-hls", "version": 1, - "rules": [] + "rules": [ + { + "id": "builtin.hls.baofeng.preroll", + "name": "暴风片头广告(实验)", + "version": 1, + "enabledByDefault": false, + "playlistHostRegex": ["(?:^|\\.)s5\\.bfzycdn|bfzy"], + "minDuration": 2.99, + "maxDuration": 3.01, + "requireDiscontinuity": true, + "minimumSignals": 2 + }, + { + "id": "builtin.hls.liangzi.preroll", + "name": "量子片头广告(实验)", + "version": 1, + "enabledByDefault": false, + "playlistHostRegex": ["vip\\.lz|hd\\.lz|v\\.cdnlz|cdnlz"], + "minDuration": 6.42, + "maxDuration": 6.45, + "requireDiscontinuity": true, + "minimumSignals": 2 + }, + { + "id": "builtin.hls.feifan.preroll", + "name": "非凡片头广告(实验)", + "version": 1, + "enabledByDefault": false, + "playlistHostRegex": ["vip\\.ffzy|hd\\.ffzy|super\\.ffzy"], + "minDuration": 6.65, + "maxDuration": 6.68, + "requireDiscontinuity": true, + "minimumSignals": 2 + } + ] } diff --git a/app/src/main/assets/subtitle_sources/builtin/assrt.py b/app/src/main/assets/subtitle_sources/builtin/assrt.py deleted file mode 100644 index 9c99efddb9..0000000000 --- a/app/src/main/assets/subtitle_sources/builtin/assrt.py +++ /dev/null @@ -1,203 +0,0 @@ -import json -import os -import re -import tempfile -import zipfile -from urllib.parse import urlencode - -import requests - - -API_BASE = "https://api.assrt.net/v1" -REFERER = "https://assrt.net/" -USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" -FORMATS = ("ass", "ssa", "vtt", "srt") - - -def _text(value): - return "" if value is None else str(value).strip() - - -def _first(item, *keys): - if not isinstance(item, dict): - return "" - for key in keys: - value = _text(item.get(key)) - if value: - return value - return "" - - -def _number(value, fallback=0): - try: - return int(value) - except (TypeError, ValueError): - return fallback - - -def _format(subtype, name): - value = (_text(subtype) + " " + _text(name)).lower() - for fmt in FORMATS[:-1]: - if "." + fmt in value or (" " + fmt) in value: - return fmt - return "srt" - - -def _season_episode(value): - text = _text(value) - season = -1 - episode = -1 - match = re.search(r"(?i)\bS(\d{1,2})(?:\s*[-.]?\s*E(\d{1,3}))?\b", text) - if match: - season = _number(match.group(1), -1) - if match.group(2): - episode = _number(match.group(2), -1) - if episode < 0: - match = re.search(r"(?i)\bEP?(?:ISODE)?[ ._-]?(\d{1,3})\b", text) - if match: - episode = _number(match.group(1), -1) - return season, episode - - -def _year(value): - match = re.search(r"\b(19\d{2}|20\d{2})\b", _text(value)) - return _number(match.group(1), 0) if match else 0 - - -def _request(url, **kwargs): - headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Connection": "close"} - headers.update(kwargs.pop("headers", {}) or {}) - return requests.get(url, headers=headers, timeout=15, **kwargs) - - -def _download(url, source_key, candidate_id, filename): - root = os.path.dirname(os.path.abspath(__file__)) - safe_id = re.sub(r"[^A-Za-z0-9_.-]", "_", _text(candidate_id))[:80] or "subtitle" - suffix = os.path.splitext(_text(filename))[1].lower() - if suffix not in (".ass", ".ssa", ".srt", ".vtt", ".zip"): - suffix = ".srt" - target = os.path.join(root, "%s_%s%s" % (source_key, safe_id, suffix)) - temporary = target + ".tmp" - response = _request(url, stream=True) - response.raise_for_status() - try: - with open(temporary, "wb") as output: - for chunk in response.iter_content(16384): - if chunk: - output.write(chunk) - os.replace(temporary, target) - finally: - response.close() - if os.path.exists(temporary): - os.remove(temporary) - return target - - -def _extract_archive(path, source_key, candidate_id): - if not zipfile.is_zipfile(path): - return path - root = os.path.join(os.path.dirname(path), "%s_%s" % (source_key, re.sub(r"[^A-Za-z0-9_.-]", "_", _text(candidate_id))[:80])) - os.makedirs(root, exist_ok=True) - total = 0 - chosen = [] - with zipfile.ZipFile(path) as archive: - for info in archive.infolist(): - name = os.path.normpath(info.filename) - if name in (".", "") or name.startswith("../") or name.startswith("/") or ":" in name.split(os.sep)[0]: - raise ValueError("unsafe subtitle archive path") - if info.is_dir(): - continue - if info.file_size > 32 * 1024 * 1024: - raise ValueError("subtitle archive entry too large") - total += info.file_size - if total > 64 * 1024 * 1024: - raise ValueError("subtitle archive too large") - destination = os.path.join(root, name) - if not os.path.realpath(destination).startswith(os.path.realpath(root) + os.sep): - raise ValueError("unsafe subtitle archive path") - os.makedirs(os.path.dirname(destination), exist_ok=True) - with archive.open(info) as source, open(destination, "wb") as output: - output.write(source.read()) - if destination.lower().endswith(tuple("." + fmt for fmt in FORMATS)): - chosen.append(destination) - if not chosen: - return path - chosen.sort(key=lambda value: (0 if value.lower().endswith(".ass") else 1, value.lower())) - return chosen[0] - - -class Spider: - def __init__(self): - self.config = {} - - def init(self, config=None): - self.config = config if isinstance(config, dict) else {} - return {"code": 0, "data": {}} - - def search(self, request): - query = (request or {}).get("query") or {} - text = _text(query.get("text")) - token = _text(self.config.get("token")) - if not text or not token: - return {"code": 0, "data": {"items": []}} - params = {"token": token, "q": text, "is_file": 1, "cnt": 15} - response = _request(API_BASE + "/sub/search?" + urlencode(params)) - if not response.ok: - return {"code": 0, "data": {"items": []}} - root = response.json() - if _number(root.get("status"), 1) != 0: - return {"code": 0, "data": {"items": []}} - subs = ((root.get("sub") or {}).get("subs") or []) - items = [] - for item in subs: - if not isinstance(item, dict): - continue - candidate_id = _first(item, "id", "fileid") - if not candidate_id: - continue - name = _first(item, "name", "sub_name", "m_version", "m_title") - video_name = _first(item, "videoname", "m_videoname", "video_chinese_name", "m_video_chinese_name") - language = _first(item.get("lang") if isinstance(item.get("lang"), dict) else {}, "desc") or _first(item, "m_lang") - fmt = _format(_first(item, "subtype", "m_subtype"), name) - season, episode = _season_episode(name + " " + video_name) - items.append({ - "id": candidate_id, - "name": name, - "language": language, - "format": fmt, - "releaseInfo": video_name, - "score": 0, - "year": _year(video_name), - "season": season, - "episode": episode, - "matchType": "METADATA_FUZZY", - "requiresResolve": True, - "payload": {"id": candidate_id, "format": fmt}, - }) - return {"code": 0, "data": {"items": items}} - - def resolve(self, request): - candidate = (request or {}).get("candidate") or {} - payload = candidate.get("payload") or {} - candidate_id = _text(payload.get("id")) or _text(candidate.get("id")) - token = _text(self.config.get("token")) - if not candidate_id or not token: - return {"code": 1, "message": "candidate_not_found", "data": {}} - response = _request(API_BASE + "/sub/detail?" + urlencode({"token": token, "id": candidate_id})) - if not response.ok: - return {"code": 1, "message": "detail_http_failed", "data": {}} - root = response.json() - if _number(root.get("status"), 1) != 0: - return {"code": 1, "message": "detail_api_failed", "data": {}} - subs = ((root.get("sub") or {}).get("subs") or []) - first = next((item for item in subs if isinstance(item, dict)), None) - url = _first(first or {}, "url") - if not url: - return {"code": 1, "message": "download_url_missing", "data": {}} - filename = _first(first or {}, "filename") - path = _download(url, "assrt", candidate_id, filename or (candidate.get("name") or "subtitle.srt")) - path = _extract_archive(path, "assrt", candidate_id) - return {"code": 0, "data": {"url": path, "fileName": candidate.get("name", ""), "language": candidate.get("language", ""), "format": candidate.get("format", "srt")}} - - def destroy(self): - self.config = {} diff --git a/app/src/main/assets/subtitle_sources/builtin/manifest.json b/app/src/main/assets/subtitle_sources/builtin/manifest.json deleted file mode 100644 index 6821be4b05..0000000000 --- a/app/src/main/assets/subtitle_sources/builtin/manifest.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "protocol": "webhtv-subtitle", - "version": 1, - "name": "WebHTV built-in Python subtitle sources", - "subtitles": [ - { - "key": "assrt", - "name": "ASSRT", - "type": 3, - "api": "py", - "ext": "assets://subtitle_sources/builtin/assrt.py", - "enabled": 1, - "priority": 30, - "protocolVersion": 1, - "params": {"token": "${ASSRT_TOKEN}"}, - "languages": ["zh", "en"], - "formats": ["srt", "ass", "ssa", "vtt"] - }, - { - "key": "xunlei", - "name": "Xunlei Subtitle", - "type": 3, - "api": "py", - "ext": "assets://subtitle_sources/builtin/xunlei.py", - "enabled": 1, - "priority": 20, - "protocolVersion": 1, - "languages": ["zh", "en"], - "formats": ["srt", "ass", "ssa", "vtt"] - }, - { - "key": "shooter", - "name": "Shooter", - "type": 3, - "api": "py", - "ext": "assets://subtitle_sources/builtin/shooter.py", - "enabled": 1, - "priority": 10, - "queryIndependent": true, - "protocolVersion": 1, - "languages": ["zh", "en"], - "formats": ["srt", "ass", "ssa", "vtt"] - } - ] -} diff --git a/app/src/main/assets/subtitle_sources/builtin/shooter.py b/app/src/main/assets/subtitle_sources/builtin/shooter.py deleted file mode 100644 index f8694cd80a..0000000000 --- a/app/src/main/assets/subtitle_sources/builtin/shooter.py +++ /dev/null @@ -1,129 +0,0 @@ -import hashlib -import os -import re - -import requests - - -API = "https://www.shooter.cn/api/subapi.php" -USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" - - -def _text(value): - return "" if value is None else str(value).strip() - - -def _number(value, fallback=0): - try: - return int(value) - except (TypeError, ValueError): - return fallback - - -def _language(value): - return "eng" if _text(value).lower().startswith(("en", "eng")) else "zh" - - -def _format(ext, url): - value = (_text(ext) or os.path.splitext(_text(url))[1]).lower().replace(".", "") - return value if value in ("ass", "ssa", "srt", "vtt") else "srt" - - -def _hash(path): - if not path or not os.path.isfile(path): - return "" - size = os.path.getsize(path) - if size < 12 * 1024: - return "" - offsets = (4 * 1024, size // 3 * 2, size // 3, size - 8 * 1024) - result = [] - with open(path, "rb") as source: - for offset in offsets: - source.seek(offset) - result.append(hashlib.md5(source.read(4 * 1024)).hexdigest()) - return ";".join(result) - - -def _download(url, candidate_id, fmt): - safe = re.sub(r"[^A-Za-z0-9_.-]", "_", _text(candidate_id))[:80] or "subtitle" - target = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shooter_" + safe + "." + (fmt or "srt")) - temporary = target + ".tmp" - response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=15, stream=True) - response.raise_for_status() - try: - with open(temporary, "wb") as output: - for chunk in response.iter_content(16384): - if chunk: - output.write(chunk) - os.replace(temporary, target) - finally: - response.close() - if os.path.exists(temporary): - os.remove(temporary) - return target - - -class Spider: - def __init__(self): - self.config = {} - - def init(self, config=None): - self.config = config if isinstance(config, dict) else {} - return {"code": 0, "data": {}} - - def search(self, request): - request = request or {} - query = request.get("query") or {} - context = request.get("context") or {} - media_path = _text(context.get("mediaPath")) - file_hash = _hash(media_path) - if not file_hash: - return {"code": 0, "data": {"items": []}} - language = _language(query.get("language") or context.get("preferredLanguage")) - body = {"filehash": file_hash, "pathinfo": media_path, "format": "json", "lang": "eng" if language == "eng" else "chn"} - response = requests.post(API, data=body, headers={"User-Agent": USER_AGENT}, timeout=15) - if not response.ok: - return {"code": 0, "data": {"items": []}} - try: - root = response.json() - except ValueError: - return {"code": 0, "data": {"items": []}} - file_name = os.path.basename(media_path) - items = [] - for group in root if isinstance(root, list) else []: - for item in (group.get("Files") or []) if isinstance(group, dict) else []: - if not isinstance(item, dict): - continue - url = _text(item.get("Link")) - if not url: - continue - fmt = _format(item.get("Ext"), url) - name = (file_name or _text(context.get("canonicalTitle")) or "subtitle") + " | 射手 | " + fmt - items.append({ - "id": url, - "name": name, - "language": language, - "format": fmt, - "releaseInfo": "hash", - "score": 120, - "year": _number(query.get("year"), _number(context.get("year"))), - "season": _number(query.get("season"), _number(context.get("season"), -1)), - "episode": _number(query.get("episode"), _number(context.get("episode"), -1)), - "matchType": "METADATA_STRICT", - "requiresResolve": True, - "payload": {"url": url, "format": fmt}, - }) - return {"code": 0, "data": {"items": items}} - - def resolve(self, request): - candidate = (request or {}).get("candidate") or {} - payload = candidate.get("payload") or {} - url = _text(payload.get("url")) - fmt = _text(payload.get("format")) or _text(candidate.get("format")) or "srt" - if not url: - return {"code": 1, "message": "download_url_missing", "data": {}} - path = _download(url, _text(candidate.get("id")) or url, fmt) - return {"code": 0, "data": {"url": path, "fileName": candidate.get("name", ""), "language": candidate.get("language", ""), "format": fmt}} - - def destroy(self): - self.config = {} diff --git a/app/src/main/assets/subtitle_sources/builtin/xunlei.py b/app/src/main/assets/subtitle_sources/builtin/xunlei.py deleted file mode 100644 index 48654eb917..0000000000 --- a/app/src/main/assets/subtitle_sources/builtin/xunlei.py +++ /dev/null @@ -1,169 +0,0 @@ -import hashlib -import json -import os -import re -from urllib.parse import quote - -import requests - - -API = "https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name=" -REFERER = "https://sl-m-ssl.xunlei.com/" -USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" - - -def _text(value): - return "" if value is None else str(value).strip() - - -def _first(item, *keys): - for key in keys: - value = _text((item or {}).get(key)) - if value: - return value - return "" - - -def _number(value, fallback=0): - try: - return int(value) - except (TypeError, ValueError): - return fallback - - -def _long(value, fallback=0): - try: - return int(value) - except (TypeError, ValueError): - return fallback - - -def _format(ext, name, url): - value = _text(ext) or os.path.splitext(_text(name) or _text(url))[1] - value = value.lower().replace(".", "") - return value if value in ("ass", "ssa", "srt", "vtt") else "srt" - - -def _language(value): - value = _text(value) - lower = value.lower() - if not value or lower == "default" or value == "默认": - return "zh" - if any(token in lower for token in ("中文", "中字", "简", "繁", "chs", "cht", "zh", "english", "eng", "en", "日", "ja", "jp", "韩", "韓", "ko", "kr")): - return value - return "zh" - - -def _release(extra, duration): - result = _text(extra) - if duration > 0: - result = (result + " " if result else "") + str(duration // 60000) + "m" - return result - - -def _content_id(path): - if not path or not os.path.isfile(path): - return "" - size = os.path.getsize(path) - with open(path, "rb") as source: - if size < 0xF000: - data = source.read() - else: - data = source.read(0x5000) - source.seek(size // 3) - data += source.read(0x5000) - source.seek(max(0, size - 0x5000)) - data += source.read(0x5000) - return hashlib.sha1(data).hexdigest().upper() - - -def _download(url, candidate_id, filename, fmt): - safe = re.sub(r"[^A-Za-z0-9_.-]", "_", _text(candidate_id))[:80] or "subtitle" - suffix = os.path.splitext(_text(filename))[1].lower() or "." + (fmt or "srt") - if suffix not in (".ass", ".ssa", ".srt", ".vtt"): - suffix = "." + (fmt or "srt") - target = os.path.join(os.path.dirname(os.path.abspath(__file__)), "xunlei_" + safe + suffix) - temporary = target + ".tmp" - response = requests.get(url, headers={"User-Agent": USER_AGENT, "Referer": REFERER, "Connection": "close"}, timeout=15, stream=True) - response.raise_for_status() - try: - with open(temporary, "wb") as output: - for chunk in response.iter_content(16384): - if chunk: - output.write(chunk) - os.replace(temporary, target) - finally: - response.close() - if os.path.exists(temporary): - os.remove(temporary) - return target - - -class Spider: - def __init__(self): - self.config = {} - - def init(self, config=None): - self.config = config if isinstance(config, dict) else {} - return {"code": 0, "data": {}} - - def search(self, request): - request = request or {} - query = request.get("query") or {} - context = request.get("context") or {} - text = _text(query.get("text")) - if not text: - return {"code": 0, "data": {"items": []}} - content_id = _content_id(_text(context.get("mediaPath"))) - response = requests.get(API + quote(text), headers={"User-Agent": USER_AGENT, "Referer": REFERER, "Connection": "close"}, timeout=15) - if not response.ok: - return {"code": 0, "data": {"items": []}} - root = response.json() - if _number(root.get("code"), 1) != 0 or (_text(root.get("result")) and _text(root.get("result")).lower() != "ok"): - return {"code": 0, "data": {"items": []}} - items = [] - for item in root.get("data") or []: - if not isinstance(item, dict): - continue - url = _text(item.get("url")) - candidate_id = _first(item, "cid", "gcid") or url - if not url or not candidate_id: - continue - name = _text(item.get("name")) or candidate_id - fmt = _format(item.get("ext"), name, url) - language_values = item.get("languages") if isinstance(item.get("languages"), list) else [] - language = _language(next((_text(value) for value in language_values if _text(value)), "")) - extra = _text(item.get("extra_name")) - duration = _long(item.get("duration")) - score = max(_number(item.get("score")), _number(item.get("fingerprintf_score"))) - strict = bool(content_id and content_id.lower() == _text(item.get("cid")).lower()) - if strict: - score += 120 - items.append({ - "id": candidate_id, - "name": name, - "language": language, - "format": fmt, - "releaseInfo": _release(extra, duration), - "score": score, - "year": 0, - "season": -1, - "episode": -1, - "matchType": "METADATA_STRICT" if strict else "METADATA_FUZZY", - "requiresResolve": True, - "payload": {"url": url, "name": name, "format": fmt, "duration": duration, "gcid": _text(item.get("gcid")), "cid": _text(item.get("cid"))}, - }) - return {"code": 0, "data": {"items": items}} - - def resolve(self, request): - candidate = (request or {}).get("candidate") or {} - payload = candidate.get("payload") or {} - url = _text(payload.get("url")) - if not url: - return {"code": 1, "message": "download_url_missing", "data": {}} - fmt = _text(payload.get("format")) or _text(candidate.get("format")) or "srt" - path = _download(url, _text(candidate.get("id")) or url, payload.get("name"), fmt) - return {"code": 0, "data": {"url": path, "fileName": candidate.get("name", ""), "language": candidate.get("language", ""), "format": fmt}} - - def destroy(self): - self.config = {} diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index f857f0f5d9..9af4b9c2da 100644 Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/androidx/media3/mpvplayer/HlsAdTimeline.java b/app/src/main/java/androidx/media3/mpvplayer/HlsAdTimeline.java deleted file mode 100644 index 615907ed9d..0000000000 --- a/app/src/main/java/androidx/media3/mpvplayer/HlsAdTimeline.java +++ /dev/null @@ -1,207 +0,0 @@ -package androidx.media3.mpvplayer; - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -/** Maps an existing detector's omissions to source time without rewriting HLS media. */ -final class HlsAdTimeline { - - static final HlsAdTimeline NONE = new HlsAdTimeline(List.of(), 0, "no-ads"); - private static final int MAX_SEGMENTS = 100_000; - // The detector is heuristic. A multi-minute candidate can be programme - // content, so preserve it instead of making that whole interval unseekable. - private static final long MAX_AUTO_SKIP_BLOCK_US = 120_000_000; - - private final List ranges; - private final long durationUs; - private final String reason; - - private HlsAdTimeline(List ranges, long durationUs, String reason) { - this.ranges = List.copyOf(ranges); - this.durationUs = durationUs; - this.reason = reason; - } - - static HlsAdTimeline from(String original, String filtered) { - if (original == null || original.equals(filtered)) return NONE; - Playlist source = parse(original); - Playlist kept = parse(filtered); - if (source == null || kept == null || kept.segments().isEmpty() - || kept.segments().size() >= source.segments().size()) { - return empty("invalid-or-unchanged-playlist"); - } - int count = kept.segments().size(); - int[] first = new int[count]; - int cursor = 0; - for (int i = 0; i < count; i++) { - Segment segment = kept.segments().get(i); - while (cursor < source.segments().size() - && !segment.equals(source.segments().get(cursor))) cursor++; - if (cursor == source.segments().size()) return empty("not-a-subsequence"); - first[i] = cursor++; - } - // Repeated URIs/durations/ranges must not silently identify the wrong occurrence. - cursor = source.segments().size() - 1; - for (int i = count - 1; i >= 0; i--) { - Segment segment = kept.segments().get(i); - while (cursor >= 0 && !segment.equals(source.segments().get(cursor))) cursor--; - if (cursor != first[i]) return empty("ambiguous-segments"); - cursor--; - } - List ranges = new ArrayList<>(); - long positionUs = 0; - long adStartUs = -1; - int keptIndex = 0; - int preservedBlocks = 0; - for (int i = 0; i < source.segments().size(); i++) { - boolean retained = keptIndex < count && first[keptIndex] == i; - // Validate separate source blocks before merging. A false positive - // programme block must not swallow the short ad next to it. - if (adStartUs >= 0 && (retained || source.discontinuities().contains(i))) { - if (!addRange(ranges, adStartUs, positionUs)) preservedBlocks++; - adStartUs = -1; - } - if (retained) { - keptIndex++; - } else if (adStartUs < 0) { - adStartUs = positionUs; - } - positionUs += source.segments().get(i).durationUs(); - } - if (adStartUs >= 0 && !addRange(ranges, adStartUs, positionUs)) preservedBlocks++; - return new HlsAdTimeline(ranges, positionUs, preservedBlocks == 0 - ? "exo-hls-detector" : "exo-hls-detector-long-blocks-preserved"); - } - - private static boolean addRange(List ranges, long startUs, long endUs) { - if (endUs - startUs > MAX_AUTO_SKIP_BLOCK_US) return false; - // Round inward: never skip extra programme content at sub-millisecond boundaries. - long startMs = startUs / 1000 + (startUs % 1000 == 0 ? 0 : 1); - long endMs = endUs / 1000; - if (endMs > startMs) { - if (!ranges.isEmpty() && ranges.get(ranges.size() - 1).endMs() == startMs) { - startMs = ranges.remove(ranges.size() - 1).startMs(); - } - ranges.add(new Range(startMs, endMs)); - } - return true; - } - - private static HlsAdTimeline empty(String reason) { - return new HlsAdTimeline(List.of(), 0, reason); - } - - private static Playlist parse(String text) { - if (text == null) return null; - String content = text.strip(); - if (content.startsWith("\uFEFF")) content = content.substring(1); - if (!content.startsWith("#EXTM3U")) return null; - List segments = new ArrayList<>(); - Set discontinuities = new HashSet<>(); - long durationUs = -1; - long totalUs = 0; - String byteRange = ""; - boolean ended = false; - try { - for (String raw : content.split("\\r?\\n")) { - String line = raw.trim(); - if (line.startsWith("#EXT-X-STREAM-INF:") - || line.startsWith("#EXT-X-PART:") - || line.startsWith("#EXT-X-SKIP:") - || line.equals("#EXT-X-I-FRAMES-ONLY")) return null; - if (line.equals("#EXT-X-ENDLIST")) { - ended = true; - } else if (line.equals("#EXT-X-DISCONTINUITY")) { - discontinuities.add(segments.size()); - } else if (line.startsWith("#EXTINF:")) { - if (ended || durationUs >= 0) return null; - int comma = line.indexOf(','); - String value = line.substring(8, comma < 0 ? line.length() : comma).trim(); - if (value.length() > 48) return null; - BigDecimal seconds = new BigDecimal(value); - if (seconds.scale() < -12 || seconds.scale() > 18) return null; - durationUs = seconds.movePointRight(6) - .setScale(0, RoundingMode.HALF_UP).longValueExact(); - if (durationUs <= 0) return null; - } else if (line.startsWith("#EXT-X-BYTERANGE:")) { - byteRange = line.substring(17).trim(); - } else if (!line.isEmpty() && !line.startsWith("#")) { - if (ended || durationUs <= 0 || segments.size() >= MAX_SEGMENTS) return null; - totalUs = Math.addExact(totalUs, durationUs); - segments.add(new Segment(line, durationUs, byteRange)); - durationUs = -1; - byteRange = ""; - } - } - } catch (IllegalArgumentException | ArithmeticException e) { - return null; - } - return ended && durationUs < 0 && !segments.isEmpty() - ? new Playlist(segments, discontinuities) : null; - } - - List ranges() { - return ranges; - } - - String reason() { - return reason; - } - - boolean sameCuts(HlsAdTimeline other) { - return other != null && durationUs == other.durationUs && ranges.equals(other.ranges); - } - - long skipTargetMs(long positionMs) { - Range range = rangeAt(positionMs); - return range == null ? positionMs : range.endMs(); - } - - Range nextRange(long positionMs) { - int low = 0; - int high = ranges.size(); - while (low < high) { - int mid = (low + high) >>> 1; - if (ranges.get(mid).endMs() <= positionMs) low = mid + 1; - else high = mid; - } - return low == ranges.size() ? null : ranges.get(low); - } - - private Range rangeAt(long positionMs) { - int low = 0; - int high = ranges.size() - 1; - while (low <= high) { - int mid = (low + high) >>> 1; - Range range = ranges.get(mid); - if (positionMs < range.startMs()) high = mid - 1; - else if (positionMs >= range.endMs()) low = mid + 1; - else return range; - } - return null; - } - - record Range(long startMs, long endMs) {} - - private record Segment(String uri, long durationUs, String byteRange) {} - - private record Playlist(List segments, Set discontinuities) {} - - /** Keeps stale position notifications from repeatedly seeking to the same cut end. */ - static final class SkipState { - private final Set requested = new HashSet<>(); - - long nextTargetMs(HlsAdTimeline timeline, long positionMs) { - Range range = timeline.rangeAt(positionMs); - return range != null && requested.add(range) ? range.endMs() : -1; - } - - void clear() { - requested.clear(); - } - } -} diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvAudioDecoderPolicy.java b/app/src/main/java/androidx/media3/mpvplayer/MpvAudioDecoderPolicy.java deleted file mode 100644 index 324011e9f3..0000000000 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvAudioDecoderPolicy.java +++ /dev/null @@ -1,37 +0,0 @@ -package androidx.media3.mpvplayer; - -import java.util.List; - -final class MpvAudioDecoderPolicy { - - private static final List HARDWARE_FIRST_DECODERS = List.of( - "aac_mediacodec", - "mp3_mediacodec", - "amrnb_mediacodec", - "amrwb_mediacodec"); - private static final List SOFTWARE_FIRST_DECODERS = List.of( - "aac", - "mp3", - "amrnb", - "amrwb"); - - private MpvAudioDecoderPolicy() { - } - - /** - * Do not force MediaCodec when no compressed AudioTrack route is usable. - * On affected devices the AAC MediaCodec decoder can backpressure without - * producing audible PCM; the normal FFmpeg decoder is the safe output - * path. Keep the hardware-first policy when the route is available. - */ - static String decoderList(String audioSpdif) { - return audioSpdif == null || audioSpdif.isBlank() - ? String.join(",", SOFTWARE_FIRST_DECODERS) - : String.join(",", HARDWARE_FIRST_DECODERS); - } - - static String hardwareFirstDecoderList() { - return String.join(",", HARDWARE_FIRST_DECODERS); - } - -} diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvAudioMimeTypes.java b/app/src/main/java/androidx/media3/mpvplayer/MpvAudioMimeTypes.java deleted file mode 100644 index b204c1ba8b..0000000000 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvAudioMimeTypes.java +++ /dev/null @@ -1,24 +0,0 @@ -package androidx.media3.mpvplayer; - -import androidx.media3.common.MimeTypes; - -import java.util.Locale; - -final class MpvAudioMimeTypes { - - private MpvAudioMimeTypes() { - } - - static String fromCodec(String codec) { - String value = codec == null ? "" : codec.toLowerCase(Locale.US); - if (value.contains("av3a") || value.contains("avs3a")) return MimeTypes.AUDIO_AV3A; - if (value.contains("aac")) return MimeTypes.AUDIO_AAC; - if (value.contains("ac3")) return MimeTypes.AUDIO_AC3; - if (value.contains("eac3") || value.contains("e-ac-3")) return MimeTypes.AUDIO_E_AC3; - if (value.contains("opus")) return MimeTypes.AUDIO_OPUS; - if (value.contains("vorbis")) return MimeTypes.AUDIO_VORBIS; - if (value.contains("flac")) return MimeTypes.AUDIO_FLAC; - if (value.contains("mp3")) return MimeTypes.AUDIO_MPEG; - return MimeTypes.BASE_TYPE_AUDIO + "/" + (value.isEmpty() ? "unknown" : value); - } -} diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvDiagnosticCollector.java b/app/src/main/java/androidx/media3/mpvplayer/MpvDiagnosticCollector.java deleted file mode 100644 index e20f0cc540..0000000000 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvDiagnosticCollector.java +++ /dev/null @@ -1,344 +0,0 @@ -package androidx.media3.mpvplayer; - -import android.os.SystemClock; - -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector; -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector.Context; -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; -import com.github.catvod.crawler.diagnostics.DiagnosticText; - -import java.util.Map; -import java.util.Set; -import java.util.concurrent.atomic.AtomicLong; - -import is.xyz.mpv.MPVLib; - -import static com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.*; - -/** Native callbacks and watchdog only publish cached facts. Never calls MPV or waits for the UI. */ -final class MpvDiagnosticCollector { - private static final Context UNRESOLVED = new Context("none", 0, 0, null, "unresolved"); - private static final String HOOK_MARKER = "WebHTV AV-DIAG "; - private static final java.util.regex.Pattern HOOK_FIELDS = java.util.regex.Pattern.compile("([A-Za-z][A-Za-z0-9]*)=([^\\s]+)"); - static final String STANDARD_COMPONENTS = ",vd=info,ffmpeg/video=info,ffmpeg/audio=info,vo=info,ao=info,cplayer=info"; - private static final Set PROPERTIES = Set.of("time-pos", "time-pos/full", "duration", "duration/full", "pause", "paused-for-cache", - "idle-active", "eof-reached", "seeking", "file-format", "seekable", "current-edition", "vid", "aid", "sid", "hwdec", "hwdec-current", "hwdec-interop", "video-codec", "audio-codec", - "current-vo", "current-gpu-context", "gpu-api", "current-ao", "audio-device", "volume", "mute", "speed", "audio-delay", "avsync", - "decoder-frame-drop-count", "frame-drop-count", "mistimed-frame-count", "vo-delayed-frame-count", "display-fps", "estimated-display-fps", - "container-fps", "estimated-vf-fps", "video-pts", "audio-pts", "mpv-version", "ffmpeg-version", "options/msg-level", - "options/af", "demuxer-cache-duration", "cache-buffering-state", "audio-spdif"); - private final PlaybackDiagnosticCollector log = new PlaybackDiagnosticCollector("mpv", "runtime-properties"); - private final com.fongmi.android.tv.player.FelBindProbeControl felBindProbe = - new com.fongmi.android.tv.player.FelBindProbeControl(log.instanceId()); - private final MpvPropertySnapshot snapshot; - private final AtomicLong nativeSeq = new AtomicLong(); - private final String cacheId = PlaybackDiagnosticCollector.id("mpv-health"); - private volatile long nativeOverflow, lastNativeMs, firstNativeMs, capture = -1, lastTickMs; - private volatile String lastVideoFailure; - private volatile String requestedMsgLevel; - private volatile boolean closed; - // Controller requests may advance before the native thread finishes the previous file. - private volatile Context nativeOwner = UNRESOLVED; - private Context pendingLoadOwner; - private long nativeGeneration = -1, loadSequence, pendingLoadId; - private final Map emitted = new java.util.HashMap<>(); - private final Set emittedMissing = new java.util.HashSet<>(); - private MpvPropertySnapshot.TrackList emittedTracks; - private int categories = -1; - private final com.fongmi.android.tv.player.OutputProgressDiagnostic videoProgress = new com.fongmi.android.tv.player.OutputProgressDiagnostic(true); - - MpvDiagnosticCollector(MpvPropertySnapshot snapshot) { this.snapshot = snapshot; } - com.fongmi.android.tv.player.FelBindProbeControl felBindProbe() { return felBindProbe; } - boolean depthActive() { - Context owner = nativeOwner; - com.github.catvod.crawler.diagnostics.DiagnosticCapture.Session capture = com.github.catvod.crawler.diagnostics.DiagnosticCapture.current(owner.trace(), owner.generation(), owner.attempt()); - return capture != null && capture.instance().equals(log.instanceId()); - } - - static boolean propertyAllowed(String name) { - return PROPERTIES.contains(name) || name.startsWith("video-params/") || name.startsWith("video-out-params/") - || name.startsWith("video-dec-params/") || name.startsWith("audio-params/") || name.startsWith("audio-out-params/") - || name.startsWith("current-tracks/video/") || name.startsWith("current-tracks/audio/"); - } - - private synchronized void capture() { - long generation = DebugLogStore.captureGeneration(); - if (capture == generation) return; - capture = generation; nativeOverflow = lastNativeMs = firstNativeMs = lastTickMs = 0; lastVideoFailure = null; - emitted.clear(); emittedMissing.clear(); emittedTracks = null; - log.baseline(); - } - - synchronized void begin(String trace) { - closed = false; log.begin(trace, "foreground"); - felBindProbe.begin(); - com.fongmi.android.tv.player.DiagnosticControls.registerFelBindProbe(felBindProbe); - emitted.clear(); emittedMissing.clear(); emittedTracks = null; - if (PlaybackDiagnosticCollector.enabled()) { capture(); health(); } - } - - synchronized long loadRequested() { - Context owner = log.context(); - // The public START_FILE callback has no playlist entry ID. Never guess across - // overlapping attempts, including load commands whose return has not arrived. - pendingLoadOwner = pendingLoadOwner == null || pendingLoadOwner == owner ? owner : UNRESOLVED; - return pendingLoadId = ++loadSequence; - } - - synchronized void loadReturned(long requestId, int result) { - if (result < 0 && pendingLoadId == requestId) pendingLoadOwner = null; - } - - synchronized void nativeLog(String prefix, int level, String text) { - if (!PlaybackDiagnosticCollector.enabled() || text == null - || !DebugLogStore.categoryEnabled(com.github.catvod.crawler.diagnostics.DiagnosticCategories.nativePrefix(prefix))) return; - capture(); - long now = SystemClock.elapsedRealtime(); - if (firstNativeMs == 0) firstNativeMs = now; - lastNativeMs = now; - long sourceSeq = nativeSeq.incrementAndGet(); - String bounded = text.substring(0, Math.min(16384, text.length())); - if (isHook(bounded)) { - hook(prefix, level, bounded, sourceSeq); - return; - } - String lower = bounded.toLowerCase(java.util.Locale.ROOT); - boolean lookup = lower.contains("failed to getcodecnamebytype"); - boolean videoFailure = lookup || lower.contains("software decoding fallback is disabled") - || (prefix != null && (prefix.equals("vd") || prefix.startsWith("ffmpeg/video")) - && (lower.contains("failed") || lower.contains("error while opening decoder") || lower.contains("could not open codec"))); - boolean boundary = videoFailure || lower.contains("using hardware decoding") || lower.contains("using software decoding") - || lower.contains("mediacodec started successfully"); - String severity = level <= 10 ? "fatal" : level <= 20 ? "error" : level <= 30 ? "warn" : level <= 40 ? "info" : level <= 50 ? "debug" : "trace"; - String stage = lookup ? "codec-name-lookup-result" : videoFailure ? "decoder-initialization-unresolved" : "native-message"; - if (videoFailure) { - String safe = DiagnosticText.clean(bounded).text(); - lastVideoFailure = safe.substring(0, Math.min(240, safe.length())); - } - log.emitNative(nativeOwner, boundary ? "mpv.decoder.attempt" : "mpv.native.output", "mpv-log-callback", "native-load-start-context; log-media-unconfirmed", prefix, e -> { - e.severity(severity).observed("nativePrefix", prefix).observed("nativeLevel", level).observed("count", sourceSeq) - .observed("stage", stage).message(bounded); - if (boundary) e.inferred().pin(cacheId + "-last-boundary"); - if (lookup || videoFailure) e.unknown("operation", UNKNOWN).observed("nativeHook", "legacy text; correlate with packaged AV-DIAG-01-v1 operation events"); - }); - // Publish after the last error itself: a silent native tail needs no later log or UI callback. - if (videoFailure || level <= 30) { partialFailure(); health(); } - } - - static boolean isHook(String text) { - if (text == null) return false; - int marker = text.indexOf(HOOK_MARKER); - // libav prepends its AVClass item name (e.g. hevc_mediacodec:). - return marker == 0 || marker > 0 && marker < 256 && text.charAt(marker - 1) == ' '; - } - - private void hook(String prefix, int level, String text, long sourceSeq) { - Map values = new java.util.LinkedHashMap<>(); - java.util.regex.Matcher matcher = HOOK_FIELDS.matcher(text.substring(text.indexOf(HOOK_MARKER))); - while (matcher.find() && values.size() < 32) values.put(matcher.group(1), matcher.group(2)); - boolean audio = prefix != null && (prefix.startsWith("ao") || prefix.startsWith("ffmpeg/audio")); - String event = text.contains("audio-write ") ? "audio.output.write" : text.contains("audio-route ") ? "audio.route" - : text.contains("audio-config ") ? "audio.output.configure" : text.contains("audio-lifecycle ") ? "audio.output.lifecycle" - : text.contains("codec-candidate ") || text.contains("codec-profile ") ? audio ? "audio.candidates" : "video.candidates" - : "mpv.decoder.attempt"; - Long code = integer(values.get("code")); - boolean failed = code != null && code < 0 || Set.of("query-failed", "jvm-unavailable", "no-eligible-candidate", "null").contains(values.getOrDefault("outcome", "")); - if (!audio && failed) lastVideoFailure = text.substring(0, Math.min(240, text.length())); - log.emitNative(nativeOwner, event, "packaged-native-hook-v1", "native-load-start-context; log-media-unconfirmed", prefix, e -> { - e.observed("nativePrefix", prefix).observed("nativeLevel", level).observed("count", sourceSeq) - .observed("nativeHook", "AV-DIAG-01-v1").observed("operation", values.get("operation")) - .observed("phase", values.get("phase")).observed("result", values.get("outcome")).message(text); - if (failed) e.severity("error").pin(cacheId + "-native-hook-failure"); - if (code != null) e.observed("errorCode", code); - if (values.containsKey("name")) e.observed("decoderName", values.get("name")); - if (values.containsKey("reason")) e.observed("reason", values.get("reason")); - if (values.containsKey("stage")) e.observed("stage", values.get("stage")); - if (values.containsKey("backend")) e.observed("backend", values.get("backend")); - if (values.containsKey("mime")) e.observed("mime", values.get("mime")); - if (values.containsKey("accepted") && !text.contains("audio-write ")) e.observed("accepted", "1".equals(values.get("accepted"))); - if (values.containsKey("encoding")) e.observed("pcm", Set.of("2", "3", "4", "21", "22").contains(values.get("encoding"))); - if (values.containsKey("offload")) e.observed("offload", "1".equals(values.get("offload"))); - if (values.containsKey("timestampValid")) e.observed("timestampValid", "1".equals(values.get("timestampValid"))); - if (values.containsKey("timestampAgeMs")) e.observed("timestampAgeMs", integer(values.get("timestampAgeMs"))); - if (values.containsKey("output")) e.observed("audioOutputId", cacheId + ":" + values.get("output")); - String[][] mapping = {{"index", "candidateIndex"}, {"profile", "profile"}, {"rate", "sampleRate"}, {"channels", "channels"}, - {"mask", "channelMask"}, {"encoding", "encoding"}, {"bufferBytes", "bufferSize"}, {"calls", "writeCalls"}, - {"requested", "requestedBytes"}, {"accepted", "acceptedBytes"}, {"short", "shortWrites"}, {"zero", "zeroWrites"}, - {"errors", "writeErrors"}, {"raw", "errorCode"}, {"maxUs", "maxWriteUs"}, {"headRaw", "headRaw"}, {"id", "routeId"}, {"type", "routeType"}, - {"candidate", "candidateIndex"}, {"count", "candidateCount"}}; - for (String[] field : mapping) if (values.containsKey(field[0])) e.observed(field[1], integer(values.get(field[0]))); - if (values.containsKey("role")) e.observed("routeRole", values.get("role")); - if (text.contains("audio-write ")) e.observed("metricScope", "AudioTrack owner: accepted bytes, raw return unit in message; head is existing backend cache"); - }); - if (failed) { partialFailure(); health(); } - } - - private static Long integer(String value) { try { return value == null ? null : Long.valueOf(value); } catch (NumberFormatException ignored) { return null; } } - - synchronized void event(int event, long propertyGeneration) { - if (event == MPVLib.MpvEvent.MPV_EVENT_START_FILE) { - nativeOwner = pendingLoadOwner == null ? UNRESOLVED : pendingLoadOwner; - pendingLoadOwner = null; nativeGeneration = propertyGeneration; lastVideoFailure = null; - emitted.clear(); emittedMissing.clear(); emittedTracks = null; - } else if (event == MPVLib.MpvEvent.MPV_EVENT_QUEUE_OVERFLOW) { - // A dropped start/end boundary invalidates media association until a new load. - nativeOwner = UNRESOLVED; pendingLoadOwner = null; lastVideoFailure = null; - } - if (!PlaybackDiagnosticCollector.enabled()) return; - capture(); - if (event == MPVLib.MpvEvent.MPV_EVENT_QUEUE_OVERFLOW) nativeOverflow++; - log.emit(nativeOwner, "mpv.event", "mpv-event-callback", "native-load-start-context", e -> e.observed("value", event).observed("propertyGeneration", propertyGeneration) - .observed("stage", event == MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART ? "playback-restart; not frame-present" : "native-event")); - health(); - } - - void option(String property, String value, int result, boolean runtime) { - if ("msg-level".equals(property)) requestedMsgLevel = value; - if (!propertyAllowed(property) && !Set.of("vo", "ao", "ad", "gpu-context", "audio-spdif", "msg-level", "hwdec-codecs").contains(property)) return; - log.emit("mpv.option", "mpv-option-api", e -> e.observed("property", property).requested("value", value) - .observed("errorCode", result).observed("phase", runtime ? "runtime-set-return" : "pre-init-set-return") - .unknown("result", PENDING_CALLBACK)); - } - - synchronized void propertyChanged(String property) { - if (!PlaybackDiagnosticCollector.enabled()) return; - if (property.equals("vid") || property.equals("aid") || property.equals("track-list")) { capture(); partialFailure(); health(); } - } - - void registration(String property, int format, int result) { - log.emit("mpv.collector.health", "mpv-observe-property", e -> e.observed("property", property) - .observed("format", format).observed("registrationResult", result) - .observed("metricScope", "registration result; property value availability reported separately")); - } - - void command(String operation, long id, int result, String phase, long elapsedMs) { - boolean reply = "completed".equals(phase); - log.emit(reply ? UNRESOLVED : log.context(), "mpv.command.result", "mpv-command-api", - reply ? "operation-id-only; media-unconfirmed" : "controller-request", e -> e.observed("operation", operation).observed("operationId", id) - .observed("errorCode", result).observed("phase", phase).observed("durationMs", elapsedMs)); - } - - synchronized void end(int reason, int error) { - felBindProbe.end(); - if (closed) return; - log.emit("mpv.event", "mpv-controller-end", e -> e.observed("reason", reason).observed("errorCode", error)); - if (nativeOwner == log.context()) tick(true); - log.end("controller-end:" + reason); closed = true; - } - - synchronized void endFile(int reason, int error) { - log.emit(nativeOwner, "mpv.event", "mpv-end-file", "native-load-start-context", e -> e - .observed("reason", reason).observed("errorCode", error).observed("propertyGeneration", nativeGeneration)); - tick(true); - // begin() already closed an older controller attempt. Its late native end - // remains evidence for that owner and must never close the new attempt. - if (nativeOwner == log.context() && !closed) { - felBindProbe.end(); - log.end("end-file:" + reason); closed = true; - } - } - - synchronized void tick(boolean force) { - if (!PlaybackDiagnosticCollector.enabled()) { felBindProbe.cancel(); return; } - capture(); - long now = SystemClock.elapsedRealtime(); - if (!force && now - lastTickMs < 5000) return; - if (categories != DebugLogStore.categories()) { - categories = DebugLogStore.categories(); emitted.clear(); emittedMissing.clear(); emittedTracks = null; - } - lastTickMs = now; - MpvPropertySnapshot.DiagnosticSnapshot state = snapshot.diagnosticSnapshot(capture); - Context owner = state.generation() == nativeGeneration ? nativeOwner : UNRESOLVED; - for (Map.Entry registration : state.registrations().entrySet()) { - if (!propertyAllowed(registration.getKey()) || state.values().containsKey(registration.getKey())) continue; - if (!force && !emittedMissing.add(registration.getKey())) continue; - log.emit(owner, "mpv.runtime", "mpv-property-observer-cache", "native-load-start-context", e -> e.observed("property", registration.getKey()) - .observed("registrationResult", registration.getValue()) - .unknown("value", registration.getValue() < 0 ? NOT_SUPPORTED : NOT_COLLECTED)); - } - for (Map.Entry entry : state.values().entrySet()) { - String property = entry.getKey(); MpvPropertySnapshot.DiagnosticValue value = entry.getValue(); - MpvPropertySnapshot.DiagnosticValue prior = emitted.put(property, value); - emittedMissing.remove(property); - boolean clock = property.startsWith("time-pos") || property.equals("avsync") || property.endsWith("frame-count"); - if (!force && !clock && prior != null && prior.generation() == value.generation() - && java.util.Objects.equals(prior.value(), value.value())) continue; - long age = Math.max(0, now - value.updatedAtMs()); - String event = property.equals("file-format") || property.equals("seekable") || property.equals("current-edition") ? "media.container" - : property.equals("avsync") || property.equals("audio-delay") ? "audio.sync" - : property.equals("mpv-version") || property.equals("ffmpeg-version") ? "mpv.init" - : property.startsWith("audio-") || property.equals("current-ao") || property.equals("volume") || property.equals("mute") - ? "mpv.audio.path" : property.startsWith("video-") || property.startsWith("hwdec") || property.equals("current-vo") - ? "mpv.video.path" : "mpv.runtime"; - log.emit(owner, event, "mpv-property-observer-cache", "native-load-start-context", e -> { - e.observed("property", property).observed("ageMs", age).observed("propertyGeneration", value.generation()); - if (value.value() == null) e.unknown("value", UNAVAILABLE); - else if ((property.startsWith("time-pos") || property.equals("avsync")) && age > 15000) e.unknown("value", STALE); - else e.observed("value", value.value()); - }); - } - if (state.tracksObserved() && state.tracks().valid() && (force || emittedTracks != state.tracks())) for (Map track : state.tracks().entries()) { - log.emit(owner, "mpv.tracks", "track-list-observer", "native-load-start-context", e -> e.observed("trackId", scalar(track.get("id"))) - .observed("trackType", scalar(track.get("type"))).observed("selected", scalar(track.get("selected"))) - .observed("flags", "default=" + scalar(track.get("default")) + ",forced=" + scalar(track.get("forced")) + ",albumart=" + scalar(track.get("albumart"))) - .observed("codecs", scalar(track.get("codec")))); - } - emittedTracks = state.tracks(); - observeVideoProgress(state, owner, now); - partialFailure(); health(); - } - - private void observeVideoProgress(MpvPropertySnapshot.DiagnosticSnapshot state, Context owner, long now) { - Object fps = value(state, "container-fps"), pts = value(state, "video-pts"); - String gate = owner == UNRESOLVED || closed ? "inactive-or-unresolved-media" : null; - if (gate == null) for (String property : new String[]{"pause", "paused-for-cache", "seeking", "idle-active", "eof-reached"}) { - if (!Boolean.FALSE.equals(value(state, property))) { gate = property + "-active-or-unavailable"; break; } - } - boolean motion = state.tracksObserved() && state.tracks().valid() && state.tracks().entries().stream().anyMatch(track -> - "video".equals(track.get("type")) && Boolean.TRUE.equals(track.get("selected")) && !Boolean.TRUE.equals(track.get("albumart"))); - if (gate == null && (!motion || !(fps instanceof Number number) || number.doubleValue() < 1)) gate = "no-motion-video-or-unknown-cadence"; - videoProgress.sample(log, owner, state.generation(), now, pts instanceof Number number ? number.doubleValue() : Double.NaN, gate, - "cached MPV video-pts; not VO present or physical display; three 5-second samples"); - } - - private static Object scalar(Object value) { return value instanceof String || value instanceof Number || value instanceof Boolean ? value : null; } - - private void partialFailure() { - if (lastVideoFailure == null) return; - MpvPropertySnapshot.DiagnosticSnapshot state = snapshot.diagnosticSnapshot(capture); - if (nativeOwner == UNRESOLVED || state.generation() != nativeGeneration) return; - Object vid = value(state, "vid"), aid = value(state, "aid"); - boolean videoAvailable = state.tracksObserved() && state.tracks().valid() - && state.tracks().entries().stream().anyMatch(track -> "video".equals(track.get("type")) && !Boolean.TRUE.equals(track.get("albumart"))); - boolean partial = videoAvailable && "no".equals(String.valueOf(vid)) && selected(aid); - log.emit(nativeOwner, "mpv.output.failure", "native-error-and-observer-cache", "native-load-start-context; log-media-unconfirmed", e -> e.inferred() - .observed("videoAvailable", state.tracksObserved() ? videoAvailable : null) - .observed("videoSelected", vid == null ? null : selected(vid)).observed("audioSelected", aid == null ? null : selected(aid)) - .observed("videoPartialFailure", partial ? true : null).observed("lastError", lastVideoFailure) - .unknown("physicalVideo", NOT_OBSERVABLE).unknown("audibility", NOT_OBSERVABLE).pin(cacheId + "-failure")); - } - private static Object value(MpvPropertySnapshot.DiagnosticSnapshot state, String key) { - MpvPropertySnapshot.DiagnosticValue item = state.values().get(key); return item == null ? null : item.value(); - } - private static boolean selected(Object value) { - if (value instanceof Number n) return n.longValue() > 0; - try { return value != null && Long.parseLong(value.toString()) > 0; } catch (NumberFormatException ignored) { return false; } - } - - private void health() { - if (!PlaybackDiagnosticCollector.enabled()) return; - MpvPropertySnapshot.DiagnosticSnapshot state = snapshot.diagnosticSnapshot(capture); - Context owner = state.generation() == nativeGeneration ? nativeOwner : UNRESOLVED; - DiagnosticEvent event = new DiagnosticEvent("mpv.collector.health", owner.trace(), cacheId, owner.generation(), owner.attempt()) - .source("mpv", "runtime-properties", owner.role(), "cached-collector", "native-load-start-context", owner.mediaId(), log.context().mediaId(), nativeSeq.get(), SystemClock.elapsedRealtimeNanos()) - .observed("captureGeneration", capture).observed("nativeOverflow", nativeOverflow).observed("javaDropped", 0) - .observed("lateEvents", state.lateEvents()).observed("nodeErrors", state.nodeErrors()) - .observed("firstSeenMs", firstNativeMs == 0 ? null : firstNativeMs).observed("lastSeenMs", lastNativeMs == 0 ? null : lastNativeMs) - .observed("subscriptionLevel", "terminal-default (packaged JNI source)").requested("msgLevel", requestedMsgLevel) - .observed("msgLevel", value(state, "options/msg-level")).unknown("sourceFiltered", NOT_OBSERVABLE) - .observed("lastError", lastVideoFailure).observed("nativeHook", "AV-DIAG-01-v1: selector operations and AudioTrack; physical output not observable") - .observed("registrationResult", "registered=" + state.registrations().size() + ",failed=" + state.registrations().values().stream().filter(code -> code < 0).count()); - DebugLogStore.collectorHealth(cacheId, event, nativeOverflow > 0 || state.lateEvents() > 0 || state.nodeErrors() > 0, capture); - } -} diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvDiagnosticsPolicy.java b/app/src/main/java/androidx/media3/mpvplayer/MpvDiagnosticsPolicy.java index 1566b34275..a22d2dc011 100644 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvDiagnosticsPolicy.java +++ b/app/src/main/java/androidx/media3/mpvplayer/MpvDiagnosticsPolicy.java @@ -1,7 +1,6 @@ package androidx.media3.mpvplayer; import java.util.Locale; -import java.util.Arrays; import java.util.regex.Pattern; final class MpvDiagnosticsPolicy { @@ -20,45 +19,6 @@ final class MpvDiagnosticsPolicy { private MpvDiagnosticsPolicy() { } - static boolean includedByLogLevel(String prefix, int level, String settings) { - int threshold = 30, specificity = -1; - if (settings != null) for (String entry : settings.split(",")) { - String[] pair = entry.split("=", 2); - if (pair.length != 2) continue; - String component = pair[0].trim(); - int rank = "all".equals(component) ? 0 : prefix != null - && (prefix.equals(component) || prefix.startsWith(component + "/")) ? component.length() : -1; - if (rank < 0 || rank < specificity) continue; - threshold = switch (pair[1].trim()) { - case "no" -> 0; case "fatal" -> 10; case "error" -> 20; case "warn" -> 30; - case "info", "status" -> 40; case "v" -> 50; case "debug" -> 60; case "trace" -> 70; - default -> threshold; - }; - specificity = rank; - } - return level <= threshold; - } - - static String diagnosticLogLevel(String base) { - return diagnosticLogLevel(base, com.github.catvod.crawler.diagnostics.DiagnosticCategories.ALL, false); - } - - static String diagnosticLogLevel(String base, int categories, boolean deep) { - StringBuilder result = new StringBuilder(base == null ? "all=warn" : base); - for (String component : new String[]{"vd", "ffmpeg/video", "ffmpeg/audio", "vo", "ao", "cplayer", "demux", "sub"}) { - com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category category = switch (component) { - case "vd", "ffmpeg/video", "vo" -> com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category.VIDEO; - case "ffmpeg/audio", "ao" -> com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category.AUDIO; - case "demux" -> com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category.NETWORK; - case "sub" -> com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category.SUBTITLE; - default -> com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category.SYSTEM; - }; - if (com.github.catvod.crawler.diagnostics.DiagnosticCategories.accepts(categories, category) - && !includedByLogLevel(component, deep ? 60 : 40, base)) result.append(',').append(component).append(deep ? "=debug" : "=info"); - } - return result.toString(); - } - static boolean allowsSynchronousProperties(Request request, boolean debugLogEnabled) { if (request == null) return false; return switch (request) { @@ -87,122 +47,6 @@ final class MpvDiagnosticsPolicy { return SENSITIVE_HEADER.matcher(safe).replaceAll("$1="); } - /** Persist startup/failure evidence without waiting for the Android main queue. */ - static boolean isFatalFelLog(int level, String text) { - return level > 0 && level <= 20 && text != null - && text.trim().startsWith("WebHTV FEL fatal:"); - } - - static boolean shouldLogNativeImmediately(int level, String line) { - // mpv: fatal=10, error=20, warn=30. In particular "failing hardware - // decode" must not disappear just because it doesn't contain "failed". - return line != null && !line.isEmpty() - && (level > 0 && level <= 30 || shouldLogNativeImmediately(line)); - } - - static final int FEL_PERFORMANCE_KINDS = 19; - - /** Only native single-line measurements may bypass playback-state processing. */ - static int felPerformanceKind(String prefix, int level, String text) { - if (level != 40 || prefix == null || text == null) return -1; - String value = text.trim(); - if (value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) return -1; - if (prefix.equals("vo/gpu-next/aimagereader")) { - if (value.startsWith("WebHTV FEL perf:")) return 0; - if (value.startsWith("WebHTV FEL perf stages:")) return 1; - if (value.startsWith("WebHTV FEL GPU init:")) return 3; - if (value.startsWith("WebHTV FEL map cost:")) return 7; - if (value.startsWith("WebHTV FEL GPU pool:")) return 8; - if (value.startsWith("WebHTV FEL reuse:")) return 11; - if (value.startsWith("WebHTV FEL api perf:")) return 12; - if (value.startsWith("WebHTV FEL frame order:")) return 13; - if (value.startsWith("WebHTV FEL api slow:")) return 14; - if (value.startsWith("WebHTV FEL descriptors:")) return 15; - if (value.startsWith("WebHTV FEL wait sample:")) return 17; - if (value.startsWith("WebHTV FEL bind probe:")) return 18; - } else if (prefix.equals("vo/gpu-next")) { - if (value.startsWith("WebHTV FEL render perf:")) return 2; - if (value.startsWith("WebHTV FEL renderer init:")) return 16; - } else if (prefix.equals("vd")) { - if (value.startsWith("WebHTV FEL decoder threads:")) return 4; - if (value.startsWith("WebHTV FEL decoder cost:")) return 5; - if (value.startsWith("WebHTV FEL producer handoff:")) return 6; - if (value.startsWith("WebHTV FEL decoder queue:")) return 10; - } else if (prefix.equals("enhancement_pair")) { - if (value.startsWith("WebHTV FEL stats:")) return 9; - } - return -1; - } - - static final class NativeLogWindow { - private long startMs = -1; - private int count; - private int suppressed; - private final long[] performanceStartMs = new long[FEL_PERFORMANCE_KINDS]; - private final int[] performanceCount = new int[FEL_PERFORMANCE_KINDS]; - private int performanceSuppressed; - - NativeLogWindow() { - Arrays.fill(performanceStartMs, -1); - } - - boolean allowPerformance(long nowMs, int kind) { - if (kind < 0 || kind >= FEL_PERFORMANCE_KINDS) return false; - if (performanceStartMs[kind] < 0 || nowMs - performanceStartMs[kind] >= 5000 - || nowMs < performanceStartMs[kind]) { - performanceStartMs[kind] = nowMs; - performanceCount[kind] = 0; - } - if (performanceCount[kind] < 8) { - performanceCount[kind]++; - return true; - } - performanceSuppressed++; - return false; - } - - int takePerformanceSuppressed() { - int result = performanceSuppressed; - performanceSuppressed = 0; - return result; - } - - boolean allow(long nowMs, String line) { - if (startMs < 0 || nowMs - startMs >= 5000 || nowMs < startMs) { - startMs = nowMs; - count = 0; - } - if (++count <= 32 || line.contains("WebHTV FEL fatal:")) return true; - suppressed++; - return false; - } - - int takeSuppressed() { - int result = suppressed; - suppressed = 0; - return result; - } - } - - static boolean shouldLogNativeImmediately(String line) { - if (line == null || line.isEmpty()) return false; - String lower = line.toLowerCase(Locale.US); - return lower.contains("webhtv android fel:") - || lower.contains("webhtv fel ") - || lower.contains("dolby vision") - || lower.contains("dovi") - || lower.contains("nlq") - || lower.contains("mediacodec started successfully") - || lower.contains("using hardware decoding") - || lower.contains("using software decoding") - || lower.contains("decoder format:") - || lower.contains("device name:") - || lower.contains("vo: [gpu-next]") - || lower.contains("error") - || lower.contains("failed") - || lower.contains("invalid"); - } - private static String scheme(String value) { int colon = value.indexOf(':'); if (colon <= 0) return ""; diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvDiscMenuPolicy.java b/app/src/main/java/androidx/media3/mpvplayer/MpvDiscMenuPolicy.java deleted file mode 100644 index ea22d857c2..0000000000 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvDiscMenuPolicy.java +++ /dev/null @@ -1,60 +0,0 @@ -package androidx.media3.mpvplayer; - -import android.view.KeyEvent; - -public final class MpvDiscMenuPolicy { - - private MpvDiscMenuPolicy() { - } - - public static boolean usesRawIso(String uri) { - return uri != null && uri.startsWith("webhtv-dvdiso://") && uri.endsWith("/raw"); - } - - public static boolean hasSinglePlaybackTimeline(String uri, boolean fileLoaded, - boolean navigationActive) { - // Wait for libbluray to distinguish HDMV from the ordinary-title fallback. - // A navigation session can change playlists even while no menu is visible. - return !usesRawIso(uri) || (fileLoaded && !navigationActive); - } - - public static boolean isTerminalEof(boolean eofReached, boolean navigationActive) { - // A menu background/clip can reach EOF while libbluray still owns the - // session and awaits input or switches playlists. Only end-file ends - // that session; ordinary media retain their keep-open EOF behavior. - return eofReached && !navigationActive; - } - - public static boolean canSaveHistory(boolean hasProgress, boolean navigationStarted) { - // A visit is worth keeping even when no single resumable timeline exists. - // Do not invent a position or overwrite an earlier ordinary-title resume. - return hasProgress || navigationStarted; - } - - public static String isoUri(String uri, boolean enabled) { - if (!enabled || uri == null || !uri.startsWith("webhtv-dvdiso://") - || !uri.endsWith("/longest")) return uri; - return uri.substring(0, uri.length() - "/longest".length()) + "/raw"; - } - - public static String keyAction(int keyCode) { - return switch (keyCode) { - case KeyEvent.KEYCODE_DPAD_UP -> "up"; - case KeyEvent.KEYCODE_DPAD_DOWN -> "down"; - case KeyEvent.KEYCODE_DPAD_LEFT -> "left"; - case KeyEvent.KEYCODE_DPAD_RIGHT -> "right"; - case KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> "select"; - case KeyEvent.KEYCODE_BACK -> "prev"; - case KeyEvent.KEYCODE_MENU -> "popup"; - default -> null; - }; - } - - public static String[] pointerCommand(int x, int y, boolean activate) { - // The native command converts the current window point using the same - // rectangle as the overlay. A separate "mouse" command only enqueues - // the position and can leave discnav reading the previous input point. - return new String[]{"discnav", activate ? "mouse-click" : "mouse-move", - Integer.toString(x), Integer.toString(y), "window"}; - } -} diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvDiscRebufferTracker.java b/app/src/main/java/androidx/media3/mpvplayer/MpvDiscRebufferTracker.java deleted file mode 100644 index 81d7a637ea..0000000000 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvDiscRebufferTracker.java +++ /dev/null @@ -1,61 +0,0 @@ -package androidx.media3.mpvplayer; - -/** Observes output starvation without pausing the interactive disc VM. */ -final class MpvDiscRebufferTracker { - static final long MIN_STALL_MS = 250; - private long position = Long.MIN_VALUE; - private long lastProgressMs; - private long activeSinceMs = -1; - private long totalMs; - private int count; - - void update(long nowMs, boolean eligible, long positionMs, long bufferedMs, - long demandWaitMs) { - if (!eligible) { - interrupt(nowMs); - return; - } - if (position != positionMs) { - finish(nowMs); - position = positionMs; - lastProgressMs = nowMs; - return; - } - boolean starved = demandWaitMs >= MIN_STALL_MS && bufferedMs <= 100 - && nowMs - lastProgressMs >= MIN_STALL_MS; - if (starved && activeSinceMs < 0) { - activeSinceMs = Math.max(lastProgressMs, nowMs - demandWaitMs); - if (count < Integer.MAX_VALUE) count++; - } else if (!starved) { - finish(nowMs); - } - } - - void interrupt(long nowMs) { - finish(nowMs); - position = Long.MIN_VALUE; - } - - private void finish(long nowMs) { - if (activeSinceMs >= 0) totalMs += Math.max(0, nowMs - activeSinceMs); - activeSinceMs = -1; - } - - int count() { - return count; - } - - long totalMs(long nowMs) { - return totalMs + (activeSinceMs < 0 ? 0 : Math.max(0, nowMs - activeSinceMs)); - } - - boolean active() { - return activeSinceMs >= 0; - } - - void reset() { - position = Long.MIN_VALUE; - activeSinceMs = -1; - lastProgressMs = totalMs = count = 0; - } -} diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvDtsHdFallbackPolicy.java b/app/src/main/java/androidx/media3/mpvplayer/MpvDtsHdFallbackPolicy.java deleted file mode 100644 index ca521022a0..0000000000 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvDtsHdFallbackPolicy.java +++ /dev/null @@ -1,81 +0,0 @@ -package androidx.media3.mpvplayer; - -import java.util.LinkedHashSet; -import java.util.Locale; -import java.util.Set; - -final class MpvDtsHdFallbackPolicy { - - private static final String DTS = "dts"; - private static final String DTS_HD = "dts-hd"; - private static final String DTS_HD_FORMAT = "spdif-dtshd"; - - private MpvDtsHdFallbackPolicy() { - } - - /** Check the log/configuration before asking native code for any audio state. */ - static boolean shouldInspectAudioState(String configuredCodecs, String logLine, - boolean attempted) { - return !attempted && isAudioTrackInitFailure(logLine) - && containsCodec(configuredCodecs, DTS_HD); - } - - static Decision evaluate(String configuredCodecs, String audioFormat, - String codecProfile, String logLine, - boolean attempted) { - if (attempted) return Decision.skip("already-attempted"); - if (!containsCodec(configuredCodecs, DTS_HD)) { - return Decision.skip("dts-hd-not-configured"); - } - if (!isDtsHd(audioFormat, codecProfile)) { - return Decision.skip("not-dts-hd-passthrough"); - } - if (!isAudioTrackInitFailure(logLine)) { - return Decision.skip("not-audiotrack-init-failure"); - } - return new Decision(true, downgradeToDtsCore(configuredCodecs), - "dts-hd-audiotrack-init-failed"); - } - - static String downgradeToDtsCore(String configuredCodecs) { - return DTS; - } - - private static boolean isDtsHd(String audioFormat, String codecProfile) { - if (DTS_HD_FORMAT.equals(normalize(audioFormat))) return true; - String profile = normalize(codecProfile).replace('_', '-'); - return profile.contains("dts-hd") || profile.contains("dts hd") - || profile.contains("dts:x") || profile.contains("dtsx"); - } - - private static boolean containsCodec(String configuredCodecs, String target) { - return splitCodecs(configuredCodecs).contains(target); - } - - private static Set splitCodecs(String value) { - Set codecs = new LinkedHashSet<>(); - if (value == null || value.isBlank()) return codecs; - for (String codec : value.split(",")) { - String normalized = normalize(codec); - if (!normalized.isEmpty()) codecs.add(normalized); - } - return codecs; - } - - private static boolean isAudioTrackInitFailure(String line) { - String normalized = normalize(line); - return normalized.contains("audiotrack init failed") - || normalized.contains("audiotrack.getstate failed"); - } - - private static String normalize(String value) { - return value == null ? "" : value.trim().toLowerCase(Locale.US); - } - - record Decision(boolean retry, String codecs, String reason) { - - static Decision skip(String reason) { - return new Decision(false, "", reason); - } - } -} diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvHlsAdBoundaryState.java b/app/src/main/java/androidx/media3/mpvplayer/MpvHlsAdBoundaryState.java deleted file mode 100644 index 5c24b28b82..0000000000 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvHlsAdBoundaryState.java +++ /dev/null @@ -1,40 +0,0 @@ -package androidx.media3.mpvplayer; - -/** Associates a native clipping EOF with one ad boundary, never the real file end. */ -final class MpvHlsAdBoundaryState { - private static final long LAST_FRAME_TOLERANCE_MS = 250; - - private HlsAdTimeline.Range range; - private long pendingTargetMs = -1; - - HlsAdTimeline.Range range() { - return range; - } - - void arm(HlsAdTimeline.Range range) { - this.range = range; - } - - long consumeEof(long positionMs, boolean seeking) { - if (range == null || seeking || pendingTargetMs >= 0 - || positionMs < Math.max(0, range.startMs() - LAST_FRAME_TOLERANCE_MS) - || positionMs > range.startMs() + LAST_FRAME_TOLERANCE_MS) return -1; - pendingTargetMs = range.endMs(); - range = null; - return pendingTargetMs; - } - - boolean pending() { - return pendingTargetMs >= 0; - } - - void playbackRestarted(long positionMs) { - if (pendingTargetMs >= 0 && Math.abs(positionMs - pendingTargetMs) - <= MpvSeekPositionState.TARGET_TOLERANCE_MS) pendingTargetMs = -1; - } - - void clear() { - range = null; - pendingTargetMs = -1; - } -} diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvHlsProxy.java b/app/src/main/java/androidx/media3/mpvplayer/MpvHlsProxy.java index b5029afbbf..cb6a2bd9d3 100644 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvHlsProxy.java +++ b/app/src/main/java/androidx/media3/mpvplayer/MpvHlsProxy.java @@ -4,13 +4,8 @@ import android.os.SystemClock; import android.text.TextUtils; import androidx.annotation.Nullable; -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.api.config.HlsRuleConfig; -import com.fongmi.android.tv.utils.HlsAdblockNotice; import com.fongmi.android.tv.utils.HlsAdblockPipeline; -import com.fongmi.android.tv.utils.HlsManifestCleaner; -import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.player.PlaybackAutoContext; import com.fongmi.android.tv.player.PlaybackRouteRegistry; @@ -146,37 +141,6 @@ public final class MpvHlsProxy extends NanoHTTPD { return proxy(url, headers, url); } - HlsAdTimeline adTimeline(long selectedBitsPerSecond) { - if (kernel != PlayerSetting.MPV || !Setting.isAdblock()) return HlsAdTimeline.NONE; - SessionStats stats = sessionStats.get(sessionId); - if (stats == null || !stats.vod) return HlsAdTimeline.NONE; - return resolveAdTimeline(stats.directAdTimeline, stats.adTimelines, - selectedBitsPerSecond, stats.variants.size()); - } - - static HlsAdTimeline resolveAdTimeline( - @Nullable HlsAdTimeline direct, - Map variants, - long selectedBitsPerSecond, int declaredVariantCount) { - if (direct != null) return direct; - HlsAdTimeline candidate = null; - int matched = 0; - for (Map.Entry entry : variants.entrySet()) { - HlsPlaylistRewriter.Variant variant = entry.getKey(); - if (variant.kind() != HlsPlaylistRewriter.VariantKind.STREAM) continue; - if (selectedBitsPerSecond > 0 && selectedBitsPerSecond != variant.bandwidth() - && selectedBitsPerSecond != variant.averageBandwidth()) continue; - if (candidate != null && !candidate.sameCuts(entry.getValue())) return HlsAdTimeline.NONE; - candidate = entry.getValue(); - matched++; - } - // Probing a rendition does not mean it is selected. Until native selection - // is known, require agreement from every declared regular video variant. - if (selectedBitsPerSecond <= 0 - && (declaredVariantCount <= 0 || matched != declaredVariantCount)) return HlsAdTimeline.NONE; - return candidate == null ? HlsAdTimeline.NONE : candidate; - } - public synchronized String proxy( String url, Map headers, String mediaKey) throws IOException { ensureStarted(); @@ -576,7 +540,7 @@ public final class MpvHlsProxy extends NanoHTTPD { SpiderDebug.log(TAG, "invalid playlist session=%d code=%d bytes=%d url=%s", id, response.code(), text.length(), shortUrl(session.url)); return error(Status.BAD_REQUEST, "invalid playlist"); } - text = applyAdblock(text, id, session.url, null, true); + text = applyAdblock(text, id, session.url); String rewritten = rewritePlaylist(response.request().url().toString(), text, id, null); byte[] data = rewritten.getBytes(StandardCharsets.UTF_8); SpiderDebug.log(TAG, "playlist session=%d code=%d bytes=%d rewritten=%d url=%s", id, response.code(), text.length(), data.length, shortUrl(session.url)); @@ -727,8 +691,7 @@ public final class MpvHlsProxy extends NanoHTTPD { boolean foregroundHandedOff = false; try { String range = requestHeader(httpSession, "range"); - boolean targetPlaylist = MpvHlsSegmentContentPolicy.isPlaylist( - target.role(), target.url, null); + boolean targetPlaylist = isPlaylistUrl(target.url, null); if (targetPlaylist) recordSelectedVariant(target); if (kernel == PlayerSetting.IJK && !targetPlaylist @@ -755,8 +718,7 @@ public final class MpvHlsProxy extends NanoHTTPD { } String finalUrl = response.request().url().toString(); MediaType type = body.contentType(); - if (targetPlaylist || MpvHlsSegmentContentPolicy.isPlaylist( - target.role(), finalUrl, type == null ? null : type.toString())) { + if (isPlaylistUrl(target.url, type) || isPlaylistUrl(finalUrl, type)) { recordSelectedVariant(target); try (response; body) { if (!response.isSuccessful()) { @@ -770,10 +732,7 @@ public final class MpvHlsProxy extends NanoHTTPD { SpiderDebug.log(TAG, "invalid nested playlist id=%s code=%d bytes=%d url=%s", id, response.code(), text.length(), shortUrl(target.url)); return error(Status.BAD_REQUEST, "invalid playlist"); } - text = applyAdblock(text, target.sessionId, target.url, target.variant(), - target.role() == HlsPlaylistRewriter.UriRole.VARIANT_PLAYLIST - && target.variant() != null - && target.variant().kind() == HlsPlaylistRewriter.VariantKind.STREAM); + text = applyAdblock(text, target.sessionId, target.url); String rewritten = rewritePlaylist(finalUrl, text, target.sessionId, target.variant()); byte[] data = rewritten.getBytes(StandardCharsets.UTF_8); SpiderDebug.log(TAG, "nested playlist id=%s code=%d bytes=%d url=%s", id, response.code(), data.length, shortUrl(target.url)); @@ -835,43 +794,21 @@ public final class MpvHlsProxy extends NanoHTTPD { return client.newCall(builder.build()).execute(); } - private String applyAdblock(String text, int session, String url, - @Nullable HlsPlaylistRewriter.Variant variant, boolean videoPlaylist) { + private String applyAdblock(String text, int session, String url) { if (!Setting.isAdblock() || !isVodPlaylist(text)) return text; if (HlsAdblockPipeline.isCoreM3u8Proxy(url)) return text; - if (kernel == PlayerSetting.MPV && !videoPlaylist) return text; try { - List rules = HlsRuleConfig.getRules(); - boolean legacyFallback = HlsRuleConfig.isLegacyFallbackEnabled(); - HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply(url, text, rules, legacyFallback); - if (kernel == PlayerSetting.MPV) { - HlsAdTimeline timeline = HlsAdTimeline.from(text, outcome.manifest()); - SessionStats stats = sessionStats.get(session); - if (stats != null) { - if (variant == null) stats.directAdTimeline = timeline; - else stats.adTimelines.merge(variant, timeline, - (previous, next) -> previous.sameCuts(next) ? previous : HlsAdTimeline.NONE); - } - if (!TextUtils.equals(outcome.manifest(), text)) { - SpiderDebug.log(TAG, - "adblock planned session=%d ranges=%d cuts=%s mode=source-timeline-seek reason=%s url=%s", - session, timeline.ranges().size(), timeline.ranges(), timeline.reason(), shortUrl(url)); - } - // Keep timestamps, implicit AES IVs, byte ranges and rendition - // synchronization intact; MpvPlayer skips the detected time ranges. - // Notify as soon as a valid removal plan is available so MPV has the - // same user-visible adblock confirmation as Exo. Runtime skip - // accounting remains separate and is recorded only after playback. - if (!TextUtils.equals(outcome.manifest(), text)) { - recordAndNotifyAdblock(url, outcome); - } - return text; - } + HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply(url, text, HlsRuleConfig.getRules(), true); if (!TextUtils.equals(outcome.manifest(), text)) { + if (kernel == PlayerSetting.MPV) { + SpiderDebug.log(TAG, + "adblock bypassed session=%d bytes=%d candidateBytes=%d reason=mpv-ts-timestamp-integrity url=%s", + session, text.length(), outcome.manifest().length(), shortUrl(url)); + return text; + } SpiderDebug.log(TAG, "adblock filtered session=%d bytes=%d->%d structured=%s legacy=%s url=%s", session, text.length(), outcome.manifest().length(), outcome.structured(), outcome.legacy(), shortUrl(url)); } - recordAndNotifyAdblock(url, outcome); return outcome.manifest(); } catch (Throwable e) { SpiderDebug.log(TAG, "adblock ignored session=%d errorType=%s", session, e.getClass().getSimpleName()); @@ -879,22 +816,6 @@ public final class MpvHlsProxy extends NanoHTTPD { } } - private void recordAndNotifyAdblock(String url, HlsAdblockPipeline.Outcome outcome) { - if (!outcome.structured() && !outcome.legacy()) return; - okhttp3.HttpUrl parsed = okhttp3.HttpUrl.parse(url); - if (parsed == null) return; - long fallbackCount = outcome.legacy() ? Math.max(1, outcome.removedSegments()) : 0; - String pipeline = kernel == PlayerSetting.IJK ? "IJK" : "MPV"; - AdBlockStatsStore.recordBlocks(parsed.host(), pipeline, outcome.ruleCounts(), fallbackCount, - parsed.host(), outcome.removedDurationSec(), outcome.removedSegmentDetails()); - if (!HlsAdblockNotice.shouldNotify(url, System.currentTimeMillis())) return; - int removed = outcome.removedSegments() > 0 ? outcome.removedSegments() : (int) fallbackCount; - String message = outcome.structured() && outcome.removedDurationSec() > 0 - ? String.format(Locale.US, "已跳过 %d 个广告片段(%.1f 秒)", removed, outcome.removedDurationSec()) - : "已跳过 " + removed + " 个广告片段"; - App.post(() -> Notify.show(message)); - } - private String rewritePlaylist(String playlistUrl, String text, int session, @Nullable HlsPlaylistRewriter.Variant inheritedVariant) { HlsPlaylistRewriter.Result result = HlsPlaylistRewriter.rewrite(text, inheritedVariant, (uri, cacheable, context) -> { String targetUrl = resolve(playlistUrl, uri); @@ -1137,8 +1058,8 @@ public final class MpvHlsProxy extends NanoHTTPD { try (okhttp3.Response response = fetch(session, url, null, true)) { if (!preloadGate.allows(preloadGeneration)) return false; ResponseBody body = response.body(); - // The caller supplies parsed media segments, including *.m3u8?ts=... URLs. - if (!response.isSuccessful() || body == null) return false; + if (!response.isSuccessful() || body == null + || isPlaylistUrl(url, body.contentType())) return false; long upstreamLength = body.contentLength(); if (upstreamLength < MIN_CACHE_FILE_BYTES) return false; boolean stripPngPrefix = MpvHlsSegmentContentPolicy.shouldProbePngPrefix( @@ -1499,6 +1420,15 @@ public final class MpvHlsProxy extends NanoHTTPD { } } + private static boolean isPlaylistUrl(String url, @Nullable MediaType type) { + String mime = type == null ? "" : type.toString().toLowerCase(Locale.US); + if (mime.contains("mpegurl") || mime.contains("m3u8")) return true; + String lower = url == null ? "" : url.toLowerCase(Locale.US); + int query = lower.indexOf('?'); + if (query >= 0) lower = lower.substring(0, query); + return lower.endsWith(".m3u8") || lower.endsWith(".m3u"); + } + private static boolean looksLikePlaylist(String text) { if (TextUtils.isEmpty(text)) return false; String value = text.trim(); @@ -2106,9 +2036,6 @@ public final class MpvHlsProxy extends NanoHTTPD { private final HlsProxyLiveLagTracker liveLag = new HlsProxyLiveLagTracker(); private volatile HlsPlaylistRewriter.Variant selectedVariant; - private volatile HlsAdTimeline directAdTimeline; - private final Map adTimelines = - new ConcurrentHashMap<>(); private volatile int variantCount; private volatile List variants = List.of(); private volatile List segments = List.of(); diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvHlsSegmentContentPolicy.java b/app/src/main/java/androidx/media3/mpvplayer/MpvHlsSegmentContentPolicy.java index 91d07c8956..50e17f2983 100644 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvHlsSegmentContentPolicy.java +++ b/app/src/main/java/androidx/media3/mpvplayer/MpvHlsSegmentContentPolicy.java @@ -11,19 +11,6 @@ final class MpvHlsSegmentContentPolicy { private MpvHlsSegmentContentPolicy() { } - static boolean isPlaylist(HlsPlaylistRewriter.UriRole role, String url, String contentType) { - // Dynamic endpoints may serve both playlists and segments at *.m3u8. - // The role declared by the parent playlist takes precedence over hints. - if (role == HlsPlaylistRewriter.UriRole.MEDIA_SEGMENT) return false; - if (role == HlsPlaylistRewriter.UriRole.VARIANT_PLAYLIST) return true; - String mime = contentType == null ? "" : contentType.toLowerCase(Locale.US); - if (mime.contains("mpegurl") || mime.contains("m3u8")) return true; - String lower = url == null ? "" : url.toLowerCase(Locale.US); - int query = lower.indexOf('?'); - if (query >= 0) lower = lower.substring(0, query); - return lower.endsWith(".m3u8") || lower.endsWith(".m3u"); - } - static boolean shouldProbePngPrefix(String contentType, boolean mediaSegment) { String mime = contentType == null ? "" : contentType.trim().toLowerCase(Locale.US); if (mime.startsWith("image/png")) return true; diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvOptionPriorityPolicy.java b/app/src/main/java/androidx/media3/mpvplayer/MpvOptionPriorityPolicy.java index 83527e42ac..7f799b22f5 100644 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvOptionPriorityPolicy.java +++ b/app/src/main/java/androidx/media3/mpvplayer/MpvOptionPriorityPolicy.java @@ -8,14 +8,6 @@ import java.util.Set; final class MpvOptionPriorityPolicy { - static final String HARDWARE_CODECS = "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1,avs3"; - - // Native applies the decoder/renderer contract to the selected DV7 chain. - // Protect only the request and original bitstream; a FEL preference must - // not override ordinary videos' VO, decoder shortcuts, or mpv.conf priority. - private static final Set FEL_REQUIRED_OPTIONS = Set.of( - "android-dovi-fel", "android-dovi-fel-vulkan", "demuxer-dovi-profile7"); - private static final Set PERFORMANCE_MANAGED_OPTIONS = Set.of( "vo", "gpu-context", @@ -25,7 +17,6 @@ final class MpvOptionPriorityPolicy { "hwdec", "hwdec-codecs", "ao", - "ad", "audio-spdif", "cache", "cache-secs", @@ -39,9 +30,6 @@ final class MpvOptionPriorityPolicy { "demuxer-readahead-secs", "demuxer-hysteresis-secs", "demuxer-dovi-profile7", - "demuxer-dovi-profile8", - "android-dovi-fel", - "android-dovi-fel-vulkan", "framedrop", "video-sync", "interpolation", @@ -54,18 +42,15 @@ final class MpvOptionPriorityPolicy { } static Map resolvePerformanceOverlay(MpvPlayerConfig config) { - boolean fel = "yes".equals(config.extraOptions().get("android-dovi-fel")); - if (!MpvStartupBufferPolicy.shouldApplyPerformanceOverlay(config.performanceOptionsPriority()) - && !fel) return Collections.emptyMap(); + if (!MpvStartupBufferPolicy.shouldApplyPerformanceOverlay(config.performanceOptionsPriority())) return Collections.emptyMap(); Map candidates = new LinkedHashMap<>(); candidates.put("vo", config.vo()); candidates.put("gpu-context", config.gpuContext()); if (config.gpuApi() != null && !config.gpuApi().isEmpty()) candidates.put("gpu-api", config.gpuApi()); - if (config.openglEs() || fel) candidates.put("opengl-es", config.openglEs() ? "yes" : "no"); + if (config.openglEs()) candidates.put("opengl-es", "yes"); candidates.put("hwdec", config.hwdec()); - candidates.put("hwdec-codecs", HARDWARE_CODECS); + candidates.put("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1"); candidates.put("ao", config.ao()); - candidates.put("ad", MpvAudioDecoderPolicy.decoderList(config.audioSpdif())); candidates.put("audio-spdif", config.audioSpdif()); candidates.put("cache", config.cache() ? "yes" : "no"); candidates.put("cache-secs", String.valueOf(config.cacheSeconds())); @@ -79,19 +64,14 @@ final class MpvOptionPriorityPolicy { candidates.put("demuxer-readahead-secs", String.valueOf(config.demuxerReadaheadSeconds())); candidates.put("demuxer-hysteresis-secs", String.valueOf(config.demuxerHysteresisSeconds())); candidates.putAll(config.extraOptions()); - return selectPerformanceOverlay(config.performanceOptionsPriority(), candidates); + return selectPerformanceOverlay(true, candidates); } static Map selectPerformanceOverlay(boolean performanceOptionsPriority, Map candidates) { - if (candidates == null || candidates.isEmpty()) return Collections.emptyMap(); - boolean fel = "yes".equals(candidates.get("android-dovi-fel")); - if (!performanceOptionsPriority && !fel) return Collections.emptyMap(); + if (!performanceOptionsPriority || candidates == null || candidates.isEmpty()) return Collections.emptyMap(); Map overlay = new LinkedHashMap<>(); for (Map.Entry entry : candidates.entrySet()) { - if (entry.getValue() != null && (performanceOptionsPriority && isPerformanceManaged(entry.getKey()) - || fel && FEL_REQUIRED_OPTIONS.contains(entry.getKey()))) { - overlay.put(entry.getKey(), entry.getValue()); - } + if (isPerformanceManaged(entry.getKey()) && entry.getValue() != null) overlay.put(entry.getKey(), entry.getValue()); } return Collections.unmodifiableMap(overlay); } diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java b/app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java index 467e10ec53..b33b844ead 100644 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java +++ b/app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java @@ -40,8 +40,6 @@ import androidx.media3.common.Tracks; import androidx.media3.common.VideoSize; import androidx.media3.common.util.UnstableApi; -import com.fongmi.android.tv.BuildConfig; -import com.fongmi.android.tv.player.AudioPlaybackDiagnostics; import com.fongmi.android.tv.player.PlaybackAutoContext; import com.fongmi.android.tv.player.PlaybackRoute; import com.fongmi.android.tv.player.PlaybackResourceClassifier; @@ -54,15 +52,11 @@ import com.fongmi.android.tv.player.engine.PlayerCacheState; import com.fongmi.android.tv.player.iso.IsoSessionManager; import com.fongmi.android.tv.player.lut.MpvLutShader; import com.fongmi.android.tv.player.mpv.MpvDirectAudioPolicy; -import com.fongmi.android.tv.player.mpv.MpvConfigStore; import com.fongmi.android.tv.player.mpv.MpvNetworkRecoveryPolicy; import com.fongmi.android.tv.player.mpv.MpvSubtitleStylePolicy; -import com.fongmi.android.tv.player.mpv.PlaybackRecoveryMonitor; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.MpvPerformanceSetting; import com.fongmi.android.tv.setting.PreloadSetting; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.utils.AudioUtil; import com.fongmi.android.tv.utils.FileUtil; import com.github.catvod.crawler.SpiderDebug; import com.google.common.collect.ImmutableList; @@ -71,9 +65,7 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import org.json.JSONArray; -import org.json.JSONException; import org.json.JSONObject; -import org.json.JSONTokener; import java.io.File; import java.io.FileOutputStream; @@ -180,11 +172,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private final Map cachedVideoIntProperties; private final Runnable coalescedPropertyDrainRunnable; private final Runnable stateRefreshRunnable; - private final Runnable discRebufferRunnable = this::sampleDiscRebuffer; - private final MpvDiscRebufferTracker discRebufferTracker = new MpvDiscRebufferTracker(); - private long discInputQuietUntilMs; - private long discDebugRequest; - private long discDebugLastHoverMs; private final Runnable mainThreadHeartbeatRunnable; private final Runnable mainThreadWatchdogRunnable; private final AtomicBoolean mainThreadHeartbeatPending; @@ -197,29 +184,11 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private final Runnable initialTrackSelectionGateTimeoutRunnable; private final Runnable isoTrackMetadataReadyListener; private final MpvHlsProxy hlsProxy; - private final HlsAdTimeline.SkipState hlsAdSkipState = new HlsAdTimeline.SkipState(); - private final MpvHlsAdBoundaryState hlsAdBoundary = new MpvHlsAdBoundaryState(); - private String hlsAdOriginalEnd; - private String hlsAdOriginalKeepOpen; - private String hlsAdOriginalKeepOpenPause; - private String hlsAdAppliedEnd; - private boolean hlsAdBoundaryUnavailable; private final MpvAutoCacheBaselineState autoCacheBaselineState; private final MpvAutoHlsBitrateState autoHlsBitrateState; private final MpvCacheObserverState cacheObserverState; private final MpvPropertyCache propertyCache; private final Set observedPropertyNames; - private final MpvPropertySnapshot propertySnapshot = new MpvPropertySnapshot(); - private final MpvDiagnosticCollector diagnostics = new MpvDiagnosticCollector(propertySnapshot); - private String diagnosticBaseMsgLevel; - private String diagnosticAppliedMsgLevel; - private volatile boolean diagnosticLogLevelApplied; - private volatile boolean diagnosticDepthApplied; - private volatile int diagnosticCategoriesApplied = -1; - private final AtomicBoolean diagnosticLogUpdatePending = new AtomicBoolean(); - private final java.util.concurrent.atomic.AtomicLong propertyGeneration = - new java.util.concurrent.atomic.AtomicLong(); - private record PropertyUpdate(long generation, Object value) {} private final MpvCacheTimeState cacheTimeState; private final MpvSeekPositionState seekPositionState; private final MpvMediaReplacementCoordinator mediaReplacementCoordinator; @@ -242,12 +211,9 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private Object videoOutput; private MpvLutShader lutShader; private String currentPlayableUri; - private volatile String playbackTraceId = PlaybackTrace.NONE; + private String playbackTraceId = PlaybackTrace.NONE; private volatile PlaybackResourceClassifier.Classification resourceClassification; private String currentIsoUri; - private boolean discMenuActive; - private boolean discMenuAvailable; - private boolean discNavigationActive; private boolean isoTrackListDumped; private long isoMetadataListenerSessionId = -1; private String appliedLutShaderPath; @@ -256,10 +222,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private Tracks currentTracks; private VideoTrackDiagnostics selectedVideoTrackDiagnostics; private VideoTrackDiagnostics availableVideoTrackDiagnostics; - private AudioPlaybackDiagnostics.Track selectedAudioTrackDiagnostics; - private AudioPlaybackDiagnostics.Track automaticAudioOriginalTrack; - private AudioPlaybackDiagnostics.Track automaticAudioActiveTrack; - private AudioPlaybackDiagnostics.Track dtsHdFallbackSourceTrack; private List currentChapters; private VideoSize videoSize; private String lastVideoSizeCandidateLog; @@ -294,7 +256,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private boolean repeatOne; private boolean ownsSurface; private boolean initialized; - private volatile boolean released; + private boolean released; private boolean surfaceAttached; private boolean osdSurfaceAttached; private boolean osdSurfaceRequested; @@ -305,7 +267,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private String initialSubtitleTrackId; private boolean pendingOsdSurfaceAttach; private volatile long pendingOsdSurfaceRequestId; - private long pendingSurfaceLoadGeneration = C.INDEX_UNSET; + private long pendingOsdLoadGeneration = C.INDEX_UNSET; private Surface pendingOsdSurface; private boolean fileLoaded; private boolean loadStarted; @@ -336,13 +298,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private boolean preferAacApplied; private boolean directAudioApplied; private boolean audioTrackManuallySelected; - private boolean dtsHdCoreFallbackAttempted; - private String automaticAudioDowngradeReason; - private String activeAudioSpdif; private BiConsumer videoSizeProbeListener; - private final List discMenuStateListeners = new ArrayList<>(); - private final List customButtonStateListeners = new ArrayList<>(); - private String customButtonStates = ""; private boolean trackRefreshScheduled; private boolean chapterRefreshScheduled; private boolean trackRefreshPrioritized; @@ -368,22 +324,10 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private int lastEndFileError; private String lastEndFileErrorText; private String cachedCurrentVo; - private boolean androidFelActive; - private boolean videoFrameSubmitted; - private boolean firstVideoFrameReported; - private boolean newlyRenderedFirstFrame; private String cachedCurrentGpuContext; private String cachedGpuApi; private String cachedCurrentAo; private String cachedAudioDevice; - private String cachedAudioFormat; - private String cachedAudioOutFormat; - private String cachedAudioDecoder; - private String cachedAudioCodecProfile; - private int cachedAudioChannels; - private int cachedAudioSampleRate; - private int cachedAudioOutChannels; - private int cachedAudioOutSampleRate; private String cachedHwdecCurrent; private double cachedAvSyncSeconds; private double cachedDisplayFps; @@ -406,11 +350,9 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private volatile boolean mainThreadWatchdogRunning; private volatile long mainThreadHeartbeatPostedAtMs; private volatile long lastMainThreadStallLogAtMs; - private long lastNativeLogQueueWarningAtMs; private volatile long activeMpvNativeCallStartedAtMs; private volatile String activeMpvNativeCallKind = ""; private volatile String activeMpvNativeCallTarget = ""; - private volatile PlaybackRecoveryMonitor playbackRecoveryMonitor; public MpvPlayer(Context context, MpvPlayerConfig config) { super(Looper.getMainLooper()); @@ -444,8 +386,8 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs stateRefreshRunnable = this::refreshPlaybackState; mainThreadHeartbeatPending = new AtomicBoolean(); mainThreadHeartbeatRunnable = () -> { - mainThreadHeartbeatPostedAtMs = 0; mainThreadHeartbeatPending.set(false); + mainThreadHeartbeatPostedAtMs = 0; }; mainThreadWatchdogRunnable = this::runMainThreadWatchdog; endFileValidationRunnable = this::validateEarlyEndFile; @@ -458,11 +400,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs () -> releaseInitialTrackSelectionGate("timeout"); isoTrackMetadataReadyListener = this::onIsoTrackMetadataReady; hlsProxy = new MpvHlsProxy(); - diagnostics.felBindProbe().connect(mainHandler::post, action -> { - if (!initialized || released || nativeContextOwner != this) return false; - if (action.equals("start") && !com.github.catvod.crawler.DebugLogStore.isEnabled()) return false; - return mpvSetPropertyString("android-fel-bind-probe", action) >= 0; - }); autoCacheBaselineState = new MpvAutoCacheBaselineState(); autoHlsBitrateState = new MpvAutoHlsBitrateState(); recentLogs = new ArrayList<>(); @@ -477,7 +414,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs currentTracks = Tracks.EMPTY; selectedVideoTrackDiagnostics = VideoTrackDiagnostics.empty(); availableVideoTrackDiagnostics = VideoTrackDiagnostics.empty(); - resetAudioPlaybackDiagnostics(); currentChapters = List.of(); videoSize = VideoSize.UNKNOWN; playbackState = Player.STATE_IDLE; @@ -492,7 +428,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs subtitlePosition = 0f; playWhenReady = true; volume = 1f; - activeAudioSpdif = config.audioSpdif(); } @Override @@ -503,8 +438,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs Log.w(TAG, "Coerce empty playlist state=" + state + " loading=" + loading + " fileLoaded=" + fileLoaded + " playbackRestarted=" + playbackRestarted); state = Player.STATE_IDLE; } - boolean firstFrameEvent = newlyRenderedFirstFrame; - newlyRenderedFirstFrame = false; State.Builder builder = new State.Builder() .setAvailableCommands(COMMANDS) .setPlayWhenReady(playWhenReady, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) @@ -516,7 +449,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs .setTextOffsetMs(textOffsetMs) .setAudioOffsetMs(audioOffsetMs) .setVideoSize(videoSize) - .setNewlyRenderedFirstFrame(firstFrameEvent) .setVolume(volume) .setCurrentMediaEditions(currentChapters) .setPlaylist(currentItem == null ? ImmutableList.of() : ImmutableList.of(mediaItemData(currentItem))) @@ -548,9 +480,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs @Override protected ListenableFuture handleSetMediaItems(List mediaItems, int startIndex, long startPositionMs) { - restoreHlsAdBoundary(); - hlsAdBoundary.clear(); - hlsAdBoundaryUnavailable = false; restorePreloadCacheOverlay(); clearCoalescedPropertyEvents(); boolean reusingContext = canReuseContextForMediaReplacement(); @@ -571,8 +500,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs currentTracks = Tracks.EMPTY; selectedVideoTrackDiagnostics = VideoTrackDiagnostics.empty(); availableVideoTrackDiagnostics = VideoTrackDiagnostics.empty(); - resetAudioPlaybackDiagnostics(); - pendingSurfaceLoadGeneration = C.INDEX_UNSET; + pendingOsdLoadGeneration = C.INDEX_UNSET; osdSurfaceUsedForCurrentMedia = initialOsdSurfaceRequested; setOsdSurfaceRequested(initialOsdSurfaceRequested); mainHandler.removeCallbacks(initialTrackSelectionGateTimeoutRunnable); @@ -604,9 +532,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs mediaReplacementCoordinator.reset(); } else { if (initialized && !reusingContext) releaseNativeContext("new media"); - // PlaybackService may keep this player after preparing a terminal - // Surface detach. Retire that context before allowing a new binding. - surfaceTeardownPolicy.beginNewMedia(); long generation = mediaReplacementCoordinator.begin(reusingContext, hadActiveMedia, stopping); if (reusingContext) { SpiderDebug.log("mpv", "context reused reason=new-media generation=%d stopPending=%s active=%s player=%s", generation, stopping, hadActiveMedia, identity(this)); @@ -661,16 +586,13 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } private boolean canReuseContextForMediaReplacement() { - return initialized && !released && nativeContextOwner == this - && surfaceTeardownPolicy.shouldBindSurface() - && "mediacodec_embed".equals(config.vo()); + return initialized && !released && nativeContextOwner == this && "mediacodec_embed".equals(config.vo()); } @Override protected ListenableFuture handleSetPlayWhenReady(boolean playWhenReady) { this.playWhenReady = playWhenReady; hlsProxy.setPlaybackPaused(!playWhenReady); - updateHlsAdBoundary(cachedPositionMs); if (initialized && playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED) { safeSetPropertyBoolean("pause", shouldPauseNativePlayback()); } @@ -682,7 +604,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs @Override protected ListenableFuture handleStop() { - diagnostics.end(MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_STOP, 0); stopInternal(true); stopMainThreadWatchdog(); return Futures.immediateVoidFuture(); @@ -690,7 +611,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs @Override protected ListenableFuture handleRelease() { - diagnostics.end(MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_QUIT, 0); prepareTerminalRelease(); released = true; startMainThreadWatchdog(); @@ -708,8 +628,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs mainHandler.removeCallbacks(seekBufferingTimeoutRunnable); releaseNativeContext("release"); } finally { - discMenuStateListeners.clear(); - customButtonStateListeners.clear(); stopMainThreadWatchdog(); } return Futures.immediateVoidFuture(); @@ -718,34 +636,17 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs @Override protected ListenableFuture handleSetRepeatMode(int repeatMode) { repeatOne = repeatMode == Player.REPEAT_MODE_ONE; - if (initialized) safeSetPropertyString("loop-file", - hlsAdOriginalEnd != null ? "no" : repeatOne ? "inf" : "no"); + if (initialized) safeSetPropertyString("loop-file", repeatOne ? "inf" : "no"); invalidateState(); return Futures.immediateVoidFuture(); } @Override protected ListenableFuture handleSeek(int mediaItemIndex, long positionMs, int seekCommand) { - return seekToPosition(positionMs, false); - } - - private ListenableFuture seekToPosition(long positionMs, boolean automaticAdSkip) { if (positionMs == C.TIME_UNSET) positionMs = 0; - if (discMenuActive) return Futures.immediateVoidFuture(); - if (!automaticAdSkip) { - hlsAdSkipState.clear(); - hlsAdBoundary.clear(); - } - positionMs = resolveHlsAdSeekTarget(Math.max(0, positionMs)); cachedPositionMs = Math.max(0, positionMs); resetCacheTimelineForSeek(cachedPositionMs); - if (!fileLoaded) { - // Keep only the latest target until FILE_LOADED restores it once, - // or recognizes that loadfile's start option already covered it. - initialSeekPositionMs = cachedPositionMs; - invalidateState(); - return Futures.immediateVoidFuture(); - } + if (!fileLoaded) initialSeekPositionMs = cachedPositionMs; if (initialized && playbackState != Player.STATE_IDLE) { long nowMs = SystemClock.elapsedRealtime(); if (fileLoaded) { @@ -756,7 +657,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs if (currentLikelyHls && playbackRestarted) { hlsProxy.cancelAutomaticPreloadForDiscontinuity(); } - updateHlsAdBoundary(cachedPositionMs); seekMpv(cachedPositionMs); if (currentLikelyHls && playbackRestarted) requestHlsPreload(cachedPositionMs); int nextState = MpvPlaybackState.resolveAfterSeekRequest(playbackState, fileLoaded, stopping); @@ -775,128 +675,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs return Futures.immediateVoidFuture(); } - private long resolveHlsAdSeekTarget(long positionMs) { - return currentLikelyHls - ? hlsProxy.adTimeline(cachedSelectedHlsBitrate).skipTargetMs(positionMs) - : positionMs; - } - - private void maybeSkipHlsAd() { - if (!currentLikelyHls || !fileLoaded || !playbackRestarted || !playWhenReady - || released || stopping || eofReached || !initialized) return; - HlsAdTimeline timeline = hlsProxy.adTimeline(cachedSelectedHlsBitrate); - long targetMs = hlsAdSkipState.nextTargetMs(timeline, cachedPositionMs); - if (targetMs < 0) return; - PlaybackTrace.log("mpv-adblock", playbackTraceId, - "skip from=%d to=%d ranges=%d timeline=source detector=exo-hls", - cachedPositionMs, targetMs, timeline.ranges().size()); - seekToPosition(targetMs, true); - } - - private void updateHlsAdBoundary(long positionMs) { - if (!initialized || !fileLoaded || released || stopping) return; - HlsAdTimeline.Range next = currentLikelyHls - ? hlsProxy.adTimeline(cachedSelectedHlsBitrate).nextRange(positionMs) : null; - if (next == null) { - if (hlsAdBoundary.pending() && hlsAdOriginalEnd != null) { - // Lift the clip before seeking into the final programme part, - // but keep native looping/EOF handling held until that seek lands. - if (!hlsAdOriginalEnd.equals(hlsAdAppliedEnd)) { - safeSetPropertyString("file-local-options/end", hlsAdOriginalEnd); - hlsAdAppliedEnd = stringProperty("options/end", hlsAdOriginalEnd); - } - hlsAdBoundary.arm(null); - return; - } - restoreHlsAdBoundary(); - return; - } - if (hlsAdBoundaryUnavailable || next.equals(hlsAdBoundary.range())) return; - try { - if (hlsAdOriginalEnd == null) { - String end = stringProperty("options/end", "none"); - String length = stringProperty("options/length", "none"); - String loopA = stringProperty("options/ab-loop-a", "no"); - String loopB = stringProperty("options/ab-loop-b", "no"); - // Explicit user clipping/AB looping owns those boundaries. - if (!"none".equals(end) || !"none".equals(length) - || !"no".equals(loopA) && !"no".equals(loopB)) { - hlsAdBoundaryUnavailable = true; - PlaybackTrace.log("mpv-adblock", playbackTraceId, - "clip unavailable reason=user-play-range"); - return; - } - hlsAdOriginalEnd = end; - hlsAdOriginalKeepOpen = stringProperty("options/keep-open", "no"); - hlsAdOriginalKeepOpenPause = stringProperty("options/keep-open-pause", "yes"); - if (mpvSetPropertyString("file-local-options/keep-open", "always") < 0 - || mpvSetPropertyString("file-local-options/keep-open-pause", "no") < 0 - || mpvSetPropertyString("file-local-options/loop-file", "no") < 0) { - throw new IllegalStateException("clip lifecycle options rejected"); - } - } - String end = String.format(Locale.US, "%.3f", next.startMs() / SECONDS_TO_MS); - if (mpvSetPropertyString("file-local-options/end", end) < 0) { - throw new IllegalStateException("clip end rejected"); - } - hlsAdAppliedEnd = stringProperty("options/end", end); - hlsAdBoundary.arm(next); - PlaybackTrace.log("mpv-adblock", playbackTraceId, - "clip armed start=%d end=%d mode=native-output-boundary", - next.startMs(), next.endMs()); - } catch (Throwable e) { - restoreHlsAdBoundary(); - hlsAdBoundaryUnavailable = true; - PlaybackTrace.log("mpv-adblock", playbackTraceId, - "clip unavailable reason=%s", e.getClass().getSimpleName()); - } - } - - private boolean handleHlsAdBoundaryEof() { - if (hlsAdBoundary.pending()) return true; - if (hlsAdBoundary.range() == null || !currentLikelyHls - || !Setting.isAdblock()) return false; - // EOF is at the last output frame, normally just before the clip end. - // playback-time is not in the coalesced time-pos cache. - long position = doubleSecondsToMs(doubleProperty("playback-time", - cachedPositionMs / SECONDS_TO_MS), cachedPositionMs); - long target = hlsAdBoundary.consumeEof(position, seekPositionState.hasTarget()); - if (target < 0) return false; - PlaybackTrace.log("mpv-adblock", playbackTraceId, - "clip reached position=%d target=%d mode=native-output-boundary", position, target); - eofReached = false; - if (cachedDurationMs > 0 && target >= cachedDurationMs - 1) { - restoreHlsAdBoundary(); - hlsAdBoundary.clear(); - if (repeatOne) seekToPosition(0, false); - else markPlaybackEnded("adblock:terminal-boundary"); - return true; - } - seekToPosition(target, true); - return true; - } - - private void restoreHlsAdBoundary() { - if (hlsAdOriginalEnd != null && initialized) { - if (hlsAdAppliedEnd != null - && hlsAdAppliedEnd.equals(stringProperty("options/end", "none"))) { - safeSetPropertyString("file-local-options/end", hlsAdOriginalEnd); - } - if ("always".equals(stringProperty("options/keep-open", "no"))) { - safeSetPropertyString("file-local-options/keep-open", hlsAdOriginalKeepOpen); - } - if ("no".equals(stringProperty("options/keep-open-pause", "yes"))) { - safeSetPropertyString("file-local-options/keep-open-pause", hlsAdOriginalKeepOpenPause); - } - safeSetPropertyString("file-local-options/loop-file", repeatOne ? "inf" : "no"); - } - hlsAdOriginalEnd = null; - hlsAdOriginalKeepOpen = null; - hlsAdOriginalKeepOpenPause = null; - hlsAdAppliedEnd = null; - hlsAdBoundary.arm(null); - } - @Override protected ListenableFuture handleSetPlaybackParameters(PlaybackParameters playbackParameters) { this.playbackParameters = playbackParameters; @@ -951,7 +729,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } public String getAudioSpdifCodecs() { - return activeAudioSpdif; + return config.audioSpdif(); } public void setPlaybackTraceId(String playbackTraceId) { @@ -962,37 +740,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs return playbackTraceId; } - /** Sends an application-owned script message without exposing MPVLib to UI code. */ - public boolean sendScriptMessage(String message, String... args) { - if (!initialized || TextUtils.isEmpty(message)) return false; - String[] command = new String[2 + (args == null ? 0 : args.length)]; - command[0] = "script-message"; - command[1] = message; - if (args != null) System.arraycopy(args, 0, command, 2, args.length); - return enqueueMpvCommand(command); - } - - /** Lua-owned state cached on the main thread; no synchronous native reads. */ - public boolean isCustomButtonActive(String id) { - return !TextUtils.isEmpty(id) && ("," + customButtonStates + ",").contains("," + id + ","); - } - - public void addCustomButtonStateListener(Runnable listener) { - if (!released && !customButtonStateListeners.contains(listener)) { - customButtonStateListeners.add(listener); - } - } - - public void removeCustomButtonStateListener(Runnable listener) { - customButtonStateListeners.remove(listener); - } - - private void setCustomButtonStates(String states) { - if (customButtonStates.equals(states)) return; - customButtonStates = states; - for (Runnable listener : List.copyOf(customButtonStateListeners)) listener.run(); - } - public PlaybackRoute.Resolution getPlaybackRouteResolution() { return PlaybackRoute.resolve(currentPlayableUri); } @@ -1386,31 +1133,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs return observedCurrentVo; } - public boolean isAndroidFelActive() { - return androidFelActive; - } - - public boolean isNativeOutputSelectionReady() { - return fileLoaded; - } - - public boolean isObservedVulkanRenderer() { - return "androidvk".equals(cachedCurrentGpuContext); - } - - /** The native VO accepted a frame in this seek/reconfigure epoch. This is - * submission evidence, not a physical display presentation timestamp. */ - public boolean hasSubmittedVideoFrame() { - return videoFrameSubmitted; - } - - private boolean reportFirstSubmittedVideoFrame() { - if (!fileLoaded || !videoFrameSubmitted || firstVideoFrameReported) return false; - firstVideoFrameReported = true; - newlyRenderedFirstFrame = true; - return true; - } - public long getDroppedFrames() { return Math.max(0, cachedDecoderDroppedFrames) + Math.max(0, cachedOutputDroppedFrames); } @@ -1481,7 +1203,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs if (videoOutput == null || videoOutput == this.videoOutput) { this.videoOutput = null; clearVideoOutput(); - resumePendingSurfaceLoad(); } return Futures.immediateVoidFuture(); } @@ -1513,37 +1234,11 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs @Override public void event(int eventId) { - long generation = eventId == MPVLib.MpvEvent.MPV_EVENT_START_FILE - ? propertyGeneration.incrementAndGet() : propertyGeneration.get(); - if (eventId == MPVLib.MpvEvent.MPV_EVENT_START_FILE) propertySnapshot.diagnosticBegin(generation); - diagnostics.event(eventId, generation); - postToMain(() -> { - if (released) return; - if (eventId == MPVLib.MpvEvent.MPV_EVENT_START_FILE) - propertySnapshot.beginFile(generation); - handleEvent(eventId); - }); - } - - @Override - public void eventPropertyNode(String property, String json) { - if (released) return; - if (json == null) PlaybackTrace.log("mpv-native", playbackTraceId, - "NODE snapshot rejected for %s: invalid or over bounded serializer limits; no synchronous fallback", property); - if ("chapter-list".equals(property)) { - dispatchProperty(property, json); - return; - } - if (!"track-list".equals(property)) return; - MpvPropertySnapshot.TrackList tracks = MpvPropertySnapshot.TrackList.parse(json); - if (!tracks.valid()) PlaybackTrace.log("mpv-native", playbackTraceId, - "track snapshot rejected: invalid or oversized NODE; no synchronous fallback"); - dispatchProperty(property, tracks); + postToMain(() -> handleEvent(eventId)); } @Override public void eventCommandReply(long requestId, int error) { - diagnostics.command("async-reply", requestId, error, "completed", -1); if (requestId == pendingOsdSurfaceRequestId) { postToMain(() -> handleOsdSurfaceReply(requestId, error)); return; @@ -1559,63 +1254,27 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs @Override public void endFile(int reason, int error, String errorText) { - diagnostics.endFile(reason, error); postToMain(() -> handleEndFile(reason, error, errorText)); } @Override public void logMessage(String prefix, int level, String text) { - if (released) return; - diagnostics.nativeLog(prefix, level, text); - if (MpvDiagnosticCollector.isHook(text)) return; - int performanceKind = MpvDiagnosticsPolicy.felPerformanceKind(prefix, level, text); - if (performanceKind >= 0) { - // Diagnostic measurements are persisted directly, never sent through playback state. - return; - } - // Added INFO coverage must not introduce new inputs to the legacy recovery policy. - if (diagnosticLogLevelApplied && !MpvDiagnosticsPolicy.includedByLogLevel(prefix, level, diagnosticBaseMsgLevel)) return; - String line = MpvDiagnosticsPolicy.redactSensitive(prefix + ": " + text); - String traceId = playbackTraceId; - long logGeneration = propertyGeneration.get(); - boolean debug = SpiderDebug.isEnabled(); - boolean fatalFel = MpvDiagnosticsPolicy.isFatalFelLog(level, text); - long enqueuedAtMs = debug ? SystemClock.elapsedRealtime() : 0; - // This callback must never call MPV. Record essential native evidence before - // a stalled UI can delay it; playback state is still confined to the main thread. postToMain(() -> { - if (released || logGeneration != propertyGeneration.get()) return; - if (enqueuedAtMs > 0) { - long nowMs = SystemClock.elapsedRealtime(); - long delayMs = Math.max(0, nowMs - enqueuedAtMs); - if (delayMs >= MAIN_THREAD_STALL_THRESHOLD_MS - && nowMs - lastNativeLogQueueWarningAtMs >= MAIN_THREAD_STALL_LOG_INTERVAL_MS) { - lastNativeLogQueueWarningAtMs = nowMs; - PlaybackTrace.log("mpv-anr", traceId, - "native-log-queue delay=%dms level=%d", delayMs, level); - } - } + if (released) return; + String line = MpvDiagnosticsPolicy.redactSensitive(prefix + ": " + text); rememberLog(line); markFailureSignal(line); - if (fatalFel && playerError == null) { - fail(mpvError(ERROR_DECODE_FAILED, line), PlaybackException.ERROR_CODE_DECODING_FAILED); - return; - } - maybeRetryDtsHdAsCore(line); String lower = line.toLowerCase(Locale.US); if (shouldDebugLogMpvLine(line)) PlaybackTrace.log("mpv", playbackTraceId, "%s", line); - if (lower.contains("sub") || lower.contains("font") || lower.contains("track switched") || lower.contains("mkv: select track")) appendSubtitleDiagnostic("native " + line); }); } private void openCurrent(long generation) { if (!mediaReplacementCoordinator.isCurrent(generation) || mediaItem == null || mediaItem.localConfiguration == null) return; - diagnostics.begin(playbackTraceId); startMainThreadWatchdog(); try { ensureInitialized(); if (!mediaReplacementCoordinator.isCurrent(generation)) return; - restoreConfiguredAudioSpdifForNewMedia(); playbackState = Player.STATE_BUFFERING; loading = true; playerError = null; @@ -1627,7 +1286,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs loadStartRetryCount = 0; videoReconfigCount = 0; lastVideoSizeCandidateLog = null; - lastNativeLogQueueWarningAtMs = 0; eofReached = false; idleActive = false; cachedDurationMs = C.TIME_UNSET; @@ -1654,8 +1312,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs currentPlayableUri = playableUri(mediaItem); logSourceDiagnostics(mediaItem, currentPlayableUri, headers); boolean declaredIso = isLikelyIso(mediaItem, currentPlayableUri); - boolean blurayMenuEnabled = PlayerSetting.isBlurayMenu(); - safeSetPropertyString("disc-menu", blurayMenuEnabled ? "yes" : "no"); if (!declaredIso && shouldProbeOpaqueIso(mediaItem, currentPlayableUri)) { String probingUri = currentPlayableUri; IsoSessionManager.probeAndCreateAsync(probingUri, headers, isoUri -> mainHandler.post(() -> { @@ -1663,8 +1319,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs IsoSessionManager.closeUri(isoUri); return; } - currentIsoUri = MpvDiscMenuPolicy.isoUri(isoUri, blurayMenuEnabled); - requestIsoOsdSurface(); + currentIsoUri = isoUri; attachIsoTrackMetadataListener(); if (currentIsoUri != null) currentPlayableUri = currentIsoUri; continueOpenCurrent(headers, generation); @@ -1673,8 +1328,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } if (declaredIso) { currentIsoUri = IsoSessionManager.create(currentPlayableUri, headers); - currentIsoUri = MpvDiscMenuPolicy.isoUri(currentIsoUri, blurayMenuEnabled); - requestIsoOsdSurface(); attachIsoTrackMetadataListener(); } if (currentIsoUri != null) currentPlayableUri = currentIsoUri; @@ -1715,54 +1368,32 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs applyShaderPipeline(true); Log.d(TAG, "load scheme=" + safeScheme(currentPlayableUri) + " urlLen=" + (currentPlayableUri == null ? 0 : currentPlayableUri.length()) + " hls=" + currentLikelyHls + " dash=" + currentLikelyDash); PlaybackTrace.log("mpv", playbackTraceId, "load scheme=%s urlLen=%d hls=%s dash=%s surface=%s attached=%s hwdec=%s vo=%s gpuContext=%s gpuApi=%s", safeScheme(currentPlayableUri), currentPlayableUri == null ? 0 : currentPlayableUri.length(), currentLikelyHls, currentLikelyDash, surface != null && surface.isValid(), surfaceAttached, config.hwdec(), config.vo(), config.gpuContext(), config.gpuApi()); - if (deferLoadUntilSurfaceReady(generation)) return; + if (deferLoadUntilOsdSurfaceReady(generation)) return; startPreparedMedia(generation); } catch (Throwable e) { fail(classifyLoadError(e, e.getMessage()), PlaybackException.ERROR_CODE_IO_UNSPECIFIED); } } - private boolean isSurfaceReadyForLoad() { - // A visible SurfaceView may exist before Android creates its Surface. - // Headless/background audio must not wait for a window that is not shown. - boolean surfaceViewShown = videoOutput instanceof SurfaceView view - && view.isShown() && view.getWindowVisibility() == View.VISIBLE; - boolean videoSurfaceReady = !(videoOutput instanceof SurfaceView) - || (surface != null && surface.isValid() && surfaceAttached - && attachedSurface == surface); - if (MpvSurfaceLoadPolicy.shouldWaitForVideoSurface( - isLikelyAudioOnlyMedia(), requiresOsdSurface(), - surfaceViewShown, videoSurfaceReady)) return false; - return !requiresOsdSurface() || !(videoOutput instanceof SurfaceView) - || (pendingOsdSurfaceRequestId == 0 - && osdSurfaceAttached == osdSurfaceRequested); - } - - private boolean isLikelyAudioOnlyMedia() { - String mimeType = mediaItem == null || mediaItem.localConfiguration == null - ? null : mediaItem.localConfiguration.mimeType; - if (!TextUtils.isEmpty(mimeType) - && mimeType.toLowerCase(Locale.ROOT).startsWith("audio/")) { - return true; - } - return AudioUtil.isAudioUrl(currentPlayableUri); - } - - private boolean deferLoadUntilSurfaceReady(long generation) { - if (isSurfaceReadyForLoad()) return false; - pendingSurfaceLoadGeneration = generation; - SpiderDebug.log("mpv", "initial surface gate waiting before load generation=%d videoAttached=%s osdRequested=%s osdAttached=%s pending=%d", - generation, surfaceAttached, osdSurfaceRequested, osdSurfaceAttached, - pendingOsdSurfaceRequestId); + private boolean deferLoadUntilOsdSurfaceReady(long generation) { + if (!requiresOsdSurface() || !(videoOutput instanceof SurfaceView)) return false; + boolean settled = pendingOsdSurfaceRequestId == 0 + && osdSurfaceAttached == osdSurfaceRequested; + if (settled) return false; + pendingOsdLoadGeneration = generation; + SpiderDebug.log("mpv", "initial OSD gate waiting before load generation=%d requested=%s attached=%s pending=%d valid=%s", + generation, osdSurfaceRequested, osdSurfaceAttached, + pendingOsdSurfaceRequestId, + osdSurface != null && osdSurface.isValid()); return true; } - private void resumePendingSurfaceLoad() { - long generation = pendingSurfaceLoadGeneration; - if (generation == C.INDEX_UNSET || released || stopping || !initialized - || playerError != null || !isSurfaceReadyForLoad()) return; - pendingSurfaceLoadGeneration = C.INDEX_UNSET; - SpiderDebug.log("mpv", "initial surface gate ready before load generation=%d", generation); + private void resumePendingOsdLoad() { + long generation = pendingOsdLoadGeneration; + if (generation == C.INDEX_UNSET || pendingOsdSurfaceRequestId != 0 + || osdSurfaceAttached != osdSurfaceRequested) return; + pendingOsdLoadGeneration = C.INDEX_UNSET; + SpiderDebug.log("mpv", "initial OSD gate ready before load generation=%d", generation); startPreparedMedia(generation); } @@ -1823,15 +1454,12 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs applyPreInitOptions(); mpvInit(); initialized = true; - activeAudioSpdif = config.audioSpdif(); - dtsHdCoreFallbackAttempted = false; MPVLib.addObserver(this); MPVLib.addLogObserver(this); applyPostInitOptions(); applyVideoAspect(); applyShaderPipeline(true); observeProperties(); - syncDiagnosticLogLevel(); } } @@ -1859,16 +1487,13 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs if (!TextUtils.isEmpty(config.gpuApi())) setOption("gpu-api", config.gpuApi()); if (config.openglEs()) setOption("opengl-es", "yes"); setOption("hwdec", config.hwdec()); - setOption("hwdec-codecs", MpvOptionPriorityPolicy.HARDWARE_CODECS); + setOption("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1"); setOption("ao", config.ao()); - setOption("ad", MpvAudioDecoderPolicy.decoderList(config.audioSpdif())); if (!TextUtils.isEmpty(config.audioSpdif())) setOption("audio-spdif", config.audioSpdif()); setOption("audio-set-media-role", "yes"); setOption("tls-verify", config.tlsVerify() ? "yes" : "no"); if (config.caFile().isFile()) setOption("tls-ca-file", config.caFile().getAbsolutePath()); setOption("input-default-bindings", "yes"); - // libbluray owns HDMV menu VM state; ordinary media ignores this. - setOption("disc-menu", PlayerSetting.isBlurayMenu() ? "yes" : "no"); setOption("cache", config.cache() ? "yes" : "no"); setOption("cache-on-disk", "no"); if (preloadCacheCapacityBytes > 0) { @@ -1895,11 +1520,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs setOption("sub-fix-timing", "yes"); setOption("sub-use-margins", "yes"); setOption("sub-font-provider", "fontconfig"); - // Keep disc input outcomes visible even when normal playback logging - // uses all=warn. This does not enable verbose decoder/network logs. - String discLogLevel = BuildConfig.DEBUG ? "debug" : "info"; - setOption("msg-level", config.logLevel() + ",iso=" + discLogLevel - + ",bd=" + discLogLevel + ",bdmv/bluray=" + discLogLevel); + setOption("msg-level", config.logLevel()); for (Map.Entry entry : config.extraOptions().entrySet()) setOption(entry.getKey(), entry.getValue()); } @@ -1981,15 +1602,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private void observeProperties() { observedPropertyNames.clear(); propertyCache.clear(); - for (String property : new String[]{"mpv-version", "ffmpeg-version", "options/msg-level", "hwdec", "hwdec-interop", - "video-codec", "audio-codec", "current-tracks/video/decoder", "current-tracks/video/codec-profile", - "audio-params/channels", "audio-out-params/channels", "video-dec-params/pixelformat", "video-params/pixelformat", - "video-out-params/pixelformat", "audio-spdif", "options/af", "file-format"}) observe(property, MPVLib.MpvFormat.MPV_FORMAT_STRING); - for (String property : new String[]{"volume", "speed", "audio-delay", "video-pts", "audio-pts"}) observe(property, MPVLib.MpvFormat.MPV_FORMAT_DOUBLE); - observe("mute", MPVLib.MpvFormat.MPV_FORMAT_FLAG); - observe("seeking", MPVLib.MpvFormat.MPV_FORMAT_FLAG); - observe("seekable", MPVLib.MpvFormat.MPV_FORMAT_FLAG); - observe("current-edition", MPVLib.MpvFormat.MPV_FORMAT_INT64); observe("time-pos", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE); observe("time-pos/full", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE); observe("duration", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE); @@ -2019,7 +1631,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs observe("eof-reached", MPVLib.MpvFormat.MPV_FORMAT_FLAG); observe("idle-active", MPVLib.MpvFormat.MPV_FORMAT_FLAG); observe("sub-visibility", MPVLib.MpvFormat.MPV_FORMAT_FLAG); - observe("disc-menu-active", MPVLib.MpvFormat.MPV_FORMAT_FLAG); observe("path", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("file-format", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("video-codec", MPVLib.MpvFormat.MPV_FORMAT_STRING); @@ -2044,21 +1655,10 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs observe("video-params/colorlevels", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("video-params/colormatrix", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("current-vo", MPVLib.MpvFormat.MPV_FORMAT_STRING); - observe("android-dovi-fel-active", MPVLib.MpvFormat.MPV_FORMAT_FLAG); - observe("android-fel-bind-probe", MPVLib.MpvFormat.MPV_FORMAT_STRING); - observe("video-frame-submitted", MPVLib.MpvFormat.MPV_FORMAT_FLAG); observe("current-gpu-context", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("gpu-api", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("current-ao", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("audio-device", MPVLib.MpvFormat.MPV_FORMAT_STRING); - observe("audio-params/format", MPVLib.MpvFormat.MPV_FORMAT_STRING); - observe("audio-params/channel-count", MPVLib.MpvFormat.MPV_FORMAT_INT64); - observe("audio-params/samplerate", MPVLib.MpvFormat.MPV_FORMAT_INT64); - observe("audio-out-params/format", MPVLib.MpvFormat.MPV_FORMAT_STRING); - observe("audio-out-params/channel-count", MPVLib.MpvFormat.MPV_FORMAT_INT64); - observe("audio-out-params/samplerate", MPVLib.MpvFormat.MPV_FORMAT_INT64); - observe("current-tracks/audio/decoder", MPVLib.MpvFormat.MPV_FORMAT_STRING); - observe("current-tracks/audio/codec-profile", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("hwdec-current", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("avsync", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE); observe("display-fps", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE); @@ -2068,9 +1668,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs observe("mistimed-frame-count", MPVLib.MpvFormat.MPV_FORMAT_INT64); observe("vo-delayed-frame-count", MPVLib.MpvFormat.MPV_FORMAT_INT64); observe("display-sync-active", MPVLib.MpvFormat.MPV_FORMAT_FLAG); - observe("track-list", MPVLib.MpvFormat.MPV_FORMAT_NODE); - observe("video-bitrate", MPVLib.MpvFormat.MPV_FORMAT_INT64); - observe("audio-bitrate", MPVLib.MpvFormat.MPV_FORMAT_INT64); + observe("track-list/count", MPVLib.MpvFormat.MPV_FORMAT_INT64); observe("vid", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("aid", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("sid", MPVLib.MpvFormat.MPV_FORMAT_STRING); @@ -2083,12 +1681,11 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs observe("current-tracks/sub/id", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("current-tracks/sub2/id", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("chapter", MPVLib.MpvFormat.MPV_FORMAT_INT64); - observe("chapter-list", MPVLib.MpvFormat.MPV_FORMAT_NODE); + observe("chapter-list", MPVLib.MpvFormat.MPV_FORMAT_STRING); observe("chapter-list/count", MPVLib.MpvFormat.MPV_FORMAT_INT64); - observe("disc-menu-active", MPVLib.MpvFormat.MPV_FORMAT_FLAG); - observe(MpvConfigStore.CUSTOM_BUTTON_STATE_PROPERTY, MPVLib.MpvFormat.MPV_FORMAT_STRING); } + private void observeTrackProperties(int count) { int limit = Math.min(Math.max(0, count), MAX_OBSERVED_TRACKS); for (int i = 0; i < limit; i++) { @@ -2120,19 +1717,13 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } private void dispatchProperty(String property, @Nullable Object value) { - PropertyUpdate update = new PropertyUpdate(propertyGeneration.get(), value); - if (SpiderDebug.isEnabled()) { - propertySnapshot.diagnosticUpdate(update.generation(), property, value, SystemClock.elapsedRealtime(), - com.github.catvod.crawler.DebugLogStore.captureGeneration()); - diagnostics.propertyChanged(property); - } if (!isCoalescedProperty(property)) { - postToMain(() -> applyPropertyUpdate(property, update)); + postToMain(() -> handleProperty(property, value)); return; } boolean scheduleDrain = false; synchronized (propertyEventLock) { - coalescedPropertyEvents.put(property, update); + coalescedPropertyEvents.put(property, value); if (!coalescedPropertyDrainScheduled) { coalescedPropertyDrainScheduled = true; scheduleDrain = true; @@ -2149,7 +1740,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs coalescedPropertyEvents.clear(); } for (Map.Entry entry : snapshot.entrySet()) { - applyPropertyUpdate(entry.getKey(), (PropertyUpdate) entry.getValue()); + handleProperty(entry.getKey(), entry.getValue()); } synchronized (propertyEventLock) { if (coalescedPropertyEvents.isEmpty()) { @@ -2191,36 +1782,8 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } } - private void applyPropertyUpdate(String property, PropertyUpdate update) { - if (released || !propertySnapshot.update(update.generation(), property, update.value())) return; - handleProperty(property, update.value()); - } - private void handleProperty(String property, @Nullable Object value) { if (released) return; - if ("options/msg-level".equals(property) && value instanceof String text) { - // An initial mpv.conf readback or a later user change is a new restoration baseline. - // Our own observer acknowledgement must not become that baseline. - if (!text.equals(diagnosticAppliedMsgLevel)) { - diagnosticBaseMsgLevel = text; - diagnosticLogLevelApplied = false; - } - syncDiagnosticLogLevel(); - } - if (MpvConfigStore.CUSTOM_BUTTON_STATE_PROPERTY.equals(property)) { - // user-data is a NODE property: MPV_FORMAT_STRING serializes it as JSON, - // including quotes around string values. Decode before matching button IDs. - String states = ""; - if (value instanceof String json) { - try { - Object decoded = new JSONTokener(json).nextValue(); - if (decoded instanceof String ids) states = ids; - } catch (JSONException ignored) { - } - } - setCustomButtonStates(states); - return; - } boolean firstCacheTimeReadback = cacheTimeState.recordObserved(property, value); if (firstCacheTimeReadback) { MpvCacheTimeState.Snapshot cacheTime = cacheTimeState.snapshot(); @@ -2251,18 +1814,10 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs cacheObservedVideoProperty(property, value); boolean stateChanged = false; switch (property) { - case "time-pos", "time-pos/full" -> { - cachedPositionMs = stabilizedPositionMs(doubleSecondsToMs(value, cachedPositionMs)); - updateHlsAdBoundary(cachedPositionMs); - maybeSkipHlsAd(); - } + case "time-pos", "time-pos/full" -> cachedPositionMs = + stabilizedPositionMs(doubleSecondsToMs(value, cachedPositionMs)); case "duration", "duration/full" -> { long durationMs = doubleSecondsToMs(value, cachedDurationMs); - // mpv can publish an initial duration observation as zero before the - // demuxer knows the real timeline. If the later value does not change, - // no property-change event arrives, so zero would stick. Keep the - // timeline unset instead of materializing an unknown duration as zero. - if (durationMs == 0) durationMs = C.TIME_UNSET; if (durationMs != cachedDurationMs) { cachedDurationMs = durationMs; stateChanged = true; @@ -2318,10 +1873,8 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs if (nextPlaybackState == Player.STATE_READY) endSeekBuffering("paused-for-cache"); } case "eof-reached" -> { - if (Boolean.TRUE.equals(value) && handleHlsAdBoundaryEof()) break; boolean wasEnded = playbackState == Player.STATE_ENDED; - eofReached = MpvDiscMenuPolicy.isTerminalEof( - Boolean.TRUE.equals(value), discNavigationActive); + eofReached = Boolean.TRUE.equals(value); if (eofReached) markPlaybackEnded("property:eof-reached"); stateChanged = !wasEnded && playbackState == Player.STATE_ENDED; } @@ -2348,30 +1901,10 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs observedCurrentVo = value instanceof String; cachedCurrentVo = value instanceof String text ? text : cachedCurrentVo; } - case "android-dovi-fel-active" -> androidFelActive = Boolean.TRUE.equals(value); - case "android-fel-bind-probe" -> diagnostics.felBindProbe().observed(value instanceof String text ? text : "unavailable"); - case "video-frame-submitted" -> { - videoFrameSubmitted = Boolean.TRUE.equals(value); - stateChanged = reportFirstSubmittedVideoFrame(); - } case "current-gpu-context" -> cachedCurrentGpuContext = stringValue(value, cachedCurrentGpuContext); case "gpu-api" -> cachedGpuApi = stringValue(value, cachedGpuApi); case "current-ao" -> cachedCurrentAo = stringValue(value, cachedCurrentAo); case "audio-device" -> cachedAudioDevice = stringValue(value, cachedAudioDevice); - case "audio-params/format" -> cachedAudioFormat = stringValue(value, ""); - case "audio-params/channel-count" -> cachedAudioChannels = Math.max(0, - (int) longValue(value, cachedAudioChannels)); - case "audio-params/samplerate" -> cachedAudioSampleRate = Math.max(0, - (int) longValue(value, cachedAudioSampleRate)); - case "audio-out-params/format" -> cachedAudioOutFormat = stringValue(value, ""); - case "audio-out-params/channel-count" -> cachedAudioOutChannels = Math.max(0, - (int) longValue(value, cachedAudioOutChannels)); - case "audio-out-params/samplerate" -> cachedAudioOutSampleRate = Math.max(0, - (int) longValue(value, cachedAudioOutSampleRate)); - case "current-tracks/audio/decoder" -> - cachedAudioDecoder = stringValue(value, ""); - case "current-tracks/audio/codec-profile" -> - cachedAudioCodecProfile = stringValue(value, ""); case "hwdec-current" -> { observedHwdecCurrent = value instanceof String; cachedHwdecCurrent = value instanceof String text ? text : cachedHwdecCurrent; @@ -2390,8 +1923,8 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs case "mistimed-frame-count" -> cachedMistimedFrames = Math.max(0, longValue(value, cachedMistimedFrames)); case "vo-delayed-frame-count" -> cachedDelayedFrames = Math.max(0, longValue(value, cachedDelayedFrames)); case "display-sync-active" -> cachedDisplaySyncActive = Boolean.TRUE.equals(value); - case "track-list/count" -> observeTrackProperties((int) Math.max(0, longValue(value, 0))); - case "track-list" -> { + case "track-list/count" -> { + observeTrackProperties((int) Math.max(0, longValue(value, 0))); int previousWidth = videoSize.width; int previousHeight = videoSize.height; updateVideoSize("property:" + property); @@ -2406,13 +1939,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs case "chapter-list" -> { if (!shouldDeferStartupMetadataRefresh()) handleChapterListProperty(value); } - case "disc-menu-active" -> { - setDiscMenuActive(Boolean.TRUE.equals(value)); - Log.d(TAG, "disc menu active=" + discMenuActive + " available=" + discMenuAvailable); - PlaybackTrace.log("mpv", playbackTraceId, "disc menu active=%s available=%s", - discMenuActive, discMenuAvailable); - if (discMenuActive) requestIsoOsdSurface(); - } case "chapter-list/count" -> { observeChapterProperties((int) Math.max(0, longValue(value, 0))); if (!shouldDeferStartupMetadataRefresh()) scheduleChapterRefresh(); @@ -2433,84 +1959,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs return selectedVideoTrackDiagnostics; } - public AudioPlaybackDiagnostics.Snapshot getAudioPlaybackDiagnostics() { - AudioPlaybackDiagnostics.Track current = selectedAudioTrackDiagnostics == null - ? AudioPlaybackDiagnostics.Track.empty() : selectedAudioTrackDiagnostics; - boolean automaticTrackDowngrade = !TextUtils.isEmpty( - automaticAudioDowngradeReason) - && sameAudioTrack(current, automaticAudioActiveTrack); - AudioPlaybackDiagnostics.Track original = automaticTrackDowngrade - ? automaticAudioOriginalTrack : current; - AudioPlaybackDiagnostics.Track active = current; - String reason = automaticTrackDowngrade - ? automaticAudioDowngradeReason : ""; - // audio-params/* describes the decoder/filter side, not the active - // AudioTrack. Do not infer PCM from it before mpv publishes the - // actual audio-out-params/* values; the panel must report runtime - // output rather than a configured or intermediate format. - String outputFormat = cachedAudioOutFormat; - AudioPlaybackDiagnostics.OutputMode outputMode = - AudioPlaybackDiagnostics.mpvOutputMode(outputFormat); - if (outputMode == AudioPlaybackDiagnostics.OutputMode.PASSTHROUGH) { - AudioPlaybackDiagnostics.Track passthrough = - AudioPlaybackDiagnostics.passthroughTrack(active, outputFormat); - String originalCodec = original.codec().toLowerCase(Locale.US); - if ("DTS Core".equals(passthrough.codec()) - && (originalCodec.contains("dts-hd") - || originalCodec.contains("dts:x"))) { - if (dtsHdFallbackSourceTrack.available()) { - original = dtsHdFallbackSourceTrack; - } - active = AudioPlaybackDiagnostics.passthroughTrack(original, outputFormat); - reason = "dts-hd-core"; - } else { - active = passthrough; - } - } - int outputChannels = cachedAudioOutChannels; - int outputSampleRate = cachedAudioOutSampleRate; - AudioPlaybackDiagnostics.DecodeMode decodeMode = - outputMode == AudioPlaybackDiagnostics.OutputMode.PASSTHROUGH - ? AudioPlaybackDiagnostics.DecodeMode.NONE - : isRawPcmTrack(active) - ? AudioPlaybackDiagnostics.DecodeMode.NONE - : outputMode == AudioPlaybackDiagnostics.OutputMode.PCM - ? mpvAudioDecodeMode(cachedAudioDecoder) - : AudioPlaybackDiagnostics.DecodeMode.UNKNOWN; - if (isAudioDiagnosticsFailure(current)) { - AudioPlaybackDiagnostics.FailureReason failureReason = - AudioPlaybackDiagnostics.failureReason(playerError.errorCode); - AudioPlaybackDiagnostics.DecodeMode attemptedDecode = - cachedAudioDecoder == null || cachedAudioDecoder.isBlank() - ? decodeMode : mpvAudioDecodeMode(cachedAudioDecoder); - return new AudioPlaybackDiagnostics.Snapshot(original, active, - attemptedDecode, cachedAudioDecoder, outputMode, outputChannels, - outputSampleRate, false, reason, - AudioPlaybackDiagnostics.lastAttemptLevel( - failureReason, outputMode, attemptedDecode), - AudioPlaybackDiagnostics.RuntimeState.FAILED, failureReason); - } - return new AudioPlaybackDiagnostics.Snapshot(original, active, - decodeMode, cachedAudioDecoder, outputMode, outputChannels, - outputSampleRate, false, reason); - } - - private boolean isAudioDiagnosticsFailure(AudioPlaybackDiagnostics.Track current) { - if (playerError == null || current == null || !current.available()) return false; - String message = playerError.getMessage(); - if (message != null && (message.startsWith(ERROR_NETWORK_FAILED) - || message.startsWith(ERROR_NO_AV_DATA) - || message.startsWith(ERROR_INVALID_MEDIA_DATA) - || message.startsWith(ERROR_VIDEO_OUTPUT_FAILED) - || message.startsWith(ERROR_DRM_UNSUPPORTED))) { - return false; - } - return AudioPlaybackDiagnostics.failureReason(playerError.errorCode) - != AudioPlaybackDiagnostics.FailureReason.UNKNOWN - || !TextUtils.isEmpty(cachedAudioDecoder) - || !TextUtils.isEmpty(cachedAudioFormat); - } - /** Metadata for the first video track, including when mpv temporarily reports vid=no. */ public VideoTrackDiagnostics getAvailableVideoTrackDiagnostics() { return availableVideoTrackDiagnostics; @@ -2564,7 +2012,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs public void resetTrackSelection() { audioTrackManuallySelected = true; preferAacApplied = true; - clearAutomaticAudioDowngrade(); setMpvTrack(C.TRACK_TYPE_VIDEO, "auto"); setMpvTrack(C.TRACK_TYPE_AUDIO, "auto"); setMpvTrack(C.TRACK_TYPE_TEXT, "auto"); @@ -2578,7 +2025,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs if (type == C.TRACK_TYPE_AUDIO) { audioTrackManuallySelected = true; preferAacApplied = true; - clearAutomaticAudioDowngrade(); } setMpvTrack(type, mpvId); refreshTracks(); @@ -2668,12 +2114,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } switch (eventId) { case MPVLib.MpvEvent.MPV_EVENT_START_FILE -> { - hlsAdSkipState.clear(); - hlsAdBoundary.clear(); - androidFelActive = false; - videoFrameSubmitted = false; - firstVideoFrameReported = false; - newlyRenderedFirstFrame = false; mediaReplacementCoordinator.onStartFile(); mainHandler.removeCallbacks(mediaReplacementStopTimeoutRunnable); loadStarted = true; @@ -2701,41 +2141,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs return; } fileLoaded = true; - // Resolve the actual native chain once at this lifecycle - // boundary. Property-change delivery may follow FILE_LOADED; - // an earlier cached false must not trigger an App rebuild. - androidFelActive = booleanProperty("android-dovi-fel-active", false); - if (androidFelActive) { - cachedCurrentVo = stringProperty("current-vo", "gpu-next"); - observedCurrentVo = true; - cachedCurrentGpuContext = stringProperty("current-gpu-context", ""); - if (config.restoreFelAutomaticSubtitles() - && TextUtils.isEmpty(initialSubtitleTrackId) - && "no".equals(config.extraOptions().get("sid"))) { - // Undo only the App's direct-output startup default. - // Saved/manual selections and explicit mpv.conf sid - // stay authoritative; file-local reset protects reuse. - safeSetPropertyString("file-local-options/sid", "auto"); - } - } - reportFirstSubmittedVideoFrame(); - discNavigationActive = MpvDiscMenuPolicy.usesRawIso(currentIsoUri) - && booleanProperty("disc-nav-active", false); - discMenuAvailable |= discNavigationActive; - if (discNavigationActive) { - // Navigation deliberately reads on demand, so waiting for a - // read-ahead cache to fill repeatedly pauses menu playback. - // Keep this override file-local: ordinary titles retain the - // configured cache policy on the next load, even on reuse. - safeSetPropertyString("file-local-options/cache-pause", "no"); - Log.i(TAG, "disc navigation start: ignore flat history position=" - + initialSeekPositionMs + " cache-pause=" - + booleanProperty("cache-pause", true)); - initialSeekPositionMs = C.TIME_UNSET; - loadStartPositionMs = C.TIME_UNSET; - cachedPositionMs = 0; - seekPositionState.clear(); - } fileLoadedAtElapsedRealtimeMs = SystemClock.elapsedRealtime(); cacheObserverState.onFileLoaded(SystemClock.elapsedRealtime()); mainHandler.removeCallbacks(endFileValidationRunnable); @@ -2752,7 +2157,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs if (shouldCollectDebugDetails()) PlaybackTrace.log("mpv", playbackTraceId, "event=file-loaded duration=%d size=%dx%d path=%s", cachedDurationMs, videoSize.width, videoSize.height, MpvDiagnosticsPolicy.sourceSummary(currentPlayableUri)); addSubtitleConfigurations(); if (initialSeekPositionMs != C.TIME_UNSET) { - long targetPositionMs = resolveHlsAdSeekTarget(initialSeekPositionMs); + long targetPositionMs = initialSeekPositionMs; initialSeekPositionMs = C.TIME_UNSET; resetCacheTimelineForSeek(targetPositionMs); seekPositionState.begin( @@ -2767,7 +2172,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } } loadStartPositionMs = C.TIME_UNSET; - updateHlsAdBoundary(resolveHlsAdSeekTarget(cachedPositionMs)); safeSetPropertyBoolean("pause", shouldPauseNativePlayback()); updatePreloadCacheOverlay(); startStateRefresh(); @@ -2790,19 +2194,8 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } } case MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART -> { - hlsAdBoundary.playbackRestarted(doubleSecondsToMs( - doubleProperty("playback-time", cachedPositionMs / SECONDS_TO_MS), cachedPositionMs)); playbackRestarted = true; endSeekBuffering("playback-restart"); - // Some sources do not send a second duration change after the startup - // zero observation. Read the settled timeline once playback restarts. - if (cachedDurationMs == C.TIME_UNSET || cachedDurationMs == 0) { - long durationMs = doublePropertyMs("duration", C.TIME_UNSET); - if (durationMs != cachedDurationMs) { - cachedDurationMs = durationMs; - invalidateState(); - } - } if (config.deferStartupTrackRefresh()) { scheduleTrackRefresh("event=playback-restart"); } @@ -3198,138 +2591,13 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } private boolean requiresOsdSurface() { - return "mediacodec_embed".equals(videoOutputVo()) || !TextUtils.isEmpty(currentIsoUri); - } - - private void requestIsoOsdSurface() { - if (!TextUtils.isEmpty(currentIsoUri)) setOsdSurfaceRequested(true); - } - - public boolean isDiscMenuActive() { - return discMenuActive; - } - - public void addDiscMenuStateListener(Runnable listener) { - if (!released && !discMenuStateListeners.contains(listener)) { - discMenuStateListeners.add(listener); - } - } - - public void removeDiscMenuStateListener(Runnable listener) { - discMenuStateListeners.remove(listener); - } - - private void setDiscMenuActive(boolean active) { - discMenuAvailable |= active; - if (discMenuActive == active) return; - discMenuActive = active; - // Property events and UI registrations both run on the main thread. - for (Runnable listener : List.copyOf(discMenuStateListeners)) listener.run(); - } - - public boolean isDiscMenuAvailable() { - return initialized && discMenuAvailable && !TextUtils.isEmpty(currentIsoUri); - } - - public boolean hasDiscNavigationTimeline() { - return !MpvDiscMenuPolicy.hasSinglePlaybackTimeline( - currentIsoUri, fileLoaded, discNavigationActive); - } - - public boolean hasStartedDiscNavigation() { - return discNavigationActive && fileLoaded && playbackRestarted; - } - - public int getDiscRebufferCount() { - return discRebufferTracker.count(); - } - - public long getDiscRebufferTotalMs(long nowMs) { - return discRebufferTracker.totalMs(nowMs); - } - - public boolean sendDiscNav(String action) { - if (!initialized || TextUtils.isEmpty(action)) { - if (BuildConfig.DEBUG) PlaybackTrace.log("mpv", playbackTraceId, - "disc-debug rejected action=%s initialized=%s", action, initialized); - return false; - } - return sendDiscNavCommand(new String[]{"discnav", action}); - } - - private boolean sendDiscNavCommand(String[] command) { - String action = command[1]; - long debugStarted = SystemClock.elapsedRealtime(); - long debugRequest = ++discDebugRequest; - boolean debug = BuildConfig.DEBUG && (!action.equals("mouse-move") - || debugStarted - discDebugLastHoverMs >= 250); - if (debug) { - if (action.equals("mouse-move")) discDebugLastHoverMs = debugStarted; - PlaybackTrace.log("mpv", playbackTraceId, - "disc-debug request=%d phase=begin t_ms=%d command=%s initialized=%s " - + "active=%s available=%s navigation=%s loaded=%s restarted=%s " - + "positionMs=%d durationMs=%d video=%dx%d surface=%s attached=%s", - debugRequest, debugStarted, String.join(" ", command), initialized, - discMenuActive, discMenuAvailable, discNavigationActive, fileLoaded, - playbackRestarted, cachedPositionMs, cachedDurationMs, - videoSize.width, videoSize.height, surface != null && surface.isValid(), surfaceAttached); - } - if (!action.equals("mouse-move") && !action.equals("up") && !action.equals("down") - && !action.equals("left") && !action.equals("right")) { - long now = SystemClock.elapsedRealtime(); - discRebufferTracker.interrupt(now); - discInputQuietUntilMs = now + 1000; - } - try { - int result = mpvCommand(command); - Log.d(TAG, "disc navigation action=" + action + " result=" + result); - PlaybackTrace.log("mpv", playbackTraceId, "disc navigation action=%s result=%d", - action, result); - if (debug) { - PlaybackTrace.log("mpv", playbackTraceId, - "disc-debug request=%d phase=returned result=%d elapsedMs=%d active=%s", - debugRequest, result, SystemClock.elapsedRealtime() - debugStarted, discMenuActive); - if (!action.equals("mouse-move")) { - String debugTrace = playbackTraceId; - mainHandler.postDelayed(() -> { - if (released || !debugTrace.equals(playbackTraceId)) return; - PlaybackTrace.log("mpv", debugTrace, - "disc-debug request=%d phase=settled elapsedMs=%d active=%s " - + "available=%s positionMs=%d loaded=%s restarted=%s", - debugRequest, SystemClock.elapsedRealtime() - debugStarted, - discMenuActive, discMenuAvailable, cachedPositionMs, fileLoaded, playbackRestarted); - }, 1000); - } - } - return result >= MPVLib.MpvError.MPV_ERROR_SUCCESS; - } catch (Throwable error) { - if (debug) PlaybackTrace.log("mpv", playbackTraceId, - "disc-debug request=%d phase=exception type=%s elapsedMs=%d", - debugRequest, error.getClass().getSimpleName(), SystemClock.elapsedRealtime() - debugStarted); - return false; - } - } - - public boolean sendDiscNavPointer(int pointerX, int pointerY, boolean activate) { - if (!initialized || !discMenuActive) { - if (BuildConfig.DEBUG && activate) PlaybackTrace.log("mpv", playbackTraceId, - "disc-debug pointer-rejected x=%d y=%d initialized=%s active=%s", - pointerX, pointerY, initialized, discMenuActive); - return false; - } - return sendDiscNavCommand(MpvDiscMenuPolicy.pointerCommand(pointerX, pointerY, activate)); + return "mediacodec_embed".equals(videoOutputVo()); } private String videoOutputVo() { return TextUtils.isEmpty(effectiveVo) ? config.vo() : effectiveVo; } - private String activeVideoOutputVo() { - if (androidFelActive) return "gpu-next"; - return observedCurrentVo && !TextUtils.isEmpty(cachedCurrentVo) - ? cachedCurrentVo : videoOutputVo(); - } - private void createOsdSurfaceView() { if (!requiresOsdSurface() || osdSurfaceView != null || !(videoOutput instanceof SurfaceView videoView)) return; @@ -3490,7 +2758,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } reconcileOsdSurface(); if (error >= MPVLib.MpvError.MPV_ERROR_SUCCESS) { - resumePendingSurfaceLoad(); + resumePendingOsdLoad(); } } @@ -3512,7 +2780,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs Log.d(SIZE_TAG, "mpv resize attached surface cached=" + surfaceWidth + "x" + surfaceHeight + " vo=" + targetVo); SpiderDebug.log("mpv", "surface resized surface=%s size=%dx%d vo=%s", surface, surfaceWidth, surfaceHeight, targetVo); reconcileOsdSurface(); - resumePendingSurfaceLoad(); return; } if (surfaceAttached || osdSurfaceAttached) detachMpvSurface(); @@ -3528,7 +2795,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs Log.d(SIZE_TAG, "mpv bind surface valid=" + surface.isValid() + " cached=" + surfaceWidth + "x" + surfaceHeight + " vo=" + targetVo); SpiderDebug.log("mpv", "surface attached video=%s osd=%s size=%dx%d vo=%s", surface, osdSurface, surfaceWidth, surfaceHeight, targetVo); reconcileOsdSurface(); - resumePendingSurfaceLoad(); } catch (Throwable e) { fail(mpvError(ERROR_VIDEO_OUTPUT_FAILED, e.getMessage(), e), PlaybackException.ERROR_CODE_VIDEO_FRAME_PROCESSING_FAILED); } @@ -3563,7 +2829,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } try { boolean detachDirectVideoFirst = surfaceAttached - && "mediacodec_embed".equals(activeVideoOutputVo()); + && "mediacodec_embed".equals(videoOutputVo()); // Clearing wid first lets direct output tear down MediaCodec before // Android releases the Surface. Resetting vo first can reconfigure // the decoder against an already released Surface. @@ -3749,7 +3015,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs // before OSD and keep the selected VO for transient window loss, // otherwise MPV may reopen MediaCodec on the released Surface. detachMpvSurface(false); - resumePendingSurfaceLoad(); } }; @@ -3791,10 +3056,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs }; private void stopInternal(boolean resetState) { - restoreHlsAdBoundary(); - hlsAdBoundary.clear(); - hlsAdBoundaryUnavailable = false; - pendingSurfaceLoadGeneration = C.INDEX_UNSET; + pendingOsdLoadGeneration = C.INDEX_UNSET; initialTrackSelectionGateActive = false; mainHandler.removeCallbacks(initialTrackSelectionGateTimeoutRunnable); restorePreloadCacheOverlay(); @@ -3809,7 +3071,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs endSeekBuffering("stop"); loading = false; fileLoaded = false; - hlsAdSkipState.clear(); fileLoadedAtElapsedRealtimeMs = 0; loadStarted = false; playbackRestarted = false; @@ -3826,7 +3087,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs currentTracks = Tracks.EMPTY; selectedVideoTrackDiagnostics = VideoTrackDiagnostics.empty(); availableVideoTrackDiagnostics = VideoTrackDiagnostics.empty(); - resetAudioPlaybackDiagnostics(); cachedSelectedHlsBitrate = 0; currentChapters = List.of(); videoSize = VideoSize.UNKNOWN; @@ -3898,7 +3158,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs initialized = false; observedPropertyNames.clear(); propertyCache.clear(); - setCustomButtonStates(""); autoHlsBitrateState.onNativeContextReleased(); surfaceAttached = false; osdSurfaceAttached = false; @@ -3980,16 +3239,8 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } private void loadCurrentUri() { - if (currentIsoUri != null && shouldCollectDebugDetails()) { - PlaybackTrace.log("mpv", playbackTraceId, - "ISO routing raw=%s disc-menu=%s access-references=%s", - MpvDiscMenuPolicy.usesRawIso(currentIsoUri), - stringProperty("disc-menu", "unavailable"), - stringProperty("access-references", "unavailable")); - } String startOption = ""; - if (!MpvDiscMenuPolicy.usesRawIso(currentIsoUri) - && initialSeekPositionMs != C.TIME_UNSET && initialSeekPositionMs > 0) { + if (initialSeekPositionMs != C.TIME_UNSET && initialSeekPositionMs > 0) { loadStartPositionMs = initialSeekPositionMs; startOption = "start=" + String.format(Locale.US, "%.3f", initialSeekPositionMs / SECONDS_TO_MS); @@ -4137,39 +3388,14 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private void startStateRefresh() { mainHandler.removeCallbacks(stateRefreshRunnable); mainHandler.postDelayed(stateRefreshRunnable, STATE_REFRESH_INTERVAL_MS); - mainHandler.removeCallbacks(discRebufferRunnable); - if (discNavigationActive) mainHandler.postDelayed(discRebufferRunnable, 100); } private void stopStateRefresh() { mainHandler.removeCallbacks(stateRefreshRunnable); - mainHandler.removeCallbacks(discRebufferRunnable); - discRebufferTracker.interrupt(SystemClock.elapsedRealtime()); - } - - private void sampleDiscRebuffer() { - long now = SystemClock.elapsedRealtime(); - boolean running = initialized && !released && !stopping && discNavigationActive - && playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED - && playerError == null; - boolean eligible = running && fileLoaded && playbackRestarted && playWhenReady - && playbackState == Player.STATE_READY && !initialTrackSelectionGateActive - && !seekPositionState.hasTarget() && now >= discInputQuietUntilMs; - boolean wasActive = discRebufferTracker.active(); - long demandWait = running ? IsoSessionManager.demandWaitMs(currentIsoUri) : 0; - discRebufferTracker.update(now, eligible, cachedPositionMs, cachedCacheDurationMs, demandWait); - if (wasActive != discRebufferTracker.active()) { - PlaybackTrace.log("mpv-disc-buffer", playbackTraceId, - "event=%s count=%d totalMs=%d positionMs=%d bufferedMs=%d demandWaitMs=%d", - discRebufferTracker.active() ? "start" : "end", discRebufferTracker.count(), - discRebufferTracker.totalMs(now), cachedPositionMs, cachedCacheDurationMs, demandWait); - } - if (running) mainHandler.postDelayed(discRebufferRunnable, 100); } private void startMainThreadWatchdog() { - if (mainThreadWatchdogRunning) return; - playbackRecoveryMonitor = new PlaybackRecoveryMonitor(context); + if (!SpiderDebug.isEnabled() || mainThreadWatchdogRunning) return; HandlerThread thread = new HandlerThread("mpv-main-watchdog", Process.THREAD_PRIORITY_BACKGROUND); thread.start(); Handler handler = new Handler(thread.getLooper()); @@ -4187,9 +3413,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs mainThreadWatchdogRunning = false; Handler handler = mainThreadWatchdogHandler; if (handler != null) handler.removeCallbacks(mainThreadWatchdogRunnable); - PlaybackRecoveryMonitor recovery = playbackRecoveryMonitor; - playbackRecoveryMonitor = null; - if (handler != null && recovery != null) handler.post(recovery::close); mainHandler.removeCallbacks(mainThreadHeartbeatRunnable); mainThreadHeartbeatPending.set(false); mainThreadHeartbeatPostedAtMs = 0; @@ -4204,31 +3427,21 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private void runMainThreadWatchdog() { if (!mainThreadWatchdogRunning) return; - diagnostics.tick(false); - if ((SpiderDebug.isEnabled() != diagnosticLogLevelApplied - || diagnosticLogLevelApplied && (diagnosticDepthApplied != diagnostics.depthActive() - || diagnosticCategoriesApplied != com.github.catvod.crawler.DebugLogStore.categories())) - && diagnosticLogUpdatePending.compareAndSet(false, true)) { - mainHandler.post(() -> { - diagnosticLogUpdatePending.set(false); - if (!released) syncDiagnosticLogLevel(); - }); + if (!SpiderDebug.isEnabled()) { + stopMainThreadWatchdog(); + return; } long nowMs = SystemClock.elapsedRealtime(); long postedAtMs = mainThreadHeartbeatPostedAtMs; if (mainThreadHeartbeatPending.get() && postedAtMs > 0) { long stalledMs = nowMs - postedAtMs; - PlaybackRecoveryMonitor recovery = playbackRecoveryMonitor; - if (recovery != null) recovery.onStall(postedAtMs, nowMs, playbackTraceId); - if (SpiderDebug.isEnabled() && stalledMs >= MAIN_THREAD_STALL_THRESHOLD_MS + if (stalledMs >= MAIN_THREAD_STALL_THRESHOLD_MS && (lastMainThreadStallLogAtMs == 0 || nowMs - lastMainThreadStallLogAtMs >= MAIN_THREAD_STALL_LOG_INTERVAL_MS)) { lastMainThreadStallLogAtMs = nowMs; logMainThreadStall(stalledMs); } } else if (mainThreadHeartbeatPending.compareAndSet(false, true)) { - PlaybackRecoveryMonitor recovery = playbackRecoveryMonitor; - if (recovery != null) recovery.onHeartbeat(); mainThreadHeartbeatPostedAtMs = nowMs; if (!mainHandler.post(mainThreadHeartbeatRunnable)) { mainThreadHeartbeatPending.set(false); @@ -4293,16 +3506,8 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private void refreshPlaybackState() { if (released || mediaItem == null || playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED || playerError != null) return; - updateHlsAdBoundary(cachedPositionMs); updatePreloadCacheOverlay(); if (currentLikelyHls) requestHlsPreload(cachedPositionMs); - // Always try to get duration if we don't have it cached, to recover from missing events. - if (initialized && cachedDurationMs <= 0) { - long timelineDurationMs = doublePropertyMs("duration", -1); - if (timelineDurationMs > 0 && timelineDurationMs != cachedDurationMs) { - cachedDurationMs = timelineDurationMs; - } - } invalidateState(); startStateRefresh(); } @@ -4363,8 +3568,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private void validateEarlyEndFile() { if (released || stopping || fileLoaded || eofReached || playerError != null || playbackState != Player.STATE_BUFFERING) return; - // An idle native player is expected until the visible Surface is bound. - if (pendingSurfaceLoadGeneration != C.INDEX_UNSET) return; if (idleActive) { fail(classifyLoadError(null, "idle-active=true"), PlaybackException.ERROR_CODE_IO_UNSPECIFIED); } else { @@ -4824,18 +4027,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs || lower.contains("video:") || lower.contains("audio:") || lower.contains("found 'hls'") - || lower.contains("iso detected") - || lower.contains("iso image") - || lower.contains("blu-ray") - || lower.contains("aacs") - || lower.contains("bd+") - || lower.contains("bdnav: cfg_title") - || lower.contains("bdnav: hdmv entered") - || lower.contains("bdnav: menu start") - || lower.contains("discnav action=") - || lower.contains("discnav pump") - || lower.contains("discnav explicit menu override") - || lower.contains("bdnav-debug") || lower.contains("opening") || lower.contains("lavf") || lower.contains("demux") @@ -5010,7 +4201,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } if (!isoTrackListDumped && !TextUtils.isEmpty(currentIsoUri)) { isoTrackListDumped = true; - PlaybackTrace.log("mpv", playbackTraceId, "async track snapshot count=%d", count); + MPVLib.dumpTrackList(); } List infos = new ArrayList<>(); int audioIndex = 0; @@ -5039,9 +4230,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } TrackInfo selectedAudioInfo = findTrack( infos, C.TRACK_TYPE_AUDIO, selectedAudio); - selectedAudioTrackDiagnostics = selectedAudioInfo == null - ? AudioPlaybackDiagnostics.Track.empty() - : selectedAudioInfo.toAudioPlaybackTrack(cachedAudioCodecProfile); TrackInfo firstVideoInfo = firstTrack(infos, C.TRACK_TYPE_VIDEO); availableVideoTrackDiagnostics = firstVideoInfo == null ? VideoTrackDiagnostics.empty() @@ -5087,7 +4275,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private boolean maybeSelectPreferredDirectAudio(List infos, String selectedAudio) { if (directAudioApplied || audioTrackManuallySelected || !initialized - || !"mediacodec_embed".equals(activeVideoOutputVo())) return false; + || !"mediacodec_embed".equals(videoOutputVo())) return false; TrackInfo selected = findTrack(infos, C.TRACK_TYPE_AUDIO, selectedAudio); if (selected == null) return false; List candidates = new ArrayList<>(); @@ -5097,8 +4285,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs info.id, info.lang, info.codec, info.title, info.channels)); } MpvDirectAudioPolicy.Selection selection = MpvDirectAudioPolicy.select( - candidates, selected.id, activeAudioSpdif, - config.multichannelPcm()); + candidates, selected.id, config.audioSpdif()); directAudioApplied = true; preferAacApplied = true; TrackInfo target = selected; @@ -5109,13 +4296,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs break; } } - if (selection.changed()) { - automaticAudioOriginalTrack = selected.toAudioPlaybackTrack( - cachedAudioCodecProfile); - automaticAudioActiveTrack = target.toAudioPlaybackTrack(""); - automaticAudioDowngradeReason = selection.reason(); - setMpvTrack(C.TRACK_TYPE_AUDIO, selection.id()); - } + if (selection.changed()) setMpvTrack(C.TRACK_TYPE_AUDIO, selection.id()); SpiderDebug.log("mpv", "direct automatic audio selection changed=%s reason=%s previous=%s/%s/%dch/%s selected=%s/%s/%dch/%s", selection.changed(), selection.reason(), selected.id, selected.codec, selected.channels, selected.lang, target.id, target.codec, @@ -5169,6 +4350,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs if (released || !initialized || shouldDeferStartupMetadataRefresh()) return; currentChapter = intProperty("chapter", currentChapter); List chapters = parseChapters(stringProperty("chapter-list", "")); + if (chapters.isEmpty()) chapters = readChaptersFromProperties(); updateCurrentChapters(chapters); } @@ -5509,7 +4691,14 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs return TextUtils.isEmpty(codec) ? MimeTypes.TEXT_UNKNOWN : MimeTypes.BASE_TYPE_TEXT + "/" + codec; } if (info.type == C.TRACK_TYPE_AUDIO) { - return audioSampleMimeType(codec); + if (codec.contains("aac")) return MimeTypes.AUDIO_AAC; + if (codec.contains("ac3")) return MimeTypes.AUDIO_AC3; + if (codec.contains("eac3") || codec.contains("e-ac-3")) return MimeTypes.AUDIO_E_AC3; + if (codec.contains("opus")) return MimeTypes.AUDIO_OPUS; + if (codec.contains("vorbis")) return MimeTypes.AUDIO_VORBIS; + if (codec.contains("flac")) return MimeTypes.AUDIO_FLAC; + if (codec.contains("mp3")) return MimeTypes.AUDIO_MPEG; + return MimeTypes.BASE_TYPE_AUDIO + "/" + (TextUtils.isEmpty(codec) ? "unknown" : codec); } if (codec.contains("hevc") || codec.contains("h265")) return MimeTypes.VIDEO_H265; if (codec.contains("h264") || codec.contains("avc")) return MimeTypes.VIDEO_H264; @@ -5524,10 +4713,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs return propertyCache.getDouble(property, fallback); } - static String audioSampleMimeType(String codec) { - return MpvAudioMimeTypes.fromCodec(codec); - } - private long doublePropertyMs(String property, long fallback) { try { Double value = mpvGetPropertyDouble(property); @@ -5666,22 +4851,10 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs observedCurrentVo = false; observedHwdecCurrent = false; cachedCurrentVo = null; - androidFelActive = false; - videoFrameSubmitted = false; - firstVideoFrameReported = false; - newlyRenderedFirstFrame = false; cachedCurrentGpuContext = null; cachedGpuApi = null; cachedCurrentAo = null; cachedAudioDevice = null; - cachedAudioFormat = null; - cachedAudioOutFormat = null; - cachedAudioDecoder = null; - cachedAudioCodecProfile = null; - cachedAudioChannels = 0; - cachedAudioSampleRate = 0; - cachedAudioOutChannels = 0; - cachedAudioOutSampleRate = 0; cachedHwdecCurrent = null; cachedAvSyncSeconds = 0; cachedDisplayFps = 0; @@ -5697,55 +4870,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs cachedDisplaySyncActive = false; } - private void resetAudioPlaybackDiagnostics() { - selectedAudioTrackDiagnostics = AudioPlaybackDiagnostics.Track.empty(); - automaticAudioOriginalTrack = AudioPlaybackDiagnostics.Track.empty(); - automaticAudioActiveTrack = AudioPlaybackDiagnostics.Track.empty(); - dtsHdFallbackSourceTrack = AudioPlaybackDiagnostics.Track.empty(); - automaticAudioDowngradeReason = ""; - } - - private void clearAutomaticAudioDowngrade() { - automaticAudioOriginalTrack = AudioPlaybackDiagnostics.Track.empty(); - automaticAudioActiveTrack = AudioPlaybackDiagnostics.Track.empty(); - automaticAudioDowngradeReason = ""; - } - - private boolean sameAudioTrack(AudioPlaybackDiagnostics.Track first, - AudioPlaybackDiagnostics.Track second) { - if (first == null || second == null || !first.available() || !second.available()) { - return false; - } - return TextUtils.equals(first.codec(), second.codec()) - && (first.channels() <= 0 || second.channels() <= 0 - || first.channels() == second.channels()) - && (first.sampleRate() <= 0 || second.sampleRate() <= 0 - || first.sampleRate() == second.sampleRate()); - } - - private boolean isRawPcmTrack(AudioPlaybackDiagnostics.Track track) { - return track != null && "PCM".equalsIgnoreCase(track.codec()); - } - - private AudioPlaybackDiagnostics.DecodeMode mpvAudioDecodeMode(String decoderName) { - if (TextUtils.isEmpty(decoderName)) { - return AudioPlaybackDiagnostics.DecodeMode.UNKNOWN; - } - String lower = decoderName.toLowerCase(Locale.US); - // The patched FFmpeg MediaCodec decoders are named _mediacodec. - if (lower.contains("mediacodec")) { - return AudioPlaybackDiagnostics.DecodeMode.HARDWARE; - } - // A spdif decoder is an encoded/direct path; wait for audio-out-params - // to identify the concrete output mode instead of calling it PCM soft decode. - if (lower.startsWith("spdif_")) { - return AudioPlaybackDiagnostics.DecodeMode.UNKNOWN; - } - // Once mpv exposes a concrete non-MediaCodec audio decoder, it is the - // FFmpeg software path (including codec-specific lavc decoder names). - return AudioPlaybackDiagnostics.DecodeMode.SOFTWARE; - } - private void resetVideoMetadataCache() { cachedVideoIntProperties.clear(); cachedContainerFps = 0; @@ -5875,59 +4999,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs stopMainThreadWatchdog(); } - private void maybeRetryDtsHdAsCore(String line) { - if (!initialized || mediaItem == null || !loadStarted) return; - if (!MpvDtsHdFallbackPolicy.shouldInspectAudioState( - activeAudioSpdif, line, dtsHdCoreFallbackAttempted)) return; - // firstNonEmpty(cached, query()) eagerly queried native code for every log - // line, even with PCM output or a populated cache. Only a real DTS-HD - // AudioTrack init failure may need a missing property, and only once. - String audioFormat = TextUtils.isEmpty(cachedAudioFormat) - ? stringProperty("audio-params/format", "") : cachedAudioFormat; - String codecProfile = TextUtils.isEmpty(cachedAudioCodecProfile) - ? stringProperty("current-tracks/audio/codec-profile", "") : cachedAudioCodecProfile; - MpvDtsHdFallbackPolicy.Decision decision = - MpvDtsHdFallbackPolicy.evaluate(activeAudioSpdif, - audioFormat, codecProfile, line, - dtsHdCoreFallbackAttempted); - if (!decision.retry()) return; - dtsHdCoreFallbackAttempted = true; - boolean applied = setRuntimeStringChecked( - "audio-spdif", decision.codecs()); - if (applied) { - activeAudioSpdif = decision.codecs(); - dtsHdFallbackSourceTrack = selectedAudioTrackDiagnostics; - } - SpiderDebug.log("mpv-audio", - "dts-hd fallback attempted=true applied=%s reason=%s format=%s profile=%s codecs=%s", - applied, decision.reason(), audioFormat, codecProfile, - applied ? activeAudioSpdif : "unchanged"); - } - - private void restoreConfiguredAudioSpdifForNewMedia() { - if (!TextUtils.equals(activeAudioSpdif, config.audioSpdif())) { - boolean restored = setRuntimeStringChecked( - "audio-spdif", config.audioSpdif()); - if (restored) activeAudioSpdif = config.audioSpdif(); - SpiderDebug.log("mpv-audio", - "dts-hd fallback reset restored=%s codecs=%s", - restored, restored ? activeAudioSpdif : "unchanged"); - } - dtsHdCoreFallbackAttempted = false; - dtsHdFallbackSourceTrack = AudioPlaybackDiagnostics.Track.empty(); - } - - private synchronized void appendSubtitleDiagnostic(String text) { - if (subtitleDiagnosticFile == null) return; - File parent = subtitleDiagnosticFile.getParentFile(); - if (parent != null && !parent.exists()) parent.mkdirs(); - String line = System.currentTimeMillis() + " " + text + "\n"; - try (OutputStream out = new FileOutputStream(subtitleDiagnosticFile, true)) { - out.write(line.getBytes(java.nio.charset.StandardCharsets.UTF_8)); - } catch (IOException ignored) { - } - } - private String diagnosticSummary() { List parts = new ArrayList<>(); parts.add("trace=" + playbackTraceId); @@ -5941,10 +5012,12 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs parts.add("position=" + cachedPositionMs); parts.add("duration=" + cachedDurationMs); parts.add("tracks=" + currentTracks.getGroups().size()); - // A failure report must not wait on the decoder that just failed. - parts.add("audio-decoder=" + cachedAudioDecoder); - parts.add("hwdec=" + cachedHwdecCurrent); - parts.add("vo=" + cachedCurrentVo); + parts.add("path=" + MpvDiagnosticsPolicy.sourceSummary(firstNonEmpty(stringProperty("path", ""), currentPlayableUri))); + parts.add("file-format=" + stringProperty("file-format", "")); + parts.add("video-codec=" + stringProperty("video-codec", "")); + parts.add("audio-codec=" + stringProperty("audio-codec", "")); + parts.add("hwdec=" + stringProperty("hwdec-current", "")); + parts.add("vo=" + stringProperty("current-vo", stringProperty("vo-configured", ""))); parts.add("shader=" + (lutShader == null ? "-" : lutShader.diagnostics())); parts.add("end-file=" + endFileReasonName(lastEndFileReason) + "/" + mpvErrorName(lastEndFileError) + "(" + lastEndFileError + ")"); if (!TextUtils.isEmpty(lastEndFileErrorText)) parts.add("end-file-text=" + MpvDiagnosticsPolicy.redactSensitive(lastEndFileErrorText)); @@ -6015,17 +5088,13 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private int mpvSetOptionString(String property, String value) { long startedAtMs = beginMpvNativeCall("set-option", property); try { - int result = MPVLib.setOptionString(property, value); - if ("msg-level".equals(property) && result >= 0) diagnosticBaseMsgLevel = value; - diagnostics.option(property, value, result, false); - return result; + return MPVLib.setOptionString(property, value); } finally { endMpvNativeCall(startedAtMs, "set-option", property); } } private Integer mpvGetPropertyInt(String property) { - if (propertySnapshot.contains(property)) return propertySnapshot.integer(property); long startedAtMs = beginMpvNativeCall("get-int", property); try { return MPVLib.getPropertyInt(property); @@ -6035,7 +5104,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } private Double mpvGetPropertyDouble(String property) { - if (propertySnapshot.contains(property)) return propertySnapshot.decimal(property); long startedAtMs = beginMpvNativeCall("get-double", property); try { return MPVLib.getPropertyDouble(property); @@ -6045,7 +5113,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } private Boolean mpvGetPropertyBoolean(String property) { - if (propertySnapshot.contains(property)) return propertySnapshot.flag(property); long startedAtMs = beginMpvNativeCall("get-boolean", property); try { return MPVLib.getPropertyBoolean(property); @@ -6055,7 +5122,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } private String mpvGetPropertyString(String property) { - if (propertySnapshot.contains(property)) return propertySnapshot.string(property); long startedAtMs = beginMpvNativeCall("get-string", property); try { return MPVLib.getPropertyString(property); @@ -6067,9 +5133,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private int mpvSetPropertyInt(String property, int value) { long startedAtMs = beginMpvNativeCall("set-int", property); try { - int result = MPVLib.setPropertyInt(property, value); - if (result >= 0) propertySnapshot.acceptedWrite(property, value); - return result; + return MPVLib.setPropertyInt(property, value); } finally { endMpvNativeCall(startedAtMs, "set-int", property); } @@ -6078,9 +5142,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private int mpvSetPropertyDouble(String property, double value) { long startedAtMs = beginMpvNativeCall("set-double", property); try { - int result = MPVLib.setPropertyDouble(property, value); - if (result >= 0) propertySnapshot.acceptedWrite(property, value); - return result; + return MPVLib.setPropertyDouble(property, value); } finally { endMpvNativeCall(startedAtMs, "set-double", property); } @@ -6089,9 +5151,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private int mpvSetPropertyBoolean(String property, boolean value) { long startedAtMs = beginMpvNativeCall("set-boolean", property); try { - int result = MPVLib.setPropertyBoolean(property, value); - if (result >= 0) propertySnapshot.acceptedWrite(property, value); - return result; + return MPVLib.setPropertyBoolean(property, value); } finally { endMpvNativeCall(startedAtMs, "set-boolean", property); } @@ -6100,10 +5160,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private int mpvSetPropertyString(String property, String value) { long startedAtMs = beginMpvNativeCall("set-string", property); try { - int result = MPVLib.setPropertyString(property, value); - diagnostics.option(property, value, result, true); - if (result >= 0) propertySnapshot.acceptedWrite(property, value); - return result; + return MPVLib.setPropertyString(property, value); } finally { endMpvNativeCall(startedAtMs, "set-string", property); } @@ -6112,14 +5169,9 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private int mpvCommand(String[] command) { String target = command == null || command.length == 0 ? "unknown" : command[0]; long startedAtMs = beginMpvNativeCall("command", target); - long diagnosticLoadId = "loadfile".equals(target) ? diagnostics.loadRequested() : 0; - int result = -1; try { - result = MPVLib.command(command); - diagnostics.command(target, 0, result, "returned", startedAtMs < 0 ? -1 : SystemClock.elapsedRealtime() - startedAtMs); - return result; + return MPVLib.command(command); } finally { - if (diagnosticLoadId != 0) diagnostics.loadReturned(diagnosticLoadId, result); endMpvNativeCall(startedAtMs, "command", target); } } @@ -6128,9 +5180,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs String target = command == null || command.length == 0 ? "unknown" : command[0]; long startedAtMs = beginMpvNativeCall("enqueue-command", target); try { - int result = MPVLib.enqueueCommand(requestId, command); - diagnostics.command(target, requestId, result, "submitted", startedAtMs < 0 ? -1 : SystemClock.elapsedRealtime() - startedAtMs); - return result; + return MPVLib.enqueueCommand(requestId, command); } finally { endMpvNativeCall(startedAtMs, "enqueue-command", target); } @@ -6139,10 +5189,7 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs private int mpvObserveProperty(String property, int format) { long startedAtMs = beginMpvNativeCall("observe", property); try { - int result = MPVLib.observeProperty(property, format); - propertySnapshot.diagnosticRegister(property, result); - diagnostics.registration(property, format, result); - return result; + return MPVLib.observeProperty(property, format); } finally { endMpvNativeCall(startedAtMs, "observe", property); } @@ -6193,24 +5240,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } } - private void syncDiagnosticLogLevel() { - if (!com.github.catvod.crawler.DebugLogStore.isEnabled()) diagnostics.felBindProbe().cancel(); - if (!initialized || released) return; - boolean enabled = SpiderDebug.isEnabled(); - boolean deep = enabled && diagnostics.depthActive(); - int categories = com.github.catvod.crawler.DebugLogStore.categories(); - if (enabled == diagnosticLogLevelApplied && (!enabled || deep == diagnosticDepthApplied && categories == diagnosticCategoriesApplied) - || diagnosticBaseMsgLevel == null) return; - String value = enabled ? MpvDiagnosticsPolicy.diagnosticLogLevel(diagnosticBaseMsgLevel, categories, deep) : diagnosticBaseMsgLevel; - // A diagnostic-only option write on the existing owner looper; never a synchronous query. - String previous = diagnosticAppliedMsgLevel; - diagnosticAppliedMsgLevel = value; - if (setRuntimeStringChecked("msg-level", value)) { - diagnosticLogLevelApplied = enabled; diagnosticDepthApplied = deep; diagnosticCategoriesApplied = categories; - } - else diagnosticAppliedMsgLevel = previous; - } - private void setRuntimeString(String name, String value) { setRuntimeStringChecked(name, value); } @@ -6219,14 +5248,14 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs if (value == null) value = ""; if (!initialized) return false; try { - return mpvSetPropertyString(name, value) >= 0; + mpvSetPropertyString(name, value); + return true; } catch (Throwable ignored) { return false; } } private void observe(String property, int format) { - propertySnapshot.register(property); if (!observedPropertyNames.add(property)) return; try { mpvObserveProperty(property, format); @@ -6271,10 +5300,13 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs safeSetPropertyString("sub-border-style", style.borderStyle); safeSetPropertyDouble("sub-border-size", style.borderSize); safeSetPropertyDouble("sub-shadow-offset", style.shadowOffset); - // sub-* supplies the plain-text/default caption style. ASS keeps its own - // font/style under sub-ass-override=scale; writing FontName through - // sub-ass-style-overrides would override every script's named styles. - // Leave explicit mpv.conf/Lua overrides under their existing ownership. + safeSetPropertyString("sub-ass-style-overrides", assStyleOverrides(style)); + // Force MPV to re-render ASS/SSA subtitles with the new sub-scale value. + // Toggling sub-visibility triggers layout recalculation. Save and restore + // the original state so we don't accidentally enable subtitles if user disabled them. + boolean wasVisible = booleanProperty("sub-visibility", true); + safeSetPropertyBoolean("sub-visibility", false); + safeSetPropertyBoolean("sub-visibility", wasVisible); } private double subtitleScale() { @@ -6334,6 +5366,23 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs return String.format(Locale.US, "%.4f/%.4f/%.4f/%.4f", Color.red(color) / 255.0, Color.green(color) / 255.0, Color.blue(color) / 255.0, Color.alpha(color) / 255.0); } + private String assStyleOverrides(CaptionStyle style) { + return "FontName=" + style.font + + ",Bold=" + (style.bold ? "1" : "0") + + ",Italic=" + (style.italic ? "1" : "0") + + ",PrimaryColour=" + assColor(style.foreground) + + ",OutlineColour=" + assColor(style.edge) + + ",BackColour=" + assColor(style.back) + + ",BorderStyle=" + ("background-box".equals(style.borderStyle) ? "4" : "1") + + ",Outline=" + String.format(Locale.US, "%.1f", style.borderSize) + + ",Shadow=" + String.format(Locale.US, "%.1f", style.shadowOffset); + } + + private String assColor(int color) { + int alpha = 255 - Color.alpha(color); + return String.format(Locale.US, "&H%02X%02X%02X%02X", alpha, Color.blue(color), Color.green(color), Color.red(color)); + } + private void safeSetPropertyDouble(String property, double value) { try { mpvSetPropertyDouble(property, value); @@ -6398,12 +5447,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs } IsoSessionManager.closeUri(currentIsoUri); currentIsoUri = null; - discMenuAvailable = false; - discNavigationActive = false; - mainHandler.removeCallbacks(discRebufferRunnable); - discRebufferTracker.reset(); - discInputQuietUntilMs = 0; - setDiscMenuActive(false); isoTrackListDumped = false; } @@ -6600,11 +5643,6 @@ public final class MpvPlayer extends SimpleBasePlayer implements MPVLib.EventObs codec, decoder, colorInfo); } - AudioPlaybackDiagnostics.Track toAudioPlaybackTrack(String profile) { - return AudioPlaybackDiagnostics.track(codec, title, profile, - sampleMimeType(this), channels, sampleRate, lang, bitrate); - } - private String sourceCodecs(int profile, int level) { if (profile <= 0) return codec; String value = String.format(Locale.US, "dvhe.%02d", profile); diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvPlayerConfig.java b/app/src/main/java/androidx/media3/mpvplayer/MpvPlayerConfig.java index 578ff05303..8a0b2797d4 100644 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvPlayerConfig.java +++ b/app/src/main/java/androidx/media3/mpvplayer/MpvPlayerConfig.java @@ -27,7 +27,6 @@ public final class MpvPlayerConfig { private final String gpuApi; private final String ao; private final String audioSpdif; - private final boolean multichannelPcm; private final String logLevel; private final boolean openglEs; private final boolean tlsVerify; @@ -42,7 +41,6 @@ public final class MpvPlayerConfig { private final boolean automaticCacheTime; private final boolean automaticHlsVariant; private final boolean deferStartupTrackRefresh; - private final boolean restoreFelAutomaticSubtitles; private final Map extraOptions; private MpvPlayerConfig(Builder builder) { @@ -57,7 +55,6 @@ public final class MpvPlayerConfig { gpuApi = builder.gpuApi; ao = builder.ao; audioSpdif = builder.audioSpdif; - multichannelPcm = builder.multichannelPcm; logLevel = builder.logLevel; openglEs = builder.openglEs; tlsVerify = builder.tlsVerify; @@ -72,7 +69,6 @@ public final class MpvPlayerConfig { automaticCacheTime = builder.automaticCacheTime; automaticHlsVariant = builder.automaticHlsVariant; deferStartupTrackRefresh = builder.deferStartupTrackRefresh; - restoreFelAutomaticSubtitles = builder.restoreFelAutomaticSubtitles; extraOptions = Collections.unmodifiableMap(new LinkedHashMap<>(builder.extraOptions)); } @@ -126,10 +122,6 @@ public final class MpvPlayerConfig { return audioSpdif; } - public boolean multichannelPcm() { - return multichannelPcm; - } - public String logLevel() { return logLevel; } @@ -186,10 +178,6 @@ public final class MpvPlayerConfig { return deferStartupTrackRefresh; } - public boolean restoreFelAutomaticSubtitles() { - return restoreFelAutomaticSubtitles; - } - public Map extraOptions() { return extraOptions; } @@ -208,7 +196,6 @@ public final class MpvPlayerConfig { private String gpuApi = ""; private String ao = "audiotrack,opensles"; private String audioSpdif = ""; - private boolean multichannelPcm; private String logLevel = "all=v"; private boolean openglEs = true; private boolean tlsVerify = true; @@ -223,7 +210,6 @@ public final class MpvPlayerConfig { private boolean automaticCacheTime; private boolean automaticHlsVariant; private boolean deferStartupTrackRefresh; - private boolean restoreFelAutomaticSubtitles; private Builder(Context context) { Context app = context.getApplicationContext(); @@ -292,11 +278,6 @@ public final class MpvPlayerConfig { return this; } - public Builder multichannelPcm(boolean multichannelPcm) { - this.multichannelPcm = multichannelPcm; - return this; - } - public Builder logLevel(String logLevel) { this.logLevel = logLevel; return this; @@ -362,11 +343,6 @@ public final class MpvPlayerConfig { return this; } - public Builder restoreFelAutomaticSubtitles(boolean restore) { - restoreFelAutomaticSubtitles = restore; - return this; - } - public Builder option(String name, String value) { extraOptions.put(name, value); return this; diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvPropertyCache.java b/app/src/main/java/androidx/media3/mpvplayer/MpvPropertyCache.java index 49d2bf79e7..8e0df44b58 100644 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvPropertyCache.java +++ b/app/src/main/java/androidx/media3/mpvplayer/MpvPropertyCache.java @@ -6,68 +6,47 @@ import java.util.Map; final class MpvPropertyCache { private final Map values = new HashMap<>(); - private MpvPropertySnapshot.TrackList tracks = MpvPropertySnapshot.TrackList.empty(); void put(String property, Object value) { if (property == null || property.isEmpty()) return; - if ("track-list".equals(property)) { - tracks = value instanceof MpvPropertySnapshot.TrackList list - ? list : MpvPropertySnapshot.TrackList.empty(); - } if (value == null) values.remove(property); else values.put(property, value); } boolean contains(String property) { - return value(property) != null; + return values.containsKey(property); } int getInt(String property, int fallback) { - Object value = value(property); + Object value = values.get(property); return value instanceof Number number ? number.intValue() : fallback; } long getLong(String property, long fallback) { - Object value = value(property); + Object value = values.get(property); return value instanceof Number number ? number.longValue() : fallback; } double getDouble(String property, double fallback) { - Object value = value(property); + Object value = values.get(property); if (!(value instanceof Number number)) return fallback; double result = number.doubleValue(); return Double.isFinite(result) ? result : fallback; } boolean getBoolean(String property, boolean fallback) { - Object value = value(property); + Object value = values.get(property); if (value instanceof Boolean flag) return flag; if (value instanceof Number number) return number.longValue() != 0; return fallback; } String getString(String property, String fallback) { - Object value = value(property); + Object value = values.get(property); return value instanceof String text ? text : fallback; } void clear() { values.clear(); - tracks = MpvPropertySnapshot.TrackList.empty(); } - - private Object value(String property) { - if (property == null) return null; - if ("track-list/count".equals(property)) return tracks.entries().size(); - if (!property.startsWith("track-list/")) return values.get(property); - String[] parts = property.split("/", 3); - if (parts.length != 3) return null; - try { - int index = Integer.parseInt(parts[1]); - return index < 0 || index >= tracks.entries().size() - ? null : tracks.entries().get(index).get(parts[2]); - } catch (NumberFormatException error) { - return null; - } - } -} +} \ No newline at end of file diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvPropertySnapshot.java b/app/src/main/java/androidx/media3/mpvplayer/MpvPropertySnapshot.java deleted file mode 100644 index 772ab079dd..0000000000 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvPropertySnapshot.java +++ /dev/null @@ -1,226 +0,0 @@ -package androidx.media3.mpvplayer; - -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; -import com.google.gson.JsonPrimitive; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** Event-owned metadata. Missing metadata must never trigger a synchronous JNI read. */ -final class MpvPropertySnapshot { - static final int MAX_JSON_CHARS = 512 * 1024; - private final Set observed = new HashSet<>(); - private final Map values = new HashMap<>(); - private TrackList tracks = TrackList.empty(); - private long generation; - record DiagnosticValue(Object value, long generation, long updatedAtMs, String status) {} - record DiagnosticSnapshot(long generation, Map values, Map registrations, - TrackList tracks, boolean tracksObserved, long lateEvents, long nodeErrors) {} - private final Map diagnosticValues = new HashMap<>(); - private final Map registrations = new HashMap<>(); - private TrackList diagnosticTracks = TrackList.empty(); - private boolean diagnosticTracksObserved; - private long diagnosticGeneration, diagnosticCapture = -1, lateEvents, nodeErrors; - - synchronized void diagnosticBegin(long nextGeneration) { - diagnosticGeneration = nextGeneration; - diagnosticValues.entrySet().removeIf(entry -> !Set.of("mpv-version", "ffmpeg-version", "options/msg-level", "hwdec", - "gpu-api", "volume", "mute", "speed", "audio-device", "audio-delay", "audio-spdif").contains(entry.getKey())); - diagnosticValues.replaceAll((name, value) -> new DiagnosticValue(value.value(), nextGeneration, value.updatedAtMs(), value.status())); - diagnosticTracks = TrackList.empty(); diagnosticTracksObserved = false; - } - - synchronized void diagnosticRegister(String property, int result) { registrations.put(property, result); } - - synchronized void diagnosticUpdate(long eventGeneration, String property, Object value, long nowMs, long capture) { - if (diagnosticCapture != capture) { - diagnosticCapture = capture; diagnosticValues.clear(); diagnosticTracks = TrackList.empty(); - diagnosticTracksObserved = false; lateEvents = nodeErrors = 0; - } - if (eventGeneration != diagnosticGeneration) { lateEvents++; return; } - if ("track-list".equals(property)) { - diagnosticTracks = value instanceof TrackList list ? list : TrackList.empty(); - diagnosticTracksObserved = value instanceof TrackList; - if (!diagnosticTracks.valid()) nodeErrors++; - } else if (MpvDiagnosticCollector.propertyAllowed(property)) { - Object bounded = value instanceof String text && text.length() > 2048 ? text.substring(0, 2048) : value; - diagnosticValues.put(property, new DiagnosticValue(bounded, eventGeneration, nowMs, - value == null ? "unavailable" : "known")); - } - } - - synchronized DiagnosticSnapshot diagnosticSnapshot(long capture) { - boolean current = diagnosticCapture == capture; - return new DiagnosticSnapshot(diagnosticGeneration, current ? Map.copyOf(diagnosticValues) : Map.of(), Map.copyOf(registrations), - current ? diagnosticTracks : TrackList.empty(), current && diagnosticTracksObserved, - current ? lateEvents : 0, current ? nodeErrors : 0); - } - - record TrackList(List> entries, boolean valid) { - static TrackList empty() { - return new TrackList(Collections.emptyList(), true); - } - - static TrackList parse(String json) { - if (json == null) return empty(); - if (json.length() > MAX_JSON_CHARS || !boundedDepth(json)) return invalid(); - try { - JsonElement root = JsonParser.parseString(json); - if (root.isJsonNull()) return empty(); - if (!root.isJsonArray() || root.getAsJsonArray().size() > 1024) return invalid(); - List> result = new ArrayList<>(); - for (JsonElement element : root.getAsJsonArray()) { - if (!element.isJsonObject() || element.getAsJsonObject().size() > 128) return invalid(); - Map fields = new HashMap<>(); - for (Map.Entry field : element.getAsJsonObject().entrySet()) { - if (!field.getValue().isJsonPrimitive()) continue; - JsonPrimitive value = field.getValue().getAsJsonPrimitive(); - Object scalar = value.isBoolean() ? value.getAsBoolean() - : value.isNumber() ? value.getAsNumber() : value.getAsString(); - fields.put(field.getKey(), scalar); - } - result.add(Collections.unmodifiableMap(fields)); - } - return new TrackList(Collections.unmodifiableList(result), true); - } catch (RuntimeException error) { - return invalid(); - } - } - - private static TrackList invalid() { - return new TrackList(Collections.emptyList(), false); - } - - private static boolean boundedDepth(String text) { - int depth = 0; - boolean quoted = false; - boolean escaped = false; - for (int i = 0; i < text.length(); i++) { - char c = text.charAt(i); - if (quoted) { - if (escaped) escaped = false; - else if (c == '\\') escaped = true; - else if (c == '"') quoted = false; - } else if (c == '"') quoted = true; - else if (c == '[' || c == '{') { - if (++depth > 12) return false; - } else if ((c == ']' || c == '}') && --depth < 0) return false; - } - return depth == 0 && !quoted; - } - } - - synchronized void register(String property) { - observed.add(property); - } - - synchronized boolean contains(String property) { - return property.startsWith("track-list/") - || observed.contains(property) && isMetadataRead(property); - } - - private static boolean isMetadataRead(String property) { - if (property.startsWith("video-params/") || property.startsWith("video-out-params/")) return true; - return switch (property) { - case "track-list", "chapter", "chapter-list", "width", "height", - "video-bitrate", "audio-bitrate", "container-fps", "estimated-vf-fps", - "vid", "aid", "sid", "secondary-sid", "sub-visibility", - "current-tracks/video/id", "current-tracks/audio/id", - "current-tracks/sub/id", "current-tracks/sub2/id", - "current-tracks/video/demux-w", "current-tracks/video/demux-h" -> true; - // Preserve existing stale-observer cache probes and the one-shot - // DTS-HD fallback query. This is not a global getter replacement. - default -> false; - }; - } - - synchronized void beginFile(long nextGeneration) { - generation = nextGeneration; - tracks = TrackList.empty(); - // Option observations can stay unchanged across a new file. File data cannot. - values.keySet().removeIf(key -> key.startsWith("current-tracks/") - || key.startsWith("video-") - || key.startsWith("audio-") && !key.equals("audio-device") - || key.startsWith("demuxer-cache-") || key.startsWith("time-pos") - || key.startsWith("duration") || key.startsWith("track-list") - || key.equals("width") || key.equals("height") - || key.equals("container-fps") || key.equals("estimated-vf-fps") - || key.equals("chapter") || key.equals("chapter-list")); - } - - synchronized boolean update(long eventGeneration, String property, Object value) { - if (eventGeneration != generation) return false; - if (property.equals("track-list")) { - tracks = value instanceof TrackList list ? list : TrackList.empty(); - } else if (value == null) { - values.remove(property); - } else { - values.put(property, value); - } - return true; - } - - synchronized void acceptedWrite(String property, Object value) { - if (observed.contains(property)) values.put(property, value); - } - - private synchronized Object value(String property) { - if (property.equals("track-list/count")) return tracks.entries().size(); - if (!property.startsWith("track-list/")) return values.get(property); - String[] parts = property.split("/", 3); - if (parts.length != 3) return null; - try { - int index = Integer.parseInt(parts[1]); - return index < 0 || index >= tracks.entries().size() - ? null : tracks.entries().get(index).get(parts[2]); - } catch (NumberFormatException error) { - return null; - } - } - - String string(String property) { - Object value = value(property); - return value instanceof Boolean flag ? flag ? "yes" : "no" - : value == null ? null : value.toString(); - } - - Integer integer(String property) { - try { - Object value = value(property); - if (value == null) return null; - long number = value instanceof Number n ? n.longValue() : Long.parseLong(value.toString()); - return number < Integer.MIN_VALUE || number > Integer.MAX_VALUE ? null : (int) number; - } catch (NumberFormatException error) { - return null; - } - } - - Double decimal(String property) { - try { - Object value = value(property); - if (value == null) return null; - double number = value instanceof Number n ? n.doubleValue() : Double.parseDouble(value.toString()); - return Double.isFinite(number) ? number : null; - } catch (NumberFormatException error) { - return null; - } - } - - Boolean flag(String property) { - Object value = value(property); - if (value instanceof Boolean flag) return flag; - if (value instanceof Number n) return n.intValue() != 0; - if (value == null) return null; - return switch (value.toString()) { - case "yes", "true", "1" -> true; - case "no", "false", "0" -> false; - default -> null; - }; - } -} diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvSurfaceLoadPolicy.java b/app/src/main/java/androidx/media3/mpvplayer/MpvSurfaceLoadPolicy.java deleted file mode 100644 index 886b7b5016..0000000000 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvSurfaceLoadPolicy.java +++ /dev/null @@ -1,15 +0,0 @@ -package androidx.media3.mpvplayer; - -/** Side-effect-free decision for the initial MPV surface gate. */ -final class MpvSurfaceLoadPolicy { - - private MpvSurfaceLoadPolicy() { - } - - static boolean shouldWaitForVideoSurface( - boolean audioOnly, boolean osdSurfaceRequired, - boolean surfaceViewShown, boolean videoSurfaceReady) { - if (audioOnly && !osdSurfaceRequired) return false; - return surfaceViewShown && !videoSurfaceReady; - } -} diff --git a/app/src/main/java/androidx/media3/mpvplayer/MpvSurfaceTeardownPolicy.java b/app/src/main/java/androidx/media3/mpvplayer/MpvSurfaceTeardownPolicy.java index 035242fe7b..0a1034336a 100644 --- a/app/src/main/java/androidx/media3/mpvplayer/MpvSurfaceTeardownPolicy.java +++ b/app/src/main/java/androidx/media3/mpvplayer/MpvSurfaceTeardownPolicy.java @@ -10,10 +10,6 @@ final class MpvSurfaceTeardownPolicy { return true; } - void beginNewMedia() { - terminalReleaseRequested = false; - } - boolean shouldBindSurface() { return !terminalReleaseRequested; } diff --git a/app/src/main/java/com/fongmi/android/tv/App.java b/app/src/main/java/com/fongmi/android/tv/App.java index 53a176364e..be1ff5a426 100644 --- a/app/src/main/java/com/fongmi/android/tv/App.java +++ b/app/src/main/java/com/fongmi/android/tv/App.java @@ -19,7 +19,6 @@ import com.fongmi.android.tv.server.proxy.MultiThreadProxy; import com.fongmi.android.tv.playback.PlaybackRemoteSyncer; import com.fongmi.android.tv.player.PlaybackMemoryMonitor; import com.fongmi.android.tv.player.PlaybackSystemConditionMonitor; -import com.fongmi.android.tv.player.mpv.PlaybackRecoveryMonitor; import com.fongmi.android.tv.remote.RemoteAgent; import com.fongmi.android.tv.setting.AppBranding; import com.fongmi.android.tv.setting.ProxySetting; @@ -45,7 +44,7 @@ public class App extends Application implements Application.ActivityLifecycleCal private final Runnable backgroundServicesStarter = this::startBackgroundServicesNow; - private volatile Activity activity; + private Activity activity; private Hook hook; private Resources resources; @@ -98,7 +97,6 @@ public class App extends Application implements Application.ActivityLifecycleCal @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); - if (PlaybackRecoveryMonitor.isRecoveryProcess(base)) return; WebViewDataDirectoryGuard.clearStaleLock(base); Init.set(base); } @@ -106,14 +104,12 @@ public class App extends Application implements Application.ActivityLifecycleCal @Override public void onCreate() { super.onCreate(); - if (PlaybackRecoveryMonitor.isRecoveryProcess(this)) return; PlaybackMemoryMonitor.process().initialize(this); PlaybackSystemConditionMonitor.process().initialize(this); Setting.applyLanguage(); AppBranding.applyLauncherIcon(this); DebugLogStore.restoreEnabled(); if (DebugLogStore.isEnabled()) { - PlaybackRecoveryMonitor.logPreviousResult(this); Setting.logDebugEnvironment("restore"); PreviousProcessExitLogger.log(this); } @@ -128,7 +124,6 @@ public class App extends Application implements Application.ActivityLifecycleCal // 猫源动作项排最前:它的判定最便宜(只比字符串),且命中就该直接开网页, // 不该让音频/阅读器 handler 先按站点规则把它认走 com.fongmi.android.tv.content.ContentDispatcher.registerHandler(new com.fongmi.android.tv.content.CatActionContentHandler()); - com.fongmi.android.tv.content.ContentDispatcher.registerHandler(new com.fongmi.android.tv.content.GameContentHandler()); com.fongmi.android.tv.content.ContentDispatcher.registerHandler(new com.fongmi.android.tv.content.AudioContentHandler()); com.fongmi.android.tv.content.ContentDispatcher.registerHandler(new com.fongmi.android.tv.content.ReaderContentHandler()); registerReaderFallback(); @@ -140,13 +135,13 @@ public class App extends Application implements Application.ActivityLifecycleCal @Override public void onTrimMemory(int level) { - if (!PlaybackRecoveryMonitor.isRecoveryProcess(this)) PlaybackMemoryMonitor.process().onTrimMemory(level); + PlaybackMemoryMonitor.process().onTrimMemory(level); super.onTrimMemory(level); } @Override public void onLowMemory() { - if (!PlaybackRecoveryMonitor.isRecoveryProcess(this)) PlaybackMemoryMonitor.process().onLowMemory(); + PlaybackMemoryMonitor.process().onLowMemory(); super.onLowMemory(); } diff --git a/app/src/main/java/com/fongmi/android/tv/Startup.java b/app/src/main/java/com/fongmi/android/tv/Startup.java index 10f551897c..fee0b56aff 100644 --- a/app/src/main/java/com/fongmi/android/tv/Startup.java +++ b/app/src/main/java/com/fongmi/android/tv/Startup.java @@ -6,7 +6,6 @@ import androidx.annotation.NonNull; import androidx.startup.Initializer; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.player.mpv.PlaybackRecoveryMonitor; import com.fongmi.android.tv.ui.activity.CrashActivity; import com.github.catvod.bean.Doh; import com.github.catvod.net.OkHttp; @@ -30,8 +29,7 @@ public class Startup implements Initializer { @NonNull @Override public Void create(@NonNull Context context) { - if (PlaybackRecoveryMonitor.isRecoveryProcess(context)) return null; - CaocConfig.Builder.create().trackActivities(true).backgroundMode(CaocConfig.BACKGROUND_MODE_SHOW_CUSTOM).errorActivity(CrashActivity.class).apply(); + CaocConfig.Builder.create().trackActivities(true).backgroundMode(CaocConfig.BACKGROUND_MODE_SILENT).errorActivity(CrashActivity.class).apply(); Logger.addLogAdapter(new AndroidLogAdapter(PrettyFormatStrategy.newBuilder().methodCount(0).showThreadInfo(false).tag("TV").build())); installEventBus(); OkHttp.dns().setDoh(() -> Doh.objectFrom(Setting.getDoh())); diff --git a/app/src/main/java/com/fongmi/android/tv/Updater.java b/app/src/main/java/com/fongmi/android/tv/Updater.java index 08f8664222..dbc0f24489 100644 --- a/app/src/main/java/com/fongmi/android/tv/Updater.java +++ b/app/src/main/java/com/fongmi/android/tv/Updater.java @@ -24,6 +24,7 @@ import com.fongmi.android.tv.utils.Github; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; +import com.fongmi.android.tv.update.GithubProxy; import com.fongmi.android.tv.update.HttpUpdateTransfer; import com.fongmi.android.tv.update.OciArtifact; import com.fongmi.android.tv.update.OciMirror; @@ -32,7 +33,6 @@ import com.fongmi.android.tv.update.UpdateHttp; import com.fongmi.android.tv.update.UpdateRoutePlanner; import com.fongmi.android.tv.update.UpdateTarget; import com.fongmi.android.tv.update.UpdateTransfer; -import com.fongmi.android.tv.utils.GithubProxy; import com.github.catvod.utils.Path; import org.json.JSONArray; @@ -240,8 +240,7 @@ public class Updater implements UpdateTransfer.Callback, UpdateListener { private Update readUpdate(String channel, String manifestUrl, String source, Map headers, String fallbackNotes) { Update update = Update.empty(channel); try { - GithubProxy.Config config = GithubProxy.config(); - String proxiedUrl = config.rewrite(manifestUrl); + String proxiedUrl = com.fongmi.android.tv.utils.GithubProxy.apply(manifestUrl); String text = UpdateHttp.string(proxiedUrl, headers, GITHUB_REQUEST_TIMEOUT_MS); if (TextUtils.isEmpty(text)) throw new IllegalStateException("Empty update manifest: " + manifestUrl); JSONObject object = new JSONObject(text); @@ -348,9 +347,7 @@ public class Updater implements UpdateTransfer.Callback, UpdateListener { private String readReleaseNotes(String tag) { try { - GithubProxy.Config config = GithubProxy.config(); - String proxiedUrl = config.rewrite(Github.getReleaseApi(tag)); - return new JSONObject(UpdateHttp.string(proxiedUrl, GITHUB_API_HEADERS, GITHUB_REQUEST_TIMEOUT_MS)).optString("body"); + return new JSONObject(UpdateHttp.string(com.fongmi.android.tv.utils.GithubProxy.apply(Github.getReleaseApi(tag)), GITHUB_API_HEADERS, GITHUB_REQUEST_TIMEOUT_MS)).optString("body"); } catch (Exception ignored) { return ""; } @@ -384,21 +381,14 @@ public class Updater implements UpdateTransfer.Callback, UpdateListener { FragmentActivity activity = activityRef == null ? null : activityRef.get(); if (activity == null || activity.isFinishing() || activity.isDestroyed()) return; - androidx.appcompat.app.AlertDialog alert = new com.google.android.material.dialog.MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) + new com.google.android.material.dialog.MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) .setTitle(R.string.update_backup_title) .setMessage(R.string.update_backup_message) .setPositiveButton(R.string.update_backup_positive, (dialog, which) -> startBackupAndUpdate(view)) .setNegativeButton(R.string.update_backup_negative, (dialog, which) -> startUpdate(view)) .setNeutralButton(R.string.dialog_negative, (dialog, which) -> view.setEnabled(true)) .setCancelable(false) - .create(); - alert.setOnShowListener(dialog -> { - View buttonPanel = alert.findViewById(com.google.android.material.R.id.buttonPanel); - if (buttonPanel != null) buttonPanel.setFocusable(false); - View positive = alert.getButton(android.content.DialogInterface.BUTTON_POSITIVE); - if (positive != null) positive.requestFocus(); - }); - alert.show(); + .show(); } private void startBackupAndUpdate(View view) { @@ -444,7 +434,7 @@ public class Updater implements UpdateTransfer.Callback, UpdateListener { private List getRoutes(Update update) { try { - GithubProxy.Config github = GithubProxy.config(); + GithubProxy.Config github = GithubProxy.resolve(Setting.getUpdateGithubProxy(), Setting.getUpdateGithubProxyUrl(), Setting.getUpdateGithubProxyMode()); String endpoint = update.oci == null ? "" : OciMirror.resolve(Setting.getUpdateOciMirror(), Setting.getUpdateOciMirrorUrl(), update.oci); return UpdateRoutePlanner.plan(Setting.getUpdateSource(), update.githubUrl, update.oci, github, endpoint); } catch (Exception e) { @@ -574,16 +564,12 @@ public class Updater implements UpdateTransfer.Callback, UpdateListener { private String validate(File file, Update update) { if (file == null || !file.exists() || file.length() <= 0) return ResUtil.getString(R.string.update_download_invalid); if (update != null && update.size > 0 && file.length() != update.size) return ResUtil.getString(R.string.update_download_incomplete); - boolean checksumVerified = false; - if (update != null && !TextUtils.isEmpty(update.sha256)) { - if (!update.sha256.equalsIgnoreCase(sha256(file))) return ResUtil.getString(R.string.update_download_checksum); - checksumVerified = true; - } - if (!validatePackage(file, update, checksumVerified)) return ResUtil.getString(R.string.update_download_identity); + if (update != null && !TextUtils.isEmpty(update.sha256) && !update.sha256.equalsIgnoreCase(sha256(file))) return ResUtil.getString(R.string.update_download_checksum); + if (!validatePackage(file, update)) return ResUtil.getString(R.string.update_download_identity); return ""; } - private boolean validatePackage(File file, Update update, boolean checksumVerified) { + private boolean validatePackage(File file, Update update) { try { PackageManager manager = App.get().getPackageManager(); int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ? PackageManager.GET_SIGNING_CERTIFICATES : PackageManager.GET_SIGNATURES; @@ -593,38 +579,34 @@ public class Updater implements UpdateTransfer.Callback, UpdateListener { long archiveCode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ? archive.getLongVersionCode() : archive.versionCode; if (update != null && update.code > 0 && archiveCode != update.code) return false; if (update != null && !TextUtils.isEmpty(update.versionName) && !update.versionName.equals(archive.versionName)) return false; - return signaturesMatch(installed, archive, checksumVerified); + return signaturesMatch(installed, archive); } catch (Exception e) { return false; } } - static boolean canAcceptUnreadableArchiveSignature(boolean checksumVerified) { - return checksumVerified; - } - - private boolean signaturesMatch(PackageInfo installed, PackageInfo archive, boolean checksumVerified) { + private boolean signaturesMatch(PackageInfo installed, PackageInfo archive) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { // Some OEM ROMs do not populate signingInfo for getPackageArchiveInfo(). // Keep the installed signature authoritative, but let an unreadable // candidate reach the OS installer, which remains the compatibility gate. if (installed.signingInfo == null) return false; - if (archive.signingInfo == null) return canAcceptUnreadableArchiveSignature(checksumVerified); + if (archive.signingInfo == null) return true; if (installed.signingInfo.hasMultipleSigners() || archive.signingInfo.hasMultipleSigners()) { Set installedPrints = fingerprints(installed.signingInfo.getApkContentsSigners()); Set archivePrints = fingerprints(archive.signingInfo.getApkContentsSigners()); if (installedPrints.isEmpty()) return false; - if (archivePrints.isEmpty()) return canAcceptUnreadableArchiveSignature(checksumVerified); + if (archivePrints.isEmpty()) return true; return installedPrints.equals(archivePrints); } Set current = fingerprints(installed.signingInfo.getApkContentsSigners()); Set candidateHistory = fingerprints(archive.signingInfo.getSigningCertificateHistory()); if (current.isEmpty()) return false; - if (candidateHistory.isEmpty()) return canAcceptUnreadableArchiveSignature(checksumVerified); + if (candidateHistory.isEmpty()) return true; return candidateHistory.containsAll(current); } if (fingerprints(installed.signatures).isEmpty()) return false; - if (fingerprints(archive.signatures).isEmpty()) return canAcceptUnreadableArchiveSignature(checksumVerified); + if (fingerprints(archive.signatures).isEmpty()) return true; return fingerprints(installed.signatures).equals(fingerprints(archive.signatures)); } diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioDiagnostics.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioDiagnostics.java index fa92382fe4..8944d78a35 100644 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioDiagnostics.java +++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioDiagnostics.java @@ -22,17 +22,7 @@ public final class AdAudioDiagnostics { SPEECH_TEXT_EMPTY, SPEECH_MATCHED, SPEECH_COOLDOWN, - SPEECH_STALE_CALLBACK, - SPEECH_PCM_DROPPED, - SPEECH_PCM_QUEUE_PEAK, - SPEECH_RESET, - SPEECH_CLOSE_PENDING, - SPEECH_CLOSED, - SPEECH_OWNER_REJECTED, - SPEECH_ACCEPT_UNDER_10_MS, - SPEECH_ACCEPT_UNDER_100_MS, - SPEECH_ACCEPT_OVER_100_MS, - SPEECH_RUNTIME_SUPPRESSED + SPEECH_STALE_CALLBACK } private final EnumMap counts = new EnumMap<>(Code.class); @@ -52,23 +42,6 @@ public final class AdAudioDiagnostics { if (shouldLogCount(total)) log("%s n=%d", code, total); } - /** In-memory only: safe while holding a short state lock or on an audio callback. */ - synchronized void recordQuietly(Code code) { - counts.put(code, counts.getOrDefault(code, 0L) + 1L); - lastCode = code; - } - - synchronized void recordSpeechQueueDepth(int depth) { - counts.put(Code.SPEECH_PCM_QUEUE_PEAK, - Math.max(counts.getOrDefault(Code.SPEECH_PCM_QUEUE_PEAK, 0L), depth)); - } - - void recordSpeechAcceptNanos(long nanos) { - recordQuietly(nanos < 10_000_000L ? Code.SPEECH_ACCEPT_UNDER_10_MS - : nanos < 100_000_000L ? Code.SPEECH_ACCEPT_UNDER_100_MS - : Code.SPEECH_ACCEPT_OVER_100_MS); - } - /** Logs the first few occurrences, then decays to one line per 100. */ static boolean shouldLogCount(long total) { if (total <= 3L) return true; @@ -103,5 +76,3 @@ public final class AdAudioDiagnostics { } } } - -// Verified build and device test for audio ad speech detection on 2026-09-10. diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java index e9cca9ded2..11a61706f8 100644 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java +++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java @@ -4,7 +4,6 @@ import com.fongmi.android.tv.player.audio.PlaybackMediaClock; import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; import com.fongmi.android.tv.subtitle.SpeechRecognitionFactory; -import java.nio.charset.StandardCharsets; import java.util.HashSet; import java.util.Map; import java.util.Objects; @@ -43,8 +42,6 @@ public final class AdAudioRuntimeController implements AutoCloseable { private final PlaybackPort playback; private final Executor worker; private final Runnable workerShutdown; - private final Executor speechWorker; - private final Runnable speechWorkerShutdown; private final ProbeProviderFactory probeProviderFactory; private final SpeechProviderFactory speechProviderFactory; private final SpeechRecognitionFactory recognitionFactory; @@ -62,38 +59,38 @@ public final class AdAudioRuntimeController implements AutoCloseable { private AdSkipPolicyController.Mode skipMode = AdSkipPolicyController.Mode.PROMPT; private SpeechAdConfig speechConfig = SpeechAdConfig.defaults(); private boolean enabled; - private boolean speechSuppressed; private String lastRefreshLog = ""; private long activeSessionId = Long.MIN_VALUE; + /** 区间反馈单独跟踪 session/generation,避免与音频管线的重建判定耦合。 */ + private long intervalSessionId = Long.MIN_VALUE; + private long intervalGeneration = Long.MIN_VALUE; private boolean closed; public AdAudioRuntimeController(PlaybackMediaSignalHub hub, PlaybackMediaClock clock, AdAudioRuleSource ruleSource, PlaybackPort playback) { - this(hub, clock, ruleSource, playback, createWorkers(), false); + this(hub, clock, ruleSource, playback, createWorker()); } public AdAudioRuntimeController(PlaybackMediaSignalHub hub, PlaybackMediaClock clock, AdAudioRuleSource ruleSource, PlaybackPort playback, SpeechRecognitionFactory recognitionFactory) { - this(hub, clock, ruleSource, playback, createWorkers(), recognitionFactory); + this(hub, clock, ruleSource, playback, createWorker(), recognitionFactory); } private AdAudioRuntimeController(PlaybackMediaSignalHub hub, PlaybackMediaClock clock, AdAudioRuleSource ruleSource, PlaybackPort playback, - Workers workers, + Worker worker, SpeechRecognitionFactory recognitionFactory) { - this(hub, clock, ruleSource, playback, workers.analysis, - workers.analysis::shutdownNow, workers.speech, - workers.speech::shutdown, + this(hub, clock, ruleSource, playback, worker.executor, + worker.executor::shutdownNow, ignored -> new NoopAdAudioSignalProvider("probe"), null, Objects.requireNonNull(recognitionFactory, "recognitionFactory")); } private AdAudioRuntimeController(PlaybackMediaSignalHub hub, PlaybackMediaClock clock, AdAudioRuleSource ruleSource, PlaybackPort playback, - Workers workers, boolean useNoopSpeechProvider) { - this(hub, clock, ruleSource, playback, workers.analysis, - workers.analysis::shutdownNow, workers.speech, - workers.speech::shutdown, + Worker worker) { + this(hub, clock, ruleSource, playback, worker.executor, + worker.executor::shutdownNow, ignored -> new NoopAdAudioSignalProvider("probe"), () -> new NoopAdAudioSignalProvider(SpeechAdSignalProvider.ID), null); } @@ -102,38 +99,15 @@ public final class AdAudioRuntimeController implements AutoCloseable { AdAudioRuleSource ruleSource, PlaybackPort playback, Executor worker, Runnable workerShutdown) { this(hub, clock, ruleSource, playback, worker, workerShutdown, - worker, () -> { }, ignored -> new NoopAdAudioSignalProvider("probe"), () -> new NoopAdAudioSignalProvider(SpeechAdSignalProvider.ID), null); } - AdAudioRuntimeController(PlaybackMediaSignalHub hub, PlaybackMediaClock clock, - AdAudioRuleSource ruleSource, PlaybackPort playback, - Executor worker, Runnable workerShutdown, - Executor speechWorker, Runnable speechWorkerShutdown, - ProbeProviderFactory probeProviderFactory, - SpeechProviderFactory speechProviderFactory) { - this(hub, clock, ruleSource, playback, worker, workerShutdown, - speechWorker, speechWorkerShutdown, - probeProviderFactory, speechProviderFactory, null); - } - - AdAudioRuntimeController(PlaybackMediaSignalHub hub, PlaybackMediaClock clock, - AdAudioRuleSource ruleSource, PlaybackPort playback, - SpeechRecognitionFactory recognitionFactory, - Executor speechWorker, Runnable speechWorkerShutdown) { - this(hub, clock, ruleSource, playback, Runnable::run, () -> { }, - speechWorker, speechWorkerShutdown, - ignored -> new NoopAdAudioSignalProvider("probe"), null, - Objects.requireNonNull(recognitionFactory, "recognitionFactory")); - } - AdAudioRuntimeController(PlaybackMediaSignalHub hub, PlaybackMediaClock clock, AdAudioRuleSource ruleSource, PlaybackPort playback, Executor worker, Runnable workerShutdown, ProbeProviderFactory probeProviderFactory) { this(hub, clock, ruleSource, playback, worker, workerShutdown, - worker, () -> { }, probeProviderFactory, () -> new NoopAdAudioSignalProvider(SpeechAdSignalProvider.ID), null); } @@ -144,14 +118,12 @@ public final class AdAudioRuntimeController implements AutoCloseable { ProbeProviderFactory probeProviderFactory, SpeechProviderFactory speechProviderFactory) { this(hub, clock, ruleSource, playback, worker, workerShutdown, - worker, () -> { }, probeProviderFactory, speechProviderFactory, null); } private AdAudioRuntimeController(PlaybackMediaSignalHub hub, PlaybackMediaClock clock, AdAudioRuleSource ruleSource, PlaybackPort playback, Executor worker, Runnable workerShutdown, - Executor speechWorker, Runnable speechWorkerShutdown, ProbeProviderFactory probeProviderFactory, SpeechProviderFactory speechProviderFactory, SpeechRecognitionFactory recognitionFactory) { @@ -161,8 +133,6 @@ public final class AdAudioRuntimeController implements AutoCloseable { this.playback = Objects.requireNonNull(playback, "playback"); this.worker = Objects.requireNonNull(worker, "worker"); this.workerShutdown = workerShutdown; - this.speechWorker = Objects.requireNonNull(speechWorker, "speechWorker"); - this.speechWorkerShutdown = speechWorkerShutdown; this.probeProviderFactory = Objects.requireNonNull( probeProviderFactory, "probeProviderFactory"); if (speechProviderFactory == null && recognitionFactory == null) { @@ -187,7 +157,6 @@ public final class AdAudioRuntimeController implements AutoCloseable { loadRulesLocked(); deactivateLocked(); PlaybackMediaSignalHub.Session session = hub.session(); - speechSuppressed = false; if (coordinator != null) coordinator.reset(session.id()); refreshLocked(); } @@ -206,12 +175,14 @@ public final class AdAudioRuntimeController implements AutoCloseable { if (coordinator != null) coordinator.close(); this.ui = ui; this.coordinator = new AdSkipCoordinator(playback, ui, 5_000L, diagnostics); + resetIntervalBaseline(); refreshLocked(); } public synchronized void unbindUi() { if (coordinator != null) coordinator.close(); coordinator = null; + resetIntervalBaseline(); ui = null; deactivateLocked(); } @@ -223,9 +194,6 @@ public final class AdAudioRuntimeController implements AutoCloseable { public synchronized void suspend() { if (closed) return; - // suspend() is called before a new media item starts. Suppression belongs to the - // previous playback session and must not silently carry into the next item. - speechSuppressed = false; deactivateLocked(); PlaybackMediaSignalHub.Session session = hub.session(); if (coordinator != null) coordinator.reset(session.id()); @@ -244,6 +212,39 @@ public final class AdAudioRuntimeController implements AutoCloseable { return snapshot; } + /** + * 执行用户框选区间的即时跳过。 + * + *

区间反馈不依赖音频指纹功能:即使指纹与语音通道都关闭、没有任何规则, + * 用户依然可以框选广告并要求跳过。因此这里在 UI 已绑定但 coordinator + * 尚未建立时按需创建一个 —— {@link #bindUi} 只在音频通道启用路径上被调用, + * 不能作为区间跳过的前置条件。 + * + * @return 是否真的执行了 seek;未绑定 UI 或校验失败时为 false + */ + public synchronized boolean skipUserInterval(long startMs, long endMs, String feedbackId) { + if (closed || ui == null) return false; + PlaybackMediaSignalHub.Session session = hub.session(); + if (coordinator == null) { + coordinator = new AdSkipCoordinator(playback, ui, 5_000L, diagnostics); + } else if (intervalSessionId != Long.MIN_VALUE + && (intervalSessionId != session.id() || intervalGeneration != session.generation())) { + // timeline reset 只经 PCM provider 的回调转发给 coordinator。音频通道关闭时 + // 那条路径不存在,coordinator 会永远卡在上一次跳过留下的 UNDO_WINDOW 上, + // 之后每一次区间提交都被拒。这里按播放实际状态主动对齐。 + // 不复用 activeSessionId:那个字段决定音频管线是否需要重建, + // 从这条路径改写它会让 refreshLocked 漏掉一次必要的重建。 + coordinator.reset(intervalSessionId); + } + boolean applied = coordinator.onUserInterval(session.id(), session.generation(), + startMs, endMs, feedbackId); + // seek 会推进 generation,基线取调用后的实际状态 + PlaybackMediaSignalHub.Session current = hub.session(); + intervalSessionId = current.id(); + intervalGeneration = current.generation(); + return applied; + } + public synchronized AdSkipPolicyController.Mode skipMode() { return skipMode; } @@ -263,23 +264,6 @@ public final class AdAudioRuntimeController implements AutoCloseable { if (rebuild) reconfigureLocked(); } - /** Suppresses only speech analysis until the next media session. */ - public synchronized void suppressSpeechForCurrentSession() { - if (closed || speechSuppressed) return; - speechSuppressed = true; - diagnostics.record(AdAudioDiagnostics.Code.SPEECH_RUNTIME_SUPPRESSED); - deactivateSpeechLocked(); - refreshLocked(); - } - - public synchronized boolean isSpeechSuppressed() { - return speechSuppressed; - } - - public synchronized boolean isSpeechConfigured() { - return speechConfig.enabled() && speechConfig.hasSpeechRules(); - } - public AdAudioDiagnostics.Snapshot diagnostics() { return diagnostics.snapshot(); } @@ -287,10 +271,10 @@ public final class AdAudioRuntimeController implements AutoCloseable { public synchronized void stop() { if (closed) return; enabled = false; - speechSuppressed = false; deactivateLocked(); if (coordinator != null) coordinator.close(); coordinator = null; + resetIntervalBaseline(); ui = null; } @@ -301,9 +285,14 @@ public final class AdAudioRuntimeController implements AutoCloseable { deactivateLocked(); if (coordinator != null) coordinator.close(); coordinator = null; + resetIntervalBaseline(); ui = null; if (workerShutdown != null) workerShutdown.run(); - if (speechWorkerShutdown != null) speechWorkerShutdown.run(); + } + + private void resetIntervalBaseline() { + intervalSessionId = Long.MIN_VALUE; + intervalGeneration = Long.MIN_VALUE; } private void loadRulesLocked() { @@ -326,8 +315,7 @@ public final class AdAudioRuntimeController implements AutoCloseable { private void refreshLocked() { boolean fingerprintReady = enabled && !snapshot.hasError() && snapshot.hasRules(); - boolean speechReady = !speechSuppressed - && speechConfig.enabled() && speechConfig.hasSpeechRules(); + boolean speechReady = speechConfig.enabled() && !speechConfig.keywords().isEmpty(); if (ui == null || (!fingerprintReady && !speechReady)) { // Transition-only: refreshLocked runs every 5s from the host position pump, and // an unsampled line here would churn the bounded debug-log ring. @@ -441,14 +429,7 @@ public final class AdAudioRuntimeController implements AutoCloseable { .map(AudioFingerprintRule::id) .forEach(allowedRuleIds::add); } - if (speechReady) { - if (!speechConfig.keywords().isEmpty()) { - allowedRuleIds.add(SpeechAdSignalProvider.RULE_ID); - } - speechConfig.rules().rules().stream() - .map(SpeechAdRule::id) - .forEach(allowedRuleIds::add); - } + if (speechReady) allowedRuleIds.add(SpeechAdSignalProvider.RULE_ID); AdAudioDetectionMultiplexer nextMux = new AdAudioDetectionMultiplexer( context, routingSnapshot.version(), Set.copyOf(allowedRuleIds), RUNTIME_CANDIDATE_CAPACITY, output); @@ -502,12 +483,6 @@ public final class AdAudioRuntimeController implements AutoCloseable { if (oldPolicy != null) oldPolicy.close(); } - private void deactivateSpeechLocked() { - AdAudioSignalProvider oldSpeech = speechProvider; - speechProvider = null; - closeProvider(oldSpeech); - } - private boolean isActiveLocked() { // IDLE counts as active for the speech provider: it parks there while the position // is not yet eligible (buffering, duration unknown) but keeps its recognizer and @@ -570,7 +545,7 @@ public final class AdAudioRuntimeController implements AutoCloseable { if (recognitionFactory != null) { return new SpeechAdSignalProvider( hub, recognitionFactory, () -> speechConfig, - speechWorker, diagnostics); + worker, diagnostics); } AdAudioSignalProvider provider = speechProviderFactory.create(); return provider == null @@ -583,47 +558,14 @@ public final class AdAudioRuntimeController implements AutoCloseable { } private AdAudioRuleSnapshot routingSnapshotLocked() { - String version = snapshot.version().isEmpty() - ? "speech-runtime-v1" : snapshot.version(); - if (!speechConfig.rules().isEmpty()) { - version = withSpeechRulesVersion(version, speechConfig.rulesVersion()); - } - if (version.equals(snapshot.version())) return snapshot; + if (!snapshot.version().isEmpty()) return snapshot; return new AdAudioRuleSnapshot( - snapshot.sourceId(), version, snapshot.ruleSet(), + snapshot.sourceId(), "speech-runtime-v1", snapshot.ruleSet(), snapshot.warnings(), snapshot.lastError(), snapshot.probeSidecar()); } - private static String withSpeechRulesVersion(String baseVersion, String rulesVersion) { - String suffix = ":speech-v2-" + rulesVersion; - if (baseVersion.length() + suffix.length() <= 128) { - return baseVersion + suffix; - } - // AdAudioDetectionMultiplexer and AdSkipPolicyController intentionally bound - // routing versions. Preserve a deterministic identity without allowing an - // unusually long external source version to break speech activation. - return "speech-base-" + sha256Prefix(baseVersion) + suffix; - } - - private static String sha256Prefix(String value) { - try { - byte[] digest = java.security.MessageDigest.getInstance("SHA-256") - .digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder result = new StringBuilder(16); - for (int i = 0; i < 8; i++) { - result.append(String.format("%02x", digest[i] & 0xff)); - } - return result.toString(); - } catch (java.security.NoSuchAlgorithmException error) { - throw new IllegalStateException("SHA-256 unavailable", error); - } - } - private void installModeResolver(AdSkipPolicyController target) { target.setMode(skipMode); - target.setPromptOnlyRuleIds(speechConfig.rules().rules().stream() - .map(SpeechAdRule::id) - .collect(java.util.stream.Collectors.toUnmodifiableSet())); target.setModeResolver(providerId -> SpeechAdSignalProvider.ID.equals(providerId) ? speechConfig.mode() : skipMode); } @@ -666,115 +608,15 @@ public final class AdAudioRuntimeController implements AutoCloseable { } } - private static Workers createWorkers() { - ExecutorService analysis = Executors.newSingleThreadExecutor(r -> { + private static Worker createWorker() { + ExecutorService executor = Executors.newSingleThreadExecutor(r -> { Thread thread = new Thread(r, "ad-audio-matcher"); thread.setDaemon(true); return thread; }); - ExecutorService speech = Executors.newSingleThreadExecutor(r -> { - Runnable speechTask = () -> { - try { - android.os.Process.setThreadPriority( - android.os.Process.THREAD_PRIORITY_BACKGROUND); - } catch (RuntimeException ignored) { - } - r.run(); - }; - Thread thread = new Thread(speechTask, "ad-audio-speech-owner"); - thread.setDaemon(true); - try { - thread.setPriority(Thread.NORM_PRIORITY - 1); - } catch (RuntimeException ignored) { - } - return thread; - }); - return new Workers(analysis, speech); + return new Worker(executor); } - /** Pure-Java playback health gate used to disable speech work after confirmed degradation. */ - public static final class SpeechAdPlaybackHealth { - - public static final long SAMPLE_INTERVAL_MS = 5_000L; - public static final long MAX_SAMPLE_GAP_MS = 10_000L; - public static final int REQUIRED_DEGRADED_SAMPLES = 2; - public static final long DROPPED_FRAMES_PER_SECOND_THRESHOLD = 4L; - - private long lastSampleAtMs = -1L; - private long lastDroppedFrames = -1L; - private long lastAudioUnderruns = -1L; - private long lastRebufferCount = -1L; - private int degradedSamples; - private boolean suppressed; - - public synchronized Decision observe(long nowMs, long droppedFrames, - long audioUnderruns, long rebufferCount) { - long now = Math.max(0L, nowMs); - long dropped = Math.max(0L, droppedFrames); - long underruns = Math.max(0L, audioUnderruns); - long rebuffers = Math.max(0L, rebufferCount); - if (suppressed) return Decision.SUPPRESSED; - if (lastSampleAtMs >= 0L && now <= lastSampleAtMs) return Decision.HELD; - if (lastSampleAtMs >= 0L && now - lastSampleAtMs < SAMPLE_INTERVAL_MS) { - return Decision.HELD; - } - boolean gapTooLarge = lastSampleAtMs >= 0L - && now - lastSampleAtMs > MAX_SAMPLE_GAP_MS; - long intervalMs = lastSampleAtMs < 0L ? 0L : now - lastSampleAtMs; - long droppedDelta = positiveDelta(dropped, lastDroppedFrames); - long underrunDelta = positiveDelta(underruns, lastAudioUnderruns); - long rebufferDelta = positiveDelta(rebuffers, lastRebufferCount); - lastSampleAtMs = now; - lastDroppedFrames = dropped; - lastAudioUnderruns = underruns; - lastRebufferCount = rebuffers; - if (gapTooLarge) { - degradedSamples = 0; - return Decision.OBSERVED; - } - long minimumDropped = intervalMs <= 0L - || intervalMs > (Long.MAX_VALUE - 999L) - / DROPPED_FRAMES_PER_SECOND_THRESHOLD - ? Long.MAX_VALUE - : (intervalMs * DROPPED_FRAMES_PER_SECOND_THRESHOLD + 999L) / 1_000L; - boolean droppedRate = droppedDelta >= minimumDropped; - boolean degraded = underrunDelta > 0L || rebufferDelta > 0L || droppedRate; - degradedSamples = degraded - ? Math.min(REQUIRED_DEGRADED_SAMPLES, degradedSamples + 1) : 0; - if (degradedSamples >= REQUIRED_DEGRADED_SAMPLES) { - suppressed = true; - return Decision.SUPPRESS; - } - return degraded ? Decision.DEGRADED : Decision.OBSERVED; - } - - public synchronized boolean isSuppressed() { - return suppressed; - } - - public synchronized void reset() { - lastSampleAtMs = -1L; - lastDroppedFrames = -1L; - lastAudioUnderruns = -1L; - lastRebufferCount = -1L; - degradedSamples = 0; - suppressed = false; - } - - private static long positiveDelta(long current, long previous) { - if (previous < 0L || current <= previous) return 0L; - return current - previous; - } - - public enum Decision { - HELD, - OBSERVED, - DEGRADED, - SUPPRESS, - SUPPRESSED - } - } - - private record Workers(ExecutorService analysis, ExecutorService speech) { + private record Worker(ExecutorService executor) { } } diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java index 6087965b8f..23836b5441 100644 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java +++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java @@ -147,6 +147,66 @@ public final class AdSkipCoordinator implements AutoCloseable { return applyAutomaticSeek(legacy, target); } + /** + * 用户显式框选的广告区间。与 {@link #onCandidate} 的区别是授权来源: + * 框选动作本身即为对本次跳过的授权,因此不经 PROMPT_PENDING 直接执行, + * 但全部安全校验照旧,并保留撤销窗口。 + * + *

{@code startMs} / {@code endMs} 已经是媒体时间,不做采集时钟映射。 + * + * @return 是否真的执行了 seek。终点已过或校验失败时返回 false, + * 调用方应把它当作「已记录,未跳过」而不是失败。 + */ + public synchronized boolean onUserInterval(long sessionId, long generation, + long startMs, long endMs, String feedbackId) { + Objects.requireNonNull(feedbackId, "feedbackId"); + if (closed) return false; + if (endMs <= startMs || startMs < 0L) return false; + if (state == State.SEEKING || state == State.UNDO_WINDOW) return false; + + CandidateKey key = new CandidateKey(sessionId, generation, feedbackId, startMs, endMs); + // 连按去重:同一区间不重复 seek + if (key.equals(suppressedKey) || key.equals(activeKey)) return false; + + PlaybackSnapshot snapshot = playback.snapshot(sessionId, generation); + if (!isCurrentSeekable(snapshot, sessionId, generation)) { + diagnostics.record(AdAudioDiagnostics.Code.SEEK_REJECTED); + return false; + } + long target = clamp(endMs, 0L, snapshot.durationMs()); + // 终点已经播过去了,没有可跳的内容 + if (target <= snapshot.positionMs()) { + diagnostics.record(AdAudioDiagnostics.Code.SEEK_REJECTED); + return false; + } + + long originalPositionMs = snapshot.positionMs(); + // 失败时要还原到调用前的状态:音频候选的 suppressedKey / PROMPT_PENDING + // 不属于本次区间反馈,不能被连带清掉。 + State previousState = state; + state = State.SEEKING; + SeekResult seek = playback.seekTo(sessionId, generation, target); + if (seek == null || !seek.applied() || seek.sessionId() != sessionId) { + diagnostics.record(AdAudioDiagnostics.Code.SEEK_REJECTED); + state = previousState; + return false; + } + + long deadline = saturatedAdd(elapsedRealtime.getAsLong(), undoWindowMs); + undoContext = new UndoContext(sessionId, seek.generation(), feedbackId, + originalPositionMs, target, deadline); + activeCandidate = null; + activeKey = null; + state = State.UNDO_WINDOW; + long undoToken = ++actionToken; + AdAudioDiagnostics.log("user interval skip id=%s targetMs=%d fromMs=%d", + feedbackId, target, originalPositionMs); + ui.showUndo(new UndoPrompt(undoContext.sessionId, undoContext.generation, + undoContext.ruleId, undoContext.originalPositionMs, + undoContext.targetPositionMs, undoWindowMs), new TokenActions(undoToken)); + return true; + } + public synchronized void reset(long sessionId) { if (closed) return; actionToken++; diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipPolicyController.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipPolicyController.java index 67c846d282..ff57ceee42 100644 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipPolicyController.java +++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipPolicyController.java @@ -3,7 +3,6 @@ package com.fongmi.android.tv.ad.audio; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Objects; -import java.util.Set; public final class AdSkipPolicyController implements AutoCloseable { @@ -39,7 +38,6 @@ public final class AdSkipPolicyController implements AutoCloseable { private String ruleVersion; private Mode mode = Mode.PROMPT; private ModeResolver modeResolver = ignored -> mode; - private Set promptOnlyRuleIds = Set.of(); private long prompted; private long automated; private long upgrades; @@ -83,18 +81,6 @@ public final class AdSkipPolicyController implements AutoCloseable { modeSwitches++; } - /** - * Rules whose time boundary is an utterance boundary rather than a verified - * word boundary must never take the automatic seek path. This is independent - * of the user's provider-wide speech mode and is intentionally a small, - * explicit policy contract rather than a provider-name convention. - */ - public synchronized void setPromptOnlyRuleIds(Set ruleIds) { - if (closed) return; - promptOnlyRuleIds = ruleIds == null ? Set.of() : Set.copyOf(ruleIds); - modeSwitches++; - } - public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { CandidateSink sink = null; synchronized (this) { @@ -167,7 +153,6 @@ public final class AdSkipPolicyController implements AutoCloseable { } private Mode resolvedMode(AdAudioSignalProvider.AdAudioCandidate candidate) { - if (promptOnlyRuleIds.contains(candidate.ruleId())) return Mode.PROMPT; Mode resolved; try { resolved = modeResolver.modeFor(candidate.providerId()); diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdConfig.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdConfig.java index 4ff7ae3103..0d62e3d442 100644 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdConfig.java +++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdConfig.java @@ -1,27 +1,12 @@ package com.fongmi.android.tv.ad.audio; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - public record SpeechAdConfig(boolean enabled, SpeechAdKeywordSet keywords, - SpeechAdRuleSet rules, int skipSeconds, - AdSkipPolicyController.Mode mode) { + int skipSeconds, AdSkipPolicyController.Mode mode) { public static final String DEFAULT_KEYWORDS = "麻将来了,澳门,赌场,娱乐城,荷官,百家乐,老虎机,时时彩,六合彩,彩票,下注,投注,首充,提现,棋牌,捕鱼,斗地主"; - /** - * Keeps the pre-V2 constructor contract for callers that only configure the - * original flat keyword list. - */ - public SpeechAdConfig(boolean enabled, SpeechAdKeywordSet keywords, - int skipSeconds, AdSkipPolicyController.Mode mode) { - this(enabled, keywords, SpeechAdRuleSet.empty(), skipSeconds, mode); - } - public SpeechAdConfig { if (keywords == null) throw new NullPointerException("keywords"); - if (rules == null) throw new NullPointerException("rules"); skipSeconds = Math.max(1, Math.min(120, skipSeconds)); if (mode == null) mode = AdSkipPolicyController.Mode.PROMPT; } @@ -32,52 +17,12 @@ public record SpeechAdConfig(boolean enabled, SpeechAdKeywordSet keywords, public static SpeechAdConfig create(boolean enabled, String keywords, int skipSeconds, String mode) { - return create(enabled, keywords, SpeechAdRuleSet.empty(), skipSeconds, mode); - } - - /** Creates a config with the bounded V2 speech-rule snapshot. */ - public static SpeechAdConfig create(boolean enabled, String keywords, - SpeechAdRuleSet rules, - int skipSeconds, String mode) { AdSkipPolicyController.Mode parsed; try { parsed = AdSkipPolicyController.Mode.valueOf(mode); } catch (RuntimeException ignored) { parsed = AdSkipPolicyController.Mode.PROMPT; } - return new SpeechAdConfig(enabled, SpeechAdKeywordSet.parse(keywords), - rules, skipSeconds, parsed); + return new SpeechAdConfig(enabled, SpeechAdKeywordSet.parse(keywords), skipSeconds, parsed); } - - /** - * Parses a local V2 rule document at the configuration boundary. A malformed - * document is rejected as a whole by {@link SpeechAdRuleCodec}; callers keep - * the previous config rather than silently enabling a partial rule set. - */ - public static SpeechAdConfig createWithRules(boolean enabled, String keywords, - String ruleDocument, - int skipSeconds, String mode) { - return create(enabled, keywords, SpeechAdRuleCodec.parse(ruleDocument), - skipSeconds, mode); - } - - public boolean hasSpeechRules() { - return !keywords.isEmpty() || !rules.isEmpty(); - } - - /** Stable routing suffix; rule contents never enter logs or diagnostics. */ - public String rulesVersion() { - if (rules.isEmpty()) return ""; - try { - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(rules.toText().getBytes(StandardCharsets.UTF_8)); - StringBuilder result = new StringBuilder(16); - for (int i = 0; i < 8; i++) { - result.append(String.format("%02x", digest[i] & 0xff)); - } - return result.toString(); - } catch (NoSuchAlgorithmException error) { - throw new IllegalStateException("SHA-256 unavailable", error); - } - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdMatcher.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdMatcher.java deleted file mode 100644 index 58d45ffc30..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdMatcher.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -/** - * Bounded literal matcher over timestamped speech results. Confined to one worker; - * callers must supply finalized, incremental, non-overlapping recognition chunks, - * filter stale session/generation callbacks, and reset on every discontinuity. - * No user-supplied regular expression is executed. - */ -public final class SpeechAdMatcher { - - public static final long MAX_SEQUENCE_WINDOW_US = 30_000_000L; - public static final long MATCH_COOLDOWN_US = 30_000_000L; - public static final int MAX_RECOGNIZED_TEXT_CODE_POINTS = 4_096; - private static final int MAX_WINDOW_CHARS = 8_192; - private static final int MAX_WINDOW_RESULTS = 128; - - private final List states; - private final ArrayDeque window = new ArrayDeque<>(); - private int windowChars; - private int timelineToken = Integer.MIN_VALUE; - private boolean hasTimeline; - private long lastEndUs = -1L; - - public SpeechAdMatcher(SpeechAdRuleSet rules) { - Objects.requireNonNull(rules, "rules"); - states = new ArrayList<>(rules.rules().size()); - for (SpeechAdRule rule : rules.rules()) states.add(new State(rule)); - } - - public List accept(String text, long startUs, long endUs, int timelineToken) { - if (!hasTimeline || this.timelineToken != timelineToken) reset(timelineToken); - if (startUs < 0L || endUs <= startUs) { - clearWindow(); - return List.of(); - } - // A revised partial or a delayed duplicate must not be concatenated as new speech. - if (startUs < lastEndUs) return List.of(); - lastEndUs = endUs; - if (endUs - startUs > MAX_SEQUENCE_WINDOW_US || text == null - || text.length() > 2 * MAX_RECOGNIZED_TEXT_CODE_POINTS - || text.codePointCount(0, text.length()) > MAX_RECOGNIZED_TEXT_CODE_POINTS) { - clearWindow(); - return List.of(); - } - String normalized = SpeechAdRule.normalizeText(text); - if (normalized.isEmpty() || normalized.codePointCount(0, normalized.length()) - > MAX_RECOGNIZED_TEXT_CODE_POINTS) { - clearWindow(); - return List.of(); - } - if (states.isEmpty()) return List.of(); - while (!window.isEmpty() && (endUs - window.peekFirst().startUs() > MAX_SEQUENCE_WINDOW_US - || window.size() >= MAX_WINDOW_RESULTS - || windowChars + normalized.length() > MAX_WINDOW_CHARS)) { - windowChars -= window.removeFirst().text().length(); - } - window.addLast(new Recognition(normalized, startUs, endUs, timelineToken)); - windowChars += normalized.length(); - // One shared window retains provenance even when the first literal is split - // across results. Whole-result eviction avoids broken Unicode boundaries. - StringBuilder buffer = new StringBuilder(windowChars); - for (Recognition result : window) buffer.append(result.text()); - String transcript = buffer.toString(); - List matches = new ArrayList<>(); - for (State state : states) { - int fromIndex = 0; - for (Recognition result : window) { - if (state.lastMatchEndUs < 0L || result.startUs() >= state.lastMatchEndUs - && result.startUs() - state.lastMatchEndUs >= MATCH_COOLDOWN_US) break; - fromIndex += result.text().length(); - } - int firstIndex = -1; - int endIndex = fromIndex; - boolean complete = true; - for (SpeechAdRule.Segment segment : state.rule.segments()) { - SpeechAdRule.Segment.Match found = segment.find(transcript, endIndex); - if (found == null) { - complete = false; - break; - } - if (firstIndex < 0) firstIndex = found.start(); - endIndex = found.end(); - } - if (!complete) continue; - long firstStartUs = resultAt(firstIndex).startUs(); - long matchEndUs = resultAt(endIndex - 1).endUs(); - matches.add(new Match(state.rule.id(), firstStartUs, matchEndUs, - state.rule.preRollMs(), state.rule.postRollMs(), timelineToken)); - state.lastMatchEndUs = matchEndUs; - } - return List.copyOf(matches); - } - - public List accept(Recognition recognition) { - Objects.requireNonNull(recognition, "recognition"); - return accept(recognition.text(), recognition.startUs(), recognition.endUs(), - recognition.timelineToken()); - } - - public void reset() { - reset(Integer.MIN_VALUE); - hasTimeline = false; - } - - public void reset(int timelineToken) { - this.timelineToken = timelineToken; - hasTimeline = true; - lastEndUs = -1L; - clearWindow(); - for (State state : states) state.lastMatchEndUs = -1L; - } - - public int timelineToken() { - return timelineToken; - } - - private void clearWindow() { - window.clear(); - windowChars = 0; - } - - private Recognition resultAt(int index) { - for (Recognition result : window) { - if (index < result.text().length()) return result; - index -= result.text().length(); - } - throw new IllegalStateException("speech match is outside the retained window"); - } - - public record Recognition(String text, long startUs, long endUs, int timelineToken) { - public Recognition { - Objects.requireNonNull(text, "text"); - } - } - - /** Times conservatively bound whole recognized chunks, not aligned individual words. */ - public record Match(String ruleId, long firstStartUs, long lastEndUs, - long preRollMs, long postRollMs, int timelineToken) { - public Match { - Objects.requireNonNull(ruleId, "ruleId"); - if (firstStartUs < 0L || lastEndUs <= firstStartUs - || preRollMs < 0L || postRollMs < 0L - || preRollMs > SpeechAdRule.MAX_ROLL_MS - || postRollMs > SpeechAdRule.MAX_ROLL_MS) { - throw new IllegalArgumentException("speech match interval is invalid"); - } - } - - public long candidateStartMs() { - return candidateStartMs(-1L); - } - - public long candidateStartMs(long durationMs) { - long startMs = Math.max(0L, firstStartUs / 1_000L - preRollMs); - return durationMs < 0L ? startMs : Math.min(durationMs, startMs); - } - - /** A clamped empty interval is not actionable; the downstream policy must reject it. */ - public long candidateEndMs(long durationMs) { - long endMs = lastEndUs / 1_000L + postRollMs; - return durationMs < 0L ? endMs : Math.min(durationMs, endMs); - } - } - - private static final class State { - private final SpeechAdRule rule; - private long lastMatchEndUs = -1L; - - private State(SpeechAdRule rule) { - this.rule = rule; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRule.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRule.java deleted file mode 100644 index 7df3e1d4df..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRule.java +++ /dev/null @@ -1,208 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import java.text.Normalizer; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -/** - * One bounded speech-ad rule. A rule is intentionally not a regular expression: - * each segment contains ordered literal parts and optional {@code *} gaps. - */ -public final class SpeechAdRule { - - static final int MAX_SEGMENTS = 8; - static final int MAX_SEGMENT_CODE_POINTS = 128; - static final long MAX_ROLL_MS = 120_000L; - - private final String id; - private final List segments; - private final long preRollMs; - private final long postRollMs; - private final String canonical; - - public SpeechAdRule(String id, List segments, - long preRollMs, long postRollMs) { - this.id = requireId(id); - if (segments == null || segments.isEmpty() || segments.size() > MAX_SEGMENTS) { - throw new IllegalArgumentException("speech rule segment count is invalid"); - } - this.segments = List.copyOf(segments); - if (preRollMs < 0L || postRollMs < 0L - || preRollMs > MAX_ROLL_MS || postRollMs > MAX_ROLL_MS - || preRollMs % 1_000L != 0L || postRollMs % 1_000L != 0L) { - throw new IllegalArgumentException("speech rule roll window is invalid"); - } - this.preRollMs = preRollMs; - this.postRollMs = postRollMs; - this.canonical = canonicalText(this.segments, preRollMs, postRollMs); - } - - public String id() { - return id; - } - - public List segments() { - return segments; - } - - public long preRollMs() { - return preRollMs; - } - - public long postRollMs() { - return postRollMs; - } - - /** Stable normalized text representation used for deduplication and ID generation. */ - public String canonical() { - return canonical; - } - - static String normalizeText(String value) { - String normalized = Normalizer.normalize(value == null ? "" : value, - Normalizer.Form.NFKC).toLowerCase(java.util.Locale.ROOT); - StringBuilder result = new StringBuilder(normalized.length()); - boolean previousWhitespace = false; - for (int i = 0; i < normalized.length(); ) { - int codePoint = normalized.codePointAt(i); - i += Character.charCount(codePoint); - if (Character.isWhitespace(codePoint)) { - if (!previousWhitespace) result.append(' '); - previousWhitespace = true; - } else if (!Character.isISOControl(codePoint) - && Character.getType(codePoint) != Character.FORMAT) { - result.appendCodePoint(codePoint); - previousWhitespace = false; - } - } - return result.toString().trim(); - } - - static String canonicalText(List segments, long preRollMs, long postRollMs) { - StringBuilder value = new StringBuilder(); - for (int i = 0; i < segments.size(); i++) { - if (i > 0) value.append('>'); - value.append(segments.get(i).canonical()); - } - if (preRollMs == 0L) { - value.append(',').append(postRollMs / 1_000L); - } else { - value.append(",[").append(preRollMs / 1_000L) - .append(',').append(postRollMs / 1_000L).append(']'); - } - return value.toString(); - } - - private static String requireId(String value) { - Objects.requireNonNull(value, "id"); - if (value.isEmpty() || value.length() > 64 - || !Character.isLowerCase(value.charAt(0)) - && !Character.isDigit(value.charAt(0))) { - throw new IllegalArgumentException("speech rule id is invalid"); - } - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - if (!(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') - && c != '.' && c != '_' && c != '-') { - throw new IllegalArgumentException("speech rule id is invalid"); - } - } - return value; - } - - public static final class Segment { - private final List literals; - private final List wildcardBetween; - private final boolean leadingWildcard; - private final boolean trailingWildcard; - private final String canonical; - - public Segment(List literals, List wildcardBetween, - boolean leadingWildcard, boolean trailingWildcard) { - if (literals == null || literals.isEmpty()) { - throw new IllegalArgumentException("speech segment literals are required"); - } - if (wildcardBetween == null || wildcardBetween.size() != literals.size() - 1) { - throw new IllegalArgumentException("speech segment wildcard layout is invalid"); - } - List normalized = new ArrayList<>(literals.size()); - for (int i = 0; i < literals.size(); i++) { - String value = normalizeText(literals.get(i)); - if (value.isEmpty() || value.contains("//") - || value.chars().anyMatch(c -> "*>,[]#".indexOf(c) >= 0)) { - throw new IllegalArgumentException("speech segment literal is invalid"); - } - // Adjacent literal parts are one search term; otherwise the first - // occurrence of a prefix could hide a later complete match. - if (i > 0 && !wildcardBetween.get(i - 1)) { - int last = normalized.size() - 1; - normalized.set(last, normalized.get(last) + value); - } else { - normalized.add(value); - } - } - this.literals = List.copyOf(normalized); - this.wildcardBetween = java.util.Collections.nCopies(normalized.size() - 1, true); - this.leadingWildcard = leadingWildcard; - this.trailingWildcard = trailingWildcard; - this.canonical = buildCanonical(this.literals, this.wildcardBetween, - leadingWildcard, trailingWildcard); - if (canonical.codePointCount(0, canonical.length()) > MAX_SEGMENT_CODE_POINTS - || canonical.codePoints().noneMatch(Character::isLetterOrDigit)) { - throw new IllegalArgumentException("speech segment length or text is invalid"); - } - } - - public List literals() { - return literals; - } - - public boolean leadingWildcard() { - return leadingWildcard; - } - - public boolean trailingWildcard() { - return trailingWildcard; - } - - public String canonical() { - return canonical; - } - - /** Finds the first ordered match at or after {@code fromIndex}. */ - Match find(String text, int fromIndex) { - int cursor = Math.max(0, fromIndex); - int start = -1; - for (int i = 0; i < literals.size(); i++) { - String literal = literals.get(i); - int found = i == 0 - ? text.indexOf(literal, cursor) - : wildcardBetween.get(i - 1) - ? text.indexOf(literal, cursor) - : text.startsWith(literal, cursor) ? cursor : -1; - if (found < 0) return null; - if (start < 0) start = found; - cursor = found + literal.length(); - } - return new Match(start, cursor); - } - - private static String buildCanonical(List literals, - List wildcardBetween, - boolean leadingWildcard, - boolean trailingWildcard) { - StringBuilder result = new StringBuilder(); - if (leadingWildcard) result.append('*'); - for (int i = 0; i < literals.size(); i++) { - if (i > 0 && wildcardBetween.get(i - 1)) result.append('*'); - result.append(literals.get(i)); - } - if (trailingWildcard) result.append('*'); - return result.toString(); - } - - record Match(int start, int end) { - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleCodec.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleCodec.java deleted file mode 100644 index 7cac1937fc..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleCodec.java +++ /dev/null @@ -1,163 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; - -/** Strict parser for the human-readable speech-ad rules v1 format. */ -public final class SpeechAdRuleCodec { - - public static final String FORMAT = "speech-ad-rules-v1"; - public static final int MAX_INPUT_BYTES = 64 * 1024; - public static final int MAX_RULES = SpeechAdRuleSet.MAX_RULES; - public static final long MAX_ROLL_SECONDS = SpeechAdRule.MAX_ROLL_MS / 1_000L; - - private static final char BOM = '\uFEFF'; - private static final String WILDCARD = "*"; - - private SpeechAdRuleCodec() { - } - - public static SpeechAdRuleSet parse(String text) { - if (text == null || text.isEmpty()) return SpeechAdRuleSet.empty(); - if (text.length() > MAX_INPUT_BYTES - || text.getBytes(StandardCharsets.UTF_8).length > MAX_INPUT_BYTES) { - throw invalid(0, "document is too large"); - } - String source = text.charAt(0) == BOM ? text.substring(1) : text; - LinkedHashMap rules = new LinkedHashMap<>(); - String[] lines = source.split("\\R", -1); - for (int i = 0; i < lines.length; i++) { - String line = stripComment(lines[i]).trim(); - if (line.isEmpty() || line.startsWith("#")) continue; - SpeechAdRule rule = parseLine(line, i + 1); - SpeechAdRule previous = rules.putIfAbsent(rule.id(), rule); - if (previous != null && !previous.canonical().equals(rule.canonical())) { - throw invalid(i + 1, "duplicate rule id"); - } - if (rules.size() > MAX_RULES) throw invalid(i + 1, "too many rules"); - } - return new SpeechAdRuleSet(new ArrayList<>(rules.values())); - } - - public static SpeechAdRuleSet fromText(String text) { - return parse(text); - } - - public static String serialize(SpeechAdRuleSet rules) { - if (rules == null) throw new NullPointerException("rules"); - return rules.toText(); - } - - private static SpeechAdRule parseLine(String line, int lineNumber) { - Action action = parseAction(line, lineNumber); - String body = action.body(); - if (body.isEmpty()) throw invalid(lineNumber, "rule body is empty"); - String[] rawSegments = body.split(">", -1); - if (rawSegments.length == 0 || rawSegments.length > SpeechAdRule.MAX_SEGMENTS) { - throw invalid(lineNumber, "invalid segment count"); - } - List segments = new ArrayList<>(rawSegments.length); - for (String rawSegment : rawSegments) { - segments.add(parseSegment(rawSegment, lineNumber)); - } - String canonical = SpeechAdRule.canonicalText(segments, - action.preRollMs(), action.postRollMs()); - String id = "speech-" + digest(canonical).substring(0, 16); - return new SpeechAdRule(id, segments, action.preRollMs(), action.postRollMs()); - } - - private static SpeechAdRule.Segment parseSegment(String raw, int lineNumber) { - String source = SpeechAdRule.normalizeText(raw); - if (source.isEmpty()) throw invalid(lineNumber, "empty segment"); - List literals = new ArrayList<>(); - List wildcardBetween = new ArrayList<>(); - boolean leadingWildcard = source.startsWith(WILDCARD); - boolean trailingWildcard = source.endsWith(WILDCARD); - String[] pieces = source.split("\\*", -1); - boolean foundLiteral = false; - for (String piece : pieces) { - String literal = SpeechAdRule.normalizeText(piece); - if (literal.isEmpty()) continue; - if (foundLiteral) wildcardBetween.add(true); - literals.add(literal); - foundLiteral = true; - } - if (literals.isEmpty()) throw invalid(lineNumber, "segment must contain text"); - try { - return new SpeechAdRule.Segment(literals, wildcardBetween, - leadingWildcard, trailingWildcard); - } catch (IllegalArgumentException error) { - throw invalid(lineNumber, error.getMessage()); - } - } - - private static Action parseAction(String line, int lineNumber) { - if (line.endsWith("]")) { - int open = line.lastIndexOf('['); - if (open <= 0) throw invalid(lineNumber, "invalid range action"); - String inner = line.substring(open + 1, line.length() - 1); - String[] values = inner.replace(',', ',').split(",", -1); - if (values.length != 2) throw invalid(lineNumber, "range action needs two values"); - long pre = parseSeconds(values[0], lineNumber); - long post = parseSeconds(values[1], lineNumber); - String body = line.substring(0, open).trim(); - if (body.endsWith(",") || body.endsWith(",")) { - body = body.substring(0, body.length() - 1).trim(); - } - return new Action(body, pre * 1_000L, post * 1_000L); - } - int comma = Math.max(line.lastIndexOf(','), line.lastIndexOf(',')); - if (comma <= 0 || comma == line.length() - 1) { - throw invalid(lineNumber, "missing action suffix"); - } - long post = parseSeconds(line.substring(comma + 1), lineNumber); - return new Action(line.substring(0, comma).trim(), 0L, post * 1_000L); - } - - private static long parseSeconds(String value, int lineNumber) { - String source = value.trim(); - if (source.isEmpty()) throw invalid(lineNumber, "empty action value"); - long seconds; - try { - if (source.startsWith("+") || source.startsWith("-")) { - throw new NumberFormatException(); - } - seconds = Long.parseLong(source); - } catch (NumberFormatException e) { - throw invalid(lineNumber, "action value is not an integer"); - } - if (seconds < 0L || seconds > MAX_ROLL_SECONDS) { - throw invalid(lineNumber, "action value is out of range"); - } - return seconds; - } - - private static String stripComment(String line) { - int comment = line.indexOf("//"); - return comment < 0 ? line : line.substring(0, comment); - } - - private static String digest(String value) { - try { - byte[] bytes = MessageDigest.getInstance("SHA-256") - .digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder result = new StringBuilder(bytes.length * 2); - for (byte item : bytes) result.append(String.format(Locale.ROOT, "%02x", item & 0xff)); - return result.toString(); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 unavailable", e); - } - } - - private static IllegalArgumentException invalid(int line, String message) { - return new IllegalArgumentException((line <= 0 ? "speech rules" : "line " + line) + ": " + message); - } - - private record Action(String body, long preRollMs, long postRollMs) { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleSet.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleSet.java deleted file mode 100644 index a79cd4536a..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleSet.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -/** Immutable, deduplicated collection of speech-ad rules. */ -public final class SpeechAdRuleSet { - - static final int MAX_RULES = 256; - - private final List rules; - private final Map byId; - - public SpeechAdRuleSet(List rules) { - if (rules == null || rules.size() > MAX_RULES) { - throw new IllegalArgumentException("speech rule count is invalid"); - } - LinkedHashMap unique = new LinkedHashMap<>(); - for (SpeechAdRule rule : rules) { - SpeechAdRule value = Objects.requireNonNull(rule, "rule"); - SpeechAdRule previous = unique.putIfAbsent(value.id(), value); - if (previous != null && !previous.canonical().equals(value.canonical())) { - throw new IllegalArgumentException("duplicate speech rule id"); - } - } - this.byId = Map.copyOf(unique); - this.rules = List.copyOf(new ArrayList<>(unique.values())); - } - - public static SpeechAdRuleSet empty() { - return new SpeechAdRuleSet(List.of()); - } - - public List rules() { - return rules; - } - - public boolean isEmpty() { - return rules.isEmpty(); - } - - public SpeechAdRule find(String id) { - return byId.get(id); - } - - public String toText() { - StringBuilder result = new StringBuilder(); - for (SpeechAdRule rule : rules) { - if (result.length() > 0) result.append('\n'); - result.append(rule.canonical()); - } - if (result.length() > SpeechAdRuleCodec.MAX_INPUT_BYTES - || result.toString().getBytes(StandardCharsets.UTF_8).length - > SpeechAdRuleCodec.MAX_INPUT_BYTES) { - throw new IllegalArgumentException("speech rules document is too large"); - } - return result.toString(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdSetting.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdSetting.java index da5f3d3777..64972ebbfd 100644 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdSetting.java @@ -1,238 +1,23 @@ package com.fongmi.android.tv.ad.audio; -import android.content.ContentResolver; -import android.content.SharedPreferences; -import android.database.Cursor; -import android.net.Uri; -import android.provider.OpenableColumns; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; import com.github.catvod.utils.Prefers; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; import java.util.Objects; public final class SpeechAdSetting { - public static final String KEY_RULES = "speech_ad_rules_v1"; - public static final String KEY_RULE_SOURCE = "speech_ad_rules_source"; - public static final String KEY_BUILTIN_ENABLED = "speech_ad_builtin_enabled"; - private static final String KEY_ENABLED = "speech_ad_enabled"; private static final String KEY_KEYWORDS = "speech_ad_keywords"; private static final String KEY_SKIP_SECONDS = "speech_ad_skip_seconds"; private static final String KEY_SKIP_MODE = "speech_ad_skip_mode"; - private static final List RULE_KEYS = List.of(KEY_RULES, KEY_RULE_SOURCE, KEY_BUILTIN_ENABLED); - private static final SpeechAdRuleSet EMPTY = SpeechAdRuleSet.empty(); - private static final RuleCache CACHE = new RuleCache(); - private static volatile SpeechAdRuleSet builtinRules; - private static volatile String lastValidationError = ""; - - public enum RuleSource { - NONE(""), USER("user"), IMPORTED("imported"); - - private final String persisted; - - RuleSource(String persisted) { - this.persisted = persisted; - } - - static RuleSource fromPersisted(String value) { - for (RuleSource source : values()) if (source.persisted.equals(value)) return source; - return NONE; - } - } - - public record RuleSnapshot(SpeechAdRuleSet rules, SpeechAdRuleSet customRules, - SpeechAdRuleSet builtinRules, boolean builtinEnabled, - RuleSource source, String error) { - public boolean hasError() { - return !error.isEmpty(); - } - } public static SpeechAdConfig snapshot() { return SpeechAdConfig.create( Prefers.getBoolean(KEY_ENABLED, false), Prefers.getString(KEY_KEYWORDS, SpeechAdConfig.DEFAULT_KEYWORDS), - ruleSnapshot().rules(), Prefers.getInt(KEY_SKIP_SECONDS, 15), Prefers.getString(KEY_SKIP_MODE, AdSkipPolicyController.Mode.PROMPT.name())); } - public static RuleSnapshot ruleSnapshot() { - boolean enabled = isBuiltinEnabled(); - SpeechAdRuleSet builtin = EMPTY; - if (enabled) { - try { - builtin = loadBuiltinRules(); - } catch (RuleDocumentException error) { - builtin = null; - } - } - RuleSnapshot result = CACHE.resolve(Prefers.getString(KEY_RULES, ""), enabled, - RuleSource.fromPersisted(Prefers.getString(KEY_RULE_SOURCE, "")), builtin); - String error = result.hasError() ? result.error() : lastValidationError; - return error.isEmpty() ? result : new RuleSnapshot(result.rules(), result.customRules(), - result.builtinRules(), result.builtinEnabled(), result.source(), error); - } - - public static String customRulesText() { - String text = Prefers.getString(KEY_RULES, ""); - // Corrupt external preferences must not populate an unbounded EditText. - return text.length() <= SpeechAdRuleCodec.MAX_INPUT_BYTES - && text.getBytes(StandardCharsets.UTF_8).length <= SpeechAdRuleCodec.MAX_INPUT_BYTES - ? text : ""; - } - - public static String builtinRulesText() { - return loadBuiltinRules().toText(); - } - - public static int builtinRuleCount() { - try { - return loadBuiltinRules().rules().size(); - } catch (RuntimeException ignored) { - return 0; - } - } - - public static boolean isBuiltinEnabled() { - return Prefers.getBoolean(KEY_BUILTIN_ENABLED, false); - } - - public static void setBuiltinEnabled(boolean enabled) { - SharedPreferences preferences = Prefers.getPrefers(); - try { - setBuiltinEnabled(preferences, enabled, enabled ? loadBuiltinRules() : EMPTY); - lastValidationError = ""; - } catch (RuntimeException error) { - recordValidationFailure(error); - throw error; - } - } - - static void setBuiltinEnabled(SharedPreferences preferences, boolean enabled, SpeechAdRuleSet builtin) { - if (enabled) merge(builtin, validatedRules(preferences.getString(KEY_RULES, ""))); - preferences.edit().putBoolean(KEY_BUILTIN_ENABLED, enabled).apply(); - } - - public static void setRulesText(String value, RuleSource source) { - SharedPreferences preferences = Prefers.getPrefers(); - try { - setRulesText(preferences, value, source, - preferences.getBoolean(KEY_BUILTIN_ENABLED, false) ? loadBuiltinRules() : EMPTY); - lastValidationError = ""; - } catch (RuntimeException error) { - recordValidationFailure(error); - throw error; - } - } - - static void setRulesText(SharedPreferences preferences, String value, RuleSource source, - SpeechAdRuleSet builtin) { - Objects.requireNonNull(source, "source"); - if (source == RuleSource.NONE) throw new RuleDocumentException("rule source is required"); - SpeechAdRuleSet custom = validatedRules(value); - if (source == RuleSource.IMPORTED && custom.isEmpty()) { - throw new RuleDocumentException("document has no rules; use Clear to remove rules"); - } - if (preferences.getBoolean(KEY_BUILTIN_ENABLED, false)) merge(builtin, custom); - // Text and provenance become visible together. apply() does not acknowledge disk durability. - preferences.edit().putString(KEY_RULES, custom.toText()) - .putString(KEY_RULE_SOURCE, custom.isEmpty() ? "" : source.persisted).apply(); - } - - public static void clearRules() { - Prefers.getPrefers().edit().remove(KEY_RULES).remove(KEY_RULE_SOURCE).apply(); - lastValidationError = ""; - } - - public static String canonicalize(String value) { - return validatedRules(value).toText(); - } - - /** Called on a background worker; do not retain the URI or its permission. */ - public static void importUri(ContentResolver resolver, Uri uri) throws IOException { - if (resolver == null || uri == null) throw new RuleDocumentException("rule URI is required"); - if (!isTextSource(resolver.getType(uri), displayName(resolver, uri))) { - throw new RuleDocumentException("rule file must be plain text (.txt)"); - } - final String text; - try (InputStream input = resolver.openInputStream(uri)) { - if (input == null) throw new IOException("rule file cannot be opened"); - text = readUtf8(input); - } - // A provider close failure must not be reported after publishing a new document. - setRulesText(text, RuleSource.IMPORTED); - } - - public static String recordValidationFailure(Throwable error) { - return lastValidationError = safeError(error); - } - - public static String safeError(Throwable error) { - // Only messages originating in the bounded codec are safe. Provider exceptions may contain URIs. - return error instanceof RuleDocumentException ? error.getMessage() : "cannot read or save rule settings"; - } - - /** Only the three V2 keys are normalized; other preference and legacy keyword contracts stay intact. */ - public static Map sanitizePreferences(Map values, - Map previous, boolean clear) { - Map result = new HashMap<>(values); - if (RULE_KEYS.stream().noneMatch(values::containsKey)) return result; - Map candidate = new HashMap<>(); - if (!clear) candidate.putAll(previous); - candidate.putAll(values); - try { - copyValidatedPreferences(candidate, result); - lastValidationError = ""; - } catch (RuntimeException error) { - // A malformed backup must not replace the last valid document or enable new rules. - String reason = safeError(error); - try { - copyValidatedPreferences(previous, result); - } catch (RuntimeException ignored) { - result.put(KEY_RULES, ""); - result.put(KEY_RULE_SOURCE, ""); - result.put(KEY_BUILTIN_ENABLED, false); - } - lastValidationError = reason; - } - return result; - } - - private static void copyValidatedPreferences(Map values, Map target) { - Object raw = values.get(KEY_RULES); - Object enabledValue = values.get(KEY_BUILTIN_ENABLED); - Object sourceValue = values.get(KEY_RULE_SOURCE); - if (raw != null && !(raw instanceof String) - || enabledValue != null && !(enabledValue instanceof Boolean) - || sourceValue != null && !(sourceValue instanceof String)) { - throw new RuleDocumentException("invalid rule preference type"); - } - SpeechAdRuleSet custom = validatedRules(raw == null ? "" : (String) raw); - boolean enabled = Boolean.TRUE.equals(enabledValue); - if (enabled) merge(loadBuiltinRules(), custom); - RuleSource source = RuleSource.fromPersisted(sourceValue == null ? "" : (String) sourceValue); - if (!custom.isEmpty() && source == RuleSource.NONE) source = RuleSource.USER; - target.put(KEY_RULES, custom.toText()); - target.put(KEY_RULE_SOURCE, custom.isEmpty() ? "" : source.persisted); - target.put(KEY_BUILTIN_ENABLED, enabled); - } - public static void setEnabled(boolean value) { Prefers.put(KEY_ENABLED, value); } @@ -249,141 +34,6 @@ public final class SpeechAdSetting { Prefers.put(KEY_SKIP_MODE, Objects.requireNonNull(value, "mode").name()); } - private static SpeechAdRuleSet loadBuiltinRules() { - SpeechAdRuleSet cached = builtinRules; - if (cached != null) return cached; - synchronized (SpeechAdSetting.class) { - if (builtinRules != null) return builtinRules; - try (InputStream input = App.get().getResources().openRawResource(R.raw.speech_ad_rules_v1)) { - return builtinRules = validatedRules(readUtf8(input)); - } catch (IOException | RuntimeException error) { - throw new RuleDocumentException("built-in rule resource is unavailable"); - } - } - } - - private static SpeechAdRuleSet validatedRules(String text) { - if (text == null) throw new RuleDocumentException("rule document is required"); - try { - return merge(EMPTY, SpeechAdRuleCodec.parse(text)); - } catch (IllegalArgumentException error) { - throw new RuleDocumentException(error.getMessage()); - } - } - - static SpeechAdRuleSet merge(SpeechAdRuleSet builtin, SpeechAdRuleSet custom) { - LinkedHashMap ids = new LinkedHashMap<>(); - Map bodies = new HashMap<>(); - Map locations = new HashMap<>(); - addRules(custom, "custom", ids, bodies, locations); - addRules(builtin, "built-in", ids, bodies, locations); - try { - SpeechAdRuleSet result = ids.isEmpty() ? EMPTY : new SpeechAdRuleSet(new ArrayList<>(ids.values())); - result.toText(); // The merged snapshot, not just each input, must fit the wire/document limit. - return result; - } catch (IllegalArgumentException error) { - throw new RuleDocumentException("combined rules exceed 256 rules or 64 KiB"); - } - } - - private static void addRules(SpeechAdRuleSet rules, String source, Map ids, - Map bodies, Map locations) { - for (int i = 0; i < rules.rules().size(); i++) { - SpeechAdRule rule = rules.rules().get(i); - SpeechAdRule sameId = ids.get(rule.id()); - if (sameId != null && !sameId.canonical().equals(rule.canonical())) { - throw new RuleDocumentException("conflicting rule id"); - } - String body = String.join(">", rule.segments().stream().map(SpeechAdRule.Segment::canonical).toList()); - SpeechAdRule sameBody = bodies.get(body); - String location = source + " rule " + (i + 1); - if (sameBody != null && !sameBody.canonical().equals(rule.canonical())) { - throw new RuleDocumentException(locations.get(body) + " / " + location - + ": conflicting windows; edit the document or disable built-in rules"); - } - bodies.put(body, rule); - locations.putIfAbsent(body, location); - ids.putIfAbsent(rule.id(), rule); - } - } - - static String readUtf8(InputStream input) throws IOException { - ByteArrayOutputStream output = new ByteArrayOutputStream(1024); - byte[] buffer = new byte[4096]; - int count; - while ((count = input.read(buffer)) != -1) { - if (count == 0) continue; - if (count > SpeechAdRuleCodec.MAX_INPUT_BYTES - output.size()) { - throw new RuleDocumentException("rule document is too large (maximum 64 KiB)"); - } - output.write(buffer, 0, count); - } - try { - return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(output.toByteArray())).toString(); - } catch (CharacterCodingException error) { - throw new RuleDocumentException("rule document must be UTF-8"); - } - } - - private static String displayName(ContentResolver resolver, Uri uri) { - try (Cursor cursor = resolver.query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) return cursor.getString(0); - } catch (RuntimeException ignored) { - } - return uri.getLastPathSegment(); - } - - static boolean isTextSource(String mimeType, String name) { - return "text/plain".equalsIgnoreCase(mimeType) - || name != null && name.toLowerCase(Locale.ROOT).endsWith(".txt"); - } - - static final class RuleCache { - private String document; - private RuleSource source; - private boolean enabled; - private SpeechAdRuleSet builtin; - private RuleSnapshot snapshot; - private RuleSnapshot good = new RuleSnapshot(EMPTY, EMPTY, EMPTY, false, RuleSource.NONE, ""); - - synchronized RuleSnapshot resolve(String text, boolean useBuiltin, RuleSource origin, SpeechAdRuleSet defaults) { - if (snapshot != null && Objects.equals(document, text) && source == origin - && enabled == useBuiltin && builtin == defaults) return snapshot; - document = text; - source = origin; - enabled = useBuiltin; - builtin = defaults; - try { - if (useBuiltin && defaults == null) throw new RuleDocumentException("built-in rule resource is unavailable"); - SpeechAdRuleSet custom = validatedRules(text); - SpeechAdRuleSet included = useBuiltin ? defaults : EMPTY; - RuleSource actual = custom.isEmpty() ? RuleSource.NONE - : origin == RuleSource.NONE ? RuleSource.USER : origin; - good = snapshot = new RuleSnapshot(merge(included, custom), custom, included, useBuiltin, actual, ""); - } catch (RuleDocumentException error) { - // Keep the last valid custom document on malformed external input. A built-in - // toggle is only retained when the previous snapshot already had it enabled; - // an explicit disable must never leave old built-ins running. - boolean keepBuiltin = useBuiltin && good.builtinEnabled(); - SpeechAdRuleSet fallbackBuiltin = keepBuiltin ? good.builtinRules() : EMPTY; - RuleSnapshot fallback = new RuleSnapshot( - merge(fallbackBuiltin, good.customRules()), good.customRules(), fallbackBuiltin, - keepBuiltin, good.source(), ""); - snapshot = new RuleSnapshot(fallback.rules(), fallback.customRules(), fallback.builtinRules(), - fallback.builtinEnabled(), fallback.source(), safeError(error)); - } - return snapshot; - } - } - - private static final class RuleDocumentException extends IllegalArgumentException { - RuleDocumentException(String message) { - super(message); - } - } - private SpeechAdSetting() { } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProvider.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProvider.java index 0e09a7c186..5da6b6e070 100644 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProvider.java +++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProvider.java @@ -5,9 +5,7 @@ import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; import com.fongmi.android.tv.subtitle.SpeechRecognitionFactory; import java.util.ArrayDeque; -import java.util.ArrayList; import java.util.Arrays; -import java.util.List; import java.util.Objects; import java.util.concurrent.Executor; @@ -23,12 +21,7 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { // a second on a 48 kHz stream, where every frame needs downsampling before it can be fed // to the recognizer. private static final int DEFAULT_MAILBOX_CAPACITY = 16; - private static final int RECOGNITION_RESULT_CAPACITY = 8; - private static final int MAX_RESULT_CHARS = 8_192; - private static final int MAX_PCM_SAMPLES = 192_000; - // Hub delivery only validates/copies into the bounded mailbox and schedules owner work; - // it must not run native recognition or matching on the audio publisher thread. - private static final Executor HUB_EXECUTOR = Runnable::run; + private static final Executor DIRECT_EXECUTOR = Runnable::run; public interface ConfigSource { SpeechAdConfig snapshot(); @@ -48,20 +41,12 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { private final Executor worker; private final AdAudioDiagnostics diagnostics; private final ArrayDeque mailbox = new ArrayDeque<>(); - private final ArrayDeque ownerCommands = new ArrayDeque<>(); - private final Object submissionLock = new Object(); - // The monitor protects only Java state/mailboxes. External code, Hub cleanup, file - // logging, resampling, matching and native calls must run outside it. private ProviderState state = ProviderState.DISABLED; private SessionContext context; private String ruleVersion = ""; private Listener listener; private SpeechAdConfig config; - // SpeechAdMatcher is confined to the speech owner. The reference is swapped - // under the short Java-state lock when a config is installed; matching and - // timeline resets never run in that lock. - private SpeechAdMatcher speechMatcher; private HostPosition hostPosition; private SpeechRecognitionFactory.Session recognitionSession; private PlaybackMediaSignalHub.Registration registration; @@ -70,15 +55,13 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { private long instanceToken; private int timelineToken; private long lastMatchPositionMs = Long.MIN_VALUE; - private boolean ownerTaskScheduled; - private boolean acceptScheduled; - private boolean modelCheckScheduled; - private int pendingRecognitionResults; - private boolean matcherResetScheduled; - private long pendingActivationToken = -1L; + private long pcmFrameCount; + private long droppedPcmCount; + private long fedFrameCount; + private long recognitionCount; + private boolean drainScheduled; private boolean enabled; private boolean closed; - private boolean ownerRejected; public SpeechAdSignalProvider(PlaybackMediaSignalHub hub, SpeechRecognitionFactory recognizerFactory, @@ -131,7 +114,6 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { this.modelStatus = ModelStatus.UNKNOWN; error = loadConfigAndReconcileLocked(); } - dispatchOwner(); notifyError(error); } @@ -142,13 +124,12 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { if (closed) return; Objects.requireNonNull(position, "position"); if (!matchesContext(position.sessionId(), position.generation())) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.STALE_GENERATION); + diagnostics.record(AdAudioDiagnostics.Code.STALE_GENERATION); return; } hostPosition = position; error = reconcileLocked(); } - dispatchOwner(); notifyError(error); } @@ -162,7 +143,6 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { currentListener = listener; applyTimelineResetLocked(reset); } - dispatchOwner(); notifyTimelineReset(currentListener, reset); } @@ -181,7 +161,6 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { error = reconcileLocked(); } } - dispatchOwner(); notifyError(error); } @@ -191,41 +170,31 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { } @Override - public void close() { - synchronized (this) { - if (!closed) { - closed = true; - instanceToken = nextInstanceToken(instanceToken); - pendingActivationToken = -1L; - discardObsoleteCommandsLocked(); - retireCaptureLocked(); - mailbox.clear(); - retireRecognitionSessionLocked(false); - context = null; - config = null; - hostPosition = null; - listener = null; - ruleVersion = ""; - speechMatcher = null; - state = ProviderState.CLOSED; - } - } - // Even a repeated close must finish an in-progress executor handoff before its - // caller shuts down the owner or creates a replacement provider. - dispatchOwner(); + public synchronized void close() { + if (closed) return; + closed = true; + instanceToken = nextInstanceToken(instanceToken); + closeRegistrationLocked(); + closeCaptureLeaseLocked(); + mailbox.clear(); + drainScheduled = false; + closeRecognitionSessionLocked(false); + context = null; + config = null; + hostPosition = null; + listener = null; + ruleVersion = ""; + state = ProviderState.CLOSED; } private ProviderError loadConfigAndReconcileLocked() { try { config = Objects.requireNonNull(configSource.snapshot(), "speech config"); - speechMatcher = config.rules().isEmpty() - ? null : new SpeechAdMatcher(config.rules()); } catch (RuntimeException error) { config = null; - speechMatcher = null; state = ProviderState.DEGRADED; - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_START_FAILED); + diagnostics.record(AdAudioDiagnostics.Code.SPEECH_START_FAILED); return providerError(ErrorCode.START_FAILED, "Speech configuration is unavailable"); } @@ -233,32 +202,21 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { } private ProviderError reconcileLocked() { - if (ownerRejected) { - state = ProviderState.DEGRADED; - return null; - } if (!enabled || config == null || !config.enabled()) { deactivateResourcesLocked(false, false); state = ProviderState.DISABLED; return null; } - if (!config.hasSpeechRules()) { + if (config.keywords().isEmpty()) { deactivateResourcesLocked(false, false); state = ProviderState.IDLE; return null; } - if (modelStatus == ModelStatus.UNKNOWN) { - state = ProviderState.IDLE; - if (!modelCheckScheduled) { - modelCheckScheduled = true; - enqueueOwnerCommandLocked(new CheckModelCommand(instanceToken), false); - } - return null; - } + ProviderError modelError = ensureModelReadyLocked(); if (modelStatus != ModelStatus.READY) { deactivateResourcesLocked(false, false); state = ProviderState.DEGRADED; - return null; + return modelError; } if (context == null || listener == null || ruleVersion.isEmpty()) { deactivateResourcesLocked(false, false); @@ -276,13 +234,6 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { state = ProviderState.IDLE; return null; } - if (pendingActivationToken == instanceToken) { - // Creation is deliberately owned by the speech worker. Do not enqueue a - // second create while the first one is still pending; doing so would create - // duplicate native sessions when refresh() races model startup. - state = ProviderState.IDLE; - return null; - } if (recognitionSession != null && registration != null && captureLease != null) { state = ProviderState.RUNNING; @@ -291,18 +242,66 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { return activateLocked(); } - private ProviderError modelErrorLocked() { - return modelStatus == ModelStatus.UNAVAILABLE - ? providerError(ErrorCode.START_FAILED, "Speech model is unavailable") - : providerError(ErrorCode.START_FAILED, "Speech model check failed"); + private ProviderError ensureModelReadyLocked() { + if (modelStatus == ModelStatus.READY) return null; + if (modelStatus == ModelStatus.UNAVAILABLE) return null; + if (modelStatus == ModelStatus.FAILED) return null; + try { + if (recognizerFactory.isReady()) { + modelStatus = ModelStatus.READY; + return null; + } + modelStatus = ModelStatus.UNAVAILABLE; + diagnostics.record(AdAudioDiagnostics.Code.SPEECH_MODEL_UNAVAILABLE); + return providerError(ErrorCode.START_FAILED, + "Speech model is unavailable"); + } catch (RuntimeException error) { + modelStatus = ModelStatus.FAILED; + diagnostics.record(AdAudioDiagnostics.Code.SPEECH_START_FAILED); + return providerError(ErrorCode.START_FAILED, + "Speech model check failed"); + } } private ProviderError activateLocked() { deactivateResourcesLocked(false, false); - pendingActivationToken = instanceToken; - state = ProviderState.IDLE; - enqueueOwnerCommandLocked(new CreateSessionCommand( - instanceToken, context.sessionId(), context.generation()), false); + PlaybackMediaSignalHub.Session hubSession = hub.session(); + if (context == null || hubSession.id() != context.sessionId() + || hubSession.generation() != context.generation()) { + state = ProviderState.DEGRADED; + diagnostics.record(AdAudioDiagnostics.Code.SPEECH_START_FAILED); + return providerError(ErrorCode.START_FAILED, + "Speech session is stale"); + } + + long token = nextInstanceToken(instanceToken); + instanceToken = token; + long sessionId = context.sessionId(); + long generation = context.generation(); + SpeechRecognitionFactory.Session nextSession = null; + PlaybackMediaSignalHub.Registration nextRegistration = null; + PlaybackMediaSignalHub.CaptureLease nextCaptureLease = null; + try { + nextSession = recognizerFactory.create( + recognitionListener(token)); + nextRegistration = hub.register( + HUB_CONSUMER_ID, DIRECT_EXECUTOR, 1, + hubConsumer(token)); + nextCaptureLease = hub.requestCapture( + PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO); + } catch (RuntimeException error) { + safeClose(nextRegistration); + safeClose(nextCaptureLease); + safeClose(nextSession); + state = ProviderState.DEGRADED; + diagnostics.record(AdAudioDiagnostics.Code.SPEECH_START_FAILED); + return providerError(ErrorCode.START_FAILED, + "Speech recognizer could not start"); + } + recognitionSession = nextSession; + registration = nextRegistration; + captureLease = nextCaptureLease; + state = ProviderState.RUNNING; return null; } @@ -311,7 +310,7 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { @Override public void onResult(String text, long startUs, long endUs, int callbackTimelineToken) { - onRecognitionResult(token, text, startUs, endUs, callbackTimelineToken); + onRecognitionResult(token, text, startUs, callbackTimelineToken); } @Override @@ -337,7 +336,7 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { public void onFailure(RuntimeException error) { synchronized (SpeechAdSignalProvider.this) { if (closed || token != instanceToken) return; - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); } } }; @@ -346,626 +345,180 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { private void enqueuePcm(long token, PlaybackMediaSignalHub.PcmFrame frame) { synchronized (this) { if (closed || token != instanceToken) return; - if (!matchesContext(frame.sessionId(), frame.generation())) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.STALE_GENERATION); + if (state != ProviderState.RUNNING) { + // Frames are dropped whenever the provider is parked. Count them, otherwise + // "audio arrives but nothing is recognized" looks identical to "no audio". + if (droppedPcmCount++ % 50L == 0L) { + AdAudioDiagnostics.log("speech pcm dropped state=%s n=%d", + state, droppedPcmCount); + } return; } - if (state != ProviderState.RUNNING) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_PCM_DROPPED); - // Buffering without input is harmless. Dropped PCM is not: invalidate the - // old segment as soon as actual input is discarded, but retain the model. - if (recognitionSession != null && !hasResetCommandLocked()) { - invalidatePcmGapLocked(); - } - } else if (frame.sampleRate() < 8_000 || frame.sampleRate() > 192_000 - || frame.captureStartTimeMs() < 0L || frame.monoSamples().length == 0 - || frame.monoSamples().length > MAX_PCM_SAMPLES) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_PCM_DROPPED); - invalidatePcmGapLocked(); - } else { - if (mailbox.size() >= mailboxCapacity) { - mailbox.removeFirst(); - diagnostics.recordQuietly(AdAudioDiagnostics.Code.QUEUE_OVERFLOW); - invalidatePcmGapLocked(); - } - mailbox.addLast(new PcmEnvelope( - token, frame.sessionId(), frame.generation(), timelineToken, - Arrays.copyOf(frame.monoSamples(), frame.monoSamples().length), - frame.sampleRate(), frame.captureStartTimeMs())); - diagnostics.recordSpeechQueueDepth(mailbox.size()); - if (!acceptScheduled) { - acceptScheduled = true; - enqueueOwnerCommandLocked(new AcceptNextPcmCommand(), false); - } - } - } - dispatchOwner(); - } - - private boolean hasResetCommandLocked() { - return ownerCommands.stream().anyMatch(command -> command instanceof ResetSessionCommand); - } - - private void invalidatePcmGapLocked() { - timelineToken = nextTimelineToken(timelineToken); - // Retain the newest bounded PCM tail, all on the fresh post-gap timeline. A result - // from an in-flight accept has the old token and cannot undo this invalidation. - int size = mailbox.size(); - for (int i = 0; i < size; i++) { - PcmEnvelope frame = mailbox.removeFirst(); - mailbox.addLast(new PcmEnvelope(frame.instanceToken(), frame.sessionId(), - frame.generation(), timelineToken, frame.samples(), frame.sampleRate(), - frame.captureStartTimeMs())); - } - ownerCommands.removeIf(command -> command instanceof ResetSessionCommand - || command instanceof RecognitionResultCommand || command instanceof RecognitionErrorCommand); - pendingRecognitionResults = 0; - matcherResetScheduled = false; - if (recognitionSession != null) { - enqueueOwnerCommandLocked(new ResetSessionCommand(instanceToken, timelineToken), true); - } - } - - private void scheduleMatcherResetLocked() { - if (speechMatcher == null || matcherResetScheduled) return; - matcherResetScheduled = true; - enqueueOwnerCommandLocked(new ResetMatcherCommand(instanceToken, timelineToken), true); - } - - private void dispatchOwner() { - // Serialize the handoff, not native work. Without this separate lock, close could - // see scheduled=true before execute accepted the wakeup and shut down the executor. - // The production executor enqueues asynchronously; never hold the state monitor - // across execute (injected test executors may run inline). - synchronized (submissionLock) { - synchronized (this) { - if (ownerRejected || ownerTaskScheduled || ownerCommands.isEmpty()) return; - ownerTaskScheduled = true; + if (!matchesContext(frame.sessionId(), frame.generation())) { + diagnostics.record(AdAudioDiagnostics.Code.STALE_GENERATION); + return; } + float[] samples; try { - worker.execute(this::runOwnerCommand); + samples = Arrays.copyOf(frame.monoSamples(), frame.monoSamples().length); } catch (RuntimeException error) { - synchronized (this) { - ownerTaskScheduled = false; - ownerRejected = true; - instanceToken = nextInstanceToken(instanceToken); - mailbox.clear(); - if (!closed) state = ProviderState.DEGRADED; - // Preserve cleanup commands/references; rejection must not masquerade as - // a successful native close or fall back to freeing on the caller thread. - } - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_OWNER_REJECTED); + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); + return; } + if (mailbox.size() >= mailboxCapacity) { + mailbox.removeFirst(); + diagnostics.record(AdAudioDiagnostics.Code.QUEUE_OVERFLOW); + } + if (pcmFrameCount++ % 200L == 0L) { + AdAudioDiagnostics.log("speech pcm n=%d rate=%d captureMs=%d", + pcmFrameCount, frame.sampleRate(), frame.captureStartTimeMs()); + } + mailbox.addLast(new PcmEnvelope( + token, frame.sessionId(), frame.generation(), timelineToken, + samples, frame.sampleRate(), frame.captureStartTimeMs())); + scheduleDrainLocked(token, timelineToken); } } - private void runOwnerCommand() { - // One merged wakeup owns this provider until idle. Check lifecycle priority between - // each bounded PCM/result command, including after a slow native call. No recursive - // resubmission after Runtime.shutdown(), and no later provider can overtake close. + private void scheduleDrainLocked(long token, int scheduledTimelineToken) { + if (drainScheduled) return; + drainScheduled = true; + try { + worker.execute(() -> drainMailbox(token, scheduledTimelineToken)); + } catch (RuntimeException error) { + drainScheduled = false; + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); + } + } + + private void drainMailbox(long token, int scheduledTimelineToken) { while (true) { - OwnerCommand command; + PcmEnvelope envelope; synchronized (this) { - command = ownerCommands.pollFirst(); - if (command == null) { - ownerTaskScheduled = false; + if (closed || token != instanceToken + || scheduledTimelineToken != timelineToken) return; + envelope = mailbox.pollFirst(); + if (envelope == null) { + drainScheduled = false; return; } - if (command instanceof RecognitionResultCommand - || command instanceof RecognitionErrorCommand) pendingRecognitionResults--; + if (!isCurrentEnvelopeLocked(envelope)) { + diagnostics.record(AdAudioDiagnostics.Code.STALE_GENERATION); + continue; + } } + + float[] samples; try { - command.run(); + samples = PlaybackMediaAudioProcessor.resample( + envelope.samples(), envelope.sampleRate(), TARGET_SAMPLE_RATE); } catch (RuntimeException error) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); + continue; } - } - } + if (samples.length == 0) { + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); + continue; + } + long startUs = millisecondsToMicroseconds(envelope.captureStartTimeMs()); + long durationUs = ((long) samples.length * 1_000_000L + + TARGET_SAMPLE_RATE - 1L) / TARGET_SAMPLE_RATE; + long endUs = saturatedAdd(startUs, durationUs); - private void enqueueOwnerCommandLocked(OwnerCommand command, boolean priority) { - if (priority) ownerCommands.addFirst(command); - else ownerCommands.addLast(command); - } - - private void acceptNextPcm() { - PcmEnvelope envelope; - SpeechRecognitionFactory.Session session; - synchronized (this) { - if (closed || state != ProviderState.RUNNING - || recognitionSession == null) { - mailbox.clear(); - acceptScheduled = false; - return; - } - envelope = mailbox.pollFirst(); - if (envelope == null) { - acceptScheduled = false; - return; - } - if (!isCurrentEnvelopeLocked(envelope)) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.STALE_GENERATION); - enqueueOwnerCommandLocked(new AcceptNextPcmCommand(), false); - return; - } - session = recognitionSession; - } - - float[] samples; - try { - samples = PlaybackMediaAudioProcessor.resample( - envelope.samples(), envelope.sampleRate(), TARGET_SAMPLE_RATE); - } catch (RuntimeException error) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); - finishAcceptCommand(); - return; - } - if (samples.length == 0) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); - finishAcceptCommand(); - return; - } - long startUs = millisecondsToMicroseconds(envelope.captureStartTimeMs()); - long durationUs = ((long) samples.length * 1_000_000L - + TARGET_SAMPLE_RATE - 1L) / TARGET_SAMPLE_RATE; - long endUs = saturatedAdd(startUs, durationUs); - synchronized (this) { - if (!isCurrentEnvelopeLocked(envelope) || session != recognitionSession - || state != ProviderState.RUNNING) { - finishAcceptCommand(); - return; - } - } - long beginNanos = System.nanoTime(); - try { - session.accept(samples, startUs, endUs, envelope.timelineToken()); - } catch (RuntimeException error) { synchronized (this) { - if (isCurrentEnvelopeLocked(envelope)) invalidatePcmGapLocked(); - } - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); - } finally { - diagnostics.recordSpeechAcceptNanos(System.nanoTime() - beginNanos); - } - finishAcceptCommand(); - } - - private void finishAcceptCommand() { - synchronized (this) { - if (closed || state != ProviderState.RUNNING - || recognitionSession == null || mailbox.isEmpty()) { - acceptScheduled = false; - return; - } - if (ownerCommands.stream().noneMatch(command -> command instanceof AcceptNextPcmCommand)) { - enqueueOwnerCommandLocked(new AcceptNextPcmCommand(), false); - } - } - } - - private interface OwnerCommand { - void run(); - } - - private final class CheckModelCommand implements OwnerCommand { - private final long token; - - private CheckModelCommand(long token) { - this.token = token; - } - - @Override - public void run() { - synchronized (SpeechAdSignalProvider.this) { - if (closed || token != instanceToken) return; - } - ModelStatus result; - try { - result = recognizerFactory.isReady() - ? ModelStatus.READY : ModelStatus.UNAVAILABLE; - } catch (RuntimeException error) { - result = ModelStatus.FAILED; - } - ProviderError error = null; - Listener errorListener; - synchronized (SpeechAdSignalProvider.this) { - if (closed || token != instanceToken) return; - modelCheckScheduled = false; - modelStatus = result; - if (result == ModelStatus.UNAVAILABLE) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_MODEL_UNAVAILABLE); - } else if (result == ModelStatus.FAILED) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_START_FAILED); + if (!isCurrentEnvelopeLocked(envelope) + || recognitionSession == null) { + diagnostics.record(AdAudioDiagnostics.Code.STALE_GENERATION); + continue; } - error = reconcileLocked(); - if (result != ModelStatus.READY) error = modelErrorLocked(); - errorListener = listener; - } - dispatchOwner(); - notifyError(errorListener, error); - } - } - - private final class CreateSessionCommand implements OwnerCommand { - private final long token; - private final long sessionId; - private final long generation; - - private CreateSessionCommand(long token, long sessionId, long generation) { - this.token = token; - this.sessionId = sessionId; - this.generation = generation; - } - - @Override - public void run() { - synchronized (SpeechAdSignalProvider.this) { - if (closed || token != instanceToken || pendingActivationToken != token) return; - } - SpeechRecognitionFactory.Session created = null; - PlaybackMediaSignalHub.Registration nextRegistration = null; - PlaybackMediaSignalHub.CaptureLease nextLease = null; - boolean installedCapture = false; - ProviderError error = null; - Listener errorListener = null; - try { - PlaybackMediaSignalHub.Session current = hub.session(); - if (current.id() != sessionId || current.generation() != generation) { - throw new IllegalStateException("stale speech activation"); - } - nextRegistration = hub.register(HUB_CONSUMER_ID, HUB_EXECUTOR, 1, hubConsumer(token)); - nextLease = hub.requestCapture(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO); - synchronized (SpeechAdSignalProvider.this) { - if (!closed && token == instanceToken) { - registration = nextRegistration; - captureLease = nextLease; - installedCapture = true; - } - } - if (!installedCapture) return; - created = Objects.requireNonNull(recognizerFactory.create( - recognitionListener(token), SpeechRecognitionFactory.ExecutionProfile.AD_AUDIO), - "speech recognition session"); - } catch (RuntimeException failure) { - error = providerError(ErrorCode.START_FAILED, "Speech recognizer could not start"); - } finally { - if (!installedCapture) { - safeClose(nextRegistration); - safeClose(nextLease); - } - } - boolean install; - synchronized (SpeechAdSignalProvider.this) { - // A seek during a slow create is safe: this new session has consumed no PCM. - // A changed media session or disabled provider must never install it. - install = error == null && !closed && token == instanceToken - && pendingActivationToken == token && context != null - && context.sessionId() == sessionId && enabled - && config != null && config.enabled(); - if (install) { - recognitionSession = created; - pendingActivationToken = -1L; - state = isEligible(hostPosition) ? ProviderState.RUNNING : ProviderState.IDLE; - } else if (!closed && token == instanceToken) { - errorListener = listener; - retireCaptureLocked(); - pendingActivationToken = -1L; - modelStatus = error == null ? ModelStatus.READY : ModelStatus.FAILED; - state = error == null ? ProviderState.IDLE : ProviderState.DEGRADED; - if (error != null) diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_START_FAILED); - } - } - if (!install) safeClose(created); - notifyError(errorListener, error); - } - } - - private final class AcceptNextPcmCommand implements OwnerCommand { - @Override - public void run() { - acceptNextPcm(); - } - } - - private final class RecognitionResultCommand implements OwnerCommand { - private final long token; - private final String text; - private final long startUs; - private final long endUs; - private final int timeline; - - private RecognitionResultCommand(long token, String text, long startUs, - long endUs, int timeline) { - this.token = token; - this.text = text; - this.startUs = startUs; - this.endUs = endUs; - this.timeline = timeline; - } - - @Override - public void run() { - processRecognitionResult(token, text, startUs, endUs, timeline); - } - } - - private final class RecognitionErrorCommand implements OwnerCommand { - private final long token; - - private RecognitionErrorCommand(long token) { - this.token = token; - } - - @Override - public void run() { - processRecognitionError(token); - } - } - - private final class ResetSessionCommand implements OwnerCommand { - private final long token; - private final int timeline; - - private ResetSessionCommand(long token, int timeline) { - this.token = token; - this.timeline = timeline; - } - - @Override - public void run() { - SpeechRecognitionFactory.Session session; - SpeechAdMatcher matcher; - synchronized (SpeechAdSignalProvider.this) { - if (closed || token != instanceToken || timeline != timelineToken) return; - session = recognitionSession; - matcher = speechMatcher; - } - if (matcher != null) matcher.reset(timeline); - if (session == null) return; - try { - session.reset(); - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_RESET); - } catch (RuntimeException error) { - synchronized (SpeechAdSignalProvider.this) { - if (token == instanceToken) { - deactivateResourcesLocked(false, false); - modelStatus = ModelStatus.FAILED; - state = ProviderState.DEGRADED; - } - } - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); - } - } - } - - private final class ResetMatcherCommand implements OwnerCommand { - private final long token; - private final int timeline; - - private ResetMatcherCommand(long token, int timeline) { - this.token = token; - this.timeline = timeline; - } - - @Override - public void run() { - SpeechAdMatcher matcher; - synchronized (SpeechAdSignalProvider.this) { - if (closed || token != instanceToken || timeline != timelineToken) return; - ownerCommands.removeIf(command -> command instanceof RecognitionResultCommand - || command instanceof RecognitionErrorCommand); - pendingRecognitionResults = 0; - matcherResetScheduled = false; - matcher = speechMatcher; - } - if (matcher != null) matcher.reset(timeline); - } - } - - private final class CloseCaptureCommand implements OwnerCommand { - private final PlaybackMediaSignalHub.Registration retiredRegistration; - private final PlaybackMediaSignalHub.CaptureLease retiredLease; - - private CloseCaptureCommand(PlaybackMediaSignalHub.Registration registration, - PlaybackMediaSignalHub.CaptureLease lease) { - retiredRegistration = registration; - retiredLease = lease; - } - - @Override - public void run() { - safeClose(retiredRegistration); - safeClose(retiredLease); - } - } - - private final class CloseSessionCommand implements OwnerCommand { - private final SpeechRecognitionFactory.Session session; - private final boolean resetFirst; - - private CloseSessionCommand(SpeechRecognitionFactory.Session session, - boolean resetFirst) { - this.session = session; - this.resetFirst = resetFirst; - } - - @Override - public void run() { - if (resetFirst) { try { - session.reset(); + recognitionSession.accept( + samples, startUs, endUs, envelope.timelineToken()); + if (fedFrameCount++ % 200L == 0L) { + AdAudioDiagnostics.log("speech fed n=%d samples=%d startMs=%d", + fedFrameCount, samples.length, + microsecondsToMilliseconds(startUs)); + } } catch (RuntimeException error) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); } } - try { - session.close(); - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_CLOSED); - } catch (RuntimeException error) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); - } } } private void onRecognitionResult(long token, String text, long startUs, - long endUs, int callbackTimelineToken) { - synchronized (this) { - if (!isCurrentCallbackLocked(token, callbackTimelineToken)) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_STALE_CALLBACK); - return; - } - if (pendingRecognitionResults >= RECOGNITION_RESULT_CAPACITY) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.QUEUE_OVERFLOW); - scheduleMatcherResetLocked(); - return; - } - if (text != null && text.length() > MAX_RESULT_CHARS) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_PCM_DROPPED); - scheduleMatcherResetLocked(); - return; - } - pendingRecognitionResults++; - enqueueOwnerCommandLocked(new RecognitionResultCommand( - token, text, startUs, endUs, callbackTimelineToken), false); - } - dispatchOwner(); - } - - private void onRecognitionError(long token) { - synchronized (this) { - if (closed || token != instanceToken || context == null) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_STALE_CALLBACK); - return; - } - if (pendingRecognitionResults >= RECOGNITION_RESULT_CAPACITY) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.QUEUE_OVERFLOW); - return; - } - pendingRecognitionResults++; - enqueueOwnerCommandLocked(new RecognitionErrorCommand(token), false); - } - dispatchOwner(); - } - - private void processRecognitionResult(long token, String text, long startUs, - long endUs, int callbackTimelineToken) { - SpeechAdConfig currentConfig; - SpeechAdMatcher currentMatcher; - SessionContext currentContext; - String currentRuleVersion; + int callbackTimelineToken) { Listener currentListener; + AdAudioCandidate candidate; synchronized (this) { if (!isCurrentCallbackLocked(token, callbackTimelineToken)) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_STALE_CALLBACK); + diagnostics.record(AdAudioDiagnostics.Code.SPEECH_STALE_CALLBACK); return; } - currentConfig = config; - currentMatcher = speechMatcher; - currentContext = context; - currentRuleVersion = ruleVersion; - currentListener = listener; - } - if (text == null || SpeechAdKeywordSet.normalize(text).isEmpty()) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_TEXT_EMPTY); - } - - List candidates = new ArrayList<>(); - if (currentMatcher != null) { - for (SpeechAdMatcher.Match match : currentMatcher.accept( - text, startUs, endUs, callbackTimelineToken)) { - AdAudioCandidate candidate = compoundCandidate( - currentContext, currentRuleVersion, match); - if (candidate != null) candidates.add(candidate); + String normalized = SpeechAdKeywordSet.normalize(text); + if (normalized.isEmpty()) { + diagnostics.record(AdAudioDiagnostics.Code.SPEECH_TEXT_EMPTY); + return; } - } - if (currentConfig != null && !currentConfig.keywords().isEmpty() - && currentConfig.keywords().firstMatch(text).isPresent()) { - long startMs = microsecondsToMilliseconds(startUs); - long endMs = saturatedAdd(startMs, - (long) currentConfig.skipSeconds() * 1_000L); - if (startMs >= 0L && endMs > startMs) { - AdAudioCandidate candidate = keywordCandidate( - currentContext, currentRuleVersion, startMs, endMs); - if (candidate != null) candidates.add(candidate); + boolean matched; + try { + matched = config != null + && config.keywords().firstMatch(text).isPresent(); + } catch (RuntimeException error) { + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); + return; } - } - if (candidates.isEmpty()) return; - - synchronized (this) { - if (!isCurrentCallbackLocked(token, callbackTimelineToken) - || currentConfig != config || currentContext != context - || !currentRuleVersion.equals(ruleVersion)) return; + // Log only the transcript LENGTH and the match verdict. The recognized text and + // the matched keyword must never reach the log. + AdAudioDiagnostics.log("speech text n=%d chars=%d matched=%b startMs=%d", + ++recognitionCount, normalized.length(), + matched, microsecondsToMilliseconds(startUs)); + if (!matched) return; HostPosition position = hostPosition; if (!isEligible(position) || !matchesContext(position.sessionId(), position.generation())) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.CLOCK_UNAVAILABLE); + diagnostics.record(AdAudioDiagnostics.Code.CLOCK_UNAVAILABLE); return; } - // Legacy keywords retain their provider-level cooldown. V2 rules have - // independent cooldown state inside SpeechAdMatcher. - candidates.removeIf(candidate -> RULE_ID.equals(candidate.ruleId()) - && !acceptLegacyMatchLocked(candidate.startMs())); - if (candidates.isEmpty()) return; - for (AdAudioCandidate ignored : candidates) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_MATCHED); + // Anchor on the capture timestamp the recognizer reported for this utterance. + // hostPosition is only republished on bind/refresh/state change, so during + // steady playback it is stale and would place the candidate at the position + // playback had when it was last sampled instead of where the keyword was said. + long startMs = microsecondsToMilliseconds(startUs); + if (startMs < 0L) { + diagnostics.record(AdAudioDiagnostics.Code.CLOCK_UNAVAILABLE); + return; } + if (lastMatchPositionMs != Long.MIN_VALUE + && startMs >= lastMatchPositionMs + && startMs - lastMatchPositionMs < MATCH_COOLDOWN_MS) { + diagnostics.record(AdAudioDiagnostics.Code.SPEECH_COOLDOWN); + return; + } + long endMs = saturatedAdd(startMs, (long) config.skipSeconds() * 1_000L); + try { + candidate = new AdAudioCandidate( + context.sessionId(), context.generation(), RULE_ID, ruleVersion, + startMs, endMs, true, 1.0d, ID); + } catch (RuntimeException error) { + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); + return; + } + lastMatchPositionMs = startMs; + diagnostics.record(AdAudioDiagnostics.Code.SPEECH_MATCHED); + currentListener = listener; } - for (AdAudioCandidate candidate : candidates) { - notifyCandidate(currentListener, candidate); - } + notifyCandidate(currentListener, candidate); } - private boolean acceptLegacyMatchLocked(long startMs) { - if (lastMatchPositionMs != Long.MIN_VALUE - && startMs >= lastMatchPositionMs - && startMs - lastMatchPositionMs < MATCH_COOLDOWN_MS) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_COOLDOWN); - return false; - } - lastMatchPositionMs = startMs; - return true; - } - - private AdAudioCandidate keywordCandidate(SessionContext currentContext, - String currentRuleVersion, - long startMs, long endMs) { - if (currentContext == null) return null; - try { - return new AdAudioCandidate( - currentContext.sessionId(), currentContext.generation(), RULE_ID, - currentRuleVersion, startMs, endMs, true, 1.0d, ID); - } catch (RuntimeException error) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); - return null; - } - } - - private AdAudioCandidate compoundCandidate(SessionContext currentContext, - String currentRuleVersion, - SpeechAdMatcher.Match match) { - if (currentContext == null) return null; - long startMs = Math.max(0L, match.firstStartUs() / 1_000L); - startMs = startMs > match.preRollMs() - ? startMs - match.preRollMs() : 0L; - long endMs = saturatedAdd(match.lastEndUs() / 1_000L, match.postRollMs()); - if (endMs <= startMs) return null; - try { - return new AdAudioCandidate( - currentContext.sessionId(), currentContext.generation(), match.ruleId(), - currentRuleVersion, startMs, endMs, false, 1.0d, ID); - } catch (RuntimeException error) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); - return null; - } - } - - private void processRecognitionError(long token) { + private void onRecognitionError(long token) { Listener currentListener; synchronized (this) { if (closed || token != instanceToken || context == null) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_STALE_CALLBACK); + diagnostics.record(AdAudioDiagnostics.Code.SPEECH_STALE_CALLBACK); return; } - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); currentListener = listener; } notifyError(currentListener, providerError( @@ -985,7 +538,6 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { currentListener = listener; applyTimelineResetLocked(reset); } - dispatchOwner(); notifyTimelineReset(currentListener, reset); } @@ -997,19 +549,16 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { lastMatchPositionMs = Long.MIN_VALUE; timelineToken = nextTimelineToken(timelineToken); if (!mailbox.isEmpty()) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.STALE_GENERATION); + diagnostics.record(AdAudioDiagnostics.Code.STALE_GENERATION); } mailbox.clear(); - ownerCommands.removeIf(command -> command instanceof ResetSessionCommand - || command instanceof AcceptNextPcmCommand || command instanceof RecognitionResultCommand - || command instanceof RecognitionErrorCommand - || command instanceof ResetMatcherCommand); - pendingRecognitionResults = 0; - matcherResetScheduled = false; - acceptScheduled = false; + drainScheduled = false; if (recognitionSession != null) { - enqueueOwnerCommandLocked(new ResetSessionCommand( - instanceToken, timelineToken), true); + try { + recognitionSession.reset(); + } catch (RuntimeException error) { + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); + } } state = !enabled || config == null || !config.enabled() ? ProviderState.DISABLED @@ -1052,40 +601,51 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { private void deactivateResourcesLocked(boolean resetSession, boolean staleQueuedPcm) { instanceToken = nextInstanceToken(instanceToken); - pendingActivationToken = -1L; - discardObsoleteCommandsLocked(); - retireCaptureLocked(); + closeRegistrationLocked(); + closeCaptureLeaseLocked(); if (staleQueuedPcm && !mailbox.isEmpty()) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.STALE_GENERATION); + diagnostics.record(AdAudioDiagnostics.Code.STALE_GENERATION); } mailbox.clear(); - acceptScheduled = false; - retireRecognitionSessionLocked(resetSession); + drainScheduled = false; + closeRecognitionSessionLocked(resetSession); } - private void discardObsoleteCommandsLocked() { - ownerCommands.removeIf(command -> !(command instanceof CloseSessionCommand) - && !(command instanceof CloseCaptureCommand)); - pendingRecognitionResults = 0; - matcherResetScheduled = false; - modelCheckScheduled = false; - acceptScheduled = false; - } - - private void retireCaptureLocked() { - if (registration == null && captureLease == null) return; - enqueueOwnerCommandLocked(new CloseCaptureCommand(registration, captureLease), true); + private void closeRegistrationLocked() { + if (registration == null) return; + try { + registration.close(); + } catch (RuntimeException error) { + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); + } registration = null; + } + + private void closeCaptureLeaseLocked() { + if (captureLease == null) return; + try { + captureLease.close(); + } catch (RuntimeException error) { + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); + } captureLease = null; } - private void retireRecognitionSessionLocked(boolean resetFirst) { + private void closeRecognitionSessionLocked(boolean resetFirst) { if (recognitionSession == null) return; - SpeechRecognitionFactory.Session retired = recognitionSession; + if (resetFirst) { + try { + recognitionSession.reset(); + } catch (RuntimeException error) { + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); + } + } + try { + recognitionSession.close(); + } catch (RuntimeException error) { + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); + } recognitionSession = null; - diagnostics.recordQuietly(AdAudioDiagnostics.Code.SPEECH_CLOSE_PENDING); - // Unregister first (also lock-free), then physically close native, before any new create. - enqueueOwnerCommandLocked(new CloseSessionCommand(retired, resetFirst), false); } private ProviderError providerError(ErrorCode code, String detail) { @@ -1097,7 +657,7 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { try { target.onCandidate(candidate); } catch (RuntimeException error) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); } } @@ -1115,7 +675,7 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { try { target.onProviderError(error); } catch (RuntimeException listenerError) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); } } @@ -1124,7 +684,7 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { try { target.onTimelineReset(reset); } catch (RuntimeException error) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); } } @@ -1133,7 +693,7 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { try { closeable.close(); } catch (Exception error) { - diagnostics.recordQuietly(AdAudioDiagnostics.Code.MATCHER_ERROR); + diagnostics.record(AdAudioDiagnostics.Code.MATCHER_ERROR); } } @@ -1164,4 +724,4 @@ public final class SpeechAdSignalProvider implements AdAudioSignalProvider { int timelineToken, float[] samples, int sampleRate, long captureStartTimeMs) { } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java new file mode 100644 index 0000000000..8c02b0adab --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java @@ -0,0 +1,62 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; + +/** + * 单个通道对区间的归因结论。 + * + * @param channelId 通道标识,"hls" / "domain" / "existing-rule" / "audio" / "speech" / "ai" + * @param category 归因类别 + * @param confidence 通道内自评置信度,0..1 + * @param risk 采纳该结论的风险等级 + * @param evidence 人类可读的证据,直接进 UI + * @param remediation 建议的去广机制 + * @param payload 落地规则所需的机器可读数据 + */ +public record AdAttribution( + String channelId, AdCategory category, float confidence, + RiskLevel risk, List evidence, RemediationKind remediation, + RulePayload payload) { + + public AdAttribution { + if (channelId == null || channelId.isBlank()) { + throw new IllegalArgumentException("channelId is required"); + } + category = category == null ? AdCategory.UNKNOWN : category; + confidence = clamp(confidence); + risk = risk == null ? RiskLevel.MEDIUM : risk; + evidence = evidence == null ? List.of() : List.copyOf(evidence); + remediation = remediation == null ? RemediationKind.NONE : remediation; + payload = payload == null ? RulePayload.empty() : payload; + } + + /** 纯诊断结论没有可落地的数据。 */ + public AdAttribution(String channelId, AdCategory category, float confidence, + RiskLevel risk, List evidence, RemediationKind remediation) { + this(channelId, category, confidence, risk, evidence, remediation, RulePayload.empty()); + } + + private static float clamp(float value) { + if (Float.isNaN(value)) return 0f; + return Math.max(0f, Math.min(1f, value)); + } + + /** + * 仲裁得分:置信度占 0.7,机制的成本-风险优先级占 0.3。 + * {@link AdCategory#ALREADY_HANDLED} 强制降到最低,见设计文档第 8.2 节。 + */ + public float score() { + if (category == AdCategory.ALREADY_HANDLED) return 0f; + return confidence * 0.7f + remediation.priorityWeight() * 0.3f; + } + + /** + * 是否能产出可落地的规则。除了机制本身要可执行,载荷也必须非空 —— + * 否则「保存规则」会写出一条空规则。 + */ + public boolean actionable() { + return remediation != RemediationKind.NONE + && remediation != RemediationKind.SESSION_SKIP_ONLY + && !payload.isEmpty(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java new file mode 100644 index 0000000000..badb414d91 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java @@ -0,0 +1,129 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 跨通道仲裁:合并同类结论、按成本-风险排序、选出最优方案。 + * + *

核心取舍见设计文档第 8 节:「最好的去广告方式」不等于「置信度最高的方式」。 + * 同等置信度下优先选成本更低、风险更小的机制。 + */ +public final class AdAttributionArbiter { + + private AdAttributionArbiter() { + } + + /** + * 仲裁结果。 + * + * @param preferred 首选方案,全部弃权时为 null + * @param alternatives 备选方案,按得分降序 + * @param diagnostics 纯诊断结论,不产出规则但需展示 + */ + public record Verdict(AdAttribution preferred, List alternatives, + List diagnostics) { + + public Verdict { + alternatives = alternatives == null ? List.of() : List.copyOf(alternatives); + diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics); + } + + /** 是否有可落地的规则方案。 */ + public boolean hasActionablePlan() { + return preferred != null && preferred.actionable(); + } + + /** 全部通道都没给出结论。 */ + public boolean empty() { + return preferred == null && diagnostics.isEmpty(); + } + } + + /** + * 仲裁若干通道结论。入参可含 null(通道弃权),会被忽略。 + */ + public static Verdict arbitrate(List attributions) { + List valid = new ArrayList<>(); + if (attributions != null) { + for (AdAttribution attribution : attributions) { + if (attribution != null) valid.add(attribution); + } + } + if (valid.isEmpty()) return new Verdict(null, List.of(), List.of()); + + List diagnostics = new ArrayList<>(); + List actionable = new ArrayList<>(); + for (AdAttribution attribution : valid) { + if (attribution.actionable()) actionable.add(attribution); + else diagnostics.add(attribution); + } + + List merged = mergeSameCategory(actionable); + merged.sort(Comparator.comparingDouble(AdAttribution::score).reversed() + .thenComparingInt(a -> a.remediation().ordinal())); + + if (merged.isEmpty()) { + // 只有诊断结论:能做的只有本次跳过 + return new Verdict(sessionSkipFallback(diagnostics), List.of(), diagnostics); + } + return new Verdict(merged.get(0), merged.subList(1, merged.size()), diagnostics); + } + + /** + * 同一 {@link AdCategory} 的多通道结论合并为一条:置信度按概率或 + * {@code 1 - Π(1 - cᵢ)},机制取成本最低的那个,证据全量保留。 + */ + private static List mergeSameCategory(List attributions) { + Map> grouped = new EnumMap<>(AdCategory.class); + for (AdAttribution attribution : attributions) { + grouped.computeIfAbsent(attribution.category(), key -> new ArrayList<>()).add(attribution); + } + + List merged = new ArrayList<>(); + for (Map.Entry> entry : grouped.entrySet()) { + List group = entry.getValue(); + if (group.size() == 1) { + merged.add(group.get(0)); + continue; + } + float inverse = 1f; + for (AdAttribution attribution : group) inverse *= (1f - attribution.confidence()); + float confidence = 1f - inverse; + + AdAttribution cheapest = group.stream() + .min(Comparator.comparingInt(a -> a.remediation().ordinal())) + .orElse(group.get(0)); + RiskLevel risk = group.stream() + .map(AdAttribution::risk) + .min(Comparator.comparingInt(Enum::ordinal)) + .orElse(cheapest.risk()); + + Set lines = new LinkedHashSet<>(); + List channels = new ArrayList<>(); + for (AdAttribution attribution : group) { + lines.addAll(attribution.evidence()); + channels.add(attribution.channelId()); + } + merged.add(new AdAttribution(String.join("+", channels), entry.getKey(), + confidence, risk, List.copyOf(lines), cheapest.remediation(), + // 载荷必须跟随被选中的机制,否则会拿 A 的数据去执行 B 的动作 + cheapest.payload())); + } + return merged; + } + + /** 只有诊断结论时的兜底:本次跳过。 */ + private static AdAttribution sessionSkipFallback(List diagnostics) { + List lines = new ArrayList<>(); + lines.add("未能归纳出可复用的去广规则"); + for (AdAttribution diagnostic : diagnostics) lines.addAll(diagnostic.evidence()); + return new AdAttribution("arbiter", AdCategory.IN_STREAM_BURNED_IN, + 0.3f, RiskLevel.LOW, lines, RemediationKind.SESSION_SKIP_ONLY); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdCategory.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdCategory.java new file mode 100644 index 0000000000..a510976414 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdCategory.java @@ -0,0 +1,26 @@ +package com.fongmi.android.tv.ad.feedback; + +/** + * 用户反馈区间的归因类别。 + * + *

见 {@code docs/ad-feedback-timerange-design.md} 第 6.2 节。 + */ +public enum AdCategory { + /** 切片来自非本站适配域名。 */ + THIRD_PARTY_CDN_SEGMENT, + /** 固定时长硬插块。 */ + FIXED_DURATION_BLOCK, + /** 被 DISCONTINUITY 包裹的独立块。 */ + DISCONTINUITY_BLOCK, + /** 命中已知广告域名。 */ + KNOWN_AD_HOST, + /** 音频频谱指纹匹配。 */ + AUDIO_FINGERPRINT, + /** 语音关键词命中。 */ + SPEECH_KEYWORD, + /** 压制进正片,无结构特征可用。 */ + IN_STREAM_BURNED_IN, + /** 已被现有规则处理,用户看到的广告来自别处。 */ + ALREADY_HANDLED, + UNKNOWN +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollector.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollector.java new file mode 100644 index 0000000000..bd6c8e0b30 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollector.java @@ -0,0 +1,173 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.bean.M3u8Evidence; + +import java.net.URI; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * 把 {@code M3u8Parser} 已产出的 playlist 证据 + 用户区间,组装成 + * {@link AdIntervalEvidence}。纯计算,不发起网络请求,不触碰 Android 类型。 + * + *

见设计文档第 6.1、7.1 节。 + */ +public final class AdEvidenceCollector { + + /** + * 区间外切片完整保留:分类器会用它证明区间外存在/不存在对照。 + * M3u8Evidence 的输入来自 HlsManifestCleaner 同样的有界 manifest(2 MiB / 20,000 行), + * 不能截断后再把 anyMatch/noneMatch 当成整个 playlist 的结论。 + */ + + private AdEvidenceCollector() { + } + + /** + * 播放上下文,全部由调用方从 Activity/PlayerManager 取出,保持本类可单测。 + * + * @param siteKey 站点标识 + * @param siteName 站点名称 + * @param vodName 剧名 + * @param flagName 线路名 + * @param episodeName 集名 + * @param playUrl 播放地址,仅用于取 host/path,不入证据 + * @param hls 是否 HLS + */ + public record Context(String siteKey, String siteName, String vodName, + String flagName, String episodeName, String playUrl, boolean hls) { + + public Context { + siteKey = siteKey == null ? "" : siteKey; + siteName = siteName == null ? "" : siteName; + vodName = vodName == null ? "" : vodName; + flagName = flagName == null ? "" : flagName; + episodeName = episodeName == null ? "" : episodeName; + playUrl = playUrl == null ? "" : playUrl; + } + } + + /** + * 组装区间证据。 + * + * @param evidence playlist 证据,可为 null(非 HLS 或抓取失败) + * @param blacklistedHosts 现有广告域名黑名单,用于填充 matchedExistingHosts + * @param legacyHeuristicActive 旧启发式引擎当次是否生效 + */ + public static AdIntervalEvidence collect(Context context, M3u8Evidence evidence, + long startMs, long endMs, StartOrigin origin, + List blacklistedHosts, + boolean legacyHeuristicActive) { + if (context == null) throw new IllegalArgumentException("context is required"); + String playlistHost = hostOf(context.playUrl()); + String urlPath = pathOf(context.playUrl()); + + List inside = new ArrayList<>(); + List outside = new ArrayList<>(); + boolean bounded = false; + + if (evidence != null && !evidence.isEmpty()) { + List durations = evidence.getDurations(); + List segments = evidence.getSegments(); + Set discontinuities = new LinkedHashSet<>(); + for (Integer index : evidence.getDiscontinuities()) { + if (index != null) discontinuities.add(index); + } + Set insideIndices = new LinkedHashSet<>( + AdIntervalMapper.insideIndices(durations, startMs, endMs)); + + int limit = Math.min(segments.size(), durations.size()); + for (int i = 0; i < limit; i++) { + SegmentFact fact = factOf(i, segments.get(i), durations.get(i), + discontinuities.contains(i)); + if (insideIndices.contains(i)) inside.add(fact); + else outside.add(fact); + } + bounded = boundedByDiscontinuity(insideIndices, discontinuities, limit); + } + + boolean crossDomain = !inside.isEmpty() && !playlistHost.isEmpty() + && inside.stream().allMatch(fact -> + !fact.host().isEmpty() && !fact.hostEndsWith(playlistHost)); + + return new AdIntervalEvidence( + startMs, endMs, origin, + context.siteKey(), context.siteName(), context.vodName(), + context.flagName(), context.episodeName(), + playlistHost, urlPath, context.hls(), + inside, outside, bounded, crossDomain, + List.of(), legacyHeuristicActive, + matchedHosts(inside, blacklistedHosts), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + /** + * 区间首尾是否都贴着断点边界。首端要求区间第一个切片自身带断点, + * 尾端要求区间后的第一个切片带断点(即广告块结束、正片重新开始)。 + */ + private static boolean boundedByDiscontinuity(Set insideIndices, + Set discontinuities, int total) { + if (insideIndices.isEmpty() || discontinuities.isEmpty()) return false; + int first = Integer.MAX_VALUE; + int last = Integer.MIN_VALUE; + for (Integer index : insideIndices) { + first = Math.min(first, index); + last = Math.max(last, index); + } + boolean head = discontinuities.contains(first); + // 区间正好到 playlist 结尾时,没有「之后的切片」,视为尾端成立 + boolean tail = last + 1 >= total || discontinuities.contains(last + 1); + return head && tail; + } + + private static List matchedHosts(List inside, List blacklist) { + if (inside.isEmpty() || blacklist == null || blacklist.isEmpty()) return List.of(); + Set matched = new LinkedHashSet<>(); + for (SegmentFact fact : inside) { + if (fact.host().isEmpty()) continue; + String host = fact.host().toLowerCase(Locale.US); + for (String entry : blacklist) { + if (entry == null || entry.isBlank()) continue; + String target = entry.toLowerCase(Locale.US).trim(); + if (host.equals(target) || host.endsWith("." + target) || host.contains(target)) { + matched.add(fact.host()); + break; + } + } + } + return List.copyOf(matched); + } + + private static SegmentFact factOf(int index, String url, Float duration, boolean discontinuity) { + double seconds = duration == null ? 0d : duration; + return new SegmentFact(index, hostOf(url), pathOf(url), seconds, discontinuity); + } + + /** 取 host,解析失败返回空串。相对 URI 没有 host,属正常情况。 */ + static String hostOf(String url) { + if (url == null || url.isBlank()) return ""; + try { + String host = URI.create(url.trim()).getHost(); + return host == null ? "" : host; + } catch (RuntimeException e) { + return ""; + } + } + + /** 取去掉 query 的 path。相对 URI 直接返回其自身去参数部分。 */ + static String pathOf(String url) { + if (url == null || url.isBlank()) return ""; + String trimmed = url.trim(); + try { + String path = URI.create(trimmed).getPath(); + if (path != null && !path.isEmpty()) return path; + } catch (RuntimeException ignored) { + // 落到下面的字符串截断 + } + int query = trimmed.indexOf('?'); + return query < 0 ? trimmed : trimmed.substring(0, query); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java new file mode 100644 index 0000000000..671c5904a1 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java @@ -0,0 +1,214 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.bean.M3u8Evidence; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 区间反馈的编排器:接收区间 → 立即跳过 → 采集证据 → 多通道归因 → 仲裁 → 回调 UI。 + * + *

三端(leanback / mobile / TmdbDetail)共用本类,Activity 只实现 {@link Host}。 + * 见设计文档第 5.2 节。所有 Android 依赖都在 {@link Host} 后面,因此本类可单测。 + */ +public final class AdFeedbackController { + + /** Activity 侧需要提供的能力。 */ + public interface Host { + /** 当前播放位置,毫秒。 */ + long positionMs(); + + /** 总时长,毫秒;未知时返回非正数。 */ + long durationMs(); + + /** 采集上下文。 */ + AdEvidenceCollector.Context context(); + + /** 抓取 playlist 证据,阻塞 I/O,由 {@link #runBackground} 调度。可返回 null。 */ + M3u8Evidence fetchEvidence(); + + /** 执行本次跳过,返回是否真的 seek 了。 */ + boolean skipInterval(long startMs, long endMs, String feedbackId); + + /** 现有广告域名黑名单。 */ + List blacklistedHosts(); + + /** 本站历史正常域名。 */ + List siteBaselineHosts(); + + /** 接口学习的待审候选域名。 */ + List interfaceCandidateHosts(); + + /** 命中接口候选时展示的来源名。 */ + String interfaceSourceName(); + + /** 已知 HLS 规则状态。 */ + List hlsRuleStates(); + + /** + * 当前已启用的 HLS 规则,新规则要与它们叠加后仍然安全 —— + * {@code HlsManifestCleaner} 的三道回退闸门作用于合并后的删除总量。 + * + *

{@code null} 表示无法确定(存在模拟不了的已启用规则,或读取配置失败), + * 与空列表语义不同:前者让自检拒绝新规则,后者表示站内确无规则生效。 + * 刻意不给默认实现 —— 默认返回空列表等于代替实现者宣称「没有规则生效」, + * 那是它无从知道的事,而这个方向恰好是不安全的那一侧。 + */ + List activeHlsRules(); + + /** 正片保护正则。 */ + List protectingExcludes(); + + /** 旧启发式引擎当次是否生效。 */ + boolean legacyHeuristicActive(); + + /** 音频/语音通道已有候选的起点,无则返回负数。 */ + default long audioCandidateStartMs() { + return -1L; + } + + /** 短按回溯窗口。 */ + default long fallbackWindowMs() { + return AdIntervalMapper.DEFAULT_FALLBACK_WINDOW_MS; + } + + /** 后台执行阻塞任务。 */ + void runBackground(Runnable task); + + /** 回到主线程更新 UI。 */ + void runOnUi(Runnable task); + + /** 展示或刷新反馈对话框。 */ + void showSession(AdFeedbackSession session); + } + + private final Host host; + private final AdFeedbackDiagnostics diagnostics; + /** + * 防止过期回调覆盖新一次反馈的结果。写在主线程({@link #submit})、 + * 读在后台线程投递回主线程的 lambda 里,故必须 volatile。 + */ + private volatile int generation; + + public AdFeedbackController(Host host) { + this(host, new AdFeedbackDiagnostics()); + } + + public AdFeedbackController(Host host, AdFeedbackDiagnostics diagnostics) { + if (host == null) throw new IllegalArgumentException("host is required"); + this.host = host; + this.diagnostics = diagnostics == null ? new AdFeedbackDiagnostics() : diagnostics; + } + + public AdFeedbackDiagnostics diagnostics() { + return diagnostics; + } + + /** + * 作废所有在途归因。换源、换集或播放器重建时必须调用 —— 否则后台 + * {@code fetchEvidence()} 返回后仍会用上一集的证据弹窗,用户按「保存规则」 + * 写入的是上一集的域名与切片条件。 + */ + public void invalidate() { + generation++; + } + + /** + * 短按:以当前位置为终点,回溯推断起点。 + * + * @return 本次反馈的会话,播放状态不允许时返回 null + */ + public AdFeedbackSession onQuickReport(M3u8Evidence cachedEvidence) { + long endMs = host.positionMs(); + if (endMs <= 0) return null; + AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart( + cachedEvidence, endMs, host.audioCandidateStartMs(), host.fallbackWindowMs()); + return submit(start.startMs(), endMs, start.origin()); + } + + /** + * 长按标记模式:用户显式框选的区间。 + * + * @param startMs 标记模式下打的起点 + * @param endMs 终点,由调用方取当前播放位置;取不到时传负数即被拒绝 + */ + public AdFeedbackSession onMarkedInterval(long startMs, long endMs) { + return submit(startMs, endMs, StartOrigin.USER_MARKED); + } + + /** + * 提交一个区间:立即尝试跳过,然后在后台归因。 + */ + public AdFeedbackSession submit(long startMs, long endMs, StartOrigin origin) { + if (endMs <= startMs || startMs < 0) return null; + long duration = host.durationMs(); + if (duration > 0 && startMs >= duration) return null; + + String feedbackId = "fb-" + UUID.randomUUID(); + diagnostics.record(AdFeedbackDiagnostics.Code.INTERVAL_SUBMITTED); + diagnostics.recordStartOrigin(origin); + + boolean skipped = host.skipInterval(startMs, endMs, feedbackId); + diagnostics.record(skipped + ? AdFeedbackDiagnostics.Code.IMMEDIATE_SKIP_APPLIED + : AdFeedbackDiagnostics.Code.IMMEDIATE_SKIP_REJECTED); + + AdFeedbackSession session = AdFeedbackSession.pending( + feedbackId, startMs, endMs, origin, skipped); + host.showSession(session); + + int current = ++generation; + host.runBackground(() -> analyse(session, current)); + return session; + } + + private void analyse(AdFeedbackSession session, int expectedGeneration) { + AdAttributionArbiter.Verdict verdict; + try { + verdict = classify(session); + } catch (RuntimeException e) { + diagnostics.record(AdFeedbackDiagnostics.Code.EVIDENCE_COLLECT_FAILED); + verdict = new AdAttributionArbiter.Verdict(null, List.of(), List.of()); + } + AdAttributionArbiter.Verdict result = verdict; + host.runOnUi(() -> { + if (expectedGeneration != generation) return; + host.showSession(session.withVerdict(result)); + }); + } + + private AdAttributionArbiter.Verdict classify(AdFeedbackSession session) { + M3u8Evidence playlist = host.fetchEvidence(); + List blacklist = host.blacklistedHosts(); + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + host.context(), playlist, session.startMs(), session.endMs(), + session.startOrigin(), blacklist, host.legacyHeuristicActive()); + + // 已启用规则参与自检:新规则叠加上去后不得越过 cleaner 的回退闸门 + List activeRules = host.activeHlsRules(); + + List attributions = new ArrayList<>(); + attributions.add(HlsSegmentClassifier.classify(evidence, activeRules)); + attributions.add(DomainReputationClassifier.classify(evidence, + new DomainReputationClassifier.Input(blacklist, host.siteBaselineHosts(), + host.interfaceCandidateHosts(), host.interfaceSourceName()), + activeRules)); + attributions.add(ExistingRuleClassifier.classify(evidence, + new ExistingRuleClassifier.Input(host.hlsRuleStates(), + host.legacyHeuristicActive(), host.protectingExcludes()), + activeRules)); + + for (AdAttribution attribution : attributions) { + if (attribution == null) diagnostics.record(AdFeedbackDiagnostics.Code.CHANNEL_ABSTAINED); + else if (attribution.category() == AdCategory.ALREADY_HANDLED) { + diagnostics.record(AdFeedbackDiagnostics.Code.ALREADY_HANDLED_DETECTED); + } + } + + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(attributions); + if (verdict.empty()) diagnostics.record(AdFeedbackDiagnostics.Code.ARBITER_NO_PLAN); + return verdict; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSource.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSource.java new file mode 100644 index 0000000000..b4259fd1aa --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSource.java @@ -0,0 +1,247 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.api.config.HlsRuleConfig; +import com.fongmi.android.tv.api.config.ImportedAdRuleCandidateStore; +import com.fongmi.android.tv.api.config.RuleConfig; +import com.fongmi.android.tv.api.config.UserAdRuleStore; +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.bean.ImportedAdRuleCandidate; +import com.fongmi.android.tv.bean.UserAdRule; +import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.utils.HlsManifestCleaner; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 把项目里的静态单例数据读成分类器需要的入参。 + * + *

三个分类器刻意做成纯函数以便单测,因此「读 SharedPreferences」这件事 + * 集中在这里。见设计文档第 5.1 节。 + */ +public final class AdFeedbackDataSource { + + /** + * 与 {@code HlsSegmentClassifier.pathOnlyPattern} 生成的前缀一致。 + * 只有以它开头的切片正则,其匹配范围才与自检的合成 manifest 语义相同。 + */ + static final String ANCHORED_PATH_PREFIX = "^[^?#]*"; + + /** 本地 Gson,避免依赖 Application 生命周期,使 compiledOf 可被单测。 */ + private static final Gson GSON = new Gson(); + + private AdFeedbackDataSource() { + } + + /** 现有广告域名黑名单:VOD ads + Live ads + 用户规则 hosts。 */ + public static List blacklistedHosts() { + try { + return List.copyOf(RuleConfig.get().getAds()); + } catch (RuntimeException e) { + return List.of(); + } + } + + /** 待审接口候选里被判为广告域名的条目。 */ + public static List interfaceCandidateHosts() { + try { + Set hosts = new LinkedHashSet<>(); + for (ImportedAdRuleCandidate candidate : ImportedAdRuleCandidateStore.pending()) { + if (candidate == null) continue; + hosts.addAll(candidate.getHosts()); + } + return List.copyOf(hosts); + } catch (RuntimeException e) { + return List.of(); + } + } + + /** 第一条待审候选的来源接口名,用于证据展示。 */ + public static String interfaceSourceName() { + try { + for (ImportedAdRuleCandidate candidate : ImportedAdRuleCandidateStore.pending()) { + if (candidate == null) continue; + String name = candidate.getSourceConfigName(); + if (!name.isEmpty()) return name; + } + } catch (RuntimeException ignored) { + // 读取失败不影响归因 + } + return ""; + } + + /** + * 用户规则中的正片保护正则。这些规则可能误保护了广告切片, + * 是 {@link ExistingRuleClassifier} 的诊断输入之一。 + */ + public static List protectingExcludes() { + try { + List excludes = new ArrayList<>(); + for (UserAdRule rule : UserAdRuleStore.load()) { + if (rule == null || !rule.isEnabled()) continue; + excludes.addAll(rule.getExclude()); + } + return List.copyOf(excludes); + } catch (RuntimeException e) { + return List.of(); + } + } + + /** + * 已知 HLS 规则状态。{@code HlsAdRule} 未暴露 hostSuffixes 的 getter, + * 因此从 {@link HlsRuleConfig.Entry#detail()} 的 JSON 里取。 + */ + public static List hlsRuleStates() { + try { + List states = new ArrayList<>(); + for (HlsRuleConfig.Entry entry : HlsRuleConfig.getEntries()) { + if (entry == null) continue; + states.add(new ExistingRuleClassifier.RuleState( + entry.key(), entry.id(), entry.name(), + entry.enabled(), entry.valid(), + hostSuffixesOf(entry.detail()), + compiledOf(entry.detail()))); + } + return List.copyOf(states); + } catch (RuntimeException e) { + return List.of(); + } + } + + /** + * 当前已启用的 HLS 规则,供叠加校验预测回退闸门。 + * + *

返回 {@code null} 表示**无法确定**,调用方必须因此拒绝新规则 —— 不是 + * 「没有规则」。两者的区别是安全性的关键:叠加校验靠「合并后是否越过闸门」下 + * 结论,而 {@code removedCount} 对规则集单调,于是 + * + *

    + *
  • 用**子集**判出「会回退」→ 全集必然也回退,拒绝方向安全;
  • + *
  • 用**子集**判出「不回退」→ 对全集一无所知,放行方向不安全。
  • + *
+ * + *

所以凡是有一条已启用规则无法被自检忠实模拟,就整体返回 {@code null}, + * 而不是把它剔除后拿剩下的规则去预测。带未锚定 {@code segmentUrlRegex} 的规则 + * 正属此类:合成 manifest 的 URI 不含 query 与 fragment(见 + * {@link #hasUnanchoredSegmentRegex}),这类规则在合成上命中 0 片,真实运行却 + * 可能命中大量切片。此前把它们静默剔除,等于让「合并后 3/30 不回退」这种结论 + * 建立在比生产小得多的规则集上,恰好放过「叠加后整份 manifest 回退」—— + * 正是叠加校验要拦的那件事。 + * + *

去广告总开关关闭时返回空列表:此时 {@code HlsAdblockPipeline} 根本不会被 + * 调用,所有规则确实删 0 片,「没有规则生效」是真话而非近似。 + * + *

刻意不按播放内核过滤:结构化净化目前只在 IJK 路径与本地 {@code /m3u8} + * 代理上执行(MPV 会为时间戳完整性丢弃净化结果,EXO/系统内核走 legacy), + * 但内核可在播放中回退切换,而规则一旦保存就长期生效。按当次内核放宽,等于 + * 让规则的安全性取决于保存那一刻的内核,换内核后即失效。 + */ + public static List activeHlsRules() { + try { + if (!Setting.isAdblock()) return List.of(); + List simulatable = new ArrayList<>(); + for (HlsRuleConfig.Entry entry : HlsRuleConfig.getEntries()) { + if (entry == null || !entry.enabled() || !entry.valid()) continue; + HlsManifestCleaner.Rule compiled = compiledOf(entry.detail()); + // compiledOf 已内含未锚定正则的拒绝。拿不到编译产物就无法预测它 + // 的删除量,只能整体弃权 —— 剔除它再预测是不安全的方向。 + if (compiled == null) return null; + simulatable.add(compiled); + } + return List.copyOf(simulatable); + } catch (RuntimeException e) { + // 读配置失败同样是「无法确定」,不能退化成「没有规则」 + return null; + } + } + + /** + * 从规则详情 JSON 还原已编译的规则,供 {@code RuleSelfCheck} 实跑验证。 + * + *

{@code HlsRuleConfig.Entry} 只暴露 detail JSON,不给编译产物;这里重新 + * 反序列化并 compile。失败返回 null,调用方会因此不建议启用该规则。 + * + *

用本地 Gson 而非 {@code App.gson()}:后者在纯 JVM 单测里 + * {@code App.get()} 为 null 会抛 NPE,被 catch 吞掉后静默让整个通道弃权, + * 这条路径因此既没测也测不了。 + * + *

带未锚定 {@code segmentUrlRegex} 的规则一律拒绝,见 + * {@link #hasUnanchoredSegmentRegex}。 + */ + static HlsManifestCleaner.Rule compiledOf(String detailJson) { + if (detailJson == null || detailJson.isBlank()) return null; + if (hasUnanchoredSegmentRegex(detailJson)) return null; + try { + HlsAdRule rule = GSON.fromJson(detailJson, HlsAdRule.class); + return rule == null ? null : rule.compile(); + } catch (RuntimeException e) { + return null; + } + } + + /** + * 规则的切片正则是否未锚定到 path。 + * + *

{@code RuleSelfCheck} 的合成 manifest 用 {@code SegmentFact.path()} 拼 URL, + * 而那是 {@code URI.getPath()} 的产物 —— 按去敏要求 query 与 fragment 都被丢弃。 + * 于是合成 URL 永不带 {@code ?}/{@code #},而真实 {@code matchesPattern} 匹配的是 + * 含 query 与 fragment 的完整 URL。 + * + *

本项目自己生成的正则用 {@code ^[^?#]*} 前缀对齐了这个差异(见 + * {@code HlsSegmentClassifier.pathOnlyPattern}),但接口下发或内置的第三方规则 + * 不会有这个锚定。实测一条 {@code segmentUrlRegex=["/ads/"]} 的既有规则在 + * 「正片 URL 带 ?ref=/ads/」时自检放行、真实运行多删正片且 {@code fallback=false} + * —— 错误不被回退兜住,用户直接看到跳帧。 + * + *

因此凡是带 {@code segmentUrlRegex} 且未显式排除 query/fragment 的规则, + * 都不能用自检结论为它背书。 + */ + static boolean hasUnanchoredSegmentRegex(String detailJson) { + if (detailJson == null || detailJson.isBlank()) return false; + try { + JsonElement parsed = JsonParser.parseString(detailJson); + if (!parsed.isJsonObject()) return false; + JsonElement regex = parsed.getAsJsonObject().get("segmentUrlRegex"); + if (regex == null || !regex.isJsonArray()) return false; + for (JsonElement element : regex.getAsJsonArray()) { + if (element == null || element.isJsonNull()) continue; + String value = element.getAsString(); + if (value == null || value.isBlank()) continue; + if (!value.startsWith(ANCHORED_PATH_PREFIX)) return true; + } + return false; + } catch (RuntimeException e) { + // 解析不出来时保守判为未锚定 + return true; + } + } + + /** 从规则详情 JSON 里解析 hostSuffixes。解析失败返回空列表,不抛异常。 */ + static List hostSuffixesOf(String detailJson) { + if (detailJson == null || detailJson.isBlank()) return List.of(); + try { + JsonElement parsed = JsonParser.parseString(detailJson); + if (!parsed.isJsonObject()) return List.of(); + JsonObject object = parsed.getAsJsonObject(); + JsonElement hosts = object.get("hostSuffixes"); + if (hosts == null || !hosts.isJsonArray()) return List.of(); + JsonArray array = hosts.getAsJsonArray(); + List result = new ArrayList<>(array.size()); + for (JsonElement element : array) { + if (element == null || element.isJsonNull()) continue; + String value = element.getAsString(); + if (value != null && !value.isBlank()) result.add(value.trim()); + } + return List.copyOf(result); + } catch (RuntimeException e) { + return List.of(); + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDiagnostics.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDiagnostics.java new file mode 100644 index 0000000000..b468ae3669 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDiagnostics.java @@ -0,0 +1,67 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.github.catvod.crawler.SpiderDebug; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; + +/** + * 区间反馈的本地诊断计数。只暴露固定枚举计数,不记录媒体 URL、规则正文、 + * Cookie、Authorization 或用户标识 —— 与 {@code AdAudioDiagnostics} 同一约定。 + */ +public final class AdFeedbackDiagnostics { + + static final String LOG_TAG = "ad-feedback"; + + public enum Code { + INTERVAL_SUBMITTED, + START_INFERRED_FROM_DISCONTINUITY, + START_INFERRED_FROM_FALLBACK, + IMMEDIATE_SKIP_APPLIED, + IMMEDIATE_SKIP_REJECTED, + EVIDENCE_COLLECT_FAILED, + CHANNEL_ABSTAINED, + ARBITER_NO_PLAN, + PLAN_ACCEPTED, + PLAN_DISCARDED, + DUPLICATE_SUBMISSION, + ALREADY_HANDLED_DETECTED + } + + private final EnumMap counts = new EnumMap<>(Code.class); + private Code lastCode; + + public void record(Code code) { + if (code == null) return; + synchronized (this) { + counts.put(code, counts.getOrDefault(code, 0L) + 1L); + lastCode = code; + } + } + + /** 把起点来源折算成对应的诊断码。 */ + public void recordStartOrigin(StartOrigin origin) { + if (origin == StartOrigin.DISCONTINUITY) { + record(Code.START_INFERRED_FROM_DISCONTINUITY); + } else if (origin == StartOrigin.FALLBACK_WINDOW) { + record(Code.START_INFERRED_FROM_FALLBACK); + } + } + + public synchronized long count(Code code) { + return counts.getOrDefault(code, 0L); + } + + public synchronized Code lastCode() { + return lastCode; + } + + public synchronized Map snapshot() { + return Collections.unmodifiableMap(new EnumMap<>(counts)); + } + + static void log(String message, Object... args) { + SpiderDebug.log(LOG_TAG + " " + String.format(message, args)); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java new file mode 100644 index 0000000000..0beb47998f --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java @@ -0,0 +1,278 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.bean.M3u8Evidence; +import com.fongmi.android.tv.utils.HlsManifestCleaner; +import com.fongmi.android.tv.utils.M3u8Parser; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * {@link AdFeedbackController.Host} 的通用实现,三端共用。 + * + *

Activity 只需提供播放状态、剧集元数据和三个调度/展示回调;读取本地配置、 + * 抓取 playlist、维护站点域名基线都在这里完成。见设计文档第 5.2 节。 + */ +public final class AdFeedbackHostAdapter implements AdFeedbackController.Host { + + /** 播放器状态。{@code hls} 由调用方判定,避免本类依赖 media3。 */ + public interface Playback { + long positionMs(); + + long durationMs(); + + String playUrl(); + + Map headers(); + + boolean hls(); + + /** 执行本次跳过,通常委托给 {@code PlayerManager.skipUserAdInterval}。 */ + boolean skipInterval(long startMs, long endMs, String feedbackId); + + /** 音频/语音通道已有候选的起点,无则返回负数。 */ + default long audioCandidateStartMs() { + return -1L; + } + } + + /** 当前剧集元数据。 */ + public interface Metadata { + String siteKey(); + + String siteName(); + + String vodName(); + + String flagName(); + + String episodeName(); + } + + /** 线程调度与界面展示。 */ + public interface Ui { + void runBackground(Runnable task); + + void runOnUi(Runnable task); + + void showSession(AdFeedbackSession session); + } + + /** + * 本地配置读取入口,默认指向 {@link AdFeedbackDataSource},测试可替换。 + */ + public record Deps(Supplier> blacklistedHosts, + Supplier> interfaceCandidateHosts, + Supplier interfaceSourceName, + Supplier> protectingExcludes, + Supplier> hlsRuleStates, + Supplier> activeHlsRules, + SitePlaylistHostBaseline baseline) { + + public static Deps defaults() { + return new Deps( + AdFeedbackDataSource::blacklistedHosts, + AdFeedbackDataSource::interfaceCandidateHosts, + AdFeedbackDataSource::interfaceSourceName, + AdFeedbackDataSource::protectingExcludes, + AdFeedbackDataSource::hlsRuleStates, + AdFeedbackDataSource::activeHlsRules, + SiteHostBaselineStore.get()); + } + } + + private final Playback playback; + private final Metadata metadata; + private final Ui ui; + private final Deps deps; + /** 最近一次抓到的 playlist 证据,短按推断起点时复用,避免重复请求。 */ + private volatile M3u8Evidence cachedEvidence; + + public AdFeedbackHostAdapter(Playback playback, Metadata metadata, Ui ui) { + this(playback, metadata, ui, Deps.defaults()); + } + + public AdFeedbackHostAdapter(Playback playback, Metadata metadata, Ui ui, Deps deps) { + this.playback = Objects.requireNonNull(playback, "playback"); + this.metadata = Objects.requireNonNull(metadata, "metadata"); + this.ui = Objects.requireNonNull(ui, "ui"); + this.deps = deps == null ? Deps.defaults() : deps; + } + + /** 供短按路径复用的缓存证据,可能为 null。 */ + public M3u8Evidence cachedEvidence() { + return cachedEvidence; + } + + /** 换源或换集时清掉缓存,避免把上一集的切片结构用于本集。 */ + public void invalidateEvidence() { + cachedEvidence = null; + } + + /** + * 当前播放位置,取不到时返回 -1 而非 0 —— 0 会被误当成合法的 + * 「片头位置」,而 -1 会让 {@code AdFeedbackController.submit} 直接拒绝。 + */ + public long safePositionMs() { + try { + long position = playback.positionMs(); + return position > 0 ? position : -1L; + } catch (RuntimeException e) { + return -1L; + } + } + + /** + * 记录一次成功播放的域名,喂给站点基线。 + * + *

必须在播放起播后调用:{@link DomainReputationClassifier} 无基线数据时 + * 一律弃权(避免首次播放即误判),不接线会让整条域名通道成为死代码。 + */ + public void recordPlaybackHost() { + try { + String host = AdEvidenceCollector.hostOf(playback.playUrl()); + if (host.isEmpty()) return; + deps.baseline().record(metadata.siteKey(), host); + } catch (RuntimeException ignored) { + // 基线只是归因的辅助输入,记录失败不应影响播放 + } + } + + @Override + public long positionMs() { + return safePositionMs(); + } + + @Override + public long durationMs() { + // 与 safePositionMs 对称:PlayerManager.getDuration() 也是裸 + // player.getDuration(),播放器 release 后 player 为 null 会 NPE + try { + return playback.durationMs(); + } catch (RuntimeException e) { + return 0L; + } + } + + @Override + public AdEvidenceCollector.Context context() { + return new AdEvidenceCollector.Context( + metadata.siteKey(), metadata.siteName(), metadata.vodName(), + metadata.flagName(), metadata.episodeName(), playback.playUrl(), playback.hls()); + } + + @Override + public M3u8Evidence fetchEvidence() { + if (!playback.hls()) return null; + String url = playback.playUrl(); + if (url == null || url.isBlank()) return null; + try { + M3u8Evidence evidence = M3u8Parser.parse(url, playback.headers()); + if (evidence != null && !evidence.isEmpty()) cachedEvidence = evidence; + return evidence; + } catch (RuntimeException e) { + return null; + } + } + + @Override + public boolean skipInterval(long startMs, long endMs, String feedbackId) { + return playback.skipInterval(startMs, endMs, feedbackId); + } + + @Override + public List blacklistedHosts() { + return orEmpty(deps.blacklistedHosts()); + } + + @Override + public List siteBaselineHosts() { + try { + return deps.baseline().hosts(metadata.siteKey()); + } catch (RuntimeException e) { + return List.of(); + } + } + + @Override + public List interfaceCandidateHosts() { + return orEmpty(deps.interfaceCandidateHosts()); + } + + @Override + public String interfaceSourceName() { + try { + String name = deps.interfaceSourceName().get(); + return name == null ? "" : name; + } catch (RuntimeException e) { + return ""; + } + } + + @Override + public List hlsRuleStates() { + try { + List states = deps.hlsRuleStates().get(); + return states == null ? List.of() : states; + } catch (RuntimeException e) { + return List.of(); + } + } + + /** + * 与其他 {@code orEmpty} 式读取不同,这里**不把失败折成空列表**:空列表意味着 + * 「站内确无规则生效」,会让自检跳过叠加校验;读不到时的真实状态是「不知道」, + * 必须原样传 {@code null} 让自检拒绝。折成空列表是 fail-open。 + */ + @Override + public List activeHlsRules() { + try { + return deps.activeHlsRules().get(); + } catch (RuntimeException e) { + return null; + } + } + + @Override + public List protectingExcludes() { + return orEmpty(deps.protectingExcludes()); + } + + @Override + public boolean legacyHeuristicActive() { + // HlsAdblockPipeline 在两个调用点都传 legacyFallback=true, + // 且内置规则默认全部关闭,所以点播 HLS 上旧启发式实际总是生效。 + return playback.hls(); + } + + @Override + public long audioCandidateStartMs() { + return playback.audioCandidateStartMs(); + } + + @Override + public void runBackground(Runnable task) { + ui.runBackground(task); + } + + @Override + public void runOnUi(Runnable task) { + ui.runOnUi(task); + } + + @Override + public void showSession(AdFeedbackSession session) { + ui.showSession(session); + } + + private static List orEmpty(Supplier> supplier) { + try { + List value = supplier.get(); + return value == null ? List.of() : value; + } catch (RuntimeException e) { + return List.of(); + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackSession.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackSession.java new file mode 100644 index 0000000000..b0e9f65254 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackSession.java @@ -0,0 +1,55 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; + +/** + * 一次区间反馈的完整状态,驱动对话框两屏展示。 + * + * @param feedbackId 去重用的稳定标识 + * @param startMs 区间起点 + * @param endMs 区间终点 + * @param startOrigin 起点来源,第一屏需明示 + * @param skipApplied 本次播放是否已跳过 + * @param verdict 归因仲裁结果,第二屏内容;尚未完成时为 null + */ +public record AdFeedbackSession(String feedbackId, long startMs, long endMs, + StartOrigin startOrigin, boolean skipApplied, + AdAttributionArbiter.Verdict verdict) { + + public AdFeedbackSession { + if (feedbackId == null || feedbackId.isBlank()) { + throw new IllegalArgumentException("feedbackId is required"); + } + startOrigin = startOrigin == null ? StartOrigin.FALLBACK_WINDOW : startOrigin; + } + + /** 第一屏:区间已确定,归因未开始。 */ + public static AdFeedbackSession pending(String feedbackId, long startMs, long endMs, + StartOrigin startOrigin, boolean skipApplied) { + return new AdFeedbackSession(feedbackId, startMs, endMs, startOrigin, skipApplied, null); + } + + /** 第二屏:附加归因结果。 */ + public AdFeedbackSession withVerdict(AdAttributionArbiter.Verdict verdict) { + return new AdFeedbackSession(feedbackId, startMs, endMs, startOrigin, skipApplied, verdict); + } + + public long durationMs() { + return endMs - startMs; + } + + public boolean analysisComplete() { + return verdict != null; + } + + /** 有可落地的规则方案。 */ + public boolean hasActionablePlan() { + return verdict != null && verdict.hasActionablePlan(); + } + + /** 首选方案的证据,无结论时为空。 */ + public List preferredEvidence() { + if (verdict == null || verdict.preferred() == null) return List.of(); + return verdict.preferred().evidence(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalEvidence.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalEvidence.java new file mode 100644 index 0000000000..d73ff694aa --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalEvidence.java @@ -0,0 +1,81 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; + +/** + * 用户反馈区间的证据快照。一次采集,之后所有分类通道只读。 + * + *

去敏原则沿用 {@code AdDetectionRequest}:只保留 host 与去参数 path, + * 不含完整 URL、query、token、Cookie。见设计文档第 6.1、10 节。 + * + * @param startMs 区间起点,毫秒 + * @param endMs 区间终点,毫秒 + * @param startOrigin 起点来源 + * @param siteKey 站点标识 + * @param siteName 站点名称 + * @param vodName 剧名 + * @param flagName 线路名 + * @param episodeName 集名 + * @param playlistHost 主 playlist 的 host + * @param urlPath 播放地址去参数 path + * @param hls 是否 HLS + * @param inside 区间内切片 + * @param outside 区间外切片,作为对照组 + * @param boundedByDiscontinuity 区间首尾是否都贴着 DISCONTINUITY 边界 + * @param crossDomain 区间内切片是否整体跨域 + * @param alreadyRemovedByStructuredRuleIds 已被结构化规则删除的规则 id + * @param legacyHeuristicActive 旧启发式引擎当次是否生效 + * @param matchedExistingHosts 命中现有黑名单的 host + * @param audio 音频指纹通道事实 + * @param speech 语音通道事实 + */ +public record AdIntervalEvidence( + long startMs, long endMs, StartOrigin startOrigin, + String siteKey, String siteName, String vodName, + String flagName, String episodeName, + String playlistHost, String urlPath, boolean hls, + List inside, List outside, + boolean boundedByDiscontinuity, boolean crossDomain, + List alreadyRemovedByStructuredRuleIds, + boolean legacyHeuristicActive, + List matchedExistingHosts, + AudioIntervalFact audio, SpeechIntervalFact speech) { + + public AdIntervalEvidence { + if (startMs < 0) throw new IllegalArgumentException("startMs must not be negative"); + if (endMs <= startMs) throw new IllegalArgumentException("endMs must be greater than startMs"); + startOrigin = startOrigin == null ? StartOrigin.FALLBACK_WINDOW : startOrigin; + siteKey = siteKey == null ? "" : siteKey; + siteName = siteName == null ? "" : siteName; + vodName = vodName == null ? "" : vodName; + flagName = flagName == null ? "" : flagName; + episodeName = episodeName == null ? "" : episodeName; + playlistHost = playlistHost == null ? "" : playlistHost; + urlPath = urlPath == null ? "" : urlPath; + inside = inside == null ? List.of() : List.copyOf(inside); + outside = outside == null ? List.of() : List.copyOf(outside); + alreadyRemovedByStructuredRuleIds = alreadyRemovedByStructuredRuleIds == null + ? List.of() : List.copyOf(alreadyRemovedByStructuredRuleIds); + matchedExistingHosts = matchedExistingHosts == null ? List.of() : List.copyOf(matchedExistingHosts); + audio = audio == null ? AudioIntervalFact.unavailable() : audio; + speech = speech == null ? SpeechIntervalFact.unavailable() : speech; + } + + /** 区间时长,毫秒。 */ + public long durationMs() { + return endMs - startMs; + } + + /** 区间内是否有可用的切片证据。无切片时 HLS 通道必须弃权。 */ + public boolean hasSegmentEvidence() { + return hls && !inside.isEmpty(); + } + + /** + * 用户报告的区间是否已被结构化规则整体处理掉。 + * 为真时说明用户看到的广告来自别处,不应再新增 HLS 规则。 + */ + public boolean handledByStructuredRule() { + return !alreadyRemovedByStructuredRuleIds.isEmpty(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapper.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapper.java new file mode 100644 index 0000000000..6d047d57ce --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapper.java @@ -0,0 +1,119 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.bean.M3u8Evidence; + +import java.util.ArrayList; +import java.util.List; + +/** + * 播放时间 ↔ HLS 切片下标的映射,以及短按场景的起点推断。 + * + *

纯计算,不做网络请求:{@code M3u8Parser.parse()} 已经产出全部所需输入。 + * 见设计文档第 4.2、7.1 节。 + */ +public final class AdIntervalMapper { + + /** 短按回溯窗口默认值,见设计文档待确认决策 1。 */ + public static final long DEFAULT_FALLBACK_WINDOW_MS = 90_000L; + + private AdIntervalMapper() { + } + + /** + * 区间起点推断结果。 + * + * @param startMs 推断出的起点 + * @param origin 起点来源 + */ + public record InferredStart(long startMs, StartOrigin origin) { + } + + /** + * 找出与 {@code [startMs, endMs)} 有交集的切片下标。 + * + *

切片 {@code i} 覆盖 {@code [cum[i], cum[i+1])},判据是半开区间相交: + * {@code cum[i+1] > startSec && cum[i] < endSec}。恰好贴边不算相交。 + */ + public static List insideIndices(List durations, long startMs, long endMs) { + List indices = new ArrayList<>(); + if (durations == null || durations.isEmpty() || endMs <= startMs) return indices; + double startSec = startMs / 1000d; + double endSec = endMs / 1000d; + double cum = 0; + for (int i = 0; i < durations.size(); i++) { + Float value = durations.get(i); + double duration = value == null ? 0 : value; + double next = cum + duration; + if (next > startSec && cum < endSec) indices.add(i); + cum = next; + } + return indices; + } + + /** 切片 {@code index} 的起始播放位置,毫秒。 */ + public static long segmentStartMs(List durations, int index) { + if (durations == null || index <= 0) return 0L; + double cum = 0; + for (int i = 0; i < index && i < durations.size(); i++) { + Float value = durations.get(i); + if (value != null) cum += value; + } + return Math.round(cum * 1000d); + } + + /** + * 短按场景下由终点回溯推断起点。按可靠性逐级尝试: + * DISCONTINUITY 边界 → 跨域切换点 → 音频候选 → 固定窗口。 + * + * @param evidence playlist 证据,可为 null + * @param endMs 用户点击时的播放位置 + * @param audioCandidateStartMs 音频/语音通道已有候选的起点,无则传负数 + * @param fallbackWindowMs 兜底回溯窗口 + */ + public static InferredStart inferStart(M3u8Evidence evidence, long endMs, + long audioCandidateStartMs, long fallbackWindowMs) { + if (endMs <= 0) return new InferredStart(0L, StartOrigin.FALLBACK_WINDOW); + + if (evidence != null && !evidence.isEmpty()) { + List durations = evidence.getDurations(); + + long discontinuity = latestBoundaryBefore(durations, evidence.getDiscontinuities(), endMs); + if (discontinuity >= 0) return new InferredStart(discontinuity, StartOrigin.DISCONTINUITY); + + long crossDomain = latestSwitchBefore(durations, evidence.getDomainSwitches(), endMs); + if (crossDomain >= 0) return new InferredStart(crossDomain, StartOrigin.CROSS_DOMAIN); + } + + if (audioCandidateStartMs >= 0 && audioCandidateStartMs < endMs) { + return new InferredStart(audioCandidateStartMs, StartOrigin.AUDIO_CANDIDATE); + } + + long window = fallbackWindowMs > 0 ? fallbackWindowMs : DEFAULT_FALLBACK_WINDOW_MS; + return new InferredStart(Math.max(0L, endMs - window), StartOrigin.FALLBACK_WINDOW); + } + + /** 最近一个位于 {@code endMs} 之前的断点边界起始位置,没有则返回 -1。 */ + private static long latestBoundaryBefore(List durations, List discontinuities, long endMs) { + if (durations == null || discontinuities == null || discontinuities.isEmpty()) return -1L; + long best = -1L; + for (Integer index : discontinuities) { + if (index == null || index < 0 || index >= durations.size()) continue; + long position = segmentStartMs(durations, index); + if (position < endMs && position > best) best = position; + } + return best; + } + + /** 最近一个位于 {@code endMs} 之前的跨域切换点起始位置,没有则返回 -1。 */ + private static long latestSwitchBefore(List durations, List domainSwitches, long endMs) { + if (durations == null || domainSwitches == null || domainSwitches.isEmpty()) return -1L; + long best = -1L; + for (int i = 0; i < domainSwitches.size() && i < durations.size(); i++) { + Boolean switched = domainSwitches.get(i); + if (switched == null || !switched) continue; + long position = segmentStartMs(durations, i); + if (position < endMs && position > best) best = position; + } + return best; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java new file mode 100644 index 0000000000..04a5eb1f7d --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java @@ -0,0 +1,98 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.api.config.HlsRuleStateStore; +import com.fongmi.android.tv.api.config.UserAdRuleStore; +import com.fongmi.android.tv.api.config.UserHlsRuleStore; +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.bean.UserAdRule; + +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +/** + * 把仲裁选出的方案写成真实规则。 + * + *

数据全部取自 {@link RulePayload},不从证据文本里解析 —— 证据是给人看的、 + * 随语言变化。见设计文档第 6.2 节。 + */ +public final class AdRulePlanApplier { + + private AdRulePlanApplier() { + } + + /** 落地结果。 */ + public enum Outcome { + /** 已写入并立即生效。 */ + APPLIED, + /** 方案不可落地或数据为空。 */ + SKIPPED, + /** 规则非法或写入失败。 */ + FAILED + } + + /** + * @param plan 仲裁首选方案 + * @param siteKey 当前站点,用于收窄用户规则作用域 + */ + public static Outcome apply(AdAttribution plan, String siteKey) { + if (plan == null || !plan.actionable()) return Outcome.SKIPPED; + try { + return switch (plan.remediation()) { + case ENABLE_EXISTING_RULE -> enableExisting(plan.payload()); + case HLS_STRUCTURED_RULE -> saveHlsRule(plan, siteKey); + case HOST_BLACKLIST -> saveUserRule(plan, siteKey, true); + case URL_REGEX_RULE -> saveUserRule(plan, siteKey, false); + default -> Outcome.SKIPPED; + }; + } catch (RuntimeException e) { + return Outcome.FAILED; + } + } + + private static Outcome enableExisting(RulePayload payload) { + if (payload.ruleKey().isEmpty()) return Outcome.SKIPPED; + HlsRuleStateStore.setEnabled(payload.ruleKey(), true); + return Outcome.APPLIED; + } + + /** + * 写入一条用户 HLS 规则。这是唯一能真正删除广告切片的机制 —— + * 域名黑名单只在 WebView 层生效,不拦播放器直连的切片请求。 + */ + private static Outcome saveHlsRule(AdAttribution plan, String siteKey) { + RulePayload payload = plan.payload(); + if (payload.hosts().isEmpty() && payload.regex().isEmpty()) return Outcome.SKIPPED; + // 没有作用域的规则会跨站点生效,HlsAdRule.compile() 也会拒绝 + if (payload.playlistHostSuffixes().isEmpty()) return Outcome.SKIPPED; + HlsAdRule rule = HlsAdRule.createUserRule( + UUID.randomUUID().toString(), name(plan, siteKey), + payload.playlistHostSuffixes(), payload.hosts(), payload.regex(), + payload.hasDurationRange() ? payload.durationMin() : null, + payload.hasDurationRange() ? payload.durationMax() : null, + payload.requireDiscontinuity(), payload.requireCrossDomain(), + // 门限由分类器按实际可编码信号数算好,不能在这里再抬高 —— + // 超过实际信号数会让 compile() 拒绝整条规则 + payload.minimumSignals()); + return UserHlsRuleStore.add(rule) ? Outcome.APPLIED : Outcome.FAILED; + } + + private static Outcome saveUserRule(AdAttribution plan, String siteKey, boolean hostMode) { + RulePayload payload = plan.payload(); + List values = hostMode ? payload.hosts() : payload.regex(); + if (values.isEmpty()) return Outcome.SKIPPED; + UserAdRule rule = UserAdRule.createManual(name(plan, siteKey)); + rule.setSource(UserAdRule.SOURCE_AI); + rule.setSiteKey(siteKey); + if (hostMode) rule.setHosts(values); + else rule.setRegex(values); + UserAdRuleStore.add(rule); + return Outcome.APPLIED; + } + + private static String name(AdAttribution plan, String siteKey) { + String site = siteKey == null || siteKey.isBlank() ? "unknown" : siteKey; + return String.format(Locale.US, "区间反馈 %s %s", + site, plan.category().name().toLowerCase(Locale.US).replace('_', '-')); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AudioIntervalFact.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AudioIntervalFact.java new file mode 100644 index 0000000000..23b1638e51 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AudioIntervalFact.java @@ -0,0 +1,25 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; + +/** + * 音频指纹通道在区间内的既有匹配结果。Phase 1 只读,不采集新指纹。 + * + * @param enabled 音频指纹功能是否已开启 + * @param matchedRuleIds 区间内命中的规则 id + * @param captureReady 本次播放是否具备采集条件(Exo、非直播、可 seek、时长已知) + */ +public record AudioIntervalFact(boolean enabled, List matchedRuleIds, boolean captureReady) { + + public AudioIntervalFact { + matchedRuleIds = matchedRuleIds == null ? List.of() : List.copyOf(matchedRuleIds); + } + + public static AudioIntervalFact unavailable() { + return new AudioIntervalFact(false, List.of(), false); + } + + public boolean hasMatch() { + return !matchedRuleIds.isEmpty(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java new file mode 100644 index 0000000000..29addb209d --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java @@ -0,0 +1,174 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * 基于域名信誉的归因通道,实现「非当前适配的主流域名」这条判据。 + * + *

三层比对见设计文档第 7.2 节。所有外部数据由调用方以 {@link Input} 传入, + * 类本身不触碰 SharedPreferences,因此可在纯 JVM 下单测。 + */ +public final class DomainReputationClassifier { + + public static final String CHANNEL_ID = "domain"; + + /** 命中已知黑名单时的置信度。 */ + static final float CONFIDENCE_KNOWN_HOST = 0.95f; + /** 仅「不在本站基线内」时的置信度。 */ + static final float CONFIDENCE_UNKNOWN_HOST = 0.55f; + /** 接口候选也认为是广告域名时的加成。 */ + static final float BONUS_INTERFACE_CANDIDATE = 0.15f; + + private DomainReputationClassifier() { + } + + /** + * 分类输入。 + * + * @param blacklistedHosts 现有黑名单,来自 {@code RuleConfig.get().getAds()} + * @param siteBaselineHosts 本站历史正常域名,来自 SitePlaylistHostBaseline + * @param interfaceCandidateHosts 接口学习的待审候选域名 + * @param interfaceSourceName 命中候选时展示的来源接口名 + */ + public record Input(List blacklistedHosts, List siteBaselineHosts, + List interfaceCandidateHosts, String interfaceSourceName) { + + public Input { + blacklistedHosts = blacklistedHosts == null ? List.of() : List.copyOf(blacklistedHosts); + siteBaselineHosts = siteBaselineHosts == null ? List.of() : List.copyOf(siteBaselineHosts); + interfaceCandidateHosts = interfaceCandidateHosts == null ? List.of() : List.copyOf(interfaceCandidateHosts); + interfaceSourceName = interfaceSourceName == null ? "" : interfaceSourceName; + } + + public static Input empty() { + return new Input(List.of(), List.of(), List.of(), ""); + } + } + + /** 无结论时返回 null 表示弃权。不含叠加校验,仅供单测。 */ + public static AdAttribution classify(AdIntervalEvidence evidence, Input input) { + return classify(evidence, input, List.of()); + } + + /** + * 带叠加校验的版本:新规则要与 {@code activeRules} 合并后仍然安全。 + * + * @param activeRules 当前已启用并生效的 HLS 规则 + */ + public static AdAttribution classify(AdIntervalEvidence evidence, Input input, + List activeRules) { + if (evidence == null || evidence.inside().isEmpty()) return null; + Input safe = input == null ? Input.empty() : input; + + Set foreignHosts = foreignHosts(evidence); + if (foreignHosts.isEmpty()) return null; + + List matchedBlacklist = matching(foreignHosts, safe.blacklistedHosts()); + List matchedCandidates = matching(foreignHosts, safe.interfaceCandidateHosts()); + + if (!matchedBlacklist.isEmpty()) { + // 已在黑名单里却仍被用户看到:拦截路径没覆盖播放器直连的切片请求。 + // 结论是诊断(NONE + 空载荷,actionable() 为 false),不会生成规则, + // 因此不受下面那道「需要同域对照」的约束 —— 整站切片都在独立 CDN + // 恰恰是这条诊断最该出现的场景。 + List evidenceLines = new ArrayList<>(); + evidenceLines.add("切片域名已在广告黑名单中:" + String.join("、", matchedBlacklist)); + evidenceLines.add("黑名单主要在 WebView 请求拦截生效,不拦播放器直连的切片请求"); + evidenceLines.add("建议改用 HLS 结构化规则删除这些切片"); + return new AdAttribution(CHANNEL_ID, AdCategory.ALREADY_HANDLED, + CONFIDENCE_KNOWN_HOST, RiskLevel.LOW, evidenceLines, RemediationKind.NONE); + } + + // 以下分支会生成规则,必须有对照组:区间外存在属于 playlist 域名的切片, + // 才能说明这些外域切片是异常的。否则整站切片都在独立 CDN + // (api.site.com + cdn.site-x.com 这种常见拆分)会被整体判为广告, + // 生成的规则命中全部切片 → HlsManifestCleaner 因 + // removedCount == segmentCount 回退原文,而 HlsAdblockPipeline 的 + // fallback 会连带停掉 legacy 启发式,比不加规则更差。 + // 与 HlsSegmentClassifier.detectCrossDomain 同一判据。 + if (!hasSameDomainOutside(evidence)) return null; + + // 无基线数据时不能断言「非本站域名」,避免首次播放即误判 + if (safe.siteBaselineHosts().isEmpty() && matchedCandidates.isEmpty()) return null; + + boolean outsideBaseline = !safe.siteBaselineHosts().isEmpty() + && foreignHosts.stream().noneMatch(host -> endsWithAny(host, safe.siteBaselineHosts())); + if (!outsideBaseline && matchedCandidates.isEmpty()) return null; + + float confidence = outsideBaseline ? CONFIDENCE_UNKNOWN_HOST : 0f; + List evidenceLines = new ArrayList<>(); + if (outsideBaseline) { + evidenceLines.add("切片域名 " + String.join("、", foreignHosts) + " 不在本站常用域名中"); + } + if (!matchedCandidates.isEmpty()) { + confidence += BONUS_INTERFACE_CANDIDATE; + String source = safe.interfaceSourceName().isEmpty() ? "接口规则" : safe.interfaceSourceName(); + evidenceLines.add(source + "也将其列为广告域名候选"); + } + evidenceLines.add("起点来源:" + evidence.startOrigin()); + + // 落地方式是 HLS 结构化规则而不是域名黑名单:黑名单只在 WebView + // 请求拦截生效(RuleConfig.getAds() 的唯一消费者是 CustomWebView), + // 拦不住播放器直连的切片请求。跨域按 foreignHosts 的定义天然成立, + // 与 hostSuffixes 合起来正好满足 minimumSignals=2。 + // playlistHost 必然非空 —— hasSameDomainOutside 在它为空时已经弃权, + // 所以这里不再有「无作用域」的退路。 + List scope = List.of(evidence.playlistHost()); + RulePayload payload = RulePayload.ofHlsRule(scope, List.copyOf(foreignHosts), + Double.NaN, Double.NaN, false, true, 2); + // 最终守门:用真实 cleaner 验证只删区间内。域名条件同样挡不住 + // 「广告与正片共用第三方 CDN、靠路径区分」这类场景。 + if (!RuleSelfCheck.isSafe(evidence, payload, activeRules)) return null; + return new AdAttribution(CHANNEL_ID, AdCategory.THIRD_PARTY_CDN_SEGMENT, + confidence, RiskLevel.LOW, evidenceLines, + RemediationKind.HLS_STRUCTURED_RULE, payload); + } + + /** + * 区间外是否存在属于 playlist 域名的切片。这是「外域切片异常」的对照前提: + * 没有同域切片作对照,说明该站本来就把切片放在独立 CDN 上。 + */ + private static boolean hasSameDomainOutside(AdIntervalEvidence evidence) { + return SegmentContrast.hasSameDomainOutside(evidence); + } + + /** 区间内不属于 playlist 域名的切片 host,去重保序。 */ + private static Set foreignHosts(AdIntervalEvidence evidence) { + Set hosts = new LinkedHashSet<>(); + String playlistHost = evidence.playlistHost(); + for (SegmentFact fact : evidence.inside()) { + if (fact.host().isEmpty()) continue; + if (!playlistHost.isEmpty() && fact.hostEndsWith(playlistHost)) continue; + hosts.add(fact.host().toLowerCase(Locale.US)); + } + return hosts; + } + + private static List matching(Set hosts, List patterns) { + List matched = new ArrayList<>(); + for (String host : hosts) { + if (endsWithAny(host, patterns)) matched.add(host); + } + return matched; + } + + /** + * host 是否命中任一片段。黑名单条目多为域名片段(如 {@code doubleclick.net}), + * 沿用现有拦截语义:后缀匹配或包含匹配。 + */ + private static boolean endsWithAny(String host, List patterns) { + String lower = host.toLowerCase(Locale.US); + for (String pattern : patterns) { + if (pattern == null || pattern.isBlank()) continue; + String target = pattern.toLowerCase(Locale.US).trim(); + if (lower.equals(target) || lower.endsWith("." + target) || lower.contains(target)) return true; + } + return false; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java new file mode 100644 index 0000000000..912f391558 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java @@ -0,0 +1,156 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * 诊断通道:回答「为什么现有机制没拦住」。 + * + *

本通道不产出新规则,但会给出成本最低的修复建议 —— 启用一条已存在 + * 但默认关闭的规则,永远优于新建规则。见设计文档第 7.3、8.1 节。 + */ +public final class ExistingRuleClassifier { + + public static final String CHANNEL_ID = "existing-rule"; + + /** 命中一条已存在但未启用的规则时的置信度。 */ + static final float CONFIDENCE_DISABLED_RULE = 0.75f; + + private ExistingRuleClassifier() { + } + + /** + * 一条内置/外部 HLS 规则的当前状态,对应 {@code HlsRuleConfig.Entry} 的子集。 + * + * @param key 复合状态键 + * @param id 规则 id + * @param name 规则名 + * @param enabled 当前是否启用 + * @param valid 编译是否通过 + * @param hostSuffixes 该规则针对的切片域名后缀 + * @param compiled 已编译的规则,供自检实跑;取不到时为 null(则不建议启用) + */ + public record RuleState(String key, String id, String name, boolean enabled, + boolean valid, List hostSuffixes, + HlsManifestCleaner.Rule compiled) { + + public RuleState { + key = key == null ? "" : key; + id = id == null ? "" : id; + name = name == null ? "" : name; + hostSuffixes = hostSuffixes == null ? List.of() : List.copyOf(hostSuffixes); + } + + /** + * 无编译产物的简化构造,仅用于诊断路径与测试。 + * + *

刻意保持包级可见:生产代码必须走 7 参构造并提供编译产物, + * 否则 {@code findDisabledMatch} 会因无法自检而跳过该规则 —— + * 用它会静默关掉「启用已有规则」整条通道。 + */ + RuleState(String key, String id, String name, boolean enabled, + boolean valid, List hostSuffixes) { + this(key, id, name, enabled, valid, hostSuffixes, null); + } + } + + /** + * 分类输入。 + * + * @param hlsRules 已知的 HLS 规则状态 + * @param legacyHeuristicActive 旧启发式引擎当次是否生效 + * @param protectingExcludes 可能误保护广告切片的正片保护正则 + */ + public record Input(List hlsRules, boolean legacyHeuristicActive, + List protectingExcludes) { + + public Input { + hlsRules = hlsRules == null ? List.of() : List.copyOf(hlsRules); + protectingExcludes = protectingExcludes == null ? List.of() : List.copyOf(protectingExcludes); + } + + public static Input empty() { + return new Input(List.of(), false, List.of()); + } + } + + /** 无诊断结论时返回 null。不含叠加校验,仅供单测。 */ + public static AdAttribution classify(AdIntervalEvidence evidence, Input input) { + return classify(evidence, input, List.of()); + } + + /** + * 带叠加校验的版本:待启用规则要与 {@code activeRules} 合并后仍然安全。 + * + * @param activeRules 当前已启用并生效的 HLS 规则 + */ + public static AdAttribution classify(AdIntervalEvidence evidence, Input input, + List activeRules) { + if (evidence == null || evidence.inside().isEmpty()) return null; + Input safe = input == null ? Input.empty() : input; + + RuleState disabled = findDisabledMatch(evidence, safe.hlsRules(), activeRules); + if (disabled != null) { + List lines = new ArrayList<>(); + lines.add(String.format(Locale.US, "已有规则「%s」覆盖该域名但当前未启用", + disabled.name().isEmpty() ? disabled.id() : disabled.name())); + lines.add("启用它即可拦掉这段广告,无需新建规则"); + return new AdAttribution(CHANNEL_ID, AdCategory.DISCONTINUITY_BLOCK, + CONFIDENCE_DISABLED_RULE, RiskLevel.LOW, lines, + RemediationKind.ENABLE_EXISTING_RULE, + RulePayload.ofRuleKey(disabled.key())); + } + + List lines = new ArrayList<>(); + if (evidence.handledByStructuredRule()) { + lines.add("该区间已被规则 " + String.join("、", evidence.alreadyRemovedByStructuredRuleIds()) + + " 处理,用户看到的广告来自其他来源"); + } + if (safe.legacyHeuristicActive()) { + lines.add("旧启发式引擎当次生效,其判定不依赖规则,可能与结构化结论冲突"); + } + if (!safe.protectingExcludes().isEmpty()) { + lines.add("存在 " + safe.protectingExcludes().size() + + " 条正片保护规则,可能阻止了这些切片被删除"); + } + RuleState invalid = findInvalid(safe.hlsRules()); + if (invalid != null) { + lines.add("规则「" + (invalid.name().isEmpty() ? invalid.id() : invalid.name()) + + "」编译失败,未参与本次净化"); + } + if (lines.isEmpty()) return null; + + return new AdAttribution(CHANNEL_ID, AdCategory.ALREADY_HANDLED, + 0.5f, RiskLevel.LOW, lines, RemediationKind.NONE); + } + + /** + * 找出一条覆盖本区间域名、有效但未启用的规则。 + * + *

必须过 {@link RuleSelfCheck}:仅凭「hostSuffixes 与区间内某片同域」建议启用, + * 不看该规则的其余条件 —— 实测一条 {@code minimumSignals=1} 的既有规则在 + * 「广告与正片共用同一 CDN」时会命中全部切片,回退并连带停掉 legacy 启发式。 + * 这条路径此前完全没有守卫。 + */ + private static RuleState findDisabledMatch(AdIntervalEvidence evidence, List rules, + List activeRules) { + for (RuleState rule : rules) { + if (rule.enabled() || !rule.valid() || rule.hostSuffixes().isEmpty()) continue; + boolean covers = evidence.inside().stream() + .anyMatch(fact -> rule.hostSuffixes().stream().anyMatch(fact::hostEndsWith)); + if (!covers) continue; + // 拿不到编译产物时无法验证,宁可不建议 + if (rule.compiled() == null) continue; + if (!RuleSelfCheck.isSafe(evidence, rule.compiled(), activeRules)) continue; + return rule; + } + return null; + } + + private static RuleState findInvalid(List rules) { + return rules.stream().filter(rule -> !rule.valid()).findFirst().orElse(null); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java new file mode 100644 index 0000000000..29bd2096c2 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java @@ -0,0 +1,367 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 基于 HLS 切片结构的归因通道。零新增运行时成本:全部输入来自 + * {@code M3u8Parser.parse()} 已产出的证据,不发起新的网络请求。 + * + *

五种信号的权重见设计文档第 7.1 节。生成的条件必须落在 + * {@code HlsManifestCleaner.Rule} 已支持的范围内,否则规则无法被现有引擎执行。 + */ +public final class HlsSegmentClassifier { + + public static final String CHANNEL_ID = "hls"; + + /** 置信度低于此值时弃权。 */ + static final float MIN_CONFIDENCE = 0.30f; + + static final float WEIGHT_CROSS_DOMAIN = 0.35f; + static final float WEIGHT_DISCONTINUITY = 0.25f; + static final float WEIGHT_DURATION_OUTLIER = 0.20f; + static final float WEIGHT_PATH_HINT = 0.15f; + static final float WEIGHT_HEAD_POSITION = 0.05f; + + /** 区间内切片时长视为「整齐」的标准差上限,秒。 */ + private static final double UNIFORM_STDDEV_MAX = 0.05d; + /** 与区间外众数时长的差异下限,秒。 */ + private static final double DURATION_GAP_MIN = 0.5d; + /** 落在 playlist 头部多少比例内算「片头」。 */ + private static final double HEAD_RATIO = 0.15d; + + /** 保守的广告路径特征,只匹配明确的目录段。 */ + private static final List PATH_HINTS = + List.of("/ad/", "/ads/", "/adv/", "/preroll/", "/midroll/", "/creative/"); + + private HlsSegmentClassifier() { + } + + /** 命中的信号,用于生成规则时决定 minimumSignals。 */ + public record Signals(boolean crossDomain, boolean discontinuity, boolean durationOutlier, + boolean pathHint, boolean headPosition) { + + /** + * 观察到的信号数,仅供诊断展示。 + * + *

刻意不用于判断能否落地 —— 信号命中不等于条件有区分度, + * 那件事由 {@code payloadOf} 的实际载荷决定。此前用它做守卫时, + * 「同域 + 路径特征区间外也有」的场景会产出一条空载荷的归因。 + */ + public int signalCount() { + int count = 0; + if (crossDomain) count++; + if (discontinuity) count++; + if (durationOutlier) count++; + if (pathHint) count++; + if (headPosition) count++; + return count; + } + + public float weightSum() { + float sum = 0f; + if (crossDomain) sum += WEIGHT_CROSS_DOMAIN; + if (discontinuity) sum += WEIGHT_DISCONTINUITY; + if (durationOutlier) sum += WEIGHT_DURATION_OUTLIER; + if (pathHint) sum += WEIGHT_PATH_HINT; + if (headPosition) sum += WEIGHT_HEAD_POSITION; + return sum; + } + } + + /** + * 对区间做结构归因,不含叠加校验。无法判断时返回 null 表示弃权。 + * + *

仅供单测;生产路径走带 {@code activeRules} 的版本。 + */ + public static AdAttribution classify(AdIntervalEvidence evidence) { + return classify(evidence, List.of()); + } + + /** + * 带叠加校验的版本:新规则要与 {@code activeRules} 合并后仍然安全。 + * + * @param activeRules 当前已启用并生效的 HLS 规则 + */ + public static AdAttribution classify(AdIntervalEvidence evidence, + List activeRules) { + if (evidence == null || !evidence.hasSegmentEvidence()) return null; + // 区间已被结构化规则整体处理,用户看到的广告来自别处,不应再加 HLS 规则 + if (evidence.handledByStructuredRule()) return null; + + Signals signals = detect(evidence); + float confidence = signals.weightSum(); + if (confidence < MIN_CONFIDENCE) return null; + + // 以实际载荷为准判断能否落地:信号命中不等于条件有区分度 + // (如站内正片路径也含 /ads/ 时,路径特征无法编码进规则) + RulePayload payload = payloadOf(evidence, signals); + if (payload.isEmpty()) return null; + // 最终守门:用真实 cleaner 跑一遍,确认只删区间内、不碰区间外、不触发回退。 + // 逐条件的对照校验挡不住条件间的组合效应(如 duration + crossDomain 在 + // 区间外同时成立),也预判不到删除比例与时长闸门。 + if (!RuleSelfCheck.isSafe(evidence, payload, activeRules)) return null; + + return new AdAttribution(CHANNEL_ID, categoryOf(signals), confidence, + RiskLevel.MEDIUM, describe(evidence, signals, payload), + RemediationKind.HLS_STRUCTURED_RULE, payload); + } + + /** + * 生成可被 {@code HlsManifestCleaner.Rule} 执行的条件。 + * + *

关键约束:{@code HlsManifestCleaner} 对每个切片独立累加信号, + * 达到 minimumSignals 即删除。因此只能编码**有区分度**的条件 —— + * 与 playlist 同域的 hostSuffixes 对每个切片都成立,requireDiscontinuity + * 对每个断点后的切片都成立,两者凑够 2 个信号会连正片一起删。 + * + *

有区分度的条件只有两类:非本站域名、广告路径特征。至少要有一类, + * 否则返回空载荷让通道弃权。 + */ + private static RulePayload payloadOf(AdIntervalEvidence evidence, Signals signals) { + String playlistHost = evidence.playlistHost(); + // 域名只在跨域信号成立时才有区分度。crossDomain 自带对照校验 + // (detectCrossDomain 要求区间外存在同域切片),少了这道门槛就会在 + // 「整站切片都在独立 CDN」的架构上把全站域名写进规则 —— 规则命中所有 + // 切片 → cleaner 因 removedCount == segmentCount 回退 → 连带停掉 + // legacy 启发式,比不加规则更差。 + List hosts = signals.crossDomain() + ? evidence.inside().stream() + .map(SegmentFact::host) + .filter(host -> !host.isEmpty()) + .filter(host -> playlistHost.isEmpty() || !hostMatches(host, playlistHost)) + .distinct() + .toList() + : List.of(); + List pathPatterns = signals.pathHint() + ? pathPatternsOf(evidence) : List.of(); + // 没有任何区分条件时不得生成规则 + if (hosts.isEmpty() && pathPatterns.isEmpty()) return RulePayload.empty(); + + double min = Double.NaN; + double max = Double.NaN; + if (signals.durationOutlier()) { + double mean = mean(evidence.inside()); + min = Math.max(0d, mean - 0.1d); + max = mean + 0.1d; + } + // 作用域收窄到当前站点的 playlist 域名,否则规则会污染其他站点 + List scope = playlistHost.isEmpty() ? List.of() : List.of(playlistHost); + + // requireDiscontinuity 只对广告块首片成立,块内后续切片拿不到这个信号。 + // 把它计入门限会导致只删掉首片、留下其余广告,因此不编码进规则 —— + // 断点已经在归因阶段用于定位区间,规则层不再需要它。 + // requireCrossDomain 对块内每一片都成立,可以安全编码。 + boolean requireCrossDomain = signals.crossDomain(); + + // 逐项统计真正对块内每一片都成立的信号,minimumSignals 不能超过它, + // 否则 HlsAdRule.compile() 会拒绝整条规则。 + int encoded = 0; + if (!hosts.isEmpty()) encoded++; + if (!pathPatterns.isEmpty()) encoded++; + if (!Double.isNaN(min)) encoded++; + if (requireCrossDomain) encoded++; + + // 门限优先取 2 以避免宽泛删片,但不得超过实际可编码的信号数 + // (超过会让 compile() 拒绝整条规则)。也不取满全部信号 —— 要求 + // 三个条件同时成立时,广告块里稍有出入的一片就会漏删。 + int minimumSignals = Math.min(2, encoded); + + return RulePayload.ofHlsRule(scope, hosts, pathPatterns, min, max, + false, requireCrossDomain, minimumSignals); + } + + /** + * 命中的广告路径目录段转成正则,用 quote 避免元字符注入。 + * + *

双向校验:区间内每片都含该目录段,且区间外**没有**任何切片含它。 + * 只查 inside 的话,站内正片路径恰好含 hint 时会被一起删掉 —— 规则是按 + * 切片独立匹配的,没有「只在这段区间内生效」的概念。 + * + *

区间外为空时无法完成对照(用户框选覆盖了整个 playlist),一律弃权: + * 此时任何条件都命中全部切片,生成的规则会触发 cleaner 整体回退。 + */ + private static List pathPatternsOf(AdIntervalEvidence evidence) { + List patterns = new ArrayList<>(); + for (String hint : PATH_HINTS) { + boolean insideAll = SegmentContrast.pathPresentInside(evidence, hint); + if (!insideAll) continue; + boolean outsideNone = SegmentContrast.pathAbsentOutside(evidence, hint); + if (outsideNone) patterns.add(pathOnlyPattern(hint)); + } + return patterns; + } + + /** + * 把目录段包成「只匹配 path 部分」的正则。 + * + *

必须锚定:本类的双向校验读 {@code SegmentFact.path()}(已去 query, + * 见去敏要求),而 {@code HlsManifestCleaner.matchesPattern} 匹配的是 + * 含 query 的完整 URL。正片带 {@code ?ref=/ads/} 这类参数时,校验认为 + * 区间外干净而放行,运行时却命中全部切片 → cleaner 回退 → 连带停掉 + * legacy 启发式。{@code [^?]*} 无法跨过 {@code ?},从而把匹配限制在 path 内。 + */ + static String pathOnlyPattern(String hint) { + // 同时排除 query 与 fragment:证据侧用 URI.getPath(),query 和 fragment + // 都被丢弃,所以 pathAbsentOutside 与 RuleSelfCheck 都看不见它们。 + // 只挡 ? 的话,正片 URL 形如 /seg/99.ts#/ads/x 会被跨 # 命中 —— 实测 + // 这种形状下真实 cleaner 会多删一片正片且 fallback=false,错误不被兜住。 + return "^[^?#]*" + Pattern.quote(hint); + } + + /** host 是否等于给定域名或为其子域。 */ + private static boolean hostMatches(String host, String domain) { + String lower = host.toLowerCase(Locale.US); + String target = domain.toLowerCase(Locale.US); + return lower.equals(target) || lower.endsWith("." + target); + } + + /** 逐项检测五种信号。 */ + public static Signals detect(AdIntervalEvidence evidence) { + List inside = evidence.inside(); + List outside = evidence.outside(); + return new Signals( + detectCrossDomain(evidence), + evidence.boundedByDiscontinuity() || inside.get(0).discontinuityBefore(), + detectDurationOutlier(inside, outside), + detectPathHint(evidence), + detectHeadPosition(inside, outside)); + } + + /** + * 跨域:区间内切片整体不属于 playlist 域名,且区间外存在足够的同域对照。 + * + *

对照由 {@link SegmentContrast#hasSameDomainOutside} 统一判定 —— + * 只看「存在一个同域切片」不够,playlist 开头一片同域、其余全在独立 CDN + * 是真实架构,那种情况下生成的规则会命中全站。 + */ + private static boolean detectCrossDomain(AdIntervalEvidence evidence) { + String playlistHost = evidence.playlistHost(); + if (playlistHost.isEmpty()) return false; + boolean insideForeign = evidence.inside().stream() + .allMatch(fact -> !fact.host().isEmpty() && !fact.hostEndsWith(playlistHost)); + if (!insideForeign) return false; + return SegmentContrast.hasSameDomainOutside(evidence); + } + + /** + * 时长离群:区间内时长高度一致,且与区间外众数明显不同。 + * 单独出现时权重不足以触发规则,必须与其他信号叠加。 + */ + private static boolean detectDurationOutlier(List inside, List outside) { + if (inside.isEmpty() || outside.isEmpty()) return false; + if (stdDev(inside) > UNIFORM_STDDEV_MAX) return false; + double insideMean = mean(inside); + Double outsideMode = mode(outside); + if (outsideMode == null) return false; + return Math.abs(insideMean - outsideMode) > DURATION_GAP_MIN; + } + + /** + * 路径特征:存在某个目录段在区间内每片都出现、且区间外一片都没有。 + * + *

信号本身就带对照,与 {@link #pathPatternsOf} 用同一判据 —— 否则会出现 + * 「信号成立但无法编码进规则」的空载荷归因。 + */ + private static boolean detectPathHint(AdIntervalEvidence evidence) { + return PATH_HINTS.stream().anyMatch(hint -> + SegmentContrast.pathPresentInside(evidence, hint) + && SegmentContrast.pathAbsentOutside(evidence, hint)); + } + + /** 位置:区间落在 playlist 头部 15% 以内。 */ + private static boolean detectHeadPosition(List inside, List outside) { + int total = inside.size() + outside.size(); + if (total == 0) return false; + int firstIndex = inside.stream().mapToInt(SegmentFact::index).min().orElse(Integer.MAX_VALUE); + return firstIndex <= Math.max(0, (int) Math.floor(total * HEAD_RATIO)); + } + + private static AdCategory categoryOf(Signals signals) { + if (signals.crossDomain()) return AdCategory.THIRD_PARTY_CDN_SEGMENT; + if (signals.discontinuity()) return AdCategory.DISCONTINUITY_BLOCK; + if (signals.durationOutlier()) return AdCategory.FIXED_DURATION_BLOCK; + return AdCategory.UNKNOWN; + } + + /** + * 生成给用户看的证据。 + * + *

分两段:先说观察到的现象(按 signals),再明确列出**实际写进规则**的 + * 条件(按 payload)。两者不是一回事 —— 例如路径特征因区间外也含而未被编码时, + * 现象成立但规则里没有它。不列清规则条件,用户无法判断这条规则的杀伤范围。 + */ + private static List describe(AdIntervalEvidence evidence, Signals signals, + RulePayload payload) { + List inside = evidence.inside(); + List lines = new ArrayList<>(); + lines.add(String.format(Locale.US, "区间内 %d 个切片,时长合计 %.1fs", + inside.size(), inside.stream().mapToDouble(SegmentFact::durationSec).sum())); + if (signals.crossDomain()) { + lines.add(String.format(Locale.US, "切片域名 %s 与 playlist 域名 %s 不一致", + inside.get(0).host(), evidence.playlistHost())); + } + if (signals.discontinuity()) lines.add("区间边界存在 #EXT-X-DISCONTINUITY"); + if (signals.durationOutlier()) { + lines.add(String.format(Locale.US, "区间内切片时长一致(%.2fs),与正片切片时长差异明显", + mean(inside))); + } + if (signals.pathHint()) lines.add("切片路径包含广告目录特征"); + if (signals.headPosition()) lines.add("区间位于播放列表头部"); + lines.add("起点来源:" + evidence.startOrigin()); + lines.addAll(describeRule(payload)); + return lines; + } + + /** 逐项列出规则实际生效的条件,让用户能判断杀伤范围。 */ + private static List describeRule(RulePayload payload) { + List lines = new ArrayList<>(); + if (payload.isEmpty()) return lines; + lines.add("规则生效范围:" + String.join("、", payload.playlistHostSuffixes())); + if (!payload.hosts().isEmpty()) { + lines.add("删除来自这些域名的切片:" + String.join("、", payload.hosts())); + } + if (!payload.regex().isEmpty()) { + lines.add("删除路径匹配的切片:" + String.join("、", payload.regex())); + } + if (payload.hasDurationRange()) { + lines.add(String.format(Locale.US, "限定切片时长 %.2f–%.2fs", + payload.durationMin(), payload.durationMax())); + } + if (payload.requireCrossDomain()) lines.add("仅当切片域名与 playlist 不同时生效"); + lines.add(String.format(Locale.US, "需同时满足其中 %d 个条件", payload.minimumSignals())); + return lines; + } + + private static double mean(List facts) { + return facts.stream().mapToDouble(SegmentFact::durationSec).average().orElse(0d); + } + + private static double stdDev(List facts) { + if (facts.size() < 2) return 0d; + double mean = mean(facts); + double variance = facts.stream() + .mapToDouble(fact -> Math.pow(fact.durationSec() - mean, 2)) + .sum() / facts.size(); + return Math.sqrt(variance); + } + + /** 区间外切片的众数时长,按 0.1s 粒度归桶。 */ + private static Double mode(List facts) { + Map buckets = new HashMap<>(); + for (SegmentFact fact : facts) { + long bucket = Math.round(fact.durationSec() * 10); + buckets.merge(bucket, 1, Integer::sum); + } + return buckets.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(entry -> entry.getKey() / 10d) + .orElse(null); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RemediationKind.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RemediationKind.java new file mode 100644 index 0000000000..571e6c73fb --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RemediationKind.java @@ -0,0 +1,32 @@ +package com.fongmi.android.tv.ad.feedback; + +/** + * 归因结论对应的去广机制。 + * + *

{@link #ordinal()} 即成本-风险优先级:越靠前越优先。见设计文档第 8.1 节。 + * 仲裁器依赖这个顺序,重排枚举会改变仲裁结果。 + */ +public enum RemediationKind { + /** 启用一条已存在但默认关闭的规则,运行时零新增成本。 */ + ENABLE_EXISTING_RULE, + /** 写入 {@code UserAdRule.hosts}。 */ + HOST_BLACKLIST, + /** 写入 {@code HlsAdRule},由 HlsManifestCleaner 执行。 */ + HLS_STRUCTURED_RULE, + /** 写入 {@code UserAdRule.regex}。 */ + URL_REGEX_RULE, + /** 写入 {@code AudioFingerprintRule}(Phase 2)。 */ + AUDIO_FINGERPRINT_RULE, + /** 追加语音关键词(Phase 2,仅建议)。 */ + SPEECH_KEYWORD, + /** 无法泛化,只能本次跳过。 */ + SESSION_SKIP_ONLY, + /** 纯诊断结论,不产出规则。 */ + NONE; + + /** 成本-风险排序权重,0..1,越大越优先。 */ + public float priorityWeight() { + int span = values().length - 1; + return span == 0 ? 1f : 1f - (float) ordinal() / span; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RiskLevel.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RiskLevel.java new file mode 100644 index 0000000000..eb02c1a87d --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RiskLevel.java @@ -0,0 +1,14 @@ +package com.fongmi.android.tv.ad.feedback; + +/** + * 规则风险等级,沿用智能去广 V2 总设计第 12 节的三级划分。 + * + *

低:广告 host 拦截、严格媒体 URL 排除; + * 中:结构化 HLS 区块删除、DOM 声明式动作; + * 高:任意 JavaScript、全文正则替换、宽泛固定时长规则。 + */ +public enum RiskLevel { + LOW, + MEDIUM, + HIGH +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java new file mode 100644 index 0000000000..b5db233817 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java @@ -0,0 +1,94 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; + +/** + * 归因结论的机器可读载荷:落地规则所需的实际数据。 + * + *

与 {@link AdAttribution#evidence()} 分开是刻意的 —— 证据是给人看的、 + * 会随文案和语言变化,不能用来提取域名或规则键。 + * + * @param playlistHostSuffixes 规则作用域,限定到当前站点的 playlist 域名; + * HLS 规则必须携带,否则会污染其他站点(设计文档第 10 节) + * @param hosts 要拉黑或匹配的切片域名 + * @param regex 广告 URL 正则 + * @param exclude 正片保护正则 + * @param ruleKey 要启用的已有规则的复合状态键 + * @param durationMin 切片时长下界,NaN 表示不限定 + * @param durationMax 切片时长上界,NaN 表示不限定 + * @param requireDiscontinuity 是否要求断点边界 + * @param requireCrossDomain 是否要求跨域 + * @param minimumSignals 生成 HLS 规则时的多信号门限 + */ +public record RulePayload(List playlistHostSuffixes, List hosts, + List regex, List exclude, + String ruleKey, double durationMin, double durationMax, + boolean requireDiscontinuity, boolean requireCrossDomain, + int minimumSignals) { + + public RulePayload { + playlistHostSuffixes = playlistHostSuffixes == null ? List.of() : List.copyOf(playlistHostSuffixes); + hosts = hosts == null ? List.of() : List.copyOf(hosts); + regex = regex == null ? List.of() : List.copyOf(regex); + exclude = exclude == null ? List.of() : List.copyOf(exclude); + ruleKey = ruleKey == null ? "" : ruleKey; + if (minimumSignals < 0) minimumSignals = 0; + } + + public static RulePayload empty() { + return new RulePayload(List.of(), List.of(), List.of(), List.of(), "", + Double.NaN, Double.NaN, false, false, 0); + } + + /** 仅域名黑名单。 */ + public static RulePayload ofHosts(List hosts) { + return new RulePayload(List.of(), hosts, List.of(), List.of(), "", + Double.NaN, Double.NaN, false, false, 0); + } + + /** 仅 URL 正则。 */ + public static RulePayload ofRegex(List regex) { + return new RulePayload(List.of(), List.of(), regex, List.of(), "", + Double.NaN, Double.NaN, false, false, 0); + } + + /** 启用一条已有规则。 */ + public static RulePayload ofRuleKey(String ruleKey) { + return new RulePayload(List.of(), List.of(), List.of(), List.of(), ruleKey, + Double.NaN, Double.NaN, false, false, 0); + } + + /** HLS 结构化条件,不带时长范围。 */ + public static RulePayload ofHls(List playlistHostSuffixes, List hosts, + int minimumSignals) { + return new RulePayload(playlistHostSuffixes, hosts, List.of(), List.of(), "", + Double.NaN, Double.NaN, false, false, minimumSignals); + } + + /** HLS 结构化条件全量。 */ + public static RulePayload ofHlsRule(List playlistHostSuffixes, List hosts, + List regex, + double durationMin, double durationMax, + boolean requireDiscontinuity, boolean requireCrossDomain, + int minimumSignals) { + return new RulePayload(playlistHostSuffixes, hosts, regex, List.of(), "", + durationMin, durationMax, requireDiscontinuity, requireCrossDomain, minimumSignals); + } + + /** HLS 结构化条件,无 URL 正则。 */ + public static RulePayload ofHlsRule(List playlistHostSuffixes, List hosts, + double durationMin, double durationMax, + boolean requireDiscontinuity, boolean requireCrossDomain, + int minimumSignals) { + return ofHlsRule(playlistHostSuffixes, hosts, List.of(), durationMin, durationMax, + requireDiscontinuity, requireCrossDomain, minimumSignals); + } + + public boolean hasDurationRange() { + return !Double.isNaN(durationMin) && !Double.isNaN(durationMax); + } + + public boolean isEmpty() { + return hosts.isEmpty() && regex.isEmpty() && exclude.isEmpty() && ruleKey.isEmpty(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheck.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheck.java new file mode 100644 index 0000000000..cf868c2f59 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheck.java @@ -0,0 +1,259 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import java.util.List; +import java.util.Locale; + +/** + * 规则自检:把候选载荷交给**真实**的 {@link HlsManifestCleaner} 跑一遍合成 manifest, + * 只有「区间内全删、区间外一片不删、且未触发回退」才放行。 + * + *

这是核心不变量的唯一守门人。前五轮评审反复出现同一类缺陷 —— 每轮为某一类条件 + * 补上对照校验,下一轮就暴露另一类没校验的条件(同域 hosts、缺 crossDomain 门槛、 + * 路径正则跨 query、采样截断、duration+crossDomain 组合、共用 CDN 靠路径区分)。 + * 根因是那些修法都在验证「条件」,而不变量说的是「规则的实际效果」。 + * + *

用执行引擎自身做判据,好处是对未来新增的任何条件类型自动生效,并且顺带覆盖了 + * cleaner 的三道回退闸门(全删、删除比例 > 35%、删除时长 > 90s)—— 那些闸门即使 + * 条件完全有区分度也会让规则整体失效。 + */ +final class RuleSelfCheck { + + /** 合成 manifest 的基准地址,只需 host 与 playlist 一致即可。 */ + private static final String SYNTHETIC_BASE_SCHEME = "https://"; + + private RuleSelfCheck() { + } + + /** + * 真实 manifest 每片的行数上界。除 EXTINF + URI 外,常见还带 + * {@code #EXT-X-KEY}、{@code #EXT-X-PROGRAM-DATE-TIME} 与 + * {@code #EXT-X-DISCONTINUITY},实测这种 5 行/片的 playlist 能正常净化。 + * + *

取上界而非典型值:低估会让规则在真实 manifest 上因行数超限而回退 —— + * 规则存下来却永久无效,用户看到「已保存」但广告依旧。高估只是让超长 + * playlist 弃权,而按 4s/片算 4000 片已是 4.4 小时,正片剧集触达不到。 + * + *

{@code #EXT-X-BYTERANGE} 不计入:{@code HlsManifestCleaner} 对含该标签的 + * manifest 无条件回退,这类 playlist 从不参与净化。 + */ + private static final int LINES_PER_SEGMENT_REAL = 5; + /** 与 {@code HlsManifestCleaner.MAX_MANIFEST_LINES} 一致。 */ + private static final int MAX_MANIFEST_LINES = 20_000; + /** 为真实 manifest 的头部标签预留的行数余量。 */ + private static final int MANIFEST_HEADER_LINES = 16; + + /** + * 只做单独校验,不含叠加。仅供单测直接验证 {@link #isSafeAlone} 的判据 —— + * 生产路径必须走三参版本,把已启用规则一起算进去。 + * + * @return 载荷是否安全可落地 + */ + static boolean isSafe(AdIntervalEvidence evidence, RulePayload payload) { + return isSafe(evidence, payload, List.of()); + } + + /** 带叠加校验的载荷版本。 */ + static boolean isSafe(AdIntervalEvidence evidence, RulePayload payload, + List activeRules) { + if (payload.isEmpty()) return false; + HlsManifestCleaner.Rule rule = compile(payload); + if (rule == null) return false; + return isSafe(evidence, rule, activeRules); + } + + /** + * 校验规则单独生效、以及与站内已启用规则叠加后是否都安全。 + * + *

也是「启用已有规则」路径的守门人:那条路径此前只判断规则的 hostSuffixes + * 与区间内某片同域,不看其余条件。实测一条 {@code minimumSignals=1} 的既有规则 + * 在「广告与正片共用同一 CDN」时会命中全部切片。 + * + *

{@code HlsManifestCleaner.matches} 是「任一规则命中即删」,而三道回退闸门 + * (全删 / 删除比例 > 35% / 删除时长 > 90s)作用于**合并后**的删除总量。 + * 一条单独安全的规则叠加到已生效规则上可能越过闸门,导致整份 manifest 回退 —— + * 不只新规则无效,连原有的结构化净化也一起丢掉。 + * + *

刻意不做「区间外切片逐一比对」:合并后的删除集恒等于两条规则各自删除集的 + * 并集({@code matches} 逐片独立求值),而 {@link #isSafeAlone} 已经钉住「新规则 + * 单独只删区间内」。合并后多删的区间外切片全部来自已启用规则 —— 那是它们本来 + * 就会删的,与新规则无关。于是新规则对区间外的影响恒为零,只有闸门会因叠加而 + * 翻转,所以只查闸门。 + * + *

合并后回退即拒绝,**不追究是谁造成的**。曾经尝试过「已启用规则自己就回退 + * 时放行」,理由是那时生产上已整体不净化、没有原有净化需要保护 —— 那是错的: + * 判据只对证据里这一份 manifest 成立,而规则一旦保存就对该站所有剧集长期生效。 + * 实测同一站点另一集里已启用规则并未越界(删 10/30),叠加新规则后越界回退, + * 原本正常工作的 10 片删除全丢。更根本的是,合并后回退时新规则在这份 manifest + * 上删 0 片,我们对它是否有效**没有任何证据**,谈不上背书。 + * + *

代价是:站内只要有一条过宽的内置或接口规则处于启用,该站点的反馈功能就 + * 一直拿不到方案。这是真实的体验问题,但解法是把肇事规则指出来让用户禁用, + * 不是保存一条未经验证的规则。 + * + * @param activeRules 当前已启用且能被忠实模拟的规则。空表示站内无规则生效; + * {@code null} 表示无法确定(存在模拟不了的已启用规则,或读取 + * 配置失败),此时一律拒绝 —— 预测不了闸门就不能为规则背书。 + */ + static boolean isSafe(AdIntervalEvidence evidence, HlsManifestCleaner.Rule rule, + List activeRules) { + if (!isSafeAlone(evidence, rule)) return false; + if (activeRules == null) return false; + if (activeRules.isEmpty()) return true; + + List ordered = ordered(evidence); + List merged = new java.util.ArrayList<>(activeRules); + merged.add(rule); + return !fallsBack(evidence, synthesize(evidence, ordered), merged); + } + + /** + * 给定规则集在合成 manifest 上是否触发回退。判不出来时按「回退」处理, + * 让调用方走拒绝路径。 + * + *

这是对**真实运行**的预测,而合成 manifest 按去敏要求丢掉了 query 与 + * fragment:靠 query 区分广告的已启用规则在这里命中不到切片,闸门预测会偏乐观。 + * {@code AdFeedbackDataSource.activeHlsRules} 因此在遇到这类规则时整体返回 + * {@code null} 而不是把它悄悄剔除 —— 剔除会让「合并后不回退」这个结论建立在 + * 比生产更小的规则集上,而那个方向不是安全的:子集不回退推不出全集不回退。 + */ + private static boolean fallsBack(AdIntervalEvidence evidence, String manifest, + List rules) { + String base = SYNTHETIC_BASE_SCHEME + evidence.playlistHost() + "/index.m3u8"; + try { + HlsManifestCleaner.Result result = HlsManifestCleaner.clean(base, manifest, rules); + return result == null || result.fallback(); + } catch (RuntimeException e) { + return true; + } + } + + /** 规则单独生效时的校验。 */ + private static boolean isSafeAlone(AdIntervalEvidence evidence, HlsManifestCleaner.Rule rule) { + if (rule == null) return false; + if (evidence.playlistHost().isEmpty()) return false; + if (evidence.inside().isEmpty() || evidence.outside().isEmpty()) return false; + // 区间内外存在同一个 URI 时,读回判据无法区分对错,只能弃权: + // 「删了区间内的 U、留了区间外的 U」(正确)与「留了区间内的 U、删了区间外的 + // U」(删反)两种结果的 URI 次数完全相同,removedSegments 也都等于 inside 的 + // 片数 —— 两道判据同时被抵消,放行后真实运行删正片且 fallback=false, + // 用户直接看到跳帧。真实 URL 靠 query 区分(如 /hls/seg.ts?i=10 与 ?i=25), + // 而证据按去敏要求丢掉了 query,合成 manifest 里两者塌成一个。 + // + // 代价是也拒掉一部分本来安全的重名场景(如内外同 URI 但时长不同、规则确实 + // 只命中区间内那片)。接受这个代价:判据无法自证对错时弃权是唯一安全方向, + // 而真实 playlist 的切片 URI 通常按位置唯一,内外重名基本只来自 query 区分 + // —— 误拒几乎全部落在危险场景上。若将来要救回这部分,得让合成 URI 带上 + // 位置标记(放在 query 里,锚定正则的 ^[^?#]* 跨不过 ?),不是补判据能解决的。 + if (hasUriOverlap(evidence)) return false; + + // 真实 manifest 的行密度高于合成,按上界预判:若真实会因规模回退, + // 这条规则在播放时不会生效,不该保存。除切片行外还要留出 header + // (#EXTM3U / #EXT-X-VERSION / #EXT-X-TARGETDURATION / + // #EXT-X-MEDIA-SEQUENCE / #EXT-X-ENDLIST 等)的余量,否则恰好卡在 + // 边界的 playlist 会被放行而真实仍然回退。 + int total = evidence.inside().size() + evidence.outside().size(); + if ((long) total * LINES_PER_SEGMENT_REAL + MANIFEST_HEADER_LINES + > MAX_MANIFEST_LINES) return false; + + List ordered = ordered(evidence); + String base = SYNTHETIC_BASE_SCHEME + evidence.playlistHost() + "/index.m3u8"; + HlsManifestCleaner.Result result; + try { + result = HlsManifestCleaner.clean(base, synthesize(evidence, ordered), List.of(rule)); + } catch (RuntimeException e) { + return false; + } + // 回退意味着这条规则在真实播放里同样会被拒(全删 / 比例超限 / 时长超限) + if (result == null || result.fallback() || !result.changed()) return false; + + // 按 URI 出现次数比对,而不是 contains 子串查找 —— 后者会被别名遮蔽: + // 删掉 /s/1 后保留的 /s/12 让判断恒为真。代理式内嵌 URL 的 path、 + // 无扩展名序号切片、同一 URI 在 playlist 中复用都会触发这种遮蔽。 + java.util.Map keptCounts = segmentUriCounts(result.manifest()); + java.util.Map expected = new java.util.HashMap<>(); + for (SegmentFact fact : evidence.outside()) { + expected.merge(uriOf(fact, evidence.playlistHost()), 1, Integer::sum); + } + // 区间外每片都必须保留,且次数完全一致:少一次就是误删。 + if (!expected.equals(keptCounts)) return false; + // 区间内每片都必须被删:与区间外同 URI 的切片会被一并删除, + // 此时上面的次数比对已经失败,这里再兜一次总数。 + return result.removedSegments() == evidence.inside().size(); + } + + /** 区间内外是否有切片在合成 manifest 里塌成同一个 URI。 */ + private static boolean hasUriOverlap(AdIntervalEvidence evidence) { + java.util.Set inside = new java.util.HashSet<>(); + for (SegmentFact fact : evidence.inside()) { + inside.add(uriOf(fact, evidence.playlistHost())); + } + for (SegmentFact fact : evidence.outside()) { + if (inside.contains(uriOf(fact, evidence.playlistHost()))) return true; + } + return false; + } + + /** 统计净化后 manifest 里每个切片 URI 的出现次数。 */ + private static java.util.Map segmentUriCounts(String cleaned) { + java.util.Map counts = new java.util.HashMap<>(); + if (cleaned == null) return counts; + for (String line : cleaned.split("\n", -1)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) continue; + counts.merge(trimmed, 1, Integer::sum); + } + return counts; + } + + /** 切片按原下标排序,作为合成与回读的共同顺序基准。 */ + private static List ordered(AdIntervalEvidence evidence) { + List all = new java.util.ArrayList<>(evidence.inside()); + all.addAll(evidence.outside()); + all.sort(java.util.Comparator.comparingInt(SegmentFact::index)); + return all; + } + + /** + * 用证据合成一份与真实 playlist 等价的 media playlist。 + * + *

切片按原下标排序,保留 host、path、时长与断点标记 —— 这些正是规则可能 + * 用到的全部条件维度。 + * + *

刻意不注入自定义标签做标记:{@code HlsManifestCleaner} 对未知 tag + * 会整份回退(实测 {@code fallback=true}),注入哨兵反而让自检永远拒绝。 + */ + private static String synthesize(AdIntervalEvidence evidence, List ordered) { + StringBuilder text = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + for (SegmentFact fact : ordered) { + if (fact.discontinuityBefore()) text.append("#EXT-X-DISCONTINUITY\n"); + text.append(String.format(Locale.US, "#EXTINF:%.3f,%n", fact.durationSec())); + text.append(uriOf(fact, evidence.playlistHost())).append('\n'); + } + return text.append("#EXT-X-ENDLIST\n").toString(); + } + + /** 还原切片的绝对地址。host 为空时按同域相对路径处理。 */ + private static String uriOf(SegmentFact fact, String playlistHost) { + String host = fact.host().isEmpty() ? playlistHost : fact.host(); + String path = fact.path().startsWith("/") ? fact.path() : "/" + fact.path(); + return SYNTHETIC_BASE_SCHEME + host + path; + } + + /** 载荷编译失败说明条件本身非法,直接判为不安全。 */ + private static HlsManifestCleaner.Rule compile(RulePayload payload) { + try { + return HlsAdRule.createUserRule( + "self-check", "self-check", + payload.playlistHostSuffixes(), payload.hosts(), payload.regex(), + payload.hasDurationRange() ? payload.durationMin() : null, + payload.hasDurationRange() ? payload.durationMax() : null, + payload.requireDiscontinuity(), payload.requireCrossDomain(), + payload.minimumSignals()).compile(); + } catch (RuntimeException e) { + return null; + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentContrast.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentContrast.java new file mode 100644 index 0000000000..3a8d9c3e2c --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentContrast.java @@ -0,0 +1,67 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; +import java.util.Locale; + +/** + * 生成 HLS 规则前必须成立的对照判据,两个分类器共用的单一真源。 + * + *

核心不变量:任何写进规则的条件,必须对广告块内每片成立、且对块外每片不成立。 + * 违反它的规则会命中全站切片 → {@code HlsManifestCleaner} 因 + * {@code removedCount == segmentCount} 或超过删除比例阈值而回退原文 → + * {@code HlsAdblockPipeline} 在回退时连带跳过 legacy {@code HlsAdsParser}, + * 结果该站去广能力整体倒退,比不加规则更差。 + * + *

此前这条判据被拆在两个分类器里各写一份,四轮评审里换了四个入口反复出问题 + * (同域 hosts、域名通道缺对照、HLS 通道缺 crossDomain 门槛、路径正则跨 query), + * 因此收敛到本类统一表达。 + */ +final class SegmentContrast { + + /** + * 区间外同域切片至少要占的比例。单一同域切片不足以证明「区间外整体不是同一 CDN」: + * playlist 开头一片同域、其余三百片都在独立 CDN 是真实存在的架构。 + */ + private static final double MIN_SAME_DOMAIN_RATIO = 0.5d; + + private SegmentContrast() { + } + + /** + * 区间外是否存在足以作为对照的同域切片。 + * + *

要求同域切片占区间外多数,而不只是「存在一个」。 + */ + static boolean hasSameDomainOutside(AdIntervalEvidence evidence) { + String playlistHost = evidence.playlistHost(); + if (playlistHost.isEmpty()) return false; + List outside = evidence.outside(); + if (outside.isEmpty()) return false; + long sameDomain = outside.stream() + .filter(fact -> fact.hostEndsWith(playlistHost)) + .count(); + return sameDomain >= Math.max(1L, (long) Math.ceil(outside.size() * MIN_SAME_DOMAIN_RATIO)); + } + + /** + * 区间外是否**完全没有**切片路径含该目录段。 + * + *

用 {@code path} 而非完整 URL 比较,与 {@code HlsSegmentClassifier.pathOnlyPattern} + * 生成的锚定正则语义一致 —— 那条正则同样只匹配 path。 + */ + static boolean pathAbsentOutside(AdIntervalEvidence evidence, String hint) { + List outside = evidence.outside(); + // 没有对照数据时无法证明「区间外不含」,一律视为不成立 + if (outside.isEmpty()) return false; + return outside.stream() + .noneMatch(fact -> fact.path().toLowerCase(Locale.US).contains(hint)); + } + + /** 区间内每片路径都含该目录段。 */ + static boolean pathPresentInside(AdIntervalEvidence evidence, String hint) { + List inside = evidence.inside(); + if (inside.isEmpty()) return false; + return inside.stream() + .allMatch(fact -> fact.path().toLowerCase(Locale.US).contains(hint)); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentFact.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentFact.java new file mode 100644 index 0000000000..5f01d5a813 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentFact.java @@ -0,0 +1,37 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.Locale; +import java.util.Objects; + +/** + * 单个 HLS 切片的去敏事实。只保留 host 与 path,不含 query 参数与 token。 + * + * @param index 在 media playlist 中的下标,从 0 开始 + * @param host 切片主机名,解析失败时为空串 + * @param path 切片路径,已去掉 query + * @param durationSec #EXTINF 时长 + * @param discontinuityBefore 该切片之前是否有 #EXT-X-DISCONTINUITY + */ +public record SegmentFact(int index, String host, String path, + double durationSec, boolean discontinuityBefore) { + + public SegmentFact { + if (index < 0) throw new IllegalArgumentException("index must not be negative"); + host = host == null ? "" : host; + path = path == null ? "" : path; + if (durationSec < 0) durationSec = 0; + } + + /** host 是否以给定后缀结尾,用于与 playlist 域名和黑名单比对。 */ + public boolean hostEndsWith(String suffix) { + if (host.isEmpty() || suffix == null || suffix.isEmpty()) return false; + String lower = host.toLowerCase(Locale.US); + String target = suffix.toLowerCase(Locale.US); + return lower.equals(target) || lower.endsWith("." + target); + } + + /** host 是否与另一切片同域。 */ + public boolean sameHost(SegmentFact other) { + return other != null && !host.isEmpty() && Objects.equals(host, other.host); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SiteHostBaselineStore.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SiteHostBaselineStore.java new file mode 100644 index 0000000000..b3470cdac8 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SiteHostBaselineStore.java @@ -0,0 +1,45 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.github.catvod.utils.Prefers; + +/** + * {@link SitePlaylistHostBaseline} 的应用级单例,持久化到 SharedPreferences。 + */ +public final class SiteHostBaselineStore { + + private static final String PREF_KEY = "ad_feedback_site_hosts"; + + private static volatile SitePlaylistHostBaseline instance; + + private SiteHostBaselineStore() { + } + + public static SitePlaylistHostBaseline get() { + if (instance == null) { + synchronized (SiteHostBaselineStore.class) { + if (instance == null) instance = new SitePlaylistHostBaseline(new PrefersStorage()); + } + } + return instance; + } + + private static final class PrefersStorage implements SitePlaylistHostBaseline.Storage { + @Override + public String read() { + try { + return Prefers.getString(PREF_KEY, ""); + } catch (RuntimeException e) { + return ""; + } + } + + @Override + public void write(String value) { + try { + Prefers.put(PREF_KEY, value == null ? "" : value); + } catch (RuntimeException ignored) { + // 基线只是归因的辅助输入,写失败不应影响播放 + } + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaseline.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaseline.java new file mode 100644 index 0000000000..1a22772306 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaseline.java @@ -0,0 +1,169 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 每站点最近成功播放的域名基线,供 {@link DomainReputationClassifier} 判断 + * 「非当前适配的域名」。见设计文档第 8.3 节。 + * + *

只存 host 字符串,不存 URL 全文、query 或 token。单站点上限 8 个域名, + * 全局上限 200 个站点,均按最久未用淘汰。 + * + *

本类只负责内存中的 LRU 结构与序列化,持久化由调用方注入的 + * {@link Storage} 完成,因此可在纯 JVM 下单测。 + */ +public final class SitePlaylistHostBaseline { + + /** 单站点保留的域名数上限。 */ + public static final int MAX_HOSTS_PER_SITE = 8; + /** 全局保留的站点数上限。 */ + public static final int MAX_SITES = 200; + + /** 持久化后端,实现通常包装 SharedPreferences。 */ + public interface Storage { + String read(); + + void write(String value); + } + + /** 内存实现,测试与无持久化场景使用。 */ + public static final class MemoryStorage implements Storage { + private String value = ""; + + @Override + public String read() { + return value; + } + + @Override + public void write(String value) { + this.value = value == null ? "" : value; + } + } + + private final Storage storage; + /** 上一次 record 命中的站点,用于判断访问顺序是否真的发生变化。 */ + private String lastAccessedSite; + // accessOrder=true:get 也算一次使用,实现真正的 LRU + private final LinkedHashMap> sites = + new LinkedHashMap<>(16, 0.75f, true); + + public SitePlaylistHostBaseline(Storage storage) { + this.storage = storage == null ? new MemoryStorage() : storage; + load(); + } + + /** 记录一次成功播放涉及的域名。 */ + public synchronized void record(String siteKey, String... hosts) { + if (siteKey == null || siteKey.isBlank() || hosts == null) return; + // accessOrder=true 的 LinkedHashMap 在读写时都会把该站点移到尾部。 + // 若本次 host 没变就直接返回,这个「站点最近被使用」的顺序只存在内存里, + // 重启后恢复的淘汰顺序会与运行期不一致,因此需要单独跟踪。 + boolean siteOrderChanged = sites.containsKey(siteKey) + && !siteKey.equals(lastAccessedSite); + lastAccessedSite = siteKey; + List known = sites.computeIfAbsent(siteKey, key -> new ArrayList<>()); + boolean seenAny = false; + boolean changed = false; + for (String host : hosts) { + if (host == null || host.isBlank()) continue; + String normalized = host.toLowerCase(Locale.US).trim(); + seenAny = true; + // 已在末尾说明状态没变,跳过写盘 —— STATE_READY 会因 seek、 + // 卡顿恢复反复触发,每次都 persist 是不必要的 I/O + int last = known.size() - 1; + if (last >= 0 && known.get(last).equals(normalized)) continue; + // 已存在则移到末尾,表示最近使用 + known.remove(normalized); + known.add(normalized); + changed = true; + } + if (!seenAny) { + // 全部输入为空白:不能留下空条目占用站点配额 + if (known.isEmpty()) sites.remove(siteKey); + return; + } + if (!changed) { + // host 没变但站点访问顺序变了,仍要落盘,否则重启后 LRU 顺序失真 + if (siteOrderChanged) persist(); + return; + } + while (known.size() > MAX_HOSTS_PER_SITE) known.remove(0); + evictSites(); + persist(); + } + + /** 某站点已知的域名,最近使用的在后。 */ + public synchronized List hosts(String siteKey) { + if (siteKey == null) return List.of(); + List known = sites.get(siteKey); + return known == null ? List.of() : List.copyOf(known); + } + + public synchronized int siteCount() { + return sites.size(); + } + + public synchronized void clear() { + sites.clear(); + persist(); + } + + private void evictSites() { + // LinkedHashMap 的迭代顺序即访问顺序,最久未用在前 + while (sites.size() > MAX_SITES) { + String oldest = sites.keySet().iterator().next(); + sites.remove(oldest); + } + } + + /** + * 序列化为紧凑文本:{@code siteKey|host1,host2;siteKey2|host3}。 + * 不用 JSON 是为了避免依赖 Gson,本类需在纯 JVM 下可测。 + */ + private void persist() { + StringBuilder builder = new StringBuilder(); + for (Map.Entry> entry : sites.entrySet()) { + if (entry.getValue().isEmpty()) continue; + if (builder.length() > 0) builder.append(';'); + builder.append(escape(entry.getKey())).append('|'); + builder.append(String.join(",", entry.getValue())); + } + storage.write(builder.toString()); + } + + private void load() { + String raw = storage.read(); + if (raw == null || raw.isBlank()) return; + for (String chunk : raw.split(";")) { + int separator = chunk.indexOf('|'); + if (separator <= 0) continue; + String siteKey = unescape(chunk.substring(0, separator)); + String hostList = chunk.substring(separator + 1); + if (siteKey.isBlank() || hostList.isBlank()) continue; + List hosts = new ArrayList<>(); + for (String host : hostList.split(",")) { + if (!host.isBlank()) hosts.add(host.trim()); + } + if (hosts.isEmpty()) continue; + while (hosts.size() > MAX_HOSTS_PER_SITE) hosts.remove(0); + sites.put(siteKey, hosts); + } + evictSites(); + } + + /** siteKey 可能含分隔符,转义避免破坏格式。 */ + private static String escape(String value) { + return value.replace("%", "%25").replace(";", "%3B") + .replace("|", "%7C").replace(",", "%2C"); + } + + private static String unescape(String value) { + return value.replace("%2C", ",").replace("%7C", "|") + .replace("%3B", ";").replace("%25", "%"); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SpeechIntervalFact.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SpeechIntervalFact.java new file mode 100644 index 0000000000..ef7b1aba86 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SpeechIntervalFact.java @@ -0,0 +1,25 @@ +package com.fongmi.android.tv.ad.feedback; + +/** + * 语音关键词通道在区间内的既有命中情况。 + * + *

刻意不记录命中的具体关键词:{@code SpeechAdSignalProvider} 的日志也遵守同一约定, + * 关键词可能包含用户自定义的敏感内容。 + * + * @param enabled 语音去广是否已开启 + * @param hitCount 区间内命中次数 + */ +public record SpeechIntervalFact(boolean enabled, int hitCount) { + + public SpeechIntervalFact { + if (hitCount < 0) hitCount = 0; + } + + public static SpeechIntervalFact unavailable() { + return new SpeechIntervalFact(false, 0); + } + + public boolean hasMatch() { + return hitCount > 0; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/StartOrigin.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/StartOrigin.java new file mode 100644 index 0000000000..50a744b6ec --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/StartOrigin.java @@ -0,0 +1,31 @@ +package com.fongmi.android.tv.ad.feedback; + +/** + * 区间起点的来源。短按只提供一个时间点,起点按可靠性逐级推断。 + * + *

顺序即可靠性:{@link #USER_MARKED} 最强,{@link #FALLBACK_WINDOW} 最弱。 + * 仲裁时用 {@link #weight()} 折算证据权重。 + */ +public enum StartOrigin { + /** 用户在标记模式下显式框选。 */ + USER_MARKED(1.0f), + /** 落在最近一个 #EXT-X-DISCONTINUITY 边界上。 */ + DISCONTINUITY(0.95f), + /** 落在最近一次切片跨域切换点上。 */ + CROSS_DOMAIN(0.85f), + /** 复用音频/语音通道已有候选的起点。 */ + AUDIO_CANDIDATE(0.80f), + /** 无任何信号,按固定窗口回溯。 */ + FALLBACK_WINDOW(0.55f); + + private final float weight; + + StartOrigin(float weight) { + this.weight = weight; + } + + /** 证据权重,0..1。 */ + public float weight() { + return weight; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/api/DanmakuApi.java b/app/src/main/java/com/fongmi/android/tv/api/DanmakuApi.java index 0b5e13b0bd..f3c3410daf 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/DanmakuApi.java +++ b/app/src/main/java/com/fongmi/android/tv/api/DanmakuApi.java @@ -8,7 +8,6 @@ import androidx.collection.ArrayMap; import com.fongmi.android.tv.App; import com.fongmi.android.tv.bean.Danmaku; -import com.fongmi.android.tv.bean.DanmakuMatchCache; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.player.danmaku.DanmakuUrlPolicy; import com.fongmi.android.tv.setting.DanmakuSetting; @@ -20,7 +19,6 @@ import com.github.catvod.net.OkHttp; import com.github.catvod.utils.Trans; import java.io.IOException; -import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; @@ -108,10 +106,7 @@ public class DanmakuApi { return; } MediaTitleResolver resolver = new MediaTitleResolver(); - DanmakuMatchCache cache = Setting.getDanmakuMatchCache(); - List manualTitles = manualSearchTitles(request, cache); List titles = resolver.queryTitles(request, 3); - titles.addAll(0, manualTitles); if (SpiderDebug.isEnabled()) SpiderDebug.log("danmaku", "resolved titles raw=%s episode=%s titles=%s", request.getRawTitle(), request.getEpisodeName(), titles); searchFirst(titles, request.getEpisodeName(), 0, found, () -> { List cleanedTitles = resolver.queryCleanedTitles(request, 3); @@ -132,15 +127,6 @@ public class DanmakuApi { return false; } - private static List manualSearchTitles(MediaTitleRequest request, DanmakuMatchCache cache) { - List titles = new ArrayList<>(); - String siteTitle = cache.findSeriesSearchTitle(request.getSiteKey(), request.getVodId()); - if (!siteTitle.isEmpty()) titles.add(siteTitle); - String tmdbTitle = cache.findTmdbSeasonSearchTitle(request.getTmdbId(), request.getTmdbSeasonNumber()); - if (!tmdbTitle.isEmpty() && !containsTitle(titles, tmdbTitle)) titles.add(tmdbTitle); - return titles; - } - private static void searchFirst(List titles, String episode, int index, Consumer found, Runnable exhausted) { if (titles == null || index >= titles.size()) { if (exhausted != null) exhausted.run(); diff --git a/app/src/main/java/com/fongmi/android/tv/api/SiteApi.java b/app/src/main/java/com/fongmi/android/tv/api/SiteApi.java index 7ef3408145..ffbffe385f 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/SiteApi.java +++ b/app/src/main/java/com/fongmi/android/tv/api/SiteApi.java @@ -12,10 +12,8 @@ import com.fongmi.android.tv.bean.Class; import com.fongmi.android.tv.bean.Result; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.bean.Vod; -import com.fongmi.android.tv.event.CatWebEvent; import com.fongmi.android.tv.player.Source; import com.fongmi.android.tv.setting.PlayerSetting; -import com.fongmi.android.tv.setting.SiteHealthStore; import com.fongmi.android.tv.utils.PushParser; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Sniffer; @@ -60,19 +58,6 @@ public class SiteApi { @NonNull public static Result homeContent(@NonNull Site site) throws Exception { - long start = System.currentTimeMillis(); - try { - Result result = homeContentRaw(site); - SiteHealthStore.recordHome(site, true, result.getList().size(), System.currentTimeMillis() - start, ""); - return result; - } catch (Exception e) { - SiteHealthStore.recordHome(site, false, 0, System.currentTimeMillis() - start, e.getMessage()); - throw e; - } - } - - @NonNull - private static Result homeContentRaw(@NonNull Site site) throws Exception { if (isSpider(site)) { Spider spider = site.recent().spider(); boolean crash = Prefers.getBoolean("crash"); @@ -108,19 +93,6 @@ public class SiteApi { @NonNull public static Result categoryContent(@NonNull String key, @NonNull String tid, @NonNull String page, boolean filter, @NonNull HashMap extend) throws Exception { - long start = System.currentTimeMillis(); - try { - Result result = categoryContentRaw(key, tid, page, filter, extend); - SiteHealthStore.recordCategory(key, true, result.getList().size(), System.currentTimeMillis() - start, ""); - return result; - } catch (Exception e) { - SiteHealthStore.recordCategory(key, false, 0, System.currentTimeMillis() - start, e.getMessage()); - throw e; - } - } - - @NonNull - private static Result categoryContentRaw(@NonNull String key, @NonNull String tid, @NonNull String page, boolean filter, @NonNull HashMap extend) throws Exception { SpiderDebug.log("category", "key=%s,tid=%s,page=%s,filter=%s,extend=%s", key, tid, page, filter, extend); Site site = VodConfig.get().getSite(key); if (isSpider(site)) { @@ -167,95 +139,31 @@ public class SiteApi { } Result result; - // 取一次时刻,隔着 spider 调用;调用后若最近一次开页请求落在这之后,就是这次调用开的页。 - // 那是个副作用,缓存会吞掉它,见 cacheDetail 的说明。 - long beforeSpider = System.currentTimeMillis(); if (isSpider(site)) { String detailContent = site.recent().spider().detailContent(Arrays.asList(requestId)); - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(detailContent); - SpiderDebug.log("detail", ingress.getSanitizedJson()); - result = Result.fromJson(ingress.getSanitizedJson()); + SpiderDebug.log("detail", detailContent); + result = Result.fromJson(detailContent); } else { ArrayMap params = new ArrayMap<>(); params.put("ac", ac(site.getType())); params.put("ids", requestId); String detailContent = call(site, params); - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(detailContent); - SpiderDebug.log("detail", ingress.getSanitizedJson()); - result = Result.fromType(site.getType(), ingress.getSanitizedJson()); + SpiderDebug.log("detail", detailContent); + result = Result.fromType(site.getType(), detailContent); } Source.get().parse(result.getVod().setFlags()); result = applyPushTitle(push, result); - cacheDetail(key, sourceKey, id, result, beforeSpider); + // 「什么都没有」的条目不值得缓存,缓存了还有害:猫源的设置项正是这种形态, + // 它的副作用(请求宿主开网页)发生在 spider 调用里,命中缓存就跳过了 spider, + // 于是网页再也不开,只剩一个空详情页。 + if (!result.getList().isEmpty() && !CatAction.blank(result.getVod())) { + String content = result.toString(); + VodDetailCache.putContent(sourceKey, id, content); + SpiderDebug.log("detail-cache", "store key=%s,id=%s,size=%d", key, id, content.length()); + } return result; } - - /** - * 把详情结果写进缓存,除非这条不该缓存。 - * - *

设置类入口的实际工作发生在 {@code detailContent} 里面——弹输入框、写配置、扫码、 - * 请求宿主开网页。缓存命中时压根不调 spider,那些副作用就再也不发生:第二次点击弹窗不出、 - * 网页不开,宿主还会拿着占位结果直奔播放页。这正是「设置类入口只有第一次点击生效, - * 第二次进播放页;过 5 分钟又能点一次」的由来。 - * - *

三道挡板,各挡一种形态: - *

    - *
  • {@link CatAction#blank}:什么都没有的条目(猫源老式动作项),缓存它也没意义 - *
  • {@link #hasMetadata}:只有占位线路、没有元数据的条目(设置类站点的签名) - *
  • {@link CatWebEvent#requestedAfter}:这次调用顺带开过网页,那是副作用 - *
- * 前两道看结果形状,第三道看副作用——形状千变万化,副作用是确凿的,两类都要。 - * - * @param beforeSpider 调 spider 之前的时刻。判定用 {@code >=},所以同一毫秒内的陈旧开页 - * 记录会让这条详情也躲过缓存——那个方向是安全的,代价只是下次点击 - * 多走一次 spider。并发的另一次开页同理。 - */ - private static void cacheDetail(String key, String sourceKey, String id, Result result, long beforeSpider) { - if (result.getList().isEmpty() || CatAction.blank(result.getVod())) return; - if (!hasMetadata(result.getVod())) { - SpiderDebug.log("detail-cache", "skip key=%s,id=%s reason=noMetadata", key, id); - return; - } - if (result.getVod().getFlags().isEmpty()) { - SpiderDebug.log("detail-cache", "skip key=%s,id=%s reason=noPlayableContent", key, id); - return; - } - if (CatWebEvent.requestedAfter(beforeSpider)) { - SpiderDebug.log("detail-cache", "skip key=%s,id=%s reason=webOpened", key, id); - return; - } - String content = result.toString(); - VodDetailCache.putContent(sourceKey, id, content); - SpiderDebug.log("detail-cache", "store key=%s,id=%s,size=%d", key, id, content.length()); - } - - /** - * 这份详情带没带元数据——名字、封面、简介,有一项就算。 - * - *

「只有线路、没有元数据」是设置类站点的签名。那类站点把每个设置项做成一个条目, - * 真正的工作发生在 {@code detailContent} 里——弹输入框、写配置、扫码;返回值只是个占位的 - * 假线路,好让宿主别报错。实测某网盘配置源返回的就是: - *

{"list":[{"vod_play_from":"Config","vod_play_url":"Config$Config"}],"parse":0,"jx":0}
- * - *

缓存住这种条目就等于把那个动作阉掉:命中缓存时 {@code detailContent} 压根不被调用, - * 弹窗再也不出现,宿主还会拿着那个叫 {@code Config} 的假地址直奔播放页——这正是 - * 「设置类入口只有第一次点击生效,第二次进播放页」的由来。 - * - *

{@link CatAction#blank} 挡不住它:{@code vod_play_from}/{@code vod_play_url} 会生成一条 - * Flag,{@code getFlags()} 非空,{@code blank} 就不成立。所以这里刻意不看线路——线路是 - * 占位结果也能轻易凑出来的东西,而元数据不是。 - * - *

代价:真把元数据全放在列表项、detail 只回线路的源,每次进详情都要重新调一次 spider。 - * 那是缓存引入之前的原有行为,不是退步;而反过来把动作缓存掉是功能损坏。 - */ - private static boolean hasMetadata(Vod vod) { - if (vod == null) return false; - return !vod.getName().isEmpty() - || !TextUtils.isEmpty(vod.getPic()) - || !TextUtils.isEmpty(vod.getContent()); - } - private static String detailCacheSourceKey(String key, Site site) { if (site == null) return key; int signature = Objects.hash(site.getType(), site.getApi(), site.getExt(), site.getHeader()); diff --git a/app/src/main/java/com/fongmi/android/tv/api/TmdbSourceCredentialIngress.java b/app/src/main/java/com/fongmi/android/tv/api/TmdbSourceCredentialIngress.java deleted file mode 100644 index 0996879ead..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/api/TmdbSourceCredentialIngress.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.fongmi.android.tv.api; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.stream.JsonReader; - -import java.io.StringReader; - -/** Removes the optional subscription-level TMDB key before config/detail logging or parsing. */ -public final class TmdbSourceCredentialIngress { - - public static final String ROOT_FIELD = "tmdb_api_key"; - private static final int MIN_KEY_LENGTH = 16; - private static final int MAX_KEY_LENGTH = 256; - private static final Gson GSON = new Gson(); - - private TmdbSourceCredentialIngress() { - } - - public static Ingress extractRootAndStrip(String rawJson) { - if (rawJson == null || rawJson.isEmpty()) return new Ingress(rawJson, ""); - try { - JsonElement parsed = JsonParser.parseString(rawJson); - if (parsed == null || !parsed.isJsonObject()) return new Ingress(rawJson, ""); - JsonObject root = parsed.getAsJsonObject(); - if (!root.has(ROOT_FIELD)) return new Ingress(rawJson, ""); - - JsonElement value = root.get(ROOT_FIELD); - root.remove(ROOT_FIELD); - String candidate = value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() - ? normalizeApiKey(value.getAsString()) - : ""; - if (countRootFieldOccurrences(rawJson) != 1) candidate = ""; - return new Ingress(GSON.toJson(root), candidate); - } catch (Exception ignored) { - return new Ingress(sanitizeMalformedJson(rawJson), ""); - } - } - - public static String normalizeApiKey(String value) { - if (value == null) return ""; - String normalized = value.trim(); - if (normalized.length() < MIN_KEY_LENGTH || normalized.length() > MAX_KEY_LENGTH) return ""; - for (int i = 0; i < normalized.length(); i++) { - char c = normalized.charAt(i); - if (c < 0x21 || c > 0x7e) return ""; - } - return normalized; - } - - private static int countRootFieldOccurrences(String rawJson) { - try (JsonReader reader = new JsonReader(new StringReader(rawJson))) { - int count = 0; - reader.beginObject(); - while (reader.hasNext()) { - if (ROOT_FIELD.equals(reader.nextName())) count++; - reader.skipValue(); - } - reader.endObject(); - return count; - } catch (Exception ignored) { - return -1; - } - } - - private static String sanitizeMalformedJson(String rawJson) { - if (rawJson == null || !rawJson.contains("\"" + ROOT_FIELD + "\"")) return rawJson; - // The malformed document is already unusable. Returning an empty object prevents callers from - // logging, caching, or parsing the raw credential-bearing text when structural parsing failed. - return "{}"; - } - - public static final class Ingress { - - private final String sanitizedJson; - private final String candidateKey; - - private Ingress(String sanitizedJson, String candidateKey) { - this.sanitizedJson = sanitizedJson == null ? "" : sanitizedJson; - this.candidateKey = candidateKey == null ? "" : candidateKey; - } - - public String getSanitizedJson() { - return sanitizedJson; - } - - public String getCandidateKey() { - return candidateKey; - } - - @Override - public String toString() { - return "Ingress{key=" + !candidateKey.isEmpty() + ",jsonLength=" + sanitizedJson.length() + "}"; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/AdBlockStatsStore.java b/app/src/main/java/com/fongmi/android/tv/api/config/AdBlockStatsStore.java index 051408a3ba..8af98bb3ca 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/config/AdBlockStatsStore.java +++ b/app/src/main/java/com/fongmi/android/tv/api/config/AdBlockStatsStore.java @@ -7,9 +7,6 @@ import com.fongmi.android.tv.bean.AdBlockStats; import com.fongmi.android.tv.bean.Rule; import com.fongmi.android.tv.bean.RuleHitRecord; import com.fongmi.android.tv.bean.UserAdRule; -import com.fongmi.android.tv.bean.Site; -import com.fongmi.android.tv.playback.PlaybackRuntime; -import com.fongmi.android.tv.utils.HlsManifestCleaner; import com.fongmi.android.tv.utils.RuleIdUtil; import com.github.catvod.crawler.SpiderDebug; import com.github.catvod.utils.Prefers; @@ -71,99 +68,19 @@ public class AdBlockStatsStore { * 记录一次拦截 */ public static void recordBlock(String siteKey, String ruleId) { - recordBlock(siteKey, "OTHER", ruleId); - } - - public static void recordBlock(String siteKey, String pipeline, String ruleId) { - recordBlock(siteKey, pipeline, ruleId, "", 0); - } - - public static void recordBlock(String siteKeyOrDomain, String pipeline, String ruleId, - String adDomain, double segmentDurationSeconds) { - SiteIdentity identity = resolveSiteIdentity(siteKeyOrDomain, adDomain); executor.execute(() -> { AdBlockStats stats = load(); - stats.incrementBlocks(identity.siteKey(), pipeline, ruleId, 1); - stats.recordBlockLog(System.currentTimeMillis(), identity.siteKey(), identity.siteName(), - identity.siteDomain(), pipeline, adDomain, ruleId, 0, segmentDurationSeconds); - save(stats); - }); - } - - /** Records a complete HLS-cleaning result atomically on the existing stats executor. */ - public static void recordBlocks(String siteKey, Map ruleCounts, long fallbackCount) { - recordBlocks(siteKey, "HLS", ruleCounts, fallbackCount); - } - - public static void recordBlocks(String siteKey, String pipeline, Map ruleCounts, long fallbackCount) { - recordBlocks(siteKey, pipeline, ruleCounts, fallbackCount, siteKey, 0); - } - - public static void recordBlocks(String sourceName, String pipeline, Map ruleCounts, - long fallbackCount, String adDomain, double totalDurationSeconds) { - recordBlocks(sourceName, pipeline, ruleCounts, fallbackCount, adDomain, - totalDurationSeconds, List.of()); - } - - public static void recordBlocks(String siteKeyOrDomain, String pipeline, Map ruleCounts, - long fallbackCount, String siteDomain, double totalDurationSeconds, - List removedSegments) { - SiteIdentity identity = resolveSiteIdentity(siteKeyOrDomain, siteDomain); - executor.execute(() -> { - AdBlockStats stats = load(); - Map detailedCounts = new HashMap<>(); - if (ruleCounts != null) { - for (Map.Entry entry : ruleCounts.entrySet()) { - long count = entry.getValue() == null ? 0 : Math.max(0, entry.getValue()); - stats.incrementBlocks(identity.siteKey(), pipeline, entry.getKey(), count); - if (count > 0) detailedCounts.put(entry.getKey(), count); - } + stats.incrementTotalBlocked(); + if (!TextUtils.isEmpty(siteKey)) { + stats.incrementSiteBlocked(siteKey); } - long safeFallbackCount = Math.max(0, fallbackCount); - stats.incrementBlocks(identity.siteKey(), pipeline, "hls.legacy-fallback", safeFallbackCount); - if (safeFallbackCount > 0) detailedCounts.put("hls.legacy-fallback", safeFallbackCount); - - long blockedAt = System.currentTimeMillis(); - if (removedSegments != null && !removedSegments.isEmpty()) { - for (HlsManifestCleaner.RemovedSegment segment : removedSegments) { - stats.recordBlockLog(blockedAt, identity.siteKey(), identity.siteName(), identity.siteDomain(), - pipeline, segment.adDomain(), segment.ruleId(), segment.startSeconds(), segment.durationSec()); - } - } else { - stats.recordBlockLogs(blockedAt, identity.siteKey(), identity.siteName(), identity.siteDomain(), - pipeline, siteDomain, detailedCounts, totalDurationSeconds); + if (!TextUtils.isEmpty(ruleId)) { + stats.incrementRuleCount(ruleId); } save(stats); }); } - private static SiteIdentity resolveSiteIdentity(String siteKeyOrDomain, String siteDomain) { - if (!TextUtils.isEmpty(siteKeyOrDomain)) { - try { - Site site = VodConfig.get().getSite(siteKeyOrDomain); - if (site != null && !site.isEmpty()) { - return new SiteIdentity(site.getKey(), site.getDisplayName(), siteDomain); - } - } catch (Throwable ignored) { - } - } - PlaybackRuntime.SiteIdentity current = PlaybackRuntime.currentSiteIdentity(siteDomain); - if (!TextUtils.isEmpty(current.siteKey())) { - return new SiteIdentity(current.siteKey(), current.siteName(), current.siteDomain()); - } - String fallbackKey = TextUtils.isEmpty(siteKeyOrDomain) ? siteDomain : siteKeyOrDomain; - String fallbackName = TextUtils.isEmpty(fallbackKey) ? current.siteName() : fallbackKey; - return new SiteIdentity(fallbackKey, fallbackName, current.siteDomain()); - } - - public static String getRuleDisplayName(String ruleId) { - RuleHitRecord record = new RuleHitRecord(); - fillRuleInfo(record, ruleId); - return TextUtils.isEmpty(record.getRuleName()) ? ruleId : record.getRuleName(); - } - - private record SiteIdentity(String siteKey, String siteName, String siteDomain) {} - /** * 记录一次 AI 反馈 */ @@ -239,50 +156,31 @@ public class AdBlockStatsStore { * 填充规则信息(名称、来源) */ private static void fillRuleInfo(RuleHitRecord record, String ruleId) { - if ("hls.legacy-fallback".equals(ruleId)) { - record.setRuleName("内置兜底规则(HLS 路径/文件名少数派、不连续短块)"); - record.setRuleSource("HLS"); - return; - } - - for (HlsRuleConfig.Entry entry : HlsRuleConfig.getEntries()) { - if (ruleId.equals(entry.id())) { - record.setRuleName(TextUtils.isEmpty(entry.name()) ? entry.id() : entry.name()); - record.setRuleSource("HLS"); + // 查找用户自定义规则(用户规则以 UUID 作为 id) + List userRules = UserAdRuleStore.load(); + for (UserAdRule rule : userRules) { + if (ruleId.equals(rule.getId())) { + record.setRuleName(rule.getName()); + record.setRuleSource("ai".equals(rule.getSource()) ? "AI" : "手动"); return; } } - if (fillConfiguredRuleInfo(record, ruleId, UserAdRuleStore.load(), RuleConfig.get().getDefaultRules())) return; + // 查找默认规则(默认规则以 computeRuleId 计算的 id 标识) + List defaultRules = RuleConfig.get().getDefaultRules(); + for (Rule rule : defaultRules) { + if (ruleId.equals(RuleIdUtil.computeRuleId(rule))) { + record.setRuleName(rule.getName()); + record.setRuleSource("默认"); + return; + } + } // 未找到规则 record.setRuleName("未知规则"); record.setRuleSource("未知"); } - static boolean fillConfiguredRuleInfo(RuleHitRecord record, String ruleId, - List userRules, List defaultRules) { - // WebView 日志使用 UUID;HLS 清理日志使用由同一自定义规则生成的 legacy 指纹。 - for (UserAdRule rule : userRules) { - if (ruleId.equals(rule.getId()) || ruleId.equals(HlsRuleConfig.legacyRuleId(rule.toRule()))) { - record.setRuleName(rule.getName()); - record.setRuleSource("ai".equals(rule.getSource()) ? "AI" : "手动"); - return true; - } - } - - // 默认规则兼容 WebView 的原始指纹与 HLS 的 legacy 指纹。 - for (Rule rule : defaultRules) { - String id = RuleIdUtil.computeRuleId(rule); - if (ruleId.equals(id) || ruleId.equals(HlsRuleConfig.legacyRuleId(rule))) { - record.setRuleName(rule.getName()); - record.setRuleSource("默认"); - return true; - } - } - return false; - } - /** * 重置统计 */ diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/BaseConfig.java b/app/src/main/java/com/fongmi/android/tv/api/config/BaseConfig.java index 77e3b94c7e..1528ece9b1 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/config/BaseConfig.java +++ b/app/src/main/java/com/fongmi/android/tv/api/config/BaseConfig.java @@ -94,21 +94,12 @@ abstract class BaseConfig { public void load(Callback callback) { beforeLoad(); - Config loadingConfig = getConfig(); int id = taskId.incrementAndGet(); if (future != null && !future.isDone()) future.cancel(true); - future = Task.submit(() -> loadConfig(id, loadingConfig, callback)); + future = Task.submit(() -> loadConfig(id, config, callback)); callback.start(); } - protected void cancelLoad(boolean interrupt) { - taskId.incrementAndGet(); - if (interrupt) { - if (future != null && !future.isDone()) future.cancel(true); - OkHttp.cancel(getTag()); - } - } - protected void loadConfig(int id, Config config, Callback callback) { try { BaseLoader.get().awaitClear(); @@ -125,7 +116,6 @@ abstract class BaseConfig { if (isCanceled(e)) return; if (taskId.get() != id) return; if (TextUtils.isEmpty(config.getUrl())) App.post(() -> callback.error("")); - else if (this instanceof VodConfig) ((VodConfig) this).onConfigFailure(config, callback, e); else App.post(() -> callback.error(Notify.getError(R.string.error_config_get, e))); } finally { if (taskId.get() == id) postEvent(); diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/DisabledDefaultRuleStore.java b/app/src/main/java/com/fongmi/android/tv/api/config/DisabledDefaultRuleStore.java index be658354df..a50a379d63 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/config/DisabledDefaultRuleStore.java +++ b/app/src/main/java/com/fongmi/android/tv/api/config/DisabledDefaultRuleStore.java @@ -33,7 +33,6 @@ public class DisabledDefaultRuleStore { public static void save(Set ids) { Prefers.put(PREF_KEY, App.gson().toJson(new ArrayList<>(ids == null ? new HashSet<>() : ids))); RuleConfig.get().invalidate(); - HlsRuleConfig.invalidate(); } public static boolean isDisabled(String ruleId) { diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java b/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java index 7aff5ab04e..767754cbf1 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java +++ b/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java @@ -3,8 +3,6 @@ package com.fongmi.android.tv.api.config; import com.fongmi.android.tv.bean.HlsAdRule; import com.fongmi.android.tv.bean.HlsRulePackage; import com.fongmi.android.tv.bean.HlsRuleState; -import com.fongmi.android.tv.bean.Rule; -import com.fongmi.android.tv.utils.RuleIdUtil; import com.fongmi.android.tv.utils.HlsManifestCleaner; import com.github.catvod.crawler.SpiderDebug; import com.google.gson.Gson; @@ -20,12 +18,6 @@ public final class HlsRuleConfig { private static final Gson DETAIL_GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); private static List rules = List.of(); private static List entries = List.of(); - public static final String LEGACY_FALLBACK_KEY = "hls.legacy-fallback"; - private static final String LEGACY_RULE_PREFIX = "legacy:"; - public static final String LEGACY_FALLBACK_SUMMARY = "仅完整 HLS 点播清单 · 路径/文件名前缀少数组 · 不连续标签明显短块"; - public static final String LEGACY_FALLBACK_DETAIL = "策略一:按分片路径或文件名前缀分组,仅移除明显少数组。\n" - + "策略二:按 #EXT-X-DISCONTINUITY 分块,仅移除相对主块明显偏短的少数块。\n" - + "适用范围:仅处理包含 #EXT-X-ENDLIST 的完整 HLS 点播清单。"; private static boolean dirty = true; private HlsRuleConfig() {} @@ -40,12 +32,6 @@ public final class HlsRuleConfig { return entries; } - public static synchronized boolean isLegacyFallbackEnabled() { - if (dirty) reload(); - return entries.stream().filter(item -> LEGACY_FALLBACK_KEY.equals(item.key())) - .findFirst().map(Entry::enabled).orElse(true); - } - public static synchronized void invalidate() { dirty = true; } @@ -59,37 +45,12 @@ public final class HlsRuleConfig { compileBuiltin(builtin, overrides, compiled, summaries); compileExternal("vod", VodConfig.get().getConfig().getUrl(), VodConfig.get().getHlsRules(), overrides, compiled, summaries); compileExternal("live", LiveConfig.get().getConfig().getUrl(), LiveConfig.get().getHlsRules(), overrides, compiled, summaries); - compileLegacyRules(RuleConfig.get().getRules(), compiled); - boolean fallbackEnabled = !Boolean.FALSE.equals(overrides.get(LEGACY_FALLBACK_KEY)); - summaries.add(new Entry(LEGACY_FALLBACK_KEY, LEGACY_FALLBACK_KEY, - "内置 HLS 兜底规则(路径/文件名前缀少数组、不连续标签明显短块)", 1, - "builtin", fallbackEnabled, true, "", LEGACY_FALLBACK_DETAIL)); + compileExternal("user", "local", UserHlsRuleStore.load(), overrides, compiled, summaries); rules = List.copyOf(compiled); entries = List.copyOf(summaries); dirty = false; } - static void compileLegacyRules(List legacyRules, List output) { - if (legacyRules == null) return; - for (Rule rule : legacyRules) { - if (rule == null || rule.getExclude().isEmpty()) continue; - try { - output.add(HlsManifestCleaner.Rule.builder() - .id(legacyRuleId(rule)) - .playlistHostPatterns(rule.getHosts()) - .segmentUrlPatterns(rule.getExclude()) - .minimumSignals(1) - .build()); - } catch (RuntimeException e) { - SpiderDebug.log(TAG, "Skip invalid legacy rule %s: %s", rule.getName(), e.getMessage()); - } - } - } - - static String legacyRuleId(Rule rule) { - return LEGACY_RULE_PREFIX + RuleIdUtil.computeRuleId(rule); - } - private static void compileBuiltin(HlsRulePackage rulePackage, Map overrides, List output, List summaries) { for (HlsAdRule rule : rulePackage.getRules()) { diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/ImportedAdRuleCandidateStore.java b/app/src/main/java/com/fongmi/android/tv/api/config/ImportedAdRuleCandidateStore.java index 9000e4bef8..30ecd66b33 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/config/ImportedAdRuleCandidateStore.java +++ b/app/src/main/java/com/fongmi/android/tv/api/config/ImportedAdRuleCandidateStore.java @@ -8,9 +8,7 @@ import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; import java.util.function.Predicate; public final class ImportedAdRuleCandidateStore { @@ -74,57 +72,26 @@ public final class ImportedAdRuleCandidateStore { } public static synchronized boolean importCandidate(String id) { - return id != null && importCandidates(List.of(id), false) > 0; - } - - public static synchronized int importCandidates(List ids, boolean enabled) { - if (ids == null || ids.isEmpty()) return 0; List stored = load(); - Set requested = new HashSet<>(ids); - Set importedFingerprints = new HashSet<>(); - for (UserAdRule rule : UserAdRuleStore.load()) { - if (rule == null) continue; - importedFingerprints.add(InterfaceAdRuleAnalyzer.fingerprint(rule.getHosts(), rule.getRegex(), rule.getExclude())); - } - List additions = new ArrayList<>(); - int imported = 0; - for (ImportedAdRuleCandidate candidate : stored) { - if (candidate.getId() == null || candidate.getFingerprint() == null || candidate.getFingerprint().isBlank() - || !requested.contains(candidate.getId()) - || !ImportedAdRuleCandidate.STATUS_PENDING.equals(candidate.getStatus())) continue; - if (!importedFingerprints.add(candidate.getFingerprint())) { - candidate.setStatus(ImportedAdRuleCandidate.STATUS_IMPORTED); - imported++; - continue; - } - additions.add(UserAdRule.fromImportedCandidate(candidate, enabled)); + ImportedAdRuleCandidate candidate = findById(stored, id); + if (candidate == null || !ImportedAdRuleCandidate.STATUS_PENDING.equals(candidate.getStatus())) return false; + if (alreadyImported(candidate.getFingerprint())) { candidate.setStatus(ImportedAdRuleCandidate.STATUS_IMPORTED); - imported++; + save(stored); + return true; } - if (imported == 0) return 0; - UserAdRuleStore.addAll(additions); + UserAdRuleStore.add(UserAdRule.fromImportedCandidate(candidate)); + candidate.setStatus(ImportedAdRuleCandidate.STATUS_IMPORTED); save(stored); - return imported; + return true; } public static synchronized void ignore(String id) { - if (id == null) return; - ignoreCandidates(List.of(id)); - } - - public static synchronized int ignoreCandidates(List ids) { - if (ids == null || ids.isEmpty()) return 0; - Set requested = new HashSet<>(ids); List stored = load(); - int ignored = 0; - for (ImportedAdRuleCandidate candidate : stored) { - if (candidate.getId() == null || !requested.contains(candidate.getId()) - || !ImportedAdRuleCandidate.STATUS_PENDING.equals(candidate.getStatus())) continue; - candidate.setStatus(ImportedAdRuleCandidate.STATUS_IGNORED); - ignored++; - } - if (ignored > 0) save(stored); - return ignored; + ImportedAdRuleCandidate candidate = findById(stored, id); + if (candidate == null) return; + candidate.setStatus(ImportedAdRuleCandidate.STATUS_IGNORED); + save(stored); } public static synchronized void reopen(String id) { @@ -141,9 +108,7 @@ public final class ImportedAdRuleCandidateStore { } private static boolean alreadyImported(String fingerprint) { - if (fingerprint == null || fingerprint.isBlank()) return false; for (UserAdRule rule : UserAdRuleStore.load()) { - if (rule == null) continue; String existing = InterfaceAdRuleAnalyzer.fingerprint(rule.getHosts(), rule.getRegex(), rule.getExclude()); if (fingerprint.equals(existing)) return true; } @@ -151,13 +116,11 @@ public final class ImportedAdRuleCandidateStore { } private static ImportedAdRuleCandidate find(List items, String fingerprint) { - if (fingerprint == null) return null; for (ImportedAdRuleCandidate item : items) if (fingerprint.equals(item.getFingerprint())) return item; return null; } private static ImportedAdRuleCandidate findById(List items, String id) { - if (id == null) return null; for (ImportedAdRuleCandidate item : items) if (id.equals(item.getId())) return item; return null; } diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/LiveConfig.java b/app/src/main/java/com/fongmi/android/tv/api/config/LiveConfig.java index 6a810f31f3..97cc12df3e 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/config/LiveConfig.java +++ b/app/src/main/java/com/fongmi/android/tv/api/config/LiveConfig.java @@ -63,11 +63,6 @@ public class LiveConfig extends BaseConfig { return get().getLives().indexOf(get().getHome()); } - public static Live getNextHome() { - int index = getHomeIndex() + 1; - return index > 0 && index < get().getLives().size() ? get().getLives().get(index) : null; - } - public static boolean isOnly() { return get().getLives().size() == 1; } diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/SubscriptionTmdbCredentialStore.java b/app/src/main/java/com/fongmi/android/tv/api/config/SubscriptionTmdbCredentialStore.java deleted file mode 100644 index bb940b0c60..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/api/config/SubscriptionTmdbCredentialStore.java +++ /dev/null @@ -1,260 +0,0 @@ -package com.fongmi.android.tv.api.config; - -import com.fongmi.android.tv.api.TmdbSourceCredentialIngress; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; - -/** Process-memory TMDB credential scope owned by the currently selected subscription. */ -public final class SubscriptionTmdbCredentialStore { - - private static final Object LOCK = new Object(); - - private static boolean hasScope; - private static int configId; - private static String configUrl = ""; - private static long scopeEpoch; - private static String apiKey = ""; - private static String fingerprint = ""; - private static long receivedAt; - private static String sourceKey = ""; - private static String sourceRevision = ""; - - private SubscriptionTmdbCredentialStore() { - } - - public static Scope beginSubscription(int nextConfigId, String nextConfigUrl, String reason) { - String normalizedUrl = normalizeUrl(nextConfigUrl); - synchronized (LOCK) { - if (!hasScope || configId != nextConfigId || !configUrl.equals(normalizedUrl)) { - scopeEpoch++; - clearCredential(); - configId = nextConfigId; - configUrl = normalizedUrl; - hasScope = true; - } - return scope(); - } - } - - public static Scope currentScope() { - synchronized (LOCK) { - return scope(); - } - } - - public static boolean accept(String candidateKey, int expectedConfigId, String expectedConfigUrl, long expectedEpoch, - String candidateSourceKey, String candidateSourceRevision) { - String normalizedKey = TmdbSourceCredentialIngress.normalizeApiKey(candidateKey); - String normalizedUrl = normalizeUrl(expectedConfigUrl); - if (normalizedKey.isEmpty()) return false; - synchronized (LOCK) { - if (!hasScope || configId != expectedConfigId || !configUrl.equals(normalizedUrl) || scopeEpoch != expectedEpoch) return false; - if (!apiKey.isEmpty() && !apiKey.equals(normalizedKey)) scopeEpoch++; - apiKey = normalizedKey; - fingerprint = fingerprint(normalizedKey); - receivedAt = System.currentTimeMillis(); - sourceKey = trim(candidateSourceKey); - sourceRevision = trim(candidateSourceRevision); - return true; - } - } - - public static Snapshot snapshot(int expectedConfigId, String expectedConfigUrl, long expectedEpoch) { - String normalizedUrl = normalizeUrl(expectedConfigUrl); - synchronized (LOCK) { - if (!hasScope || configId != expectedConfigId || !configUrl.equals(normalizedUrl) || scopeEpoch != expectedEpoch) { - return Snapshot.empty(expectedEpoch); - } - if (apiKey.isEmpty()) return Snapshot.empty(expectedEpoch); - return new Snapshot(apiKey, subscriptionKey(configId, configUrl), scopeEpoch, receivedAt, sourceKey, sourceRevision, fingerprint); - } - } - - public static Snapshot snapshot(Scope scope) { - if (scope == null) return Snapshot.empty(currentScope().getEpoch()); - return snapshot(scope.configId, scope.configUrl, scope.epoch); - } - - public static boolean isCurrent(Scope scope) { - if (scope == null || !scope.available) return false; - return isCurrent(scope.configId, scope.configUrl, scope.epoch); - } - - public static boolean isCurrent(int expectedConfigId, String expectedConfigUrl, long expectedEpoch) { - String normalizedUrl = normalizeUrl(expectedConfigUrl); - synchronized (LOCK) { - return hasScope && configId == expectedConfigId && configUrl.equals(normalizedUrl) && scopeEpoch == expectedEpoch; - } - } - - public static boolean isCurrent(String expectedSubscriptionKey, long expectedEpoch) { - if (expectedSubscriptionKey == null || expectedSubscriptionKey.isEmpty()) return false; - synchronized (LOCK) { - return hasScope && scopeEpoch == expectedEpoch - && expectedSubscriptionKey.equals(subscriptionKey(configId, configUrl)); - } - } - - public static void clear() { - synchronized (LOCK) { - scopeEpoch++; - clearCredential(); - } - } - - public static boolean clearIfCurrent(String expectedSubscriptionKey, long expectedEpoch) { - if (expectedSubscriptionKey == null || expectedSubscriptionKey.isEmpty()) return false; - synchronized (LOCK) { - if (!hasScope || scopeEpoch != expectedEpoch || !expectedSubscriptionKey.equals(subscriptionKey(configId, configUrl))) return false; - scopeEpoch++; - clearCredential(); - return true; - } - } - - public static void discardCredential() { - synchronized (LOCK) { - if (apiKey.isEmpty()) return; - scopeEpoch++; - clearCredential(); - } - } - - private static Scope scope() { - return new Scope(configId, configUrl, scopeEpoch, hasScope); - } - - private static void clearCredential() { - apiKey = ""; - fingerprint = ""; - receivedAt = 0L; - sourceKey = ""; - sourceRevision = ""; - } - - private static String normalizeUrl(String value) { - String normalized = trim(value); - while (normalized.endsWith("/")) normalized = normalized.substring(0, normalized.length() - 1); - return normalized; - } - - private static String trim(String value) { - return value == null ? "" : value.trim(); - } - - private static String subscriptionKey(int configId, String configUrl) { - return configId + "\u0000" + configUrl; - } - - private static String fingerprint(String value) { - try { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder hex = new StringBuilder(digest.length * 2); - for (byte item : digest) hex.append(String.format("%02x", item & 0xff)); - return hex.toString(); - } catch (Throwable ignored) { - return ""; - } - } - - public static final class Scope { - - private final int configId; - private final String configUrl; - private final long epoch; - private final boolean available; - - private Scope(int configId, String configUrl, long epoch, boolean available) { - this.configId = configId; - this.configUrl = configUrl; - this.epoch = epoch; - this.available = available; - } - - public int getConfigId() { - return configId; - } - - public String getConfigUrl() { - return configUrl; - } - - public long getEpoch() { - return epoch; - } - - public boolean isAvailable() { - return available; - } - } - - public static final class Snapshot { - - private static final Snapshot EMPTY = new Snapshot("", "", 0L, 0L, "", "", ""); - - private final String apiKey; - private final String subscriptionKey; - private final long scopeEpoch; - private final long receivedAt; - private final String sourceKey; - private final String sourceRevision; - private final String fingerprint; - - private Snapshot(String apiKey, String subscriptionKey, long scopeEpoch, long receivedAt, - String sourceKey, String sourceRevision, String fingerprint) { - this.apiKey = apiKey; - this.subscriptionKey = subscriptionKey; - this.scopeEpoch = scopeEpoch; - this.receivedAt = receivedAt; - this.sourceKey = sourceKey; - this.sourceRevision = sourceRevision; - this.fingerprint = fingerprint; - } - - private static Snapshot empty(long scopeEpoch) { - return scopeEpoch == EMPTY.scopeEpoch ? EMPTY : new Snapshot("", "", scopeEpoch, 0L, "", "", ""); - } - - public String getApiKey() { - return apiKey; - } - - public String getSubscriptionKey() { - return subscriptionKey; - } - - public long getScopeEpoch() { - return scopeEpoch; - } - - public long getReceivedAt() { - return receivedAt; - } - - public String getSourceKey() { - return sourceKey; - } - - public String getSourceRevision() { - return sourceRevision; - } - - public String getFingerprint() { - return fingerprint; - } - - public boolean isEmpty() { - return apiKey.isEmpty(); - } - - public boolean isPresent() { - return !isEmpty(); - } - - @Override - public String toString() { - return "Snapshot{hasKey=" + !isEmpty() + ",scopeEpoch=" + scopeEpoch + ",sourceKey='" + sourceKey + "'}"; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/UserAdRuleStore.java b/app/src/main/java/com/fongmi/android/tv/api/config/UserAdRuleStore.java index 1214a8e27b..fca9f26f1d 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/config/UserAdRuleStore.java +++ b/app/src/main/java/com/fongmi/android/tv/api/config/UserAdRuleStore.java @@ -33,24 +33,13 @@ public class UserAdRuleStore { public static synchronized void save(List rules) { Prefers.put(PREF_KEY, App.gson().toJson(rules == null ? new ArrayList<>() : rules)); RuleConfig.get().invalidate(); - HlsRuleConfig.invalidate(); } public static synchronized void add(UserAdRule rule) { if (rule == null) return; - addAll(List.of(rule)); - } - - public static synchronized void addAll(List incoming) { - if (incoming == null || incoming.isEmpty()) return; List rules = load(); - boolean added = false; - for (UserAdRule rule : incoming) { - if (rule == null) continue; - rules.add(rule); - added = true; - } - if (added) save(rules); + rules.add(rule); + save(rules); } public static void delete(String id) { @@ -81,18 +70,6 @@ public class UserAdRuleStore { save(rules); } - public static synchronized int setInterfaceRulesEnabled(boolean enabled) { - List rules = load(); - int changed = 0; - for (UserAdRule rule : rules) { - if (rule == null || !UserAdRule.isInterfaceSource(rule.getSource()) || rule.isEnabled() == enabled) continue; - rule.setEnabled(enabled); - changed++; - } - if (changed > 0) save(rules); - return changed; - } - /** * 转为可合并进 RuleConfig 的 Rule 列表,仅包含已启用且有内容的规则 */ diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/UserHlsRuleStore.java b/app/src/main/java/com/fongmi/android/tv/api/config/UserHlsRuleStore.java new file mode 100644 index 0000000000..d06a2c63e3 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/api/config/UserHlsRuleStore.java @@ -0,0 +1,78 @@ +package com.fongmi.android.tv.api.config; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.bean.HlsAdRule; +import com.github.catvod.utils.Prefers; +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +/** + * 用户自建的 HLS 结构化规则。 + * + *

内置规则来自 APK assets、接口规则来自点播/直播配置,两者都不可写; + * 而区间反馈需要产出真正能删除广告切片的规则 —— {@code RuleConfig.getAds()} + * 的唯一消费者是 {@code CustomWebView},只拦 WebView 请求,不拦播放器直连的 + * 切片请求。因此新增这条用户可写的来源。 + */ +public final class UserHlsRuleStore { + + private static final String PREF_KEY = "user_hls_rules"; + private static final Type LIST_TYPE = new TypeToken>() {}.getType(); + /** 上限防止规则库无限膨胀拖慢每次 manifest 匹配。 */ + private static final int MAX_RULES = 200; + + private UserHlsRuleStore() { + } + + public static synchronized List load() { + try { + List rules = App.gson().fromJson(Prefers.getString(PREF_KEY, "[]"), LIST_TYPE); + return rules == null ? new ArrayList<>() : rules; + } catch (Throwable e) { + return new ArrayList<>(); + } + } + + public static synchronized void save(List rules) { + List value = rules == null ? new ArrayList<>() : new ArrayList<>(rules); + while (value.size() > MAX_RULES) value.remove(0); + Prefers.put(PREF_KEY, App.gson().toJson(value)); + HlsRuleConfig.invalidate(); + } + + /** + * 追加一条规则。写入前先 compile 校验,非法规则直接拒绝 —— + * 否则会在 HlsRuleConfig 里变成一条永久报错的条目。 + * + * @return 是否写入成功 + */ + public static synchronized boolean add(HlsAdRule rule) { + if (rule == null) return false; + try { + rule.compile(); + } catch (RuntimeException e) { + return false; + } + List rules = load(); + if (rules.stream().anyMatch(existing -> existing.getId().equals(rule.getId()))) return false; + rules.add(rule); + save(rules); + return true; + } + + public static synchronized boolean delete(String id) { + if (id == null || id.isBlank()) return false; + List rules = load(); + boolean removed = rules.removeIf(rule -> id.equals(rule.getId())); + if (removed) save(rules); + return removed; + } + + public static synchronized void clear() { + Prefers.put(PREF_KEY, "[]"); + HlsRuleConfig.invalidate(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java b/app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java index 09af681da8..649ca5743d 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java +++ b/app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java @@ -2,14 +2,10 @@ package com.fongmi.android.tv.api.config; import android.text.TextUtils; -import androidx.appcompat.app.AlertDialog; - import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.CatSource; import com.fongmi.android.tv.api.CspWarmup; import com.fongmi.android.tv.api.Decoder; -import com.fongmi.android.tv.api.TmdbSourceCredentialIngress; import com.fongmi.android.tv.api.loader.BaseLoader; import com.fongmi.android.tv.bean.Config; import com.fongmi.android.tv.bean.Depot; @@ -18,34 +14,23 @@ import com.fongmi.android.tv.bean.HlsAdRule; import com.fongmi.android.tv.bean.Parse; import com.fongmi.android.tv.bean.Rule; import com.fongmi.android.tv.bean.Site; -import com.fongmi.android.tv.bean.TmdbConfig; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.setting.CustomCspSetting; -import com.fongmi.android.tv.setting.InterfaceFailoverPolicy; -import com.fongmi.android.tv.setting.InterfaceFailoverState; -import com.fongmi.android.tv.setting.InterfaceOrderStore; import com.fongmi.android.tv.setting.GroupRuleConfig; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.UrlUtil; import com.fongmi.android.tv.web.ext.WebHomeExtensionRegistry; import com.github.catvod.bean.Doh; import com.github.catvod.bean.Header; import com.github.catvod.bean.Proxy; -import com.github.catvod.crawler.SpiderDebug; import com.github.catvod.utils.Json; import com.google.gson.JsonObject; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -63,8 +48,6 @@ public class VodConfig extends BaseConfig { private List ads; private List flags; private List parses; - private volatile FailoverRound failoverRound; - private volatile AlertDialog failoverDialog; public static VodConfig get() { return Loader.INSTANCE; @@ -91,33 +74,7 @@ public class VodConfig extends BaseConfig { } public static void load(Config config, Callback callback) { - get().startNewLoad(config, callback, "vod-config-load"); - } - - public static void selectConfig(Config config, Callback callback) { - if (config == null) return; - get().startNewLoad(config, callback, "vod-config-select"); - } - - public static void cancelFailover() { - get().stopFailover(); - } - - private void startNewLoad(Config config, Callback callback, String reason) { - abandonFailover(); - clear(reason).config(config); - super.load(callback); - } - - @Override - public void load(Callback callback) { - abandonFailover(); - super.load(callback); - } - - private void loadFailoverAttempt(Config config, Callback callback) { - clear("vod-config-failover").config(config); - super.load(callback); + get().clear("vod-config-load").config(config).load(callback); } public VodConfig init() { @@ -125,7 +82,6 @@ public class VodConfig extends BaseConfig { } public VodConfig config(Config config) { - if (config != null) SubscriptionTmdbCredentialStore.beginSubscription(config.getId(), config.getUrl(), "vod-config"); this.config = config; return this; } @@ -165,7 +121,6 @@ public class VodConfig extends BaseConfig { @Override protected void postEvent() { - if (failoverRound != null) return; super.postEvent(); ConfigEvent.vod(); } @@ -175,40 +130,7 @@ public class VodConfig extends BaseConfig { // 猫源填的是 bundle 地址(.js.md5),要先在本机把 Node 服务跑起来,再读它的 /config String url = CatSource.isBundle(config.getUrl()) ? CatSource.serve(config.getUrl()) : UrlUtil.convert(config.getUrl()); String json = Decoder.getJson(url, TAG); - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(json); - checkJson(config, CatSource.normalize(url, Json.parse(ingress.getSanitizedJson()))); - if (!isLoaded()) throw new Exception("VOD sites is empty"); - acceptSubscriptionCredential(ingress.getCandidateKey(), config); - } - - private static void acceptSubscriptionCredential(String candidateApiKey, Config config) { - if (candidateApiKey == null || candidateApiKey.isEmpty() || config == null) { - SpiderDebug.log("tmdb-credential", "config-candidate skip candidate=%s config=%s", candidateApiKey != null && !candidateApiKey.isEmpty(), config != null); - return; - } - boolean userReady = TmdbConfig.objectFrom(Setting.getTmdbConfig()).isReady(); - SubscriptionTmdbCredentialStore.Scope scope = SubscriptionTmdbCredentialStore.currentScope(); - boolean urlMatch = scope.isAvailable() && scope.getConfigUrl().equals(normalizeConfigUrl(config.getUrl())); - SpiderDebug.log("tmdb-credential", "config-candidate present=true userReady=%s scopeAvailable=%s scopeId=%d configId=%d urlMatch=%s", - userReady, scope.isAvailable(), scope.getConfigId(), config.getId(), urlMatch); - if (userReady) { - SubscriptionTmdbCredentialStore.discardCredential(); - return; - } - if (!scope.isAvailable() || scope.getConfigId() != config.getId() || !scope.getConfigUrl().equals(normalizeConfigUrl(config.getUrl()))) return; - if (TmdbConfig.objectFrom(Setting.getTmdbConfig()).isReady()) { - SubscriptionTmdbCredentialStore.discardCredential(); - return; - } - boolean accepted = SubscriptionTmdbCredentialStore.accept(candidateApiKey, scope.getConfigId(), scope.getConfigUrl(), - scope.getEpoch(), "subscription-config", config.getUrl()); - SpiderDebug.log("tmdb-credential", "config-candidate accept=%s epoch=%d", accepted, scope.getEpoch()); - } - - private static String normalizeConfigUrl(String value) { - String normalized = value == null ? "" : value.trim(); - while (normalized.endsWith("/")) normalized = normalized.substring(0, normalized.length() - 1); - return normalized; + checkJson(config, CatSource.normalize(url, Json.parse(json))); } @Override @@ -259,245 +181,6 @@ public class VodConfig extends BaseConfig { config.setDanmaku(Json.safeString(object, "danmaku")); } - void onConfigFailure(Config config, Callback callback, Throwable error) { - FailoverRound current = failoverRound; - String message = Notify.getError(R.string.error_config_get, error); - if (current != null && callback == current.attemptCallback) { - current.lastError = message; - App.post(() -> onAttemptFailure(current)); - return; - } - - int mode = Setting.getInterfaceFailoverMode(); - if (!InterfaceFailoverPolicy.shouldFailover(mode)) { - App.post(() -> callback.error(message)); - return; - } - - List configs = InterfaceOrderStore.sortVodConfigs(Config.getAll(VOD)); - String originUrl = config.getUrl(); - int index = indexOfUrl(configs, originUrl); - int limit = InterfaceFailoverPolicy.fallbackLimit(configs.size()); - List remaining = new ArrayList<>(); - for (int i = Math.max(index + 1, 0); i < configs.size() && remaining.size() < limit; i++) { - Config candidate = configs.get(i); - if (!TextUtils.equals(candidate.getUrl(), originUrl)) remaining.add(candidate); - } - if (remaining.isEmpty()) { - App.post(() -> callback.error(message)); - return; - } - - FailoverRound round = new FailoverRound(config.getDesc(), remaining, callback, message, - new InterfaceFailoverState(mode, originUrl, urls(remaining))); - failoverRound = round; - if (InterfaceFailoverPolicy.isConfirm(mode)) { - App.post(() -> showConfirmDialog(round)); - } else { - App.post(this::startNextAttempt); - } - } - - private void onAttemptFailure(FailoverRound round) { - if (failoverRound != round) return; - int mode = Setting.getInterfaceFailoverMode(); - if (!InterfaceFailoverPolicy.shouldFailover(mode) || !round.state.shouldContinueAfterFailure()) { - finishFailure(round, round.lastError); - return; - } - startNextAttempt(); - } - - private void startNextAttempt() { - FailoverRound round = failoverRound; - if (round == null) return; - if (!InterfaceFailoverPolicy.shouldFailover(Setting.getInterfaceFailoverMode())) { - finishFailure(round, round.lastError); - return; - } - String nextUrl = round.state.nextAutomatic(); - if (nextUrl == null) { - finishFailure(round, round.lastError); - return; - } - Config next = findCandidate(round.candidates, nextUrl); - if (next == null) { - finishFailure(round, round.lastError); - return; - } - Notify.show(ResUtil.getString(R.string.interface_failover_next, next.getDesc())); - round.attemptCallback = new Callback() { - @Override - public void success() { - if (round.attemptCallback != this) return; - finishSuccess(round); - } - - @Override - public void error(String msg) { - if (round.attemptCallback != this) return; - round.lastError = msg; - App.post(() -> { - if (round.attemptCallback == this) onAttemptFailure(round); - }); - } - }; - loadFailoverAttempt(next, round.attemptCallback); - } - - private void startSelectedAttempt(FailoverRound round, int index) { - if (failoverRound != round || index < 0 || index >= round.candidates.size()) return; - if (!InterfaceFailoverPolicy.shouldFailover(Setting.getInterfaceFailoverMode())) { - finishFailure(round, round.lastError); - return; - } - Config selected = round.candidates.get(index); - String selectedUrl = round.state.select(index); - if (selectedUrl == null) { - finishFailure(round, round.lastError); - return; - } - Notify.show(ResUtil.getString(R.string.interface_failover_next, selected.getDesc())); - round.attemptCallback = new Callback() { - @Override - public void success() { - if (round.attemptCallback != this) return; - finishSuccess(round); - } - - @Override - public void error(String msg) { - if (round.attemptCallback != this) return; - round.lastError = msg; - App.post(() -> { - if (round.attemptCallback == this) onAttemptFailure(round); - }); - } - }; - loadFailoverAttempt(selected, round.attemptCallback); - } - - private void showConfirmDialog(FailoverRound round) { - if (failoverRound != round) return; - android.app.Activity activity = App.activity(); - if (activity == null || activity.isFinishing() || activity.isDestroyed()) { - finishFailure(round, round.lastError); - return; - } - CharSequence[] labels = new CharSequence[round.candidates.size()]; - for (int i = 0; i < labels.length; i++) labels[i] = round.candidates.get(i).getDesc(); - final int[] selected = {0}; - AlertDialog dialog = new MaterialAlertDialogBuilder(activity) - .setTitle(R.string.interface_failover_title) - .setMessage(ResUtil.getString(R.string.interface_failover_message, round.originDesc)) - .setSingleChoiceItems(labels, 0, (dialog1, which) -> selected[0] = which) - .setPositiveButton(R.string.interface_failover_switch, (dialog1, which) -> startSelectedAttempt(round, selected[0])) - .setNegativeButton(R.string.dialog_cancel, (dialog1, which) -> cancelFailover(round)) - .create(); - failoverDialog = dialog; - dialog.setOnCancelListener(dialog1 -> cancelFailover(round)); - dialog.setOnDismissListener(dialog1 -> { - if (failoverDialog == dialog) failoverDialog = null; - }); - dialog.show(); - } - - private void cancelFailover(FailoverRound round) { - if (failoverRound != round) return; - cancelRound(round); - Notify.show(R.string.interface_failover_cancelled); - } - - private void stopFailover() { - FailoverRound round = failoverRound; - if (round == null) return; - round.state.cancel(); - boolean loading = round.attemptCallback != null; - if (loading) cancelLoad(true); - failoverRound = null; - AlertDialog dialog = failoverDialog; - failoverDialog = null; - if (dialog != null) dialog.dismiss(); - round.callback.error(errorMessage(round.lastError)); - } - - private void cancelRound(FailoverRound round) { - if (failoverRound != round) return; - round.state.cancel(); - if (round.attemptCallback != null) cancelLoad(true); - failoverRound = null; - AlertDialog dialog = failoverDialog; - failoverDialog = null; - if (dialog != null) dialog.dismiss(); - round.callback.error(errorMessage(round.lastError)); - } - - private void finishSuccess(FailoverRound round) { - if (failoverRound != round) return; - failoverRound = null; - super.postEvent(); - ConfigEvent.vod(); - round.callback.success(); - } - - private void finishFailure(FailoverRound round, String message) { - if (failoverRound != round) return; - failoverRound = null; - super.postEvent(); - ConfigEvent.vod(); - round.callback.error(errorMessage(message)); - } - - private String errorMessage(String message) { - return TextUtils.isEmpty(message) ? Notify.getError(R.string.error_config_get, new Exception("Configuration get failed")) : message; - } - - private void abandonFailover() { - if (failoverRound != null) { - failoverRound.state.cancel(); - cancelLoad(false); - } - failoverRound = null; - AlertDialog dialog = failoverDialog; - failoverDialog = null; - if (dialog != null) App.post(dialog::dismiss); - } - - private int indexOfUrl(List configs, String url) { - for (int i = 0; i < configs.size(); i++) if (TextUtils.equals(configs.get(i).getUrl(), url)) return i; - return -1; - } - - private List urls(List configs) { - List urls = new ArrayList<>(); - for (Config config : configs) urls.add(config.getUrl()); - return urls; - } - - private Config findCandidate(List configs, String url) { - for (Config config : configs) if (TextUtils.equals(config.getUrl(), url)) return config; - return null; - } - - private static final class FailoverRound { - - private final String originDesc; - private final List candidates; - private final Callback callback; - private final InterfaceFailoverState state; - private String lastError; - private Callback attemptCallback; - - private FailoverRound(String originDesc, List candidates, Callback callback, String lastError, - InterfaceFailoverState state) { - this.originDesc = originDesc; - this.candidates = candidates; - this.callback = callback; - this.lastError = lastError; - this.state = state; - } - } - private void initList(JsonObject object) { setHeaders(Header.arrayFrom(fetchArray(object, "headers"))); setProxy(Proxy.arrayFrom(fetchArray(object, "proxy"))); diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/WallConfig.java b/app/src/main/java/com/fongmi/android/tv/api/config/WallConfig.java index 1e8aef6512..2ee112cfab 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/config/WallConfig.java +++ b/app/src/main/java/com/fongmi/android/tv/api/config/WallConfig.java @@ -37,8 +37,6 @@ public class WallConfig extends BaseConfig { return get().getConfig().getDesc(); } - private boolean preserveSelection; - public static void load(Config config, Callback callback) { get().config(config).load(callback); } @@ -47,10 +45,6 @@ public class WallConfig extends BaseConfig { return config(Config.wall()); } - public WallConfig initPreservingSelection() { - return config(Config.wall()).preserveSelection(); - } - public WallConfig config(Config config) { this.config = config; if (config.isEmpty()) return this; @@ -58,11 +52,6 @@ public class WallConfig extends BaseConfig { return this; } - public WallConfig preserveSelection() { - this.preserveSelection = true; - return this; - } - public void load() { if (sync) return; load(new Callback()); @@ -90,10 +79,8 @@ public class WallConfig extends BaseConfig { checkUrl(config.getUrl(), file); int type = wallType(file); setSnapshot(file); - if (!preserveSelection) { - Setting.putWall(0); - Setting.putWallType(type); - } + Setting.putWall(0); + Setting.putWallType(type); } @Override diff --git a/app/src/main/java/com/fongmi/android/tv/bean/AdBlockLog.java b/app/src/main/java/com/fongmi/android/tv/bean/AdBlockLog.java deleted file mode 100644 index 3098398bbc..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/bean/AdBlockLog.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.fongmi.android.tv.bean; - -/** 单条广告切片拦截日志。 */ -public class AdBlockLog { - - private long blockedAt; - private String sourceName; // legacy JSON field; new records use siteKey/siteName - private String siteKey; - private String siteName; - private String siteDomain; - private String pipelineName; - private String adDomain; - private String ruleId; - private String vodName; - private String lineName; - private String episodeName; - private double segmentStartSeconds; - private double segmentDurationSeconds; - - public AdBlockLog() { - } - - public AdBlockLog(long blockedAt, String sourceName, String pipelineName, String adDomain, - String ruleId, double segmentStartSeconds, double segmentDurationSeconds) { - this(blockedAt, sourceName, sourceName, adDomain, pipelineName, adDomain, ruleId, - segmentStartSeconds, segmentDurationSeconds); - } - - public AdBlockLog(long blockedAt, String siteKey, String siteName, String siteDomain, - String pipelineName, String adDomain, String ruleId, - double segmentStartSeconds, double segmentDurationSeconds) { - this.blockedAt = blockedAt; - this.sourceName = siteName; - this.siteKey = siteKey; - this.siteName = siteName; - this.siteDomain = siteDomain; - this.pipelineName = pipelineName; - this.adDomain = adDomain; - this.ruleId = ruleId; - this.segmentStartSeconds = segmentStartSeconds; - this.segmentDurationSeconds = segmentDurationSeconds; - } - - public long getBlockedAt() { - return blockedAt; - } - - public String getSiteKey() { - return empty(siteKey) ? "" : siteKey; - } - - public String getSiteName() { - if (!empty(siteName)) return siteName; - if (!empty(sourceName) && !sourceName.equals(getSiteDomain())) return sourceName; - return ""; - } - - public String getSiteDomain() { - return empty(siteDomain) ? adDomain : siteDomain; - } - - /** Backward-compatible grouping accessor for statistics saved before site identity was split. */ - public String getSourceName() { - if (!empty(getSiteName())) return getSiteName(); - if (!empty(sourceName)) return sourceName; - return getSiteDomain(); - } - - public String getPipelineName() { - return pipelineName; - } - - public String getAdDomain() { - return adDomain; - } - - public String getRuleId() { - return ruleId; - } - - public String getVodName() { - return empty(vodName) ? "" : vodName; - } - - public String getLineName() { - return empty(lineName) ? "" : lineName; - } - - public String getEpisodeName() { - return empty(episodeName) ? "" : episodeName; - } - - public AdBlockLog setPlaybackContext(String vodName, String lineName, String episodeName) { - this.vodName = vodName; - this.lineName = lineName; - this.episodeName = episodeName; - return this; - } - - public double getSegmentStartSeconds() { - return segmentStartSeconds; - } - - public double getSegmentEndSeconds() { - return segmentStartSeconds + segmentDurationSeconds; - } - - public boolean hasSegmentTiming() { - return segmentDurationSeconds > 0; - } - - public double getSegmentDurationSeconds() { - return segmentDurationSeconds; - } - - private static boolean empty(String value) { - return value == null || value.isBlank(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/bean/AdBlockLogFilter.java b/app/src/main/java/com/fongmi/android/tv/bean/AdBlockLogFilter.java deleted file mode 100644 index 9ec8f1dc62..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/bean/AdBlockLogFilter.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.fongmi.android.tv.bean; - -import java.util.ArrayList; -import java.util.EnumMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; - -/** - * 广告拦截日志的列筛选工具。 - * - *

筛选值按列分别保存,多个非空条件采用 AND 关系,并以不区分大小写的 - * 包含匹配执行。UI 可以通过 {@link ValueProvider} 提供格式化后的显示值, - * 从而保证筛选内容与表格中看到的文本一致。

- */ -public final class AdBlockLogFilter { - - public enum Column { - SITE_NAME, - SITE_DOMAIN, - VOD_NAME, - LINE_NAME, - EPISODE_NAME, - RULE, - AD_DOMAIN, - BLOCKED_AT, - SEGMENT_START, - SEGMENT_END, - SEGMENT_DURATION, - PIPELINE - } - - /** 将一条日志转换为各列可搜索文本的回调。 */ - public interface ValueProvider { - Map values(AdBlockLog log); - } - - private AdBlockLogFilter() { - } - - /** 返回日志字段的默认文本映射,供界面在需要时覆盖格式化列。 */ - public static EnumMap values(AdBlockLog log) { - EnumMap values = new EnumMap<>(Column.class); - if (log == null) return values; - values.put(Column.SITE_NAME, safe(log.getSiteName())); - values.put(Column.SITE_DOMAIN, safe(log.getSiteDomain())); - values.put(Column.VOD_NAME, safe(log.getVodName())); - values.put(Column.LINE_NAME, safe(log.getLineName())); - values.put(Column.EPISODE_NAME, safe(log.getEpisodeName())); - values.put(Column.RULE, safe(log.getRuleId())); - values.put(Column.AD_DOMAIN, safe(log.getAdDomain())); - values.put(Column.BLOCKED_AT, log.getBlockedAt() > 0 ? String.valueOf(log.getBlockedAt()) : ""); - values.put(Column.SEGMENT_START, log.hasSegmentTiming() ? String.valueOf(log.getSegmentStartSeconds()) : ""); - values.put(Column.SEGMENT_END, log.hasSegmentTiming() ? String.valueOf(log.getSegmentEndSeconds()) : ""); - values.put(Column.SEGMENT_DURATION, log.hasSegmentTiming() ? String.valueOf(log.getSegmentDurationSeconds()) : ""); - values.put(Column.PIPELINE, safe(log.getPipelineName())); - return values; - } - - public static List filter(List logs, Map filters) { - return filter(logs, filters, AdBlockLogFilter::values); - } - - public static List filter(List logs, - Map filters, - ValueProvider provider) { - return filter(logs, filters, Map.of(), provider); - } - - /** - * 使用文本条件和去重多选条件筛选日志。 - * 同一列的多选值采用 OR,不同列之间与文本条件采用 AND。 - */ - public static List filter(List logs, - Map filters, - Map> selections, - ValueProvider provider) { - List result = new ArrayList<>(); - if (logs == null || logs.isEmpty()) return result; - ValueProvider valueProvider = provider == null ? AdBlockLogFilter::values : provider; - for (AdBlockLog log : logs) { - if (matches(valueProvider.values(log), filters, selections)) result.add(log); - } - return result; - } - - public static boolean matches(Map values, Map filters) { - return matches(values, filters, Map.of()); - } - - public static boolean matches(Map values, - Map filters, - Map> selections) { - if (selections != null) { - for (Map.Entry> entry : selections.entrySet()) { - Set selected = entry.getValue(); - if (selected == null || selected.isEmpty()) continue; - String value = values == null ? "" : normalize(values.get(entry.getKey())); - boolean selectedValue = false; - for (String candidate : selected) { - if (value.equals(normalize(candidate))) { - selectedValue = true; - break; - } - } - if (!selectedValue) return false; - } - } - if (filters == null || filters.isEmpty()) return true; - for (Map.Entry entry : filters.entrySet()) { - String query = normalize(entry.getValue()); - if (query.isEmpty()) continue; - String value = values == null ? "" : normalize(values.get(entry.getKey())); - if (!value.contains(query)) return false; - } - return true; - } - - private static String safe(String value) { - return value == null ? "" : value; - } - - private static String normalize(String value) { - return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/bean/AdBlockStats.java b/app/src/main/java/com/fongmi/android/tv/bean/AdBlockStats.java index 3b398639eb..528afcbd54 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/AdBlockStats.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/AdBlockStats.java @@ -1,11 +1,6 @@ package com.fongmi.android.tv.bean; -import com.fongmi.android.tv.playback.PlaybackRuntime; - -import java.util.ArrayList; import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; import java.util.Map; import java.util.Objects; @@ -20,15 +15,11 @@ public class AdBlockStats { private long aiAnalysisFailed; // AI 分析失败次数 private Map siteBlocked; // 按站点统计拦截次数 private Map ruleCounts; // 按规则 ID 统计匹配次数 - private Map pipelineCounts; // 按播放链路统计拦截次数 - private List blockLogs; // 每条广告切片的拦截明细 private long lastResetAt; // 上次重置时间 public AdBlockStats() { this.siteBlocked = new HashMap<>(); this.ruleCounts = new HashMap<>(); - this.pipelineCounts = new HashMap<>(); - this.blockLogs = new ArrayList<>(); this.lastResetAt = System.currentTimeMillis(); } @@ -80,112 +71,6 @@ public class AdBlockStats { this.ruleCounts = ruleCounts; } - public Map getPipelineCounts() { - return pipelineCounts == null ? new HashMap<>() : pipelineCounts; - } - - public void setPipelineCounts(Map pipelineCounts) { - this.pipelineCounts = pipelineCounts; - } - - public List getBlockLogs() { - if (blockLogs == null) blockLogs = new ArrayList<>(); - return blockLogs; - } - - public void setBlockLogs(List blockLogs) { - this.blockLogs = blockLogs; - } - - public void recordBlockLog(long blockedAt, String sourceName, String pipelineName, - String adDomain, String ruleId, double segmentDurationSeconds) { - recordBlockLog(blockedAt, sourceName, pipelineName, adDomain, ruleId, 0, segmentDurationSeconds); - } - - public void recordBlockLog(long blockedAt, String sourceName, String pipelineName, - String adDomain, String ruleId, double segmentStartSeconds, double segmentDurationSeconds) { - recordBlockLog(blockedAt, sourceName, sourceName, adDomain, pipelineName, adDomain, - ruleId, segmentStartSeconds, segmentDurationSeconds); - } - - public void recordBlockLog(long blockedAt, String siteKey, String siteName, String siteDomain, - String pipelineName, String adDomain, String ruleId, - double segmentStartSeconds, double segmentDurationSeconds) { - List logs = getBlockLogs(); - AdBlockLog log = new AdBlockLog(blockedAt, siteKey, siteName, siteDomain, pipelineName, - adDomain, ruleId, segmentStartSeconds, segmentDurationSeconds); - PlaybackRuntime.PlaybackIdentity playback = PlaybackRuntime.currentPlaybackIdentity(); - log.setPlaybackContext(playback.vodName(), playback.lineName(), playback.episodeName()); - logs.add(0, log); - while (logs.size() > 1000) logs.remove(logs.size() - 1); - } - - public void recordBlockLogs(long blockedAt, String sourceName, String pipelineName, - String adDomain, Map ruleCounts, - double totalDurationSeconds) { - recordBlockLogs(blockedAt, sourceName, sourceName, adDomain, pipelineName, adDomain, - ruleCounts, totalDurationSeconds); - } - - public void recordBlockLogs(long blockedAt, String siteKey, String siteName, String siteDomain, - String pipelineName, String adDomain, Map ruleCounts, - double totalDurationSeconds) { - if (ruleCounts == null || ruleCounts.isEmpty()) return; - long totalCount = 0; - for (Long count : ruleCounts.values()) { - if (count != null && count > 0) totalCount += count; - } - if (totalCount <= 0) return; - double durationPerSegment = Math.max(0, totalDurationSeconds) / totalCount; - for (Map.Entry entry : ruleCounts.entrySet()) { - long count = entry.getValue() == null ? 0 : Math.max(0, entry.getValue()); - for (long index = 0; index < count; index++) { - recordBlockLog(blockedAt, siteKey, siteName, siteDomain, pipelineName, adDomain, - entry.getKey(), 0, durationPerSegment); - } - } - } - - public List getBlockLogsBySource(String sourceName) { - return filterLogs(sourceName, 0); - } - - /** Supports stable site-key aggregates while remaining compatible with older display-name keys. */ - public List getBlockLogsBySite(String siteKeyOrName) { - List result = new ArrayList<>(); - for (AdBlockLog log : getBlockLogs()) { - if (Objects.equals(siteKeyOrName, log.getSiteKey()) - || Objects.equals(siteKeyOrName, log.getSourceName())) result.add(log); - } - return result; - } - - public List getBlockLogsByRule(String ruleId) { - return filterLogs(ruleId, 1); - } - - public List getBlockLogsByPipeline(String pipelineName) { - return filterLogs(pipelineName, 2); - } - - public List getSourceNamesWithBlocks() { - LinkedHashSet names = new LinkedHashSet<>(); - for (AdBlockLog log : getBlockLogs()) { - if (log.getSourceName() != null && !log.getSourceName().isEmpty()) names.add(log.getSourceName()); - } - return new ArrayList<>(names); - } - - private List filterLogs(String value, int dimension) { - List result = new ArrayList<>(); - for (AdBlockLog log : getBlockLogs()) { - String candidate = dimension == 0 ? log.getSourceName() - : dimension == 1 ? log.getRuleId() : log.getPipelineName(); - if (Objects.equals(value, candidate)) result.add(log); - } - return result; - } - public long getLastResetAt() { return lastResetAt; } @@ -208,26 +93,10 @@ public class AdBlockStats { } public void incrementRuleCount(String ruleId) { - incrementBlocks(null, null, ruleId, 1, false); - } - - public void incrementBlocks(String siteKey, String pipeline, String ruleId, long count) { - incrementBlocks(siteKey, pipeline, ruleId, count, true); - } - - private void incrementBlocks(String siteKey, String pipeline, String ruleId, long count, boolean includeTotal) { - if (count <= 0) return; - if (includeTotal) { - totalBlocked += count; - merge(siteBlocked = getSiteBlocked(), siteKey, count); - merge(pipelineCounts = getPipelineCounts(), pipeline, count); - } - merge(ruleCounts = getRuleCounts(), ruleId, count); - } - - private static void merge(Map target, String key, long count) { - if (key == null || key.isEmpty() || count <= 0) return; - target.put(key, toLong(target.get(key)) + count); + if (ruleId == null || ruleId.isEmpty()) return; + Map map = getRuleCounts(); + map.put(ruleId, toLong(map.get(ruleId)) + 1); + this.ruleCounts = map; } /** @@ -258,8 +127,6 @@ public class AdBlockStats { this.aiAnalysisFailed = 0; this.siteBlocked = new HashMap<>(); this.ruleCounts = new HashMap<>(); - this.pipelineCounts = new HashMap<>(); - this.blockLogs = new ArrayList<>(); this.lastResetAt = System.currentTimeMillis(); } @@ -273,16 +140,6 @@ public class AdBlockStats { return toLong(getSiteBlocked().get(siteKey)); } - public long getRuleBlockedCount(String ruleId) { - if (ruleId == null || ruleId.isEmpty()) return 0; - return toLong(getRuleCounts().get(ruleId)); - } - - public long getPipelineBlockedCount(String pipeline) { - if (pipeline == null || pipeline.isEmpty()) return 0; - return toLong(getPipelineCounts().get(pipeline)); - } - public int getAiAnalysisTotal() { return (int) (aiAnalysisSuccess + aiAnalysisFailed); } @@ -303,12 +160,11 @@ public class AdBlockStats { aiAnalysisFailed == that.aiAnalysisFailed && lastResetAt == that.lastResetAt && Objects.equals(siteBlocked, that.siteBlocked) && - Objects.equals(ruleCounts, that.ruleCounts) && - Objects.equals(pipelineCounts, that.pipelineCounts); + Objects.equals(ruleCounts, that.ruleCounts); } @Override public int hashCode() { - return Objects.hash(totalBlocked, aiRuleFeedbackCount, aiAnalysisSuccess, aiAnalysisFailed, siteBlocked, ruleCounts, pipelineCounts, lastResetAt); + return Objects.hash(totalBlocked, aiRuleFeedbackCount, aiAnalysisSuccess, aiAnalysisFailed, siteBlocked, ruleCounts, lastResetAt); } } diff --git a/app/src/main/java/com/fongmi/android/tv/bean/Backup.java b/app/src/main/java/com/fongmi/android/tv/bean/Backup.java index 4eac969ea6..f8c20cbccc 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/Backup.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/Backup.java @@ -1,7 +1,5 @@ package com.fongmi.android.tv.bean; -import com.fongmi.android.tv.api.loader.BaseLoader; - import android.content.SharedPreferences; import androidx.annotation.NonNull; @@ -14,10 +12,6 @@ import com.fongmi.android.tv.api.config.WallConfig; import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingBackupCodec; -import com.fongmi.android.tv.following.FollowingDatabase; -import com.fongmi.android.tv.following.FollowingSource; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.web.HomeWebController; import com.fongmi.android.tv.web.ext.WebHomeExtensionRegistry; @@ -41,7 +35,7 @@ public class Backup { public static final String PREF_WEB_HOME_EXTENSION = "web_home_extension"; public static final String PREF_WEB_HOME_EXTENSION_SOURCES = "web_home_extension_user_sources"; - private static final Set APP_PREFS = Set.of("doh", "ua", "wall", "wall_type", "speech_ad_rules_v1", "speech_ad_rules_source", "speech_ad_builtin_enabled", "reset", "site_mode", "site_block_keys", "site_names", "search_column", "sync_mode", "sync_paths", "incognito", "touch_optimized", "playback_overlay_enabled", "drive_check", "drive_check_cache", "compact_episode_title", "web_home_fullscreen", "web_home_theme_enabled", "web_home_theme_url", "audio_config", "short_drama_config", "tmdb_enabled", "tmdb_config", "tmdb_model", "ai_config", "ai_title_extraction", "ai_ad_detection", "user_ad_rules", "user_group_rules", "disabled_group_rule_ids", "disabled_default_rule_ids", "builtin_hls_rule_overrides", "subtitle_ai_max_concurrency", "subtitle_ai_chunk_count", "detail_open_mode", "detail_interaction_mode", "detail_theme_mode", "tmdb_detail_theme", "tmdb_detail_backdrop_slide", "personal_recommendation", "episode_history", "global_history_mode", "history_aggregation_by_tmdb", "ai_recommendation", "auto_skip_intro_outro", "viewing_record_sync_enabled", "viewing_record_sync_local_write", "playback_remote_sync_config", "playback_webhook_config", "playback_webhook_privacy_accepted", "shell_proxy", "shell_proxy_rules", "shell_proxy_url", "shell_proxy_hosts", "github_proxy", "github_proxy_enabled", "github_proxy_mode", "update", "adblock", "zhuyin", "theme_color", "wall_color", "crash", "render", "pad_live_mode", "size", "scale", "custom_aspect_width", "custom_aspect_height", "buffer", "buffer_bytes", "back_buffer", "play_cache", "preload", "preload_threads", "preload_size", "preload_time", "player_auto_change", "player_failure_fallback", "background", "speed", "play_speed", "caption", "tunnel", "exo_4k_compat", "playback_performance_profile", "playback_performance_initialized", "perf_codec_async_queueing", "perf_dynamic_scheduling", "perf_video_duration_progress", "perf_late_drop_input", "perf_load_only_selected_tracks", "perf_surface_fixed_size", "perf_decoder_fallback", "perf_soft_video_tune", "perf_high_buffer", "perf_bandwidth_meter", "perf_exo_network_protection_mode", "player_button_order", "player_button_hidden", "audio_prefer", "video_prefer", "prefer_aac", "subtitle_text_size", "subtitle_position", "display_time", "display_traffic", "display_size", "display_progress", "display_mini", "display_title", "player_osd_title", "player_osd_resolution", "player_osd_time", "player_osd_progress", "player_osd_traffic", "player_osd_mini", "player_osd_diagnostics", "boot_live", "across", "change", "invert", "scale_live", "live_epg_url", "live_epg_history", "update_source", "update_github_proxy", "update_github_proxy_url", "update_github_proxy_mode", "update_oci_mirror", "update_oci_mirror_url", "playback_bluray_menu", "perf_track_limit", "perf_adaptive_downgrade", "interface_failover_mode", "interface_order_vod", "following_enabled", "following_notifications", "following_source_probe", "following_server_import"); + private static final Set APP_PREFS = Set.of("doh", "ua", "wall", "wall_type", "reset", "site_mode", "site_block_keys", "site_names", "search_column", "sync_mode", "sync_paths", "incognito", "drive_check", "drive_check_cache", "compact_episode_title", "web_home_fullscreen", "web_home_theme_enabled", "web_home_theme_url", "audio_config", "short_drama_config", "tmdb_enabled", "tmdb_config", "tmdb_model", "ai_config", "ai_title_extraction", "ai_ad_detection", "user_ad_rules", "user_group_rules", "disabled_group_rule_ids", "disabled_default_rule_ids", "builtin_hls_rule_overrides", "subtitle_ai_max_concurrency", "subtitle_ai_chunk_count", "detail_open_mode", "detail_interaction_mode", "detail_theme_mode", "tmdb_detail_theme", "tmdb_detail_backdrop_slide", "personal_recommendation", "episode_history", "global_history_mode", "history_aggregation_by_tmdb", "ai_recommendation", "auto_skip_intro_outro", "viewing_record_sync_enabled", "viewing_record_sync_local_write", "playback_remote_sync_config", "playback_webhook_config", "playback_webhook_privacy_accepted", "shell_proxy", "shell_proxy_rules", "shell_proxy_url", "shell_proxy_hosts", "github_proxy", "github_proxy_enabled", "update", "adblock", "zhuyin", "theme_color", "wall_color", "crash", "render", "ffmpeg_mode", "pad_live_mode", "size", "scale", "custom_aspect_width", "custom_aspect_height", "buffer", "buffer_bytes", "back_buffer", "play_cache", "preload", "preload_threads", "preload_size", "preload_time", "player_auto_change", "player_failure_fallback", "background", "speed", "play_speed", "caption", "tunnel", "exo_4k_compat", "playback_performance_profile", "playback_performance_initialized", "perf_codec_async_queueing", "perf_dynamic_scheduling", "perf_video_duration_progress", "perf_late_drop_input", "perf_track_limit", "perf_adaptive_downgrade", "perf_load_only_selected_tracks", "perf_surface_fixed_size", "perf_decoder_fallback", "perf_soft_video_tune", "perf_high_buffer", "perf_bandwidth_meter", "perf_exo_network_protection_mode", "player_button_order", "player_button_hidden", "audio_prefer", "video_prefer", "prefer_aac", "subtitle_text_size", "subtitle_position", "display_time", "display_traffic", "display_size", "display_progress", "display_mini", "display_title", "player_osd_title", "player_osd_resolution", "player_osd_time", "player_osd_progress", "player_osd_traffic", "player_osd_mini", "player_osd_diagnostics", "boot_live", "across", "change", "invert", "scale_live", "live_epg_url", "live_epg_history"); @SerializedName("site") private List site; @@ -55,12 +49,6 @@ public class Backup { private List history; @SerializedName("tmdbSeasonProgress") private List tmdbSeasonProgress; - @SerializedName("following") - private List following; - @SerializedName("followingSource") - private List followingSource; - @SerializedName("followingSchemaVersion") - private Integer followingSchemaVersion; @SerializedName("track") private List track; @SerializedName("device") @@ -77,10 +65,6 @@ public class Backup { backup.setConfig(AppDatabase.get().getConfigDao().findAll()); backup.setHistory(AppDatabase.get().getHistoryDao().findAll()); backup.setTmdbSeasonProgress(AppDatabase.get().getTmdbSeasonProgressDao().findAll()); - FollowingBackupCodec.Payload payload = FollowingBackupCodec.capture(); - backup.setFollowing(payload.following()); - backup.setFollowingSource(payload.sources()); - backup.setFollowingSchemaVersion(FollowingDatabase.VERSION); backup.setTrack(AppDatabase.get().getTrackDao().findAll()); backup.setDevice(AppDatabase.get().getDeviceDao().findAll()); return backup; @@ -98,12 +82,6 @@ public class Backup { backup.setHistory(AppDatabase.get().getHistoryDao().findAll()); backup.setTmdbSeasonProgress(AppDatabase.get().getTmdbSeasonProgressDao().findAll()); } - if (options.isFollow()) { - FollowingBackupCodec.Payload payload = FollowingBackupCodec.capture(); - backup.setFollowing(payload.following()); - backup.setFollowingSource(payload.sources()); - backup.setFollowingSchemaVersion(FollowingDatabase.VERSION); - } backup.setPrefers(filter(Prefers.getPrefers().getAll(), options)); return backup; } @@ -132,7 +110,6 @@ public class Backup { restoreTmdbSeasonProgress(Collections.emptyMap()); AppDatabase.get().getTrackDao().insertOrUpdate(getTrack()); AppDatabase.get().getDeviceDao().insertOrUpdate(getDevice()); - if (hasFollowingPayload()) FollowingBackupCodec.restoreFull(following, followingSource); restorePrefers(getPrefers(), true, preserveMissingWebHomePrefs); } @@ -160,11 +137,7 @@ public class Backup { AppDatabase.get().getHistoryDao().insertOrUpdate(getHistory()); restoreTmdbSeasonProgress(cids); } - if (options.isFollow() && hasFollowingPayload()) FollowingBackupCodec.merge(following, followingSource); - Map prefers = filter(getPrefers(), options); - if (options.isMpvConfig()) clearMpvConfigPreferences(); - restorePrefers(prefers, false, false); - if (options.isConfig() || options.isSpider() || options.isWebHome() || options.isLoginState()) BaseLoader.get().clear(); + restorePrefers(filter(getPrefers(), options), false, false); if (options.isConfig() || options.isSpider() || options.isWebHome() || options.isLoginState()) reloadConfig(); if (options.isWebHome()) refreshWebHomeExtensions(); if (options.isKeep()) RefreshEvent.keep(); @@ -175,7 +148,7 @@ public class Backup { private void reloadConfig() { VodConfig.get().clear("sync-restore").init().load(new Callback()); LiveConfig.get().clear().init().load(); - WallConfig.get().initPreservingSelection().load(); + WallConfig.get().init().load(); ConfigEvent.common(); } @@ -270,7 +243,6 @@ public class Backup { if ("keyword".equals(key) || "hot".equals(key) || key.startsWith("hot_")) return options.isSearch(); if ("git_cloud_accounts".equals(key)) return options.isSpider() || options.isSettings() || options.isLoginState(); if (key.startsWith("login_state_")) return options.isLoginState(); - if (key.startsWith("mpv_config_")) return options.isMpvConfig(); if (isAppPref(key)) return options.isSettings(); return options.isSpider(); } @@ -281,9 +253,6 @@ public class Backup { private static boolean isAppPref(String key) { return APP_PREFS.contains(key) - // 三个 update_github_proxy* 是已废弃的旧键,但必须继续随「设置」备份走: - // 从旧版备份恢复时它们要落盘,Setting.migrateLegacyGithubProxy() 才会触发 - // 并把用户当年的代理选择迁到 github_proxy。剔掉它们等于把那个选择丢弃而非迁移。 || Set.of("update_source", "update_github_proxy", "update_github_proxy_url", "update_github_proxy_mode", "update_oci_mirror", "update_oci_mirror_url").contains(key) || key.startsWith("danmaku_") || key.startsWith("playback_performance_") || key.startsWith("perf_exo_") || key.startsWith("perf_mpv_") || key.startsWith("perf_ijk_") || key.startsWith("perf_kernel_"); } @@ -306,19 +275,6 @@ public class Backup { HlsRuleConfig.invalidate(); } - private static void clearMpvConfigPreferences() { - SharedPreferences preferences = Prefers.getPrefers(); - SharedPreferences.Editor editor = preferences.edit(); - boolean changed = false; - for (String key : preferences.getAll().keySet()) { - if (key.startsWith("mpv_config_")) { - editor.remove(key); - changed = true; - } - } - if (changed) editor.commit(); - } - private static boolean containsPlaybackPerformanceProfile( Map values) { return values.containsKey("playback_performance_profile") @@ -413,34 +369,6 @@ public class Backup { this.tmdbSeasonProgress = progress; } - public List getFollowing() { - return following == null ? Collections.emptyList() : following; - } - - public void setFollowing(List following) { - this.following = following; - } - - public List getFollowingSource() { - return followingSource == null ? Collections.emptyList() : followingSource; - } - - public void setFollowingSource(List followingSource) { - this.followingSource = followingSource; - } - - public Integer getFollowingSchemaVersion() { - return followingSchemaVersion; - } - - public void setFollowingSchemaVersion(Integer followingSchemaVersion) { - this.followingSchemaVersion = followingSchemaVersion; - } - - public boolean hasFollowingPayload() { - return following != null && followingSchemaVersion != null && followingSchemaVersion > 0; - } - public List getTrack() { return track == null ? Collections.emptyList() : track; } diff --git a/app/src/main/java/com/fongmi/android/tv/bean/DanmakuMatchCache.java b/app/src/main/java/com/fongmi/android/tv/bean/DanmakuMatchCache.java index 36fb18794a..1846ed8950 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/DanmakuMatchCache.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/DanmakuMatchCache.java @@ -14,8 +14,6 @@ public final class DanmakuMatchCache { private static final Gson GSON = new Gson(); private Map items; - private Map seriesItems; - private Map tmdbSeasonItems; public static DanmakuMatchCache objectFrom(String json) { try { @@ -28,8 +26,6 @@ public final class DanmakuMatchCache { public DanmakuMatchCache() { this.items = new HashMap<>(); - this.seriesItems = new HashMap<>(); - this.tmdbSeasonItems = new HashMap<>(); } public Danmaku find(String siteKey, String vodId, String episodeName) { @@ -56,64 +52,15 @@ public final class DanmakuMatchCache { return result; } - public String findSeriesSearchTitle(String siteKey, String vodId) { - if (isBlank(siteKey) || isBlank(vodId)) return ""; - Entry entry = getSeriesItems().get(seriesKey(siteKey, vodId)); - return entry == null ? "" : clean(entry.searchTitle); - } - - public void putSeries(String siteKey, String vodId, String searchTitle, String rawTitle, Danmaku item) { - if (isBlank(siteKey) || isBlank(vodId)) return; - putManual(seriesKey(siteKey, vodId), searchTitle, rawTitle, item, getSeriesItems()); - } - - public String findTmdbSeasonSearchTitle(int tmdbId, int seasonNumber) { - if (tmdbId <= 0 || seasonNumber <= 0) return ""; - Entry entry = getTmdbSeasonItems().get(tmdbSeasonKey(tmdbId, seasonNumber)); - return entry == null ? "" : clean(entry.searchTitle); - } - - public void putTmdbSeason(int tmdbId, int seasonNumber, String searchTitle, String rawTitle, Danmaku item) { - if (tmdbId <= 0 || seasonNumber <= 0) return; - putManual(tmdbSeasonKey(tmdbId, seasonNumber), searchTitle, rawTitle, item, getTmdbSeasonItems()); - } - public Map getItems() { if (items == null) items = new HashMap<>(); return items; } - public Map getSeriesItems() { - if (seriesItems == null) seriesItems = new HashMap<>(); - return seriesItems; - } - - public Map getTmdbSeasonItems() { - if (tmdbSeasonItems == null) tmdbSeasonItems = new HashMap<>(); - return tmdbSeasonItems; - } - private String key(String siteKey, String vodId, String episodeName) { return clean(siteKey) + "@@@" + clean(vodId) + "@@@" + normalizedEpisodeKey(episodeName); } - private String seriesKey(String siteKey, String vodId) { - return clean(siteKey) + "@@@" + clean(vodId); - } - - private String tmdbSeasonKey(int tmdbId, int seasonNumber) { - return "tmdb@@@" + tmdbId + "@@@" + seasonNumber; - } - - private static void putManual(String key, String searchTitle, String rawTitle, Danmaku item, Map target) { - if (isBlank(key) || item == null || item.isEmpty()) return; - String keyword = isBlank(searchTitle) ? clean(rawTitle) : clean(searchTitle); - if (keyword.isEmpty()) return; - Entry entry = Entry.from(keyword, rawTitle, item); - if (entry == null) return; - target.put(key, entry); - } - private static String normalizedEpisodeKey(String episodeName) { int number = new MediaTitleParser().episodeNumber(episodeName); return number > 0 ? "e" + number : normalize(episodeName); diff --git a/app/src/main/java/com/fongmi/android/tv/bean/History.java b/app/src/main/java/com/fongmi/android/tv/bean/History.java index 43fcab1cb7..2781da7699 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/History.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/History.java @@ -18,13 +18,11 @@ import com.fongmi.android.tv.api.SiteApi; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.following.FollowingStore; import com.fongmi.android.tv.impl.Diffable; import com.fongmi.android.tv.history.HistoryDisplayPolicy; import com.fongmi.android.tv.player.VideoAspectMode; import com.fongmi.android.tv.playback.PlaybackProgressWriter; import com.fongmi.android.tv.playback.PlaybackDeleteTombstoneStore; -import com.fongmi.android.tv.playback.SubtitleSource; import com.fongmi.android.tv.playback.TmdbSeasonProgressStore; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; @@ -120,15 +118,6 @@ public class History implements Diffable { @SerializedName("player") @ColumnInfo(defaultValue = "-1") private int player = PlayerSetting.NONE; - /** - * 外挂字幕来源的 JSON({@link SubtitleSource}),空串表示这条记录没有外部字幕偏好。 - * - *

存整个值对象而不是拆五列:这几个字段没有一个会进 WHERE 或 ORDER BY, - * 拆列只会让 {@link #copy()}、迁移和备份各多几行。 - */ - @SerializedName("subtitleSource") - @ColumnInfo(defaultValue = "") - private String subtitleSource; private transient long updateTime; private transient String playbackSourceKey; @Ignore @@ -176,7 +165,6 @@ public class History implements Diffable { item.tmdbSeasonNumber = tmdbSeasonNumber; item.tmdbEpisodeNumber = tmdbEpisodeNumber; item.player = player; - item.subtitleSource = subtitleSource; item.updateTime = updateTime; item.playbackSourceKey = playbackSourceKey; item.sourceBindingKey = sourceBindingKey; @@ -790,23 +778,6 @@ public class History implements Diffable { this.player = PlayerSetting.sanitizePlayer(player); } - public String getSubtitleSource() { - return subtitleSource == null ? "" : subtitleSource; - } - - public void setSubtitleSource(String subtitleSource) { - this.subtitleSource = subtitleSource == null ? "" : subtitleSource; - } - - /** 解析失败返回 null,不抛——一条脏数据不能让整条历史不可用。 */ - public SubtitleSource getSubtitleSourceObject() { - return SubtitleSource.decode(subtitleSource); - } - - public void setSubtitleSourceObject(SubtitleSource source) { - this.subtitleSource = SubtitleSource.encode(source); - } - public int getCid() { return cid; } @@ -1133,7 +1104,6 @@ public class History implements Diffable { return null; }); if (saved[0] && recommendationSignalsChanged(before[0], this)) notifyChanged(); - if (saved[0]) FollowingStore.project(this); } public History save(int cid) { @@ -1170,7 +1140,6 @@ public class History implements Diffable { return null; }); if (saved[0] && recommendationSignalsChanged(before[0], this)) notifyChanged(); - if (saved[0]) FollowingStore.project(this); return this; } diff --git a/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java b/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java index d755b8cf10..8bb774fe61 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java @@ -47,6 +47,35 @@ public class HlsAdRule { return items == null ? Collections.emptyList() : items; } + /** + * 构造一条用户规则。作用域 {@code playlistHostSuffixes} 必填: + * {@link #compile()} 会拒绝没有作用域的规则,避免规则跨站点生效。 + */ + public static HlsAdRule createUserRule(String id, String name, + List playlistHostSuffixes, + List hostSuffixes, + List segmentUrlRegex, + Double minDuration, Double maxDuration, + boolean requireDiscontinuity, + boolean requireCrossDomain, + int minimumSignals) { + HlsAdRule rule = new HlsAdRule(); + rule.id = id; + rule.name = name; + rule.version = 1; + rule.enabledByDefault = true; + rule.enabled = Boolean.TRUE; + rule.playlistHostSuffixes = playlistHostSuffixes; + rule.hostSuffixes = hostSuffixes; + rule.segmentUrlRegex = segmentUrlRegex; + rule.minDuration = minDuration; + rule.maxDuration = maxDuration; + rule.requireDiscontinuity = requireDiscontinuity; + rule.requireCrossDomain = requireCrossDomain; + rule.minimumSignals = minimumSignals; + return rule; + } + public String getId() { return id == null ? "" : id; } @@ -76,7 +105,6 @@ public class HlsAdRule { int signals = signalCount(); if (signals == 0 || minimumSignals <= 0 || minimumSignals > signals) throw new IllegalArgumentException("Invalid minimum signals"); HlsManifestCleaner.Rule.Builder builder = HlsManifestCleaner.Rule.builder() - .id(getId()) .playlistHostSuffixes(playlistHostSuffixes) .playlistHostPatterns(playlistHostRegex) .hostSuffixes(hostSuffixes) diff --git a/app/src/main/java/com/fongmi/android/tv/bean/SyncOptions.java b/app/src/main/java/com/fongmi/android/tv/bean/SyncOptions.java index 29be125cfa..7f9d053542 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/SyncOptions.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/SyncOptions.java @@ -18,8 +18,6 @@ public class SyncOptions { private boolean history = true; @SerializedName("keep") private boolean keep = true; - @SerializedName("follow") - private boolean follow; @SerializedName("webHome") private boolean webHome = true; @SerializedName("settings") @@ -28,8 +26,6 @@ public class SyncOptions { private boolean loginState = true; @SerializedName("remoteRelay") private boolean remoteRelay; - @SerializedName("mpvConfig") - private boolean mpvConfig; @SerializedName("paths") private String paths = Setting.getSyncPaths(); @@ -91,15 +87,6 @@ public class SyncOptions { return this; } - public boolean isFollow() { - return follow; - } - - public SyncOptions follow(boolean follow) { - this.follow = follow; - return this; - } - public boolean isWebHome() { return webHome; } @@ -136,15 +123,6 @@ public class SyncOptions { return this; } - public boolean isMpvConfig() { - return mpvConfig; - } - - public SyncOptions mpvConfig(boolean mpvConfig) { - this.mpvConfig = mpvConfig; - return this; - } - public String getPaths() { return paths == null ? Setting.getSyncPaths() : paths; } diff --git a/app/src/main/java/com/fongmi/android/tv/bean/TmdbConfig.java b/app/src/main/java/com/fongmi/android/tv/bean/TmdbConfig.java index 723db4589b..f1a61f0b46 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/TmdbConfig.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/TmdbConfig.java @@ -2,12 +2,9 @@ package com.fongmi.android.tv.bean; import android.text.TextUtils; -import com.fongmi.android.tv.api.config.SubscriptionTmdbCredentialStore; -import com.fongmi.android.tv.setting.Setting; import com.google.gson.Gson; import com.google.gson.annotations.SerializedName; -import java.net.URI; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -15,9 +12,6 @@ import java.util.regex.Pattern; public class TmdbConfig { - public static final String ORIGIN_USER = "USER"; - public static final String ORIGIN_TRANSIENT_SUBSCRIPTION = "TRANSIENT_SUBSCRIPTION"; - private static final Gson GSON = new Gson(); private static final String DEFAULT_API_BASE = "https://api.tmdb.org/3"; private static final String DEFAULT_IMAGE_HOST = "https://images.tmdb.org"; @@ -64,9 +58,6 @@ public class TmdbConfig { private List disabledSites; @SerializedName(value = "allowedSites", alternate = {"includeSites", "whitelistSites"}) private List allowedSites; - private transient String credentialOrigin = ORIGIN_USER; - private transient String credentialSubscriptionKey = ""; - private transient long credentialScopeEpoch; public static TmdbConfig objectFrom(String json) { try { @@ -77,36 +68,7 @@ public class TmdbConfig { } } - public static TmdbConfig effectiveCurrent() { - TmdbConfig configured = objectFrom(Setting.getTmdbConfig()); - if (configured.isReady()) SubscriptionTmdbCredentialStore.discardCredential(); - SubscriptionTmdbCredentialStore.Scope scope = SubscriptionTmdbCredentialStore.currentScope(); - return effective(configured, SubscriptionTmdbCredentialStore.snapshot(scope)); - } - - public static TmdbConfig effective(TmdbConfig configured, SubscriptionTmdbCredentialStore.Snapshot snapshot) { - TmdbConfig effective = configured == null ? new TmdbConfig() : configured.copy(); - effective.sanitize(); - if (effective.isReady()) { - effective.credentialOrigin = ORIGIN_USER; - return effective; - } - if (snapshot != null && !snapshot.isEmpty() && isOfficialApiBase(DEFAULT_API_BASE)) { - effective.apiBase = DEFAULT_API_BASE; - effective.apiKey = snapshot.getApiKey(); - effective.apiKeyCompat = effective.apiKey; - effective.accessToken = ""; - effective.credentialOrigin = ORIGIN_TRANSIENT_SUBSCRIPTION; - effective.credentialSubscriptionKey = snapshot.getSubscriptionKey(); - effective.credentialScopeEpoch = snapshot.getScopeEpoch(); - return effective; - } - effective.credentialOrigin = ORIGIN_USER; - return effective; - } - public TmdbConfig sanitize() { - if (TextUtils.isEmpty(credentialOrigin)) credentialOrigin = ORIGIN_USER; apiBase = normalizeApiBase(trimOr(apiBase, DEFAULT_API_BASE)); apiKey = trimOr(apiKey, trimOr(apiKeyCompat, "")); apiKeyCompat = apiKey; @@ -202,37 +164,6 @@ public class TmdbConfig { return !TextUtils.isEmpty(getAccessToken()) || !TextUtils.isEmpty(getApiKey()); } - public boolean isTransientSubscriptionCredential() { - return ORIGIN_TRANSIENT_SUBSCRIPTION.equals(credentialOrigin); - } - - public String getCredentialOrigin() { - return credentialOrigin; - } - - public String getCredentialSubscriptionKey() { - return credentialSubscriptionKey == null ? "" : credentialSubscriptionKey; - } - - public long getCredentialScopeEpoch() { - return credentialScopeEpoch; - } - - public static boolean isOfficialApiBase(String value) { - if (TextUtils.isEmpty(value)) return false; - try { - URI uri = new URI(value.trim()); - if (!"https".equalsIgnoreCase(uri.getScheme())) return false; - if (uri.getUserInfo() != null || uri.getPort() != -1 || uri.getQuery() != null || uri.getFragment() != null) return false; - String host = uri.getHost(); - if (!"api.tmdb.org".equalsIgnoreCase(host) && !"api.themoviedb.org".equalsIgnoreCase(host)) return false; - String path = uri.getPath(); - return "/3".equals(path) || "/3/".equals(path); - } catch (Throwable ignored) { - return false; - } - } - public boolean hasSiteRules() { return !getEnabledSites().isEmpty() || !getAllowedSites().isEmpty() || !getDisabledSites().isEmpty(); } @@ -248,39 +179,7 @@ public class TmdbConfig { } public String toJson() { - TmdbConfig persistable = copy(); - if (persistable.isTransientSubscriptionCredential()) { - persistable.apiKey = ""; - persistable.apiKeyCompat = ""; - persistable.accessToken = ""; - persistable.credentialOrigin = ORIGIN_USER; - } - return GSON.toJson(persistable.sanitize()); - } - - private TmdbConfig copy() { - TmdbConfig copy = new TmdbConfig(); - copy.apiBase = apiBase; - copy.apiKey = apiKey; - copy.apiKeyCompat = apiKeyCompat; - copy.accessToken = accessToken; - copy.omdbApiKey = omdbApiKey; - copy.language = language; - copy.imageBase = imageBase; - copy.backdropBase = backdropBase; - copy.enabledSites = copyList(enabledSites); - copy.excludeKeywords = copyList(excludeKeywords); - copy.excludeKeywordsConfigured = excludeKeywordsConfigured; - copy.disabledSites = copyList(disabledSites); - copy.allowedSites = copyList(allowedSites); - copy.credentialOrigin = credentialOrigin; - copy.credentialSubscriptionKey = credentialSubscriptionKey; - copy.credentialScopeEpoch = credentialScopeEpoch; - return copy; - } - - private static List copyList(List values) { - return values == null ? null : new ArrayList<>(values); + return GSON.toJson(sanitize()); } private static String trimOr(String value, String fallback) { diff --git a/app/src/main/java/com/fongmi/android/tv/bean/TmdbSourceDetail.java b/app/src/main/java/com/fongmi/android/tv/bean/TmdbSourceDetail.java deleted file mode 100644 index 994f79dec5..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/bean/TmdbSourceDetail.java +++ /dev/null @@ -1,233 +0,0 @@ -package com.fongmi.android.tv.bean; - -import androidx.annotation.Nullable; - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.util.Set; - -/** Lightweight, type-safe reader for the TMDB-shaped detail object in a C16 payload. */ -public final class TmdbSourceDetail { - - private static final Set FIELDS = Set.of( - "id", "title", "name", "original_title", "original_name", "overview", "tagline", "status", - "poster_path", "backdrop_path", "images", "release_date", "first_air_date", "vote_average", - "vote_count", "genres", "origin_country", "original_language", "runtime", "episode_run_time", - "number_of_seasons", "number_of_episodes", "seasons", "episodes", "external_ids", "credits", - "aggregate_credits", "videos", "recommendations", "similar", "translations", "content_ratings", - "release_dates" - ); - - private final JsonObject detail; - - public TmdbSourceDetail(@Nullable String detailJson) { - this.detail = objectOrEmpty(detailJson); - } - - public String toJson() { - return detail.toString(); - } - - public JsonObject object() { - return detail.deepCopy(); - } - - public boolean has(String field) { - return FIELDS.contains(field) && detail.has(field) && !detail.get(field).isJsonNull(); - } - - public int id() { - return intValue("id", 0); - } - - public String title() { - String title = string("title"); - return title.isEmpty() ? string("name") : title; - } - - public String name() { - return string("name"); - } - - public String originalTitle() { - String title = string("original_title"); - return title.isEmpty() ? string("original_name") : title; - } - - public String overview() { - return string("overview"); - } - - public String tagline() { - return string("tagline"); - } - - public String status() { - return string("status"); - } - - public String posterPath() { - return string("poster_path"); - } - - public String backdropPath() { - return string("backdrop_path"); - } - - public String releaseDate() { - String date = string("release_date"); - return date.isEmpty() ? string("first_air_date") : date; - } - - public double voteAverage() { - return doubleValue("vote_average", 0d); - } - - public long voteCount() { - return longValue("vote_count", 0L); - } - - public JsonArray genres() { - return array("genres"); - } - - public JsonArray originCountry() { - return array("origin_country"); - } - - public String originalLanguage() { - return string("original_language"); - } - - public int runtime() { - return intValue("runtime", 0); - } - - public JsonArray episodeRunTime() { - return array("episode_run_time"); - } - - public int numberOfSeasons() { - return intValue("number_of_seasons", 0); - } - - public int numberOfEpisodes() { - return intValue("number_of_episodes", 0); - } - - @Nullable - public JsonObject images() { - return object("images"); - } - - @Nullable - public JsonObject credits() { - JsonObject value = object("credits"); - return value == null ? object("aggregate_credits") : value; - } - - @Nullable - public JsonObject externalIds() { - return object("external_ids"); - } - - @Nullable - public JsonObject videos() { - return object("videos"); - } - - @Nullable - public JsonObject recommendations() { - return object("recommendations"); - } - - @Nullable - public JsonObject similar() { - return object("similar"); - } - - public JsonArray seasons() { - return array("seasons"); - } - - public JsonArray episodes() { - return array("episodes"); - } - - @Nullable - public JsonObject translations() { - return object("translations"); - } - - @Nullable - public JsonObject contentRatings() { - return object("content_ratings"); - } - - @Nullable - public JsonObject releaseDates() { - return object("release_dates"); - } - - public String string(String field) { - if (!FIELDS.contains(field)) return ""; - JsonElement value = detail.get(field); - if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) return ""; - String result = value.getAsString(); - return result == null ? "" : result.trim(); - } - - public int intValue(String field, int fallback) { - long value = longValue(field, fallback); - return value > Integer.MAX_VALUE || value < Integer.MIN_VALUE ? fallback : (int) value; - } - - public long longValue(String field, long fallback) { - if (!FIELDS.contains(field)) return fallback; - JsonElement value = detail.get(field); - if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) return fallback; - try { - return value.getAsLong(); - } catch (RuntimeException ignored) { - return fallback; - } - } - - public double doubleValue(String field, double fallback) { - if (!FIELDS.contains(field)) return fallback; - JsonElement value = detail.get(field); - if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) return fallback; - try { - double result = value.getAsDouble(); - return Double.isFinite(result) ? result : fallback; - } catch (RuntimeException ignored) { - return fallback; - } - } - - @Nullable - public JsonObject object(String field) { - if (!FIELDS.contains(field)) return null; - JsonElement value = detail.get(field); - return value != null && value.isJsonObject() ? value.getAsJsonObject().deepCopy() : null; - } - - public JsonArray array(String field) { - if (!FIELDS.contains(field)) return new JsonArray(); - JsonElement value = detail.get(field); - return value != null && value.isJsonArray() ? value.getAsJsonArray().deepCopy() : new JsonArray(); - } - - private static JsonObject objectOrEmpty(@Nullable String json) { - if (json == null || json.isBlank()) return new JsonObject(); - try { - JsonElement element = JsonParser.parseString(json); - return element != null && element.isJsonObject() ? element.getAsJsonObject() : new JsonObject(); - } catch (RuntimeException ignored) { - return new JsonObject(); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/bean/TmdbSourcePayload.java b/app/src/main/java/com/fongmi/android/tv/bean/TmdbSourcePayload.java deleted file mode 100644 index cb26877a3f..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/bean/TmdbSourcePayload.java +++ /dev/null @@ -1,346 +0,0 @@ -package com.fongmi.android.tv.bean; - -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.utils.VodDetailCache; -import com.google.gson.JsonArray; -import com.google.gson.JsonDeserializationContext; -import com.google.gson.JsonDeserializer; -import com.google.gson.JsonElement; -import com.google.gson.JsonNull; -import com.google.gson.JsonObject; -import com.google.gson.JsonParseException; -import com.google.gson.JsonParser; -import com.google.gson.JsonSerializationContext; -import com.google.gson.JsonSerializer; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.annotations.SerializedName; - -import java.lang.reflect.Type; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Objects; -import java.util.Set; -import java.util.TreeSet; - -/** C16 source-embedded TMDB payload. */ -@JsonAdapter(TmdbSourcePayload.ProtocolAdapter.class) -public final class TmdbSourcePayload implements Parcelable { - - public static final int SCHEMA_VERSION = 1; - public static final int MAX_PARCEL_BYTES = 256 * 1024; - public static final String SOURCE_TMDB = "source_tmdb"; - public static final String CACHE_TMDB = "cache_tmdb"; - public static final String REMOTE_TMDB = "remote_tmdb"; - - @SerializedName("schema") - private int schema; - @SerializedName("id") - private int tmdbId; - @SerializedName("media_type") - private String mediaType; - @SerializedName("season_number") - private int seasonNumber; - @SerializedName("language") - private String language; - @SerializedName("fetched_at") - private String fetchedAt; - @SerializedName("complete") - private Set complete; - @SerializedName("detail") - private String detailJson; - private transient String sourceKind; - - public TmdbSourcePayload() { - schema = 0; - mediaType = ""; - language = ""; - fetchedAt = ""; - complete = new LinkedHashSet<>(); - detailJson = "{}"; - sourceKind = SOURCE_TMDB; - } - - public int getSchema() { - return schema; - } - - public void setSchema(int schema) { - this.schema = schema; - } - - public int getTmdbId() { - return tmdbId; - } - - public void setTmdbId(int tmdbId) { - this.tmdbId = tmdbId; - } - - public String getMediaType() { - return mediaType == null ? "" : mediaType; - } - - public void setMediaType(String mediaType) { - this.mediaType = mediaType; - } - - public int getSeasonNumber() { - return seasonNumber; - } - - public void setSeasonNumber(int seasonNumber) { - this.seasonNumber = seasonNumber; - } - - public String getLanguage() { - return language == null ? "" : language; - } - - public void setLanguage(String language) { - this.language = language; - } - - public String getFetchedAt() { - return fetchedAt == null ? "" : fetchedAt; - } - - public void setFetchedAt(String fetchedAt) { - this.fetchedAt = fetchedAt; - } - - public Set getComplete() { - return complete == null ? Collections.emptySet() : Collections.unmodifiableSet(complete); - } - - public void setComplete(@Nullable Collection complete) { - this.complete = complete == null ? new LinkedHashSet<>() : new LinkedHashSet<>(complete); - } - - public boolean hasCapability(String group) { - return getComplete().contains(group); - } - - public String getDetailJson() { - return detailJson == null || detailJson.isBlank() ? "{}" : detailJson; - } - - public void setDetailJson(@Nullable String detailJson) { - this.detailJson = detailJson == null || detailJson.isBlank() ? "{}" : detailJson; - } - - public TmdbSourceDetail getDetail() { - return new TmdbSourceDetail(getDetailJson()); - } - - public String getSourceKind() { - return sourceKind == null || sourceKind.isBlank() ? SOURCE_TMDB : sourceKind; - } - - public void setSourceKind(String sourceKind) { - this.sourceKind = sourceKind; - } - - public TmdbSourcePayload copy() { - TmdbSourcePayload copy = new TmdbSourcePayload(); - copy.schema = schema; - copy.tmdbId = tmdbId; - copy.mediaType = mediaType; - copy.seasonNumber = seasonNumber; - copy.language = language; - copy.fetchedAt = fetchedAt; - copy.complete = new LinkedHashSet<>(getComplete()); - copy.detailJson = getDetailJson(); - copy.sourceKind = getSourceKind(); - return copy; - } - - public boolean isIdentityValid() { - return schema == SCHEMA_VERSION && tmdbId > 0 && ("movie".equals(getMediaType()) || "tv".equals(getMediaType())) - && (("movie".equals(getMediaType()) && seasonNumber == 0) || ("tv".equals(getMediaType()) && seasonNumber >= 0)); - } - - public String identityKey() { - return getMediaType() + ':' + tmdbId + ":season=" + seasonNumber; - } - - public long estimatedParcelBytes() { - long size = 32L; - size += utf8Length(mediaType); - size += utf8Length(language); - size += utf8Length(fetchedAt); - size += utf8Length(detailJson); - size += utf8Length(sourceKind); - for (String group : getComplete()) size += 8L + utf8Length(group); - return size; - } - - public boolean requiresParcelCache() { - return estimatedParcelBytes() > MAX_PARCEL_BYTES; - } - - String putParcelCacheCopy() { - Vod holder = new Vod(); - holder.setTmdb(copy()); - return VodDetailCache.put(holder); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(@NonNull Parcel dest, int flags) { - if (requiresParcelCache()) { - dest.writeByte((byte) 2); - dest.writeString(putParcelCacheCopy()); - return; - } - dest.writeByte((byte) 1); - dest.writeInt(schema); - dest.writeInt(tmdbId); - dest.writeString(mediaType); - dest.writeInt(seasonNumber); - dest.writeString(language); - dest.writeString(fetchedAt); - dest.writeStringList(new ArrayList<>(getComplete())); - dest.writeString(getDetailJson()); - dest.writeString(getSourceKind()); - } - - public static final Creator CREATOR = new Creator<>() { - @Override - public TmdbSourcePayload createFromParcel(Parcel source) { - byte mode = source.readByte(); - if (mode == 2) return takeParcelCache(source.readString()); - TmdbSourcePayload payload = new TmdbSourcePayload(); - payload.schema = source.readInt(); - payload.tmdbId = source.readInt(); - payload.mediaType = source.readString(); - payload.seasonNumber = source.readInt(); - payload.language = source.readString(); - payload.fetchedAt = source.readString(); - ArrayList complete = new ArrayList<>(); - source.readStringList(complete); - payload.complete = new LinkedHashSet<>(complete); - payload.detailJson = source.readString(); - payload.sourceKind = source.readString(); - return payload; - } - - @Override - public TmdbSourcePayload[] newArray(int size) { - return new TmdbSourcePayload[size]; - } - }; - - @Nullable - private static TmdbSourcePayload takeParcelCache(String key) { - Vod cached = VodDetailCache.take(key); - return cached == null ? null : cached.getTmdb(); - } - - private static int utf8Length(String value) { - return value == null ? 0 : value.getBytes(StandardCharsets.UTF_8).length; - } - - @Override - public boolean equals(@Nullable Object obj) { - if (this == obj) return true; - if (!(obj instanceof TmdbSourcePayload it)) return false; - return schema == it.schema && tmdbId == it.tmdbId && seasonNumber == it.seasonNumber - && Objects.equals(getMediaType(), it.getMediaType()) && Objects.equals(getLanguage(), it.getLanguage()) - && Objects.equals(getFetchedAt(), it.getFetchedAt()) && Objects.equals(getComplete(), it.getComplete()) - && Objects.equals(getDetailJson(), it.getDetailJson()); - } - - @Override - public int hashCode() { - return Objects.hash(schema, tmdbId, getMediaType(), seasonNumber, getLanguage(), getFetchedAt(), getComplete(), getDetailJson()); - } - - public static final class ProtocolAdapter implements JsonDeserializer, JsonSerializer { - - @Override - public TmdbSourcePayload deserialize(JsonElement json, Type type, JsonDeserializationContext context) throws JsonParseException { - if (json == null || !json.isJsonObject()) return null; - JsonObject object = json.getAsJsonObject(); - TmdbSourcePayload payload = new TmdbSourcePayload(); - payload.schema = intValue(object.get("schema"), 0); - payload.tmdbId = intValue(object.get("id"), 0); - payload.mediaType = stringValue(object.get("media_type")); - payload.seasonNumber = intValue(object.get("season_number"), 0); - payload.language = stringValue(object.get("language")); - payload.fetchedAt = stringValue(object.get("fetched_at")); - payload.complete = stringSet(object.get("complete")); - payload.detailJson = objectString(object.get("detail")); - payload.sourceKind = SOURCE_TMDB; - return payload; - } - - @Override - public JsonElement serialize(TmdbSourcePayload payload, Type type, JsonSerializationContext context) { - if (payload == null) return JsonNull.INSTANCE; - JsonObject object = new JsonObject(); - object.addProperty("schema", payload.schema); - object.addProperty("id", payload.tmdbId); - if (!payload.getMediaType().isEmpty()) object.addProperty("media_type", payload.getMediaType()); - if (payload.seasonNumber != 0 || "tv".equals(payload.getMediaType())) object.addProperty("season_number", payload.seasonNumber); - if (!payload.getLanguage().isEmpty()) object.addProperty("language", payload.getLanguage()); - if (!payload.getFetchedAt().isEmpty()) object.addProperty("fetched_at", payload.getFetchedAt()); - if (!payload.getComplete().isEmpty()) { - JsonArray complete = new JsonArray(); - for (String group : new TreeSet<>(payload.getComplete())) complete.add(group); - object.add("complete", complete); - } - object.add("detail", parseObject(payload.getDetailJson())); - return object; - } - - private static int intValue(JsonElement element, int fallback) { - if (element == null || !element.isJsonPrimitive() || !element.getAsJsonPrimitive().isNumber()) return fallback; - try { - return element.getAsInt(); - } catch (RuntimeException ignored) { - return fallback; - } - } - - private static String stringValue(JsonElement element) { - if (element == null || !element.isJsonPrimitive() || !element.getAsJsonPrimitive().isString()) return ""; - String value = element.getAsString(); - return value == null ? "" : value; - } - - private static Set stringSet(JsonElement element) { - LinkedHashSet result = new LinkedHashSet<>(); - if (element == null || !element.isJsonArray()) return result; - for (JsonElement item : element.getAsJsonArray()) { - String value = stringValue(item); - if (!value.isEmpty()) result.add(value); - } - return result; - } - - private static String objectString(JsonElement element) { - return element != null && element.isJsonObject() ? element.getAsJsonObject().toString() : "{}"; - } - - private static JsonObject parseObject(String json) { - try { - JsonElement element = JsonParser.parseString(json); - return element != null && element.isJsonObject() ? element.getAsJsonObject() : new JsonObject(); - } catch (RuntimeException ignored) { - return new JsonObject(); - } - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/bean/Update.java b/app/src/main/java/com/fongmi/android/tv/bean/Update.java index 46edd7e5ac..dc8bfe88be 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/Update.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/Update.java @@ -36,10 +36,7 @@ public class Update { } public boolean hasManifest() { - return !TextUtils.isEmpty(name) - && !TextUtils.isEmpty(versionName) - && code > 0 - && (!TextUtils.isEmpty(githubUrl) || oci != null && oci.isValid()); + return !TextUtils.isEmpty(name) && (!TextUtils.isEmpty(githubUrl) || oci != null && oci.isValid()); } public boolean hasUpdate() { diff --git a/app/src/main/java/com/fongmi/android/tv/bean/UserAdRule.java b/app/src/main/java/com/fongmi/android/tv/bean/UserAdRule.java index 5112bcddfd..c0e7e98cb1 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/UserAdRule.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/UserAdRule.java @@ -18,10 +18,6 @@ public class UserAdRule { public static final String SOURCE_INTERFACE_ADS = "interface_ads"; public static final String SOURCE_INTERFACE_RULE = "interface_rule"; - public static boolean isInterfaceSource(String source) { - return SOURCE_INTERFACE_ADS.equals(source) || SOURCE_INTERFACE_RULE.equals(source); - } - @SerializedName("id") private String id; @@ -82,10 +78,6 @@ public class UserAdRule { } public static UserAdRule fromImportedCandidate(ImportedAdRuleCandidate candidate) { - return fromImportedCandidate(candidate, false); - } - - public static UserAdRule fromImportedCandidate(ImportedAdRuleCandidate candidate, boolean enabled) { UserAdRule rule = new UserAdRule(); rule.name = candidate.getName(); rule.source = "ads".equals(candidate.getSourceType()) ? SOURCE_INTERFACE_ADS : SOURCE_INTERFACE_RULE; @@ -94,7 +86,7 @@ public class UserAdRule { rule.regex = new ArrayList<>(candidate.getRegex()); rule.exclude = new ArrayList<>(candidate.getExclude()); rule.importedCandidateId = candidate.getId(); - rule.enabled = enabled; + rule.enabled = false; return rule; } @@ -205,7 +197,7 @@ public class UserAdRule { * 列表摘要:来源 · 域名 N · 广告规则 N · 白名单 N */ public String getSummary() { - String label = SOURCE_AI.equals(source) ? "AI 识别" : isInterfaceSource(source) ? "接口导入" : "手动添加"; + String label = SOURCE_AI.equals(source) ? "AI 识别" : source != null && source.startsWith("interface_") ? "接口导入" : "手动添加"; if (!getSourceName().isEmpty()) label += " · " + getSourceName(); return label + " · 域名 " + getHosts().size() + " · 广告规则 " + getRegex().size() + " · 白名单 " + getExclude().size(); } diff --git a/app/src/main/java/com/fongmi/android/tv/bean/Vod.java b/app/src/main/java/com/fongmi/android/tv/bean/Vod.java index e1fff5f9a3..3b9327481c 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/Vod.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/Vod.java @@ -70,8 +70,6 @@ public class Vod implements Parcelable, Diffable { private String vodTag; @SerializedName("action") private String action; - @SerializedName("tmdb") - private TmdbSourcePayload tmdb; @SerializedName("cate") private Cate cate; @SerializedName("style") @@ -105,7 +103,6 @@ public class Vod implements Parcelable, Diffable { this.vodPlayUrl = in.readString(); this.vodTag = in.readString(); this.action = in.readString(); - this.tmdb = in.readParcelable(TmdbSourcePayload.class.getClassLoader()); this.land = (Integer) in.readValue(Integer.class.getClassLoader()); this.circle = (Integer) in.readValue(Integer.class.getClassLoader()); this.ratio = (Float) in.readValue(Float.class.getClassLoader()); @@ -227,18 +224,7 @@ public class Vod implements Parcelable, Diffable { } public String getAction() { - // 兼容动作卡协议:部分源将动作 JSON 放在 vod_id,并用 vod_tag=action 标记。 - if (!TextUtils.isEmpty(action)) return action; - if ("action".equals(getTag()) && !TextUtils.isEmpty(getId()) && getId().trim().startsWith("{")) return getId(); - return ""; - } - - public TmdbSourcePayload getTmdb() { - return tmdb; - } - - public void setTmdb(TmdbSourcePayload tmdb) { - this.tmdb = tmdb; + return TextUtils.isEmpty(action) ? "" : action; } public Cate getCate() { @@ -384,7 +370,6 @@ public class Vod implements Parcelable, Diffable { dest.writeString(this.vodPlayUrl); dest.writeString(this.vodTag); dest.writeString(this.action); - dest.writeParcelable(this.tmdb, flags); dest.writeValue(this.land); dest.writeValue(this.circle); dest.writeValue(this.ratio); @@ -401,7 +386,7 @@ public class Vod implements Parcelable, Diffable { @Override public boolean isSameContent(Vod other) { - return getName().equals(other.getName()) && getPic().equals(other.getPic()) && getRemarks().equals(other.getRemarks()) && Objects.equals(getTmdb(), other.getTmdb()) && Objects.equals(getSite(), other.getSite()); + return getName().equals(other.getName()) && getPic().equals(other.getPic()) && getRemarks().equals(other.getRemarks()) && Objects.equals(getSite(), other.getSite()); } public static final Creator CREATOR = new Creator<>() { diff --git a/app/src/main/java/com/fongmi/android/tv/content/ActionCardHelper.java b/app/src/main/java/com/fongmi/android/tv/content/ActionCardHelper.java deleted file mode 100755 index f895bff927..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/content/ActionCardHelper.java +++ /dev/null @@ -1,244 +0,0 @@ -package com.fongmi.android.tv.content; - -import android.app.Activity; -import android.os.Handler; -import android.os.Looper; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.EditText; -import android.widget.Toast; - -import androidx.appcompat.app.AlertDialog; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.Site; -import com.fongmi.android.tv.ui.web.GameWebActivity; -import com.github.catvod.net.OkHttp; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -/** - * 实验室:影视+ VodPlus「动作卡片」执行器。 - * - * 兼容上游 Vod.action 原生字段与 vod_tag:'action' + vod_id JSON 两种协议, - * 由 TypeFragment.onItemClick 的 isAction 分支调用,替代上游只 Toast msg 的简陋处理。 - * - * 流程:点击动作卡 → 自包含卡片本地直接执行(browser 开内置 WebView / input 弹输入框); - * 其余卡片异步调 spider.action(卡片JSON) → 按响应类型分发: - * - type:"browser"(含嵌套在 action 字段里的变体)→ GameWebActivity 内置 WebView 打开 - * - type:"input" → 弹输入框(title/tip/value)→ 确认后把输入填回 value 字段再次调用, - * 形成影视+ 的链式输入(如「访问网址」→输入→开网页、「磁力链接设置」→输入→保存) - * - list 非空 → 对话框显示结果卡片(vod_name + vod_remarks) - * - msg 非空 → Toast 提示 - */ -public final class ActionCardHelper { - - private static final ExecutorService executor = Executors.newFixedThreadPool(2); - private static final Handler main = new Handler(Looper.getMainLooper()); - - /** 点击动作卡片入口:siteKey 为站点 key,actionJson 为卡片 vod_id JSON 或原生 action 字段内容。 */ - public static void handleAction(Activity activity, String siteKey, String actionJson) { - if (activity == null || TextUtils.isEmpty(actionJson)) return; - // 实验室:自包含 browser 动作卡(vod_id 直接携带 type:browser + url,如 小游戏.js、 - // 资源管理.py 的游戏大厅/书签卡)本地直接执行,不经 spider.action() 回传—— - // JS 爬虫未实现 action(),回传只会得到 null(表现为「动作无响应」)。 - JsonObject self = GameContentHandler.browserAction(actionJson); - if (self != null) { - String url = normalizeUrl(GameContentHandler.urlOf(self)); - if (!GameContentHandler.isWebUrl(url)) { - Toast.makeText(activity, "网页地址无效", Toast.LENGTH_SHORT).show(); - return; - } - GameWebActivity.start(activity, url, GameContentHandler.titleOf(self, ""), - null, GameContentHandler.headersOf(self)); - return; - } - // 实验室:自包含 input 动作卡(vod_id 携带 type:input + title/tip/value,如 资源管理.py 的 - // 访问网址/添加书签/重命名卡)本地直接弹输入框,确认后带用户输入回传 spider—— - // 卡片自带 value 仅是预填值,若先回传会被 py 端误当作用户输入处理(重命名卡直接报同名错误)。 - JsonObject card = parse(actionJson); - if (card != null && "input".equals(stringOf(card, "type"))) { - showInput(activity, siteKey, card); - return; - } - submit(activity, siteKey, actionJson); - } - - /** 异步调 spider.action 并分发响应(回传专用,不再经过卡片本地执行判定,避免 input 回传死循环)。 */ - private static void submit(Activity activity, String siteKey, String actionJson) { - executor.execute(() -> { - String resp = callSpider(siteKey, actionJson); - main.post(() -> dispatch(activity, siteKey, resp)); - }); - } - - /** 补全协议头:裸域名(如 baidu.com)按 https:// 处理,对齐 py 端 _open_url_action。 */ - private static String normalizeUrl(String url) { - String u = url == null ? "" : url.trim(); - if (!u.isEmpty() && !u.startsWith("http://") && !u.startsWith("https://")) u = "https://" + u; - return u; - } - - /* ---------------- spider 调用(对齐 SiteApi.action) ---------------- */ - - private static String callSpider(String siteKey, String actionJson) { - try { - Site site = VodConfig.get().getSite(siteKey); - if (site == null) return ""; - if (site.getType() == 3) return site.recent().spider().action(actionJson); - if (site.getType() == 4) return OkHttp.string(actionJson); - } catch (Throwable e) { - return error(e.getMessage()); - } - return ""; - } - - private static String error(String message) { - JsonObject obj = new JsonObject(); - obj.addProperty("msg", TextUtils.isEmpty(message) ? "动作执行失败" : message); - return obj.toString(); - } - - /* ---------------- 响应分发 ---------------- */ - - private static void dispatch(Activity activity, String siteKey, String resp) { - if (activity == null || activity.isFinishing()) return; - JsonObject obj = parse(resp); - if (obj == null) { - Toast.makeText(activity, "动作无响应", Toast.LENGTH_SHORT).show(); - return; - } - // 嵌套变体:{"action":{...}}(py 端 _open_url_action / toast 响应)→ 取内层对象分发, - // 使内层的 type / list / msg 均可命中(原先嵌套 toast 只会显示「操作完成」)。 - JsonElement nested = obj.get("action"); - if (nested != null && nested.isJsonObject()) obj = nested.getAsJsonObject(); - // 1. browser 动作:开内置 WebView(兼容顶层与嵌套 action 变体) - JsonObject browser = browserAction(obj); - if (browser != null) { - String url = normalizeUrl(stringOf(browser, "url")); - if (!GameContentHandler.isWebUrl(url)) { - Toast.makeText(activity, "网页地址无效", Toast.LENGTH_SHORT).show(); - return; - } - GameWebActivity.start(activity, url, stringOf(browser, "title"), null, null); - return; - } - // 2. input 动作:弹输入框,输入后带 value 回传(链式输入) - if ("input".equals(stringOf(obj, "type"))) { - showInput(activity, siteKey, obj); - return; - } - // 3. 结果列表:对话框显示 - JsonElement list = obj.get("list"); - if (list != null && list.isJsonArray() && list.getAsJsonArray().size() > 0) { - showResult(activity, list); - return; - } - // 4. msg 提示 - String msg = stringOf(obj, "msg"); - Toast.makeText(activity, TextUtils.isEmpty(msg) ? "操作完成" : msg, Toast.LENGTH_LONG).show(); - } - - /** 解析响应中的 browser 动作对象:顶层 type:browser,或嵌套在 action 字段里。 */ - private static JsonObject browserAction(JsonObject obj) { - if ("browser".equals(stringOf(obj, "type"))) return hasUrl(obj) ? obj : null; - JsonElement nested = obj.get("action"); - if (nested != null && nested.isJsonObject()) { - JsonObject inner = nested.getAsJsonObject(); - if ("browser".equals(stringOf(inner, "type")) && hasUrl(inner)) return inner; - } - return null; - } - - private static boolean hasUrl(JsonObject obj) { - return !TextUtils.isEmpty(stringOf(obj, "url")); - } - - /* ---------------- 输入弹窗 ---------------- */ - - private static void showInput(Activity activity, String siteKey, JsonObject input) { - String title = emptyTo(stringOf(input, "title"), "请输入"); - String tip = stringOf(input, "tip"); - String value = stringOf(input, "value"); - - View view = LayoutInflater.from(activity).inflate(R.layout.dialog_action_input, null); - EditText edit = view.findViewById(R.id.action_input); - if (!TextUtils.isEmpty(value)) edit.setText(value); - if (!TextUtils.isEmpty(tip)) edit.setHint(tip.replace("\\n", "\n")); - - AlertDialog dialog = new MaterialAlertDialogBuilder(activity) - .setTitle(title) - .setView(view) - .setPositiveButton(android.R.string.ok, null) - .setNegativeButton(android.R.string.cancel, null) - .create(); - dialog.show(); - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { - String text = edit.getText().toString().trim(); - if (TextUtils.isEmpty(text)) { - Toast.makeText(activity, "内容不能为空", Toast.LENGTH_SHORT).show(); - return; - } - dialog.dismiss(); - // 把输入填回 value 字段后直接回传(py 端支持 str 与 {text:...} 两种结构); - // 走 submit 而非 handleAction,避免再次被本地 input 卡判定拦截成死循环。 - input.addProperty("value", text); - submit(activity, siteKey, input.toString()); - }); - } - - /* ---------------- 结果展示 ---------------- */ - - private static void showResult(Activity activity, JsonElement list) { - StringBuilder sb = new StringBuilder(); - for (JsonElement el : list.getAsJsonArray()) { - if (!el.isJsonObject()) continue; - JsonObject vod = el.getAsJsonObject(); - String name = stringOf(vod, "vod_name"); - if (TextUtils.isEmpty(name)) continue; - if (sb.length() > 0) sb.append("\n\n"); - sb.append(name); - String remarks = stringOf(vod, "vod_remarks"); - if (!TextUtils.isEmpty(remarks)) sb.append("\n").append(remarks); - } - if (sb.length() == 0) sb.append("操作完成"); - new MaterialAlertDialogBuilder(activity) - .setTitle("执行结果") - .setMessage(sb.toString()) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - - /* ---------------- 工具 ---------------- */ - - private static String stringOf(JsonObject obj, String key) { - if (obj == null) return null; - JsonElement el = obj.get(key); - if (el == null || !el.isJsonPrimitive()) return null; - String value = el.getAsString(); - return value == null ? null : value.trim(); - } - - private static String emptyTo(String value, String fallback) { - return TextUtils.isEmpty(value) ? fallback : value; - } - - private static JsonObject parse(String json) { - if (TextUtils.isEmpty(json)) return null; - String s = json.trim(); - if (!s.startsWith("{")) return null; - try { - JsonElement el = JsonParser.parseString(s); - if (el != null && el.isJsonObject()) return el.getAsJsonObject(); - } catch (Throwable ignore) { - } - return null; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/content/GameContentHandler.java b/app/src/main/java/com/fongmi/android/tv/content/GameContentHandler.java deleted file mode 100755 index 505c4a9e48..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/content/GameContentHandler.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.fongmi.android.tv.content; - -import android.app.Activity; -import android.text.TextUtils; - -import com.fongmi.android.tv.bean.Episode; -import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.ui.web.GameWebActivity; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.util.List; -import java.util.Locale; -import java.util.Map; - -/** - * 实验室:小游戏内容处理器(对齐 ReaderContentHandler)。 - * - * 消费「动作卡片」协议(与影视+ VodPlus 的 action 卡片兼容)。 - * 按协议的 type 字段判定:仅 type == "browser" 且含非空 url 的卡片才接管, - * 用内置 WebView(GameWebActivity)直接打开,无需进详情页、无需 playerContent 解析。 - * - * 支持的 vod_id JSON 变体: - * {"actionId":"browser","type":"browser","url":"https://...","header":{...}} ← 小游戏.js - * {"actionId":"OPEN_URL","type":"browser","title":"...","url":"https://..."} ← 资源管理.py 游戏大厅/书签/网页浏览器 - * {"action":{"actionId":"OPEN_URL","type":"browser","title":"...","url":"https://..."}} ← 资源管理.py _open_url_action 嵌套变体 - * - * 不接管的卡片(type 为 input / toast 等,或 vod_id 非 JSON 的普通详情卡)一律返回 false, - * 回退原详情页流程——即「没有 type:browser 的不调用 web」。 - */ -public class GameContentHandler implements ContentHandler { - - private static final String GAME_URL_PREFIX = "game://"; - private static final String TYPE_BROWSER = "browser"; - - @Override - public boolean canHandleSite(String key, String name) { - // action 卡片的判定依赖 vod_id 内容而非站点名(dispatchSite 以此为门), - // 必须放行才能进入 handleSite;本 handler 注册在 Reader/Audio 之后,不影响白名单接管。 - return true; - } - - @Override - public boolean canHandleUrl(String url) { - if (url == null) return false; - String value = url.trim(); - return value.startsWith(GAME_URL_PREFIX) && isWebUrl(value.substring(GAME_URL_PREFIX.length())); - } - - @Override - public boolean handleSite(Activity activity, String key, String id, String name, String pic, String mark) { - JsonObject action = browserAction(id); - if (action == null) return false; // 非 type:browser 动作卡(input/toast 等)或普通详情卡 → 走原详情页 - GameWebActivity.start(activity, urlOf(action), titleOf(action, name), null, headersOf(action)); - return true; - } - - @Override - public boolean handleUrl(Activity activity, String url, String title) { - if (!canHandleUrl(url)) return false; - String target = url.trim().substring(GAME_URL_PREFIX.length()).trim(); - if (!isWebUrl(target)) return false; - GameWebActivity.start(activity, target, title, null, null); - return true; - } - - @Override - public boolean handleResult(Activity activity, String historyKey, String siteKey, String flag, - String vodName, String vodPic, List episodes, int position, - Result result, long timeout) { - return false; // 小游戏不接管播放结果 - } - - /* ---------------- 动作卡片解析 ---------------- */ - - /** - * 解析 vod_id 并判定是否为 browser 动作卡片(调 web 的网页/游戏卡)。 - * 兼容顶层直接是 action config,与 config 嵌套在 "action" 字段里的两种变体。 - * 判定:type == "browser",或 type 缺失时按 actionId 兜底(OPEN_URL / browser); - * input / toast 等其他动作(actionId 为中文动作名)不接管。 - * - * @return 带 browser 动作且含非空 url 的 action 对象;否则 null - */ - static JsonObject browserAction(String id) { - JsonObject obj = parseJson(id); - if (obj == null) return null; - // 嵌套变体:{"action":{...真正的 action config...}} - JsonElement nested = obj.get("action"); - if (nested != null && nested.isJsonObject()) obj = nested.getAsJsonObject(); - if (!isBrowserAction(obj)) return null; - String url = urlOf(obj); - return isWebUrl(url) ? obj : null; - } - - static boolean isWebUrl(String url) { - if (TextUtils.isEmpty(url)) return false; - String value = url.trim().toLowerCase(Locale.ROOT); - return value.startsWith("http://") || value.startsWith("https://"); - } - - private static boolean isBrowserAction(JsonObject obj) { - if (TYPE_BROWSER.equals(stringOf(obj, "type"))) return true; - String actionId = stringOf(obj, "actionId"); - return "OPEN_URL".equals(actionId) || TYPE_BROWSER.equals(actionId); - } - - static String urlOf(JsonObject obj) { - return stringOf(obj, "url"); - } - - static String titleOf(JsonObject obj, String fallback) { - String title = stringOf(obj, "title"); - return TextUtils.isEmpty(title) ? (fallback == null ? "" : fallback) : title; - } - - /** 从动作卡片 JSON 中提取 header(含 User-Agent);无返回 null。 */ - static java.util.HashMap headersOf(JsonObject obj) { - if (obj == null) return null; - JsonElement header = obj.get("header"); - if (header == null || !header.isJsonObject()) return null; - java.util.HashMap map = new java.util.HashMap<>(); - for (Map.Entry entry : header.getAsJsonObject().entrySet()) { - if (entry.getValue() != null && entry.getValue().isJsonPrimitive()) { - map.put(entry.getKey(), entry.getValue().getAsString()); - } - } - return map.isEmpty() ? null : map; - } - - private static String stringOf(JsonObject obj, String key) { - if (obj == null) return null; - JsonElement el = obj.get(key); - if (el == null || !el.isJsonPrimitive()) return null; - String value = el.getAsString(); - return value == null ? null : value.trim(); - } - - private static JsonObject parseJson(String id) { - if (TextUtils.isEmpty(id)) return null; - String s = id.trim(); - if (!s.startsWith("{") || !s.endsWith("}")) return null; - try { - JsonElement el = JsonParser.parseString(s); - if (el != null && el.isJsonObject()) return el.getAsJsonObject(); - } catch (Throwable ignore) { - } - return null; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/db/AppDatabase.java b/app/src/main/java/com/fongmi/android/tv/db/AppDatabase.java index df6ec07b98..f8d8298cd1 100644 --- a/app/src/main/java/com/fongmi/android/tv/db/AppDatabase.java +++ b/app/src/main/java/com/fongmi/android/tv/db/AppDatabase.java @@ -40,7 +40,7 @@ import java.util.concurrent.atomic.AtomicBoolean; @Database(entities = {Keep.class, Site.class, Live.class, Track.class, Config.class, Device.class, History.class, PlaybackDeleteTombstone.class, TmdbSeasonProgress.class}, version = AppDatabase.VERSION) public abstract class AppDatabase extends RoomDatabase { - public static final int VERSION = 45; + public static final int VERSION = 44; public static final String NAME = "tv"; public static final String SYMBOL = "@@@"; private static final int BACKUP_KEEP_COUNT = 7; @@ -178,7 +178,6 @@ public abstract class AppDatabase extends RoomDatabase { .addMigrations(Migrations.MIGRATION_41_42) .addMigrations(Migrations.MIGRATION_42_43) .addMigrations(Migrations.MIGRATION_43_44) - .addMigrations(Migrations.MIGRATION_44_45) .fallbackToDestructiveMigration(true) .allowMainThreadQueries().build(); } diff --git a/app/src/main/java/com/fongmi/android/tv/db/Migrations.java b/app/src/main/java/com/fongmi/android/tv/db/Migrations.java index 4cd9c94268..00d34c5cf3 100644 --- a/app/src/main/java/com/fongmi/android/tv/db/Migrations.java +++ b/app/src/main/java/com/fongmi/android/tv/db/Migrations.java @@ -142,21 +142,6 @@ public class Migrations { } }; - /** - * 外挂字幕随历史恢复:History 新增 subtitleSource 列。 - * 空串表示这条记录没有外部字幕偏好,起播时不注入。 - * - *

列声明必须与 Room 导出的 45.json 一致——实体里是普通 String, - * 所以是可空列。写成 NOT NULL 会让迁移后的 validateMigration 失败。 - */ - public static final Migration MIGRATION_44_45 = new Migration(44, 45) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - addColumnIfMissing(database, "History", "subtitleSource", - "ALTER TABLE History ADD COLUMN `subtitleSource` TEXT DEFAULT ''"); - } - }; - private static void addColumnIfMissing( SupportSQLiteDatabase database, String table, diff --git a/app/src/main/java/com/fongmi/android/tv/event/RefreshEvent.java b/app/src/main/java/com/fongmi/android/tv/event/RefreshEvent.java index a037df52b4..2cc8a27739 100644 --- a/app/src/main/java/com/fongmi/android/tv/event/RefreshEvent.java +++ b/app/src/main/java/com/fongmi/android/tv/event/RefreshEvent.java @@ -30,10 +30,6 @@ public class RefreshEvent { EventBus.getDefault().post(new RefreshEvent(Type.SIZE)); } - public static void uiScale() { - EventBus.getDefault().post(new RefreshEvent(Type.UI_SCALE)); - } - public static void theme() { EventBus.getDefault().post(new RefreshEvent(Type.THEME)); } @@ -113,7 +109,7 @@ public class RefreshEvent { } public enum Type { - HOME, CATEGORY, HISTORY, KEEP, SIZE, UI_SCALE, THEME, LANGUAGE, LIVE, DETAIL, PLAYER, SUBTITLE, DANMAKU, + HOME, CATEGORY, HISTORY, KEEP, SIZE, THEME, LANGUAGE, LIVE, DETAIL, PLAYER, SUBTITLE, DANMAKU, VOD, VOD_CORE, VOD_RECOMMENDATIONS, VOD_PERSONAL, VOD_EPISODE_TITLES, VOD_RELATED_VIDEOS } } diff --git a/app/src/main/java/com/fongmi/android/tv/following/AlistSubscriptionImporter.java b/app/src/main/java/com/fongmi/android/tv/following/AlistSubscriptionImporter.java deleted file mode 100644 index 85b3127522..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/AlistSubscriptionImporter.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.text.TextUtils; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; - -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; - -public final class AlistSubscriptionImporter { - - private static final OkHttpClient CLIENT = new OkHttpClient(); - private static final Gson GSON = new Gson(); - - private AlistSubscriptionImporter() { - } - - public static List fetch(String baseUrl, String token) throws IOException { - if (!FollowingSettings.isServerImportEnabled()) throw new IOException("服务端订阅导入未启用"); - String url = normalizeBase(baseUrl); - if (TextUtils.isEmpty(url)) throw new IOException("服务端地址为空"); - Request.Builder request = new Request.Builder().url(url + "/api/media-subscriptions").get(); - if (!TextUtils.isEmpty(token)) request.header("Authorization", "Bearer " + token.trim()); - try (Response response = CLIENT.newCall(request.build()).execute()) { - if (!response.isSuccessful() || response.body() == null) throw new IOException("HTTP " + response.code()); - return parse(response.body().string()); - } - } - - public static List parse(String json) { - List result = new ArrayList<>(); - JsonElement root = GSON.fromJson(json, JsonElement.class); - JsonArray array = null; - if (root != null && root.isJsonArray()) array = root.getAsJsonArray(); - else if (root != null && root.isJsonObject()) { - JsonElement data = root.getAsJsonObject().get("data"); - if (data != null && data.isJsonArray()) array = data.getAsJsonArray(); - else if (data != null && data.isJsonObject()) { - JsonElement list = data.getAsJsonObject().get("list"); - if (list != null && list.isJsonArray()) array = list.getAsJsonArray(); - } - } - if (array == null) return result; - for (JsonElement element : array) { - Candidate candidate = parseCandidate(element); - if (candidate != null) result.add(candidate); - } - return result; - } - - public static ImportResult importCandidates(List candidates) { - ImportResult result = new ImportResult(); - if (candidates == null) return result; - long now = System.currentTimeMillis(); - for (Candidate candidate : candidates) { - if (candidate == null || TextUtils.isEmpty(candidate.title)) continue; - Following existing = findExisting(candidate); - if (existing != null) { - updateExisting(existing, candidate, now); - result.updated++; - continue; - } - Following item = toFollowing(candidate, now); - FollowingSource source = toSource(item, candidate, now); - FollowingStore.saveNew(item, source); - result.created++; - } - return result; - } - - static Candidate parseCandidate(JsonElement element) { - if (element == null || !element.isJsonObject()) return null; - JsonObject object = element.getAsJsonObject(); - String title = string(object, "name", "title"); - if (TextUtils.isEmpty(title)) return null; - Candidate candidate = new Candidate(); - candidate.title = title; - candidate.season = integer(object, "season", "seasonNumber"); - candidate.tmdbId = parseProviderId(object, "tmdb"); - candidate.tmdbMediaType = normalizeProvider(object, "tv"); - candidate.doubanId = TextUtils.isEmpty(string(object, "doubanId")) ? integer(object, "douban_id") : integer(object, "doubanId"); - candidate.currentEpisodes = integer(object, "currentEpisodes", "current_episodes"); - candidate.status = string(object, "status"); - return candidate; - } - - static Following findExisting(Candidate candidate) { - if (candidate.tmdbId > 0) { - Following item = FollowingStore.findByTmdb( - new com.fongmi.android.tv.bean.TmdbItem(candidate.tmdbId, candidate.mediaType(), candidate.title, - "", "", "", ""), candidate.season); - if (item != null) return item; - } - for (Following item : FollowingStore.list()) { - if (item.trackedSeason != candidate.season) continue; - if (candidate.tmdbId > 0 && item.tmdbId == candidate.tmdbId) return item; - if (titleMatches(item.vodName, candidate.title)) return item; - } - return null; - } - - static void updateExisting(Following item, Candidate candidate, long now) { - if (candidate.currentEpisodes > 0) { - FollowingSource source = FollowingStore.preferredSource(item.identityKey); - if (source == null) source = toSource(item, candidate, now); - source.playableSeason = Math.max(source.playableSeason, candidate.season); - source.playableEpisode = Math.max(source.playableEpisode, candidate.currentEpisodes); - source.playableCount = Math.max(source.playableCount, candidate.currentEpisodes); - source.lastProbeAt = now; - source.lastError = ""; - FollowingStore.updateSource(source); - } - FollowingUpdatePolicy.refreshDerived(item, now); - FollowingStore.update(item); - } - - static Following toFollowing(Candidate candidate, long now) { - String seriesKey = candidate.tmdbId > 0 - ? "tmdb:" + FollowingIdentity.normalizeMediaType(candidate.mediaType()) + ":" + candidate.tmdbId - : "alist:" + FollowingIdentity.normalize(candidate.title) + ":s" + candidate.season; - Following item = new Following(); - item.identityKey = FollowingIdentity.identityKey(seriesKey, candidate.season); - item.seriesKey = seriesKey; - item.cid = 0; - item.siteKey = ""; - item.vodId = ""; - item.vodName = candidate.title; - item.mediaType = candidate.mediaType(); - item.tmdbId = candidate.tmdbId; - item.trackedSeason = candidate.season; - item.trackedEpisode = candidate.currentEpisodes; - item.seasonReleasedEpisodes = candidate.currentEpisodes; - item.latestReleasedSeason = candidate.season; - item.latestReleasedEpisode = candidate.currentEpisodes; - item.officialStatus = FollowingMetadataSnapshot.normalizeStatus(candidate.status); - item.notifyEnabled = FollowingSettings.isNotificationsEnabled(); - item.enabled = true; - item.createdAt = now; - item.updatedAt = now; - FollowingUpdatePolicy.initializeNew(item, candidate.currentEpisodes, now); - item.nextCheckAt = FollowingSchedulePolicy.nextCheckAt(now, item.officialStatus, 0); - return item; - } - - static FollowingSource toSource(Following item, Candidate candidate, long now) { - FollowingSource source = new FollowingSource(); - source.followingKey = item.identityKey; - source.cid = 0; - source.siteKey = ""; - source.vodId = ""; - source.vodName = candidate.title; - source.playableSeason = candidate.season; - source.playableEpisode = candidate.currentEpisodes; - source.playableCount = candidate.currentEpisodes; - source.preferred = true; - source.lastProbeAt = now; - return source; - } - - private static String normalizeBase(String value) { - String base = value == null ? "" : value.trim(); - while (base.endsWith("/")) base = base.substring(0, base.length() - 1); - if (!base.startsWith("http://") && !base.startsWith("https://")) return ""; - HttpUrl parsed = HttpUrl.parse(base); - return parsed == null ? "" : parsed.toString().replaceAll("/$", ""); - } - - private static int parseProviderId(JsonObject object, String provider) { - String metaProvider = string(object, "metaProvider", "meta_provider").toLowerCase(Locale.ROOT); - if (provider.equals(metaProvider)) return integer(object, "metaId", "meta_id"); - int direct = integer(object, provider + "Id"); - return direct > 0 ? direct : integer(object, provider + "_id"); - } - - private static String normalizeProvider(JsonObject object, String fallback) { - String mediaType = string(object, "mediaType", "media_type"); - if (!TextUtils.isEmpty(mediaType)) return FollowingIdentity.normalizeMediaType(mediaType); - String provider = string(object, "metaProvider", "meta_provider"); - return "movie".equalsIgnoreCase(provider) ? "movie" : fallback; - } - - private static boolean titleMatches(String left, String right) { - String a = left == null ? "" : left.replaceAll("\\s+", "").toLowerCase(Locale.ROOT); - String b = right == null ? "" : right.replaceAll("\\s+", "").toLowerCase(Locale.ROOT); - return !a.isEmpty() && (a.equals(b) || a.contains(b) || b.contains(a)); - } - - private static int integer(JsonObject object, String... keys) { - for (String key : keys) { - try { - JsonElement value = object.get(key); - if (value != null && !value.isJsonNull()) return Math.max(0, value.getAsInt()); - } catch (Throwable ignored) { - } - } - return 0; - } - - private static String string(JsonObject object, String... keys) { - for (String key : keys) { - try { - JsonElement value = object.get(key); - if (value != null && !value.isJsonNull()) return value.getAsString().trim(); - } catch (Throwable ignored) { - } - } - return ""; - } - - public static final class Candidate { - public String title = ""; - public int season; - public int tmdbId; - public String tmdbMediaType = "tv"; - public int doubanId; - public int currentEpisodes; - public String status = ""; - - public String mediaType() { - return FollowingIdentity.normalizeMediaType(tmdbMediaType); - } - } - - public static final class ImportResult { - public int created; - public int updated; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/Following.java b/app/src/main/java/com/fongmi/android/tv/following/Following.java deleted file mode 100644 index 1ba897174d..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/Following.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.fongmi.android.tv.following; - -import androidx.annotation.NonNull; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.Index; -import androidx.room.PrimaryKey; - -@Entity( - tableName = "following", - indices = { - @Index(value = {"series_key"}), - @Index(value = {"enabled", "next_check_at"}), - @Index(value = {"has_update", "updated_at"}), - @Index(value = {"cid", "site_key", "vod_id"}), - @Index(value = {"tmdb_id", "media_type", "tracked_season"}) - }) -public class Following { - - @PrimaryKey - @NonNull - @ColumnInfo(name = "identity_key") - public String identityKey = ""; - - @NonNull - @ColumnInfo(name = "series_key") - public String seriesKey = ""; - - public int cid; - - @NonNull - @ColumnInfo(name = "site_key") - public String siteKey = ""; - - @NonNull - @ColumnInfo(name = "vod_id") - public String vodId = ""; - - @NonNull - @ColumnInfo(name = "vod_name") - public String vodName = ""; - - @NonNull - @ColumnInfo(name = "vod_pic") - public String vodPic = ""; - - @NonNull - @ColumnInfo(name = "media_type") - public String mediaType = "tv"; - - @ColumnInfo(name = "tmdb_id") - public int tmdbId; - - @ColumnInfo(name = "tracked_season") - public int trackedSeason; - - @ColumnInfo(name = "tracked_episode") - public int trackedEpisode; - - @ColumnInfo(name = "watched_season") - public int watchedSeason; - - @ColumnInfo(name = "watched_episode") - public int watchedEpisode; - - public long position; - public long duration; - - @NonNull - @ColumnInfo(name = "official_status") - public String officialStatus = FollowingMetadataSnapshot.UNKNOWN; - - @ColumnInfo(name = "latest_released_season") - public int latestReleasedSeason; - - @ColumnInfo(name = "latest_released_episode") - public int latestReleasedEpisode; - - @ColumnInfo(name = "season_total_episodes") - public int seasonTotalEpisodes; - - @ColumnInfo(name = "season_released_episodes") - public int seasonReleasedEpisodes; - - @ColumnInfo(name = "series_total_episodes") - public int seriesTotalEpisodes; - - @ColumnInfo(name = "next_air_season") - public int nextAirSeason; - - @ColumnInfo(name = "next_air_episode") - public int nextAirEpisode; - - @ColumnInfo(name = "next_air_at") - public long nextAirAt; - - @ColumnInfo(name = "last_observed_episode") - public int lastObservedEpisode; - - @ColumnInfo(name = "read_watermark_episode") - public int readWatermarkEpisode; - - @ColumnInfo(name = "last_notified_episode") - public int lastNotifiedEpisode; - - @ColumnInfo(name = "last_notified_at") - public long lastNotifiedAt; - - @ColumnInfo(name = "has_update") - public boolean hasUpdate; - - @ColumnInfo(name = "unwatched_count") - public int unwatchedCount; - - @ColumnInfo(name = "notify_enabled") - public boolean notifyEnabled; - - public boolean enabled = true; - - @ColumnInfo(name = "metadata_updated_at") - public long metadataUpdatedAt; - - @ColumnInfo(name = "last_checked_at") - public long lastCheckedAt; - - @ColumnInfo(name = "next_check_at") - public long nextCheckAt; - - @ColumnInfo(name = "failure_count") - public int failureCount; - - @NonNull - @ColumnInfo(name = "last_error") - public String lastError = ""; - - @ColumnInfo(name = "created_at") - public long createdAt; - - @ColumnInfo(name = "updated_at") - public long updatedAt; - - public Following copy() { - Following item = new Following(); - item.identityKey = identityKey; - item.seriesKey = seriesKey; - item.cid = cid; - item.siteKey = siteKey; - item.vodId = vodId; - item.vodName = vodName; - item.vodPic = vodPic; - item.mediaType = mediaType; - item.tmdbId = tmdbId; - item.trackedSeason = trackedSeason; - item.trackedEpisode = trackedEpisode; - item.watchedSeason = watchedSeason; - item.watchedEpisode = watchedEpisode; - item.position = position; - item.duration = duration; - item.officialStatus = officialStatus; - item.latestReleasedSeason = latestReleasedSeason; - item.latestReleasedEpisode = latestReleasedEpisode; - item.seasonTotalEpisodes = seasonTotalEpisodes; - item.seasonReleasedEpisodes = seasonReleasedEpisodes; - item.seriesTotalEpisodes = seriesTotalEpisodes; - item.nextAirSeason = nextAirSeason; - item.nextAirEpisode = nextAirEpisode; - item.nextAirAt = nextAirAt; - item.lastObservedEpisode = lastObservedEpisode; - item.readWatermarkEpisode = readWatermarkEpisode; - item.lastNotifiedEpisode = lastNotifiedEpisode; - item.lastNotifiedAt = lastNotifiedAt; - item.hasUpdate = hasUpdate; - item.unwatchedCount = unwatchedCount; - item.notifyEnabled = notifyEnabled; - item.enabled = enabled; - item.metadataUpdatedAt = metadataUpdatedAt; - item.lastCheckedAt = lastCheckedAt; - item.nextCheckAt = nextCheckAt; - item.failureCount = failureCount; - item.lastError = lastError; - item.createdAt = createdAt; - item.updatedAt = updatedAt; - return item; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingBackupCodec.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingBackupCodec.java deleted file mode 100644 index 8e6e624fee..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingBackupCodec.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.fongmi.android.tv.following; - -import java.util.ArrayList; -import java.util.List; - -public final class FollowingBackupCodec { - - private FollowingBackupCodec() { - } - - public static Payload capture() { - return new Payload(FollowingStore.list(), FollowingStore.database().getFollowingSourceDao().findAll()); - } - - public static void restoreFull(List following, List sources) { - FollowingStore.replaceAll(safe(following), safe(sources)); - } - - public static void merge(List following, List sources) { - FollowingStore.mergeAll(safe(following), safe(sources)); - } - - private static List safe(List items) { - return items == null ? new ArrayList<>() : items; - } - - public record Payload(List following, List sources) { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingDao.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingDao.java deleted file mode 100644 index 58d70e54e0..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingDao.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.fongmi.android.tv.following; - -import androidx.room.Dao; -import androidx.room.Query; -import androidx.room.Upsert; - -import java.util.List; - -@Dao -public abstract class FollowingDao { - - @Upsert - public abstract void insertOrUpdate(Following item); - - @Upsert - public abstract void insertOrUpdate(List items); - - @Query("SELECT * FROM following ORDER BY has_update DESC, updated_at DESC") - public abstract List findAll(); - - @Query("SELECT * FROM following WHERE enabled = 1 AND next_check_at <= :now ORDER BY next_check_at ASC LIMIT :limit") - public abstract List findDue(long now, int limit); - - @Query("SELECT * FROM following WHERE identity_key = :identityKey LIMIT 1") - public abstract Following find(String identityKey); - - @Query("SELECT * FROM following WHERE tmdb_id = :tmdbId AND media_type = :mediaType AND tracked_season = :season LIMIT 1") - public abstract Following findByTmdb(int tmdbId, String mediaType, int season); - - @Query("SELECT * FROM following WHERE cid = :cid AND site_key = :siteKey AND vod_id = :vodId AND tracked_season = :season LIMIT 1") - public abstract Following findBySource(int cid, String siteKey, String vodId, int season); - - @Query("SELECT * FROM following WHERE cid = :cid AND site_key = :siteKey AND vod_id = :vodId AND tmdb_id = 0 ORDER BY tracked_season ASC") - public abstract List findUnmatchedBySource(int cid, String siteKey, String vodId); - - @Query("SELECT COUNT(*) FROM following WHERE enabled = 1 AND has_update = 1") - public abstract int unreadCount(); - - @Query("UPDATE following SET read_watermark_episode = MAX(read_watermark_episode, :episode), has_update = CASE WHEN latest_released_episode > MAX(read_watermark_episode, :episode) THEN 1 ELSE 0 END, updated_at = :now WHERE identity_key = :identityKey") - public abstract int markRead(String identityKey, int episode, long now); - - @Query("UPDATE following SET notify_enabled = :enabled, updated_at = :now WHERE identity_key = :identityKey") - public abstract int setNotifyEnabled(String identityKey, boolean enabled, long now); - - @Query("UPDATE following SET enabled = :enabled, updated_at = :now WHERE identity_key = :identityKey") - public abstract int setEnabled(String identityKey, boolean enabled, long now); - - @Query("DELETE FROM following WHERE identity_key = :identityKey") - public abstract int delete(String identityKey); - - @Query("DELETE FROM following") - public abstract int deleteAll(); -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingDatabase.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingDatabase.java deleted file mode 100644 index ceb45e9349..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingDatabase.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; - -import androidx.room.Database; -import androidx.room.Room; -import androidx.room.RoomDatabase; - -import com.fongmi.android.tv.App; - -@Database(entities = {Following.class, FollowingSource.class}, version = 1, exportSchema = true) -public abstract class FollowingDatabase extends RoomDatabase { - - public static final String NAME = "following"; - public static final int VERSION = 1; - private static volatile FollowingDatabase instance; - - public static FollowingDatabase get() { - if (instance == null) { - synchronized (FollowingDatabase.class) { - if (instance == null) instance = create(App.get()); - } - } - return instance; - } - - public static FollowingDatabase create(Context context) { - return Room.databaseBuilder(context.getApplicationContext(), FollowingDatabase.class, NAME) - .fallbackToDestructiveMigrationOnDowngrade() - .build(); - } - - public abstract FollowingDao getFollowingDao(); - - public abstract FollowingSourceDao getFollowingSourceDao(); -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingIdentity.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingIdentity.java deleted file mode 100644 index ff558dbd0f..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingIdentity.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.text.TextUtils; - -import com.fongmi.android.tv.bean.TmdbItem; - -import java.util.Locale; - -public final class FollowingIdentity { - - private FollowingIdentity() { - } - - public static String normalizeMediaType(String value) { - String mediaType = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); - return mediaType.isEmpty() ? "tv" : mediaType; - } - - public static String normalize(String value) { - return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); - } - - public static String seriesKey(int cid, String siteKey, String vodId) { - return "source:" + cid + ":" + normalize(siteKey) + ":" + normalize(vodId); - } - - public static String seriesKey(TmdbItem item) { - return item == null || item.getTmdbId() <= 0 ? "" : "tmdb:" + normalizeMediaType(item.getMediaType()) + ":" + item.getTmdbId(); - } - - public static String identityKey(String seriesKey, int season) { - return normalizeSeriesKey(seriesKey) + ":s" + Math.max(0, season); - } - - public static String identityKey(TmdbItem item, int season) { - String seriesKey = seriesKey(item); - return TextUtils.isEmpty(seriesKey) ? "" : identityKey(seriesKey, season); - } - - public static String identityKey(int cid, String siteKey, String vodId, int season) { - return identityKey(seriesKey(cid, siteKey, vodId), season); - } - - private static String normalizeSeriesKey(String seriesKey) { - String value = seriesKey == null ? "" : seriesKey.trim(); - if (value.startsWith("tmdb:")) { - String[] parts = value.split(":", 3); - if (parts.length == 3) return "tmdb:" + normalizeMediaType(parts[1]) + ":" + parts[2].trim(); - } - if (value.startsWith("source:")) { - String[] parts = value.split(":", 4); - if (parts.length == 4) return seriesKey(Integer.parseInt(parts[1]), parts[2], parts[3]); - } - return value; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingMergePolicy.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingMergePolicy.java deleted file mode 100644 index 81c674c4b7..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingMergePolicy.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.fongmi.android.tv.following; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -public final class FollowingMergePolicy { - - private FollowingMergePolicy() { - } - - public static List mergeFollowing(List local, List remote) { - Map merged = new LinkedHashMap<>(); - for (Following item : safe(local)) if (valid(item)) merged.put(item.identityKey, item.copy()); - for (Following item : safe(remote)) { - if (!valid(item)) continue; - Following current = merged.get(item.identityKey); - merged.put(item.identityKey, current == null ? item.copy() : mergeOne(current, item)); - } - return new ArrayList<>(merged.values()); - } - - public static List mergeSources(List local, List remote) { - Map merged = new LinkedHashMap<>(); - for (FollowingSource item : safe(local)) if (valid(item)) merged.put(key(item), item.copy()); - for (FollowingSource item : safe(remote)) { - if (!valid(item)) continue; - FollowingSource current = merged.get(key(item)); - merged.put(key(item), current == null ? item.copy() : mergeSource(current, item)); - } - return new ArrayList<>(merged.values()); - } - - static Following mergeOne(Following local, Following remote) { - Following result = local.copy(); - if (remote.metadataUpdatedAt > local.metadataUpdatedAt) { - result.vodName = prefer(remote.vodName, local.vodName); - result.vodPic = prefer(remote.vodPic, local.vodPic); - result.mediaType = prefer(remote.mediaType, local.mediaType); - result.tmdbId = remote.tmdbId > 0 ? remote.tmdbId : local.tmdbId; - result.trackedSeason = Math.max(local.trackedSeason, remote.trackedSeason); - result.trackedEpisode = Math.max(local.trackedEpisode, remote.trackedEpisode); - result.officialStatus = prefer(remote.officialStatus, local.officialStatus); - result.latestReleasedSeason = Math.max(local.latestReleasedSeason, remote.latestReleasedSeason); - result.latestReleasedEpisode = Math.max(local.latestReleasedEpisode, remote.latestReleasedEpisode); - result.seasonTotalEpisodes = Math.max(local.seasonTotalEpisodes, remote.seasonTotalEpisodes); - result.seasonReleasedEpisodes = Math.max(local.seasonReleasedEpisodes, remote.seasonReleasedEpisodes); - result.seriesTotalEpisodes = Math.max(local.seriesTotalEpisodes, remote.seriesTotalEpisodes); - result.nextAirSeason = remote.nextAirAt >= local.nextAirAt ? remote.nextAirSeason : local.nextAirSeason; - result.nextAirEpisode = remote.nextAirAt >= local.nextAirAt ? remote.nextAirEpisode : local.nextAirEpisode; - result.nextAirAt = Math.max(local.nextAirAt, remote.nextAirAt); - result.metadataUpdatedAt = remote.metadataUpdatedAt; - } - if (remote.updatedAt >= local.updatedAt) { - result.watchedSeason = remote.watchedSeason; - result.watchedEpisode = remote.watchedEpisode; - result.position = remote.position; - result.duration = remote.duration; - } else { - result.watchedEpisode = Math.max(local.watchedEpisode, remote.watchedEpisode); - if (remote.watchedEpisode > local.watchedEpisode) { - result.watchedSeason = remote.watchedSeason; - result.position = remote.position; - result.duration = remote.duration; - } - } - result.readWatermarkEpisode = Math.max(local.readWatermarkEpisode, remote.readWatermarkEpisode); - result.lastNotifiedEpisode = Math.max(local.lastNotifiedEpisode, remote.lastNotifiedEpisode); - result.lastNotifiedAt = Math.max(local.lastNotifiedAt, remote.lastNotifiedAt); - result.lastObservedEpisode = Math.max(local.lastObservedEpisode, remote.lastObservedEpisode); - result.enabled = local.enabled && remote.enabled; - result.notifyEnabled = local.notifyEnabled && remote.notifyEnabled; - result.createdAt = local.createdAt > 0 ? Math.min(local.createdAt, positive(remote.createdAt)) : remote.createdAt; - result.updatedAt = Math.max(local.updatedAt, remote.updatedAt); - FollowingUpdatePolicy.refreshDerived(result, 0); - return result; - } - - private static FollowingSource mergeSource(FollowingSource local, FollowingSource remote) { - FollowingSource result = local.copy(); - result.vodName = prefer(remote.vodName, local.vodName); - result.vodPic = prefer(remote.vodPic, local.vodPic); - result.vodFlag = prefer(remote.vodFlag, local.vodFlag); - result.playableSeason = Math.max(local.playableSeason, remote.playableSeason); - result.playableEpisode = Math.max(local.playableEpisode, remote.playableEpisode); - result.playableCount = Math.max(local.playableCount, remote.playableCount); - if (remote.lastProbeAt >= local.lastProbeAt) result.preferred = remote.preferred; - result.lastProbeAt = Math.max(local.lastProbeAt, remote.lastProbeAt); - result.lastError = remote.lastProbeAt >= local.lastProbeAt ? remote.lastError : local.lastError; - return result; - } - - private static long positive(long value) { - return value > 0 ? value : Long.MAX_VALUE; - } - - private static String prefer(String preferred, String fallback) { - return preferred == null || preferred.isBlank() ? fallback : preferred; - } - - private static boolean valid(Following item) { - return item != null && item.identityKey != null && !item.identityKey.isBlank(); - } - - private static boolean valid(FollowingSource item) { - return item != null && item.followingKey != null && !item.followingKey.isBlank(); - } - - private static String key(FollowingSource item) { - return item.followingKey + "\u0000" + item.cid + "\u0000" + item.siteKey + "\u0000" + item.vodId; - } - - private static List safe(List items) { - return items == null ? List.of() : items; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingMetadataClient.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingMetadataClient.java deleted file mode 100644 index c24f5050b4..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingMetadataClient.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.service.TmdbService; -import com.fongmi.android.tv.setting.Setting; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.TimeZone; - -public class FollowingMetadataClient { - - private final TmdbService service; - - public FollowingMetadataClient() { - this(new TmdbService()); - } - - FollowingMetadataClient(TmdbService service) { - this.service = service; - } - - public FollowingMetadataSnapshot fetch(Following item, boolean refresh) throws Exception { - if (item == null) throw new IllegalArgumentException("following == null"); - if (item.tmdbId <= 0 || !Setting.isTmdbReady()) return null; - TmdbItem tmdbItem = tmdbItem(item); - TmdbConfig config = TmdbConfig.objectFrom(Setting.getTmdbConfig()); - JsonObject detail = service.detailForFollowing(tmdbItem, config, refresh); - FollowingMetadataSnapshot snapshot = parseDetail(detail, item, System.currentTimeMillis()); - if (item.trackedSeason >= 0) { - try { - JsonObject season = service.season(tmdbItem, item.trackedSeason, config, detail, refresh); - applySeason(snapshot, season, item.trackedSeason, System.currentTimeMillis()); - } catch (Throwable ignored) { - // TV detail already carries enough state for a safe fallback. - } - } - return snapshot; - } - - public static TmdbItem tmdbItem(Following item) { - return new TmdbItem(item.tmdbId, FollowingIdentity.normalizeMediaType(item.mediaType), item.vodName, - "", "", item.vodPic, "", "", 0.0); - } - - static FollowingMetadataSnapshot parseDetail(JsonObject detail, Following item, long now) { - FollowingMetadataSnapshot snapshot = new FollowingMetadataSnapshot(); - snapshot.source = "tmdb"; - snapshot.status = FollowingMetadataSnapshot.normalizeStatus(string(detail, "status")); - snapshot.seriesTotalEpisodes = integer(detail, "number_of_episodes"); - snapshot.fetchedAt = now; - - JsonObject last = object(detail, "last_episode_to_air"); - if (last != null && !last.isJsonNull()) { - snapshot.latestReleasedSeason = integer(last, "season_number"); - snapshot.latestReleasedEpisode = integer(last, "episode_number"); - } - JsonObject next = object(detail, "next_episode_to_air"); - if (next != null && !next.isJsonNull()) { - snapshot.nextAirSeason = integer(next, "season_number"); - snapshot.nextAirEpisode = integer(next, "episode_number"); - snapshot.nextAirAt = airTime(string(next, "air_date")); - } - JsonArray seasons = array(detail, "seasons"); - if (seasons != null) { - for (JsonElement element : seasons) { - if (!element.isJsonObject()) continue; - JsonObject season = element.getAsJsonObject(); - if (integer(season, "season_number") != item.trackedSeason) continue; - snapshot.seasonTotalEpisodes = integer(season, "episode_count"); - break; - } - } - if (snapshot.seasonTotalEpisodes <= 0 && snapshot.latestReleasedSeason == item.trackedSeason) { - snapshot.seasonTotalEpisodes = snapshot.latestReleasedEpisode; - } - snapshot.seasonReleasedEpisodes = snapshot.latestReleasedSeason == item.trackedSeason - ? snapshot.latestReleasedEpisode : 0; - return snapshot; - } - - static void applySeason(FollowingMetadataSnapshot snapshot, JsonObject season, int trackedSeason, long now) { - if (snapshot == null || season == null) return; - snapshot.seasonTotalEpisodes = Math.max(snapshot.seasonTotalEpisodes, integer(season, "episode_count")); - int released = 0; - int latestReleased = 0; - JsonArray episodes = array(season, "episodes"); - if (episodes != null) { - for (JsonElement element : episodes) { - if (!element.isJsonObject()) continue; - JsonObject episode = element.getAsJsonObject(); - int number = integer(episode, "episode_number"); - if (number <= 0) continue; - long airAt = airTime(string(episode, "air_date")); - if (airAt > 0 && airAt <= now) { - released++; - latestReleased = Math.max(latestReleased, number); - } - } - } - if (released > 0) snapshot.seasonReleasedEpisodes = Math.max(snapshot.seasonReleasedEpisodes, released); - if (latestReleased > 0 && snapshot.latestReleasedSeason == trackedSeason) { - snapshot.latestReleasedEpisode = Math.max(snapshot.latestReleasedEpisode, latestReleased); - } - } - - static long airTime(String date) { - if (date == null || date.length() < 10) return 0; - try { - SimpleDateFormat source = new SimpleDateFormat("yyyy-MM-dd", Locale.US); - source.setLenient(false); - source.setTimeZone(TimeZone.getTimeZone("UTC")); - Date parsed = source.parse(date.substring(0, 10)); - if (parsed == null) return 0; - Calendar calendar = Calendar.getInstance(TimeZone.getDefault(), Locale.US); - calendar.setTime(parsed); - calendar.set(Calendar.HOUR_OF_DAY, 20); - calendar.set(Calendar.MINUTE, 0); - calendar.set(Calendar.SECOND, 0); - calendar.set(Calendar.MILLISECOND, 0); - return calendar.getTimeInMillis(); - } catch (Throwable ignored) { - return 0; - } - } - - private static JsonObject object(JsonObject object, String key) { - JsonElement value = object == null ? null : object.get(key); - return value != null && value.isJsonObject() ? value.getAsJsonObject() : null; - } - - private static JsonArray array(JsonObject object, String key) { - JsonElement value = object == null ? null : object.get(key); - return value != null && value.isJsonArray() ? value.getAsJsonArray() : null; - } - - private static int integer(JsonObject object, String key) { - try { - JsonElement value = object == null ? null : object.get(key); - return value == null || value.isJsonNull() ? 0 : value.getAsInt(); - } catch (Throwable ignored) { - return 0; - } - } - - private static String string(JsonObject object, String key) { - try { - JsonElement value = object == null ? null : object.get(key); - return value == null || value.isJsonNull() ? "" : value.getAsString(); - } catch (Throwable ignored) { - return ""; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingMetadataSnapshot.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingMetadataSnapshot.java deleted file mode 100644 index 4a556eae8e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingMetadataSnapshot.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.fongmi.android.tv.following; - -import java.util.Locale; - -public final class FollowingMetadataSnapshot { - - public static final String UNKNOWN = "UNKNOWN"; - public static final String RETURNING = "RETURNING"; - public static final String PLANNED = "PLANNED"; - public static final String ENDED = "ENDED"; - public static final String CANCELED = "CANCELED"; - - public String source = ""; - public String status = UNKNOWN; - public int latestReleasedSeason; - public int latestReleasedEpisode; - public int seasonTotalEpisodes; - public int seasonReleasedEpisodes; - public int seriesTotalEpisodes; - public int nextAirSeason; - public int nextAirEpisode; - public long nextAirAt; - public long fetchedAt; - - public static String normalizeStatus(String value) { - String status = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); - if (status.contains("RETURNING") || status.contains("PRODUCTION") || status.contains("PILOT")) return RETURNING; - if (status.contains("PLANNED")) return PLANNED; - if (status.contains("ENDED")) return ENDED; - if (status.contains("CANCEL")) return CANCELED; - return UNKNOWN; - } - - public FollowingMetadataSnapshot copy() { - FollowingMetadataSnapshot item = new FollowingMetadataSnapshot(); - item.source = source; - item.status = status; - item.latestReleasedSeason = latestReleasedSeason; - item.latestReleasedEpisode = latestReleasedEpisode; - item.seasonTotalEpisodes = seasonTotalEpisodes; - item.seasonReleasedEpisodes = seasonReleasedEpisodes; - item.seriesTotalEpisodes = seriesTotalEpisodes; - item.nextAirSeason = nextAirSeason; - item.nextAirEpisode = nextAirEpisode; - item.nextAirAt = nextAirAt; - item.fetchedAt = fetchedAt; - return item; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingNotifier.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingNotifier.java deleted file mode 100644 index 31658acfc8..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingNotifier.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.Manifest; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; - -import androidx.core.app.NotificationChannelCompat; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.core.content.ContextCompat; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.activity.FollowingActivity; - -public final class FollowingNotifier { - - public static final String CHANNEL_ID = "webhtv.following.updates"; - - private FollowingNotifier() { - } - - public static void createChannel() { - NotificationManagerCompat manager = NotificationManagerCompat.from(App.get()); - manager.createNotificationChannel(new NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) - .setName(App.get().getString(R.string.following_channel_name)) - .setDescription(App.get().getString(R.string.following_channel_description)) - .build()); - } - - public static boolean canNotify() { - return FollowingSettings.isEnabled() && FollowingSettings.isNotificationsEnabled() - && ContextCompat.checkSelfPermission(App.get(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED - && NotificationManagerCompat.from(App.get()).areNotificationsEnabled(); - } - - public static boolean notifyUpdate(Following item, FollowingSource source) { - // Keep the database-derived site state fresh while the user is already looking at the app; - // defer the system notification until the next background check. - if (App.activity() != null) return false; - if (item == null || !FollowingUpdatePolicy.shouldNotify(item) || !canNotify()) return false; - createChannel(); - Intent intent = FollowingActivity.intent(App.get(), item.identityKey); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); - PendingIntent pending = PendingIntent.getActivity(App.get(), item.identityKey.hashCode(), intent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - String text = App.get().getString(R.string.following_notification_text, - FollowingUpdatePolicy.releasedEpisode(item), - source == null ? 0 : source.playableEpisode); - NotificationCompat.Builder builder = new NotificationCompat.Builder(App.get(), CHANNEL_ID) - .setSmallIcon(R.drawable.ic_home_following) - .setContentTitle(App.get().getString(R.string.following_notification_title, item.vodName)) - .setContentText(text) - .setStyle(new NotificationCompat.BigTextStyle().bigText(text)) - .setAutoCancel(true) - .setContentIntent(pending) - .setPriority(NotificationCompat.PRIORITY_DEFAULT); - NotificationManagerCompat.from(App.get()).notify(item.identityKey.hashCode(), builder.build()); - FollowingUpdatePolicy.markNotified(item, System.currentTimeMillis()); - return true; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingPlaybackBridge.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingPlaybackBridge.java deleted file mode 100644 index 3d72bb7dfe..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingPlaybackBridge.java +++ /dev/null @@ -1,236 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.text.TextUtils; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.utils.Task; - -import java.util.function.Consumer; -import java.util.Collection; - -/** Main-thread-safe bridge between playback state and the independent following database. */ -public final class FollowingPlaybackBridge { - - public interface SaveCallback { - void onResult(Following item, Throwable error); - } - - private static volatile int cachedUnreadCount; - - private FollowingPlaybackBridge() { - } - - public static int cachedUnreadCount() { - return cachedUnreadCount; - } - - public static void refreshUnreadCountAsync(Consumer callback) { - Task.execute(() -> { - int unread = 0; - try { - unread = FollowingStore.unreadCount(); - } catch (Throwable ignored) { - } - cachedUnreadCount = unread; - final int result = unread; - if (callback != null) App.post(() -> callback.accept(result)); - }); - } - - public static boolean isEligible(History history) { - if (history == null) return false; - String mediaType = FollowingIdentity.normalizeMediaType(history.getMediaType()); - if ("movie".equals(mediaType)) return false; - if (history.getTmdbId() > 0) return "tv".equals(mediaType); - return !TextUtils.isEmpty(siteKey(history)) && !TextUtils.isEmpty(vodId(history)); - } - - public static int trackedSeason(History history, int fallbackSeason) { - if (history == null) return Math.max(0, fallbackSeason); - int season = history.getTmdbEpisodeNumber() > 0 ? history.getTmdbSeasonNumber() : fallbackSeason; - return Math.max(0, season); - } - - public static String identityKey(History history, int fallbackSeason) { - if (history == null) return ""; - int season = trackedSeason(history, fallbackSeason); - if (history.getTmdbId() > 0) { - String mediaType = FollowingIdentity.normalizeMediaType(history.getMediaType()); - return FollowingIdentity.identityKey(new TmdbItem(history.getTmdbId(), mediaType, - safe(history.getVodName()), "", "", safe(history.getVodPic()), ""), season); - } - return FollowingIdentity.identityKey(history.getCid(), siteKey(history), vodId(history), season); - } - - public static Following build(History history, int fallbackSeason) { - if (history == null) return null; - String identityKey = identityKey(history, fallbackSeason); - if (TextUtils.isEmpty(identityKey)) return null; - int season = trackedSeason(history, fallbackSeason); - String mediaType = FollowingIdentity.normalizeMediaType(history.getMediaType()); - long now = System.currentTimeMillis(); - Following item = new Following(); - item.identityKey = identityKey; - item.seriesKey = history.getTmdbId() > 0 - ? FollowingIdentity.seriesKey(new TmdbItem(history.getTmdbId(), mediaType, - safe(history.getVodName()), "", "", safe(history.getVodPic()), "")) - : FollowingIdentity.seriesKey(history.getCid(), siteKey(history), vodId(history)); - item.cid = history.getCid(); - item.siteKey = FollowingIdentity.normalize(siteKey(history)); - item.vodId = FollowingIdentity.normalize(vodId(history)); - item.vodName = safe(history.getVodName()); - item.vodPic = safe(history.getVodPic()); - item.mediaType = mediaType; - item.tmdbId = history.getTmdbId(); - item.trackedSeason = season; - item.trackedEpisode = Math.max(0, history.getTmdbEpisodeNumber()); - item.watchedSeason = Math.max(0, history.getTmdbSeasonNumber()); - item.watchedEpisode = Math.max(0, history.getTmdbEpisodeNumber()); - item.position = Math.max(0, history.getPosition()); - item.duration = Math.max(0, history.getDuration()); - item.notifyEnabled = FollowingSettings.isNotificationsEnabled(); - item.enabled = true; - item.createdAt = now; - item.updatedAt = now; - item.nextCheckAt = now; - FollowingUpdatePolicy.initializeNew(item, 0, now); - item.nextCheckAt = now; - return item; - } - - public static FollowingSource source(Following item, History history) { - if (item == null || history == null) return null; - FollowingSource source = new FollowingSource(); - source.followingKey = item.identityKey; - source.cid = item.cid; - source.siteKey = item.siteKey; - source.vodId = item.vodId; - source.vodName = item.vodName; - source.vodPic = item.vodPic; - source.vodFlag = safe(history.getVodFlag()); - source.playableSeason = item.trackedSeason; - source.preferred = true; - return source; - } - - public static void findAsync(String identityKey, Consumer callback) { - if (TextUtils.isEmpty(identityKey)) { - if (callback != null) App.post(() -> callback.accept(null)); - return; - } - Task.execute(() -> { - Following item = null; - try { - item = FollowingStore.find(identityKey); - } catch (Throwable ignored) { - } - final Following result = item; - if (callback != null) App.post(() -> callback.accept(result)); - }); - } - - public static void resolveTmdbAsync(TmdbItem tmdb, int season, int cid, String siteKey, String vodId, - FollowingMetadataSnapshot snapshot, Consumer callback) { - Task.execute(() -> { - Following item = null; - try { - item = FollowingStore.resolveTmdb(tmdb, season, cid, siteKey, vodId, snapshot); - } catch (Throwable ignored) { - } - final Following result = item; - if (callback != null) App.post(() -> callback.accept(result)); - }); - } - - public static void addAsync(Following item, FollowingSource source, SaveCallback callback) { - if (item == null || TextUtils.isEmpty(item.identityKey)) { - if (callback != null) App.post(() -> callback.onResult(null, new IllegalArgumentException("following identity is empty"))); - return; - } - Task.execute(() -> { - try { - Following existing = FollowingStore.find(item.identityKey); - if (existing == null) { - FollowingStore.saveNew(item, source); - existing = item; - } - final Following result = existing; - if (callback != null) App.post(() -> callback.onResult(result, null)); - } catch (Throwable error) { - if (callback != null) App.post(() -> callback.onResult(null, error)); - } - }); - } - - public static void deleteAsync(String identityKey, Consumer callback) { - if (TextUtils.isEmpty(identityKey)) { - if (callback != null) App.post(() -> callback.accept(null)); - return; - } - Task.execute(() -> { - Throwable error = null; - try { - FollowingStore.delete(identityKey); - } catch (Throwable throwable) { - error = throwable; - } - final Throwable result = error; - if (callback != null) App.post(() -> callback.accept(result)); - }); - } - - public static void markReadAsync(String identityKey, Consumer callback) { - writeAsync(() -> FollowingStore.markRead(identityKey), callback); - } - - public static void markReadAllAsync(Collection identityKeys, Consumer callback) { - if (identityKeys == null || identityKeys.isEmpty()) { - if (callback != null) App.post(() -> callback.accept(null)); - return; - } - writeAsync(() -> FollowingStore.markReadAll(identityKeys), callback); - } - - public static void setNotifyEnabledAsync(String identityKey, boolean enabled, Consumer callback) { - writeAsync(() -> FollowingStore.setNotifyEnabled(identityKey, enabled), callback); - } - - private static void writeAsync(Runnable action, Consumer callback) { - if (action == null) { - if (callback != null) App.post(() -> callback.accept(null)); - return; - } - Task.execute(() -> { - Throwable error = null; - try { - action.run(); - } catch (Throwable throwable) { - error = throwable; - } - final Throwable result = error; - if (callback != null) App.post(() -> callback.accept(result)); - }); - } - - private static String safe(String value) { - return value == null ? "" : value; - } - - private static String siteKey(History history) { - try { - return safe(history.getSiteKey()); - } catch (Throwable ignored) { - return ""; - } - } - - private static String vodId(History history) { - try { - return safe(history.getVodId()); - } catch (Throwable ignored) { - return ""; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingSchedulePolicy.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingSchedulePolicy.java deleted file mode 100644 index db053aa124..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingSchedulePolicy.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.fongmi.android.tv.following; - -import java.util.concurrent.TimeUnit; - -public final class FollowingSchedulePolicy { - - public static final long PERIODIC_INTERVAL = TimeUnit.HOURS.toMillis(6); - public static final long PERIODIC_FLEX = TimeUnit.HOURS.toMillis(1); - public static final long MIN_ONE_SHOT_DELAY = TimeUnit.MINUTES.toMillis(15); - public static final long MIN_ONE_SHOT_AFTER_CHECK = TimeUnit.HOURS.toMillis(1); - public static final long NEXT_AIR_GRACE = TimeUnit.MINUTES.toMillis(30); - - private FollowingSchedulePolicy() { - } - - public static long nextCheckAt(long now, String status, long nextAirAt) { - String normalized = FollowingMetadataSnapshot.normalizeStatus(status); - long nextAirCheckAt = nextAirCheckAt(now, nextAirAt); - if (FollowingMetadataSnapshot.RETURNING.equals(normalized)) { - return nextAirCheckAt > 0 ? Math.min(now + PERIODIC_INTERVAL, nextAirCheckAt) : now + PERIODIC_INTERVAL; - } - if (FollowingMetadataSnapshot.PLANNED.equals(normalized)) { - long daily = now + TimeUnit.HOURS.toMillis(24); - return nextAirCheckAt > 0 ? Math.min(daily, nextAirCheckAt) : daily; - } - if (FollowingMetadataSnapshot.ENDED.equals(normalized) || FollowingMetadataSnapshot.CANCELED.equals(normalized)) { - return now + TimeUnit.DAYS.toMillis(7); - } - return now + PERIODIC_INTERVAL; - } - - private static long nextAirCheckAt(long now, long nextAirAt) { - if (nextAirAt <= 0) return 0; - if (nextAirAt <= now) return now + MIN_ONE_SHOT_AFTER_CHECK; - return Math.max(now + MIN_ONE_SHOT_AFTER_CHECK, nextAirAt + NEXT_AIR_GRACE); - } - - public static long backoffAt(long now, int failureCount) { - long delay = switch (Math.max(1, failureCount)) { - case 1 -> TimeUnit.MINUTES.toMillis(15); - case 2 -> TimeUnit.MINUTES.toMillis(30); - case 3 -> TimeUnit.HOURS.toMillis(1); - case 4 -> TimeUnit.HOURS.toMillis(2); - case 5 -> TimeUnit.HOURS.toMillis(6); - default -> TimeUnit.DAYS.toMillis(1); - }; - return now + delay; - } - - public static boolean isPermanentFailure(Throwable error) { - String message = error == null || error.getMessage() == null ? "" : error.getMessage(); - String lower = message.toLowerCase(); - return lower.contains("401") || lower.contains("403") || lower.contains("未配置") - || lower.contains("配置缺失") || lower.contains("api key") - || lower.contains("access token"); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingScheduler.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingScheduler.java deleted file mode 100644 index e0bb08c5ba..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingScheduler.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; - -import androidx.work.BackoffPolicy; -import androidx.work.Constraints; -import androidx.work.ExistingPeriodicWorkPolicy; -import androidx.work.ExistingWorkPolicy; -import androidx.work.NetworkType; -import androidx.work.OneTimeWorkRequest; -import androidx.work.PeriodicWorkRequest; -import androidx.work.WorkManager; - -import java.util.Locale; -import java.util.concurrent.TimeUnit; - -public final class FollowingScheduler { - - public static final String PERIODIC_WORK = "webhtv.following.periodic"; - public static final String ONE_SHOT_WORK = "webhtv.following.one-shot"; - public static final String ONE_SHOT_PREFIX = ONE_SHOT_WORK + "."; - public static final String TAG = "webhtv.following"; - - private FollowingScheduler() { - } - - public static void ensurePeriodic(Context context) { - if (!FollowingSettings.isEnabled()) { - cancelAll(context); - return; - } - PeriodicWorkRequest request = new PeriodicWorkRequest.Builder( - FollowingUpdateWorker.class, 6, TimeUnit.HOURS, 1, TimeUnit.HOURS) - .setConstraints(networkConstraints()) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES) - .addTag(TAG) - .build(); - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - PERIODIC_WORK, ExistingPeriodicWorkPolicy.UPDATE, request); - } - - public static void enqueueDueNow(Context context) { - if (!FollowingSettings.isEnabled()) return; - OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(FollowingUpdateWorker.class) - .setConstraints(networkConstraints()) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES) - .addTag(TAG) - .build(); - WorkManager.getInstance(context).enqueueUniqueWork(ONE_SHOT_WORK, ExistingWorkPolicy.KEEP, request); - } - - public static void scheduleNext(Context context, Following item) { - if (item == null || !item.enabled || !FollowingSettings.isEnabled()) { - if (item != null) cancelNext(context, item.identityKey); - return; - } - long delay = Math.max(FollowingSchedulePolicy.MIN_ONE_SHOT_DELAY, item.nextCheckAt - System.currentTimeMillis()); - OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(FollowingUpdateWorker.class) - .setInitialDelay(delay, TimeUnit.MILLISECONDS) - .setConstraints(networkConstraints()) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES) - .addTag(TAG) - .build(); - WorkManager.getInstance(context).enqueueUniqueWork(oneShotName(item.identityKey), ExistingWorkPolicy.REPLACE, request); - } - - public static void cancelNext(Context context, String identityKey) { - if (identityKey == null || identityKey.isBlank()) return; - WorkManager.getInstance(context).cancelUniqueWork(oneShotName(identityKey)); - } - - public static void cancelAll(Context context) { - WorkManager manager = WorkManager.getInstance(context); - manager.cancelUniqueWork(PERIODIC_WORK); - manager.cancelUniqueWork(ONE_SHOT_WORK); - manager.cancelAllWorkByTag(TAG); - } - - private static Constraints networkConstraints() { - return new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(); - } - - private static String oneShotName(String identityKey) { - return String.format(Locale.US, "%s%08x", ONE_SHOT_PREFIX, identityKey.hashCode()); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingSettings.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingSettings.java deleted file mode 100644 index 007437b10d..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingSettings.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.github.catvod.utils.Prefers; - -public final class FollowingSettings { - - public static final String ENABLED = "following_enabled"; - public static final String NOTIFICATIONS = "following_notifications"; - public static final String SOURCE_PROBE = "following_source_probe"; - public static final String SERVER_IMPORT = "following_server_import"; - public static final String SOURCE_PROBE_WHEN_UPDATE = "when_update"; - public static final String SOURCE_PROBE_MANUAL = "manual"; - public static final String SOURCE_PROBE_NEVER = "never"; - - private FollowingSettings() { - } - - public static boolean isEnabled() { - return Prefers.getBoolean(ENABLED, false); - } - - public static void setEnabled(boolean enabled) { - Prefers.put(ENABLED, enabled); - } - - public static boolean isNotificationsEnabled() { - return Prefers.getBoolean(NOTIFICATIONS, false); - } - - public static void setNotificationsEnabled(boolean enabled) { - Prefers.put(NOTIFICATIONS, enabled); - } - - public static String sourceProbeMode() { - String value = Prefers.getString(SOURCE_PROBE, SOURCE_PROBE_WHEN_UPDATE); - return value == null || value.isBlank() ? SOURCE_PROBE_WHEN_UPDATE : value; - } - - public static void setSourceProbeMode(String mode) { - if (SOURCE_PROBE_MANUAL.equals(mode) || SOURCE_PROBE_NEVER.equals(mode)) Prefers.put(SOURCE_PROBE, mode); - else Prefers.put(SOURCE_PROBE, SOURCE_PROBE_WHEN_UPDATE); - } - - public static boolean isServerImportEnabled() { - return Prefers.getBoolean(SERVER_IMPORT, false); - } - - public static void setServerImportEnabled(boolean enabled) { - Prefers.put(SERVER_IMPORT, enabled); - } - - public static boolean shouldProbe(boolean manual, boolean metadataChanged, FollowingSource source) { - if (!isEnabled() || SOURCE_PROBE_NEVER.equals(sourceProbeMode())) return false; - if (manual) return true; - if (SOURCE_PROBE_MANUAL.equals(sourceProbeMode())) return source == null; - return metadataChanged || source == null || source.lastProbeAt <= 0; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingSource.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingSource.java deleted file mode 100644 index da7667f902..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingSource.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.fongmi.android.tv.following; - -import androidx.annotation.NonNull; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.Index; - -@Entity( - tableName = "following_source", - primaryKeys = {"following_key", "cid", "site_key", "vod_id"}, - indices = { - @Index(value = {"following_key", "preferred"}), - @Index(value = {"cid", "site_key", "vod_id"}) - }) -public class FollowingSource { - - @NonNull - @ColumnInfo(name = "following_key") - public String followingKey = ""; - - public int cid; - - @NonNull - @ColumnInfo(name = "site_key") - public String siteKey = ""; - - @NonNull - @ColumnInfo(name = "vod_id") - public String vodId = ""; - - @NonNull - @ColumnInfo(name = "vod_name") - public String vodName = ""; - - @NonNull - @ColumnInfo(name = "vod_pic") - public String vodPic = ""; - - @NonNull - @ColumnInfo(name = "vod_flag") - public String vodFlag = ""; - - @ColumnInfo(name = "playable_season") - public int playableSeason; - - @ColumnInfo(name = "playable_episode") - public int playableEpisode; - - @ColumnInfo(name = "playable_count") - public int playableCount; - - public boolean preferred; - - @ColumnInfo(name = "last_probe_at") - public long lastProbeAt; - - @NonNull - @ColumnInfo(name = "last_error") - public String lastError = ""; - - public FollowingSource copy() { - FollowingSource item = new FollowingSource(); - item.followingKey = followingKey; - item.cid = cid; - item.siteKey = siteKey; - item.vodId = vodId; - item.vodName = vodName; - item.vodPic = vodPic; - item.vodFlag = vodFlag; - item.playableSeason = playableSeason; - item.playableEpisode = playableEpisode; - item.playableCount = playableCount; - item.preferred = preferred; - item.lastProbeAt = lastProbeAt; - item.lastError = lastError; - return item; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingSourceDao.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingSourceDao.java deleted file mode 100644 index 2e884220f5..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingSourceDao.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.fongmi.android.tv.following; - -import androidx.room.Dao; -import androidx.room.Query; -import androidx.room.Upsert; - -import java.util.List; - -@Dao -public abstract class FollowingSourceDao { - - @Upsert - public abstract void insertOrUpdate(FollowingSource item); - - @Upsert - public abstract void insertOrUpdate(List items); - - @Query("SELECT * FROM following_source ORDER BY preferred DESC, last_probe_at DESC") - public abstract List findAll(); - - @Query("SELECT * FROM following_source WHERE following_key = :followingKey ORDER BY preferred DESC, last_probe_at DESC") - public abstract List findForFollowing(String followingKey); - - @Query("SELECT * FROM following_source WHERE following_key = :followingKey ORDER BY preferred DESC, last_probe_at DESC LIMIT 1") - public abstract FollowingSource findPreferred(String followingKey); - - @Query("DELETE FROM following_source WHERE following_key = :followingKey") - public abstract int deleteForFollowing(String followingKey); - - @Query("DELETE FROM following_source") - public abstract int deleteAll(); -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingSourceProbe.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingSourceProbe.java deleted file mode 100644 index 98f9c668ac..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingSourceProbe.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.text.TextUtils; - -import com.fongmi.android.tv.api.SiteApi; -import com.fongmi.android.tv.bean.Episode; -import com.fongmi.android.tv.bean.Flag; -import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.Vod; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -public class FollowingSourceProbe { - - public FollowingSourceSnapshot probe(Following item, FollowingSource source) throws Exception { - if (item == null || source == null) throw new IllegalArgumentException("following/source == null"); - Result result = SiteApi.detailContent(source.siteKey, source.vodId, true); - Vod vod = result == null ? null : result.getVod(); - Flag flag = chooseFlag(vod, source.vodFlag, item.trackedSeason); - long now = System.currentTimeMillis(); - FollowingSourceSnapshot snapshot = new FollowingSourceSnapshot(); - snapshot.source = source; - snapshot.probedAt = now; - if (vod != null) { - if (TextUtils.isEmpty(source.vodName)) source.vodName = vod.getName(); - if (TextUtils.isEmpty(source.vodPic)) source.vodPic = vod.getPic(); - } - if (flag == null) { - source.lastProbeAt = now; - source.lastError = "来源没有可播放线路"; - throw new IllegalStateException(source.lastError); - } - source.vodFlag = flag.getFlag(); - source.playableSeason = Math.max(0, item.trackedSeason); - Set urls = new HashSet<>(); - int max = 0; - int count = 0; - for (Episode episode : flag.getEpisodes()) { - if (episode == null || TextUtils.isEmpty(episode.getUrl())) continue; - if (!matchesSeason(episode, item.trackedSeason)) continue; - if (!urls.add(episode.getUrl())) continue; - int number = episodeNumber(episode); - if (number <= 0) continue; - count++; - max = Math.max(max, number); - } - source.playableEpisode = max; - source.playableCount = count; - source.lastProbeAt = now; - source.lastError = ""; - snapshot.playableEpisode = max; - snapshot.playableCount = count; - return snapshot; - } - - static Flag chooseFlag(Vod vod, String preferred) { - return chooseFlag(vod, preferred, -1); - } - - static Flag chooseFlag(Vod vod, String preferred, int trackedSeason) { - if (vod == null || vod.getFlags() == null) return null; - Flag preferredFlag = null; - Flag fallback = null; - int fallbackCount = -1; - for (Flag flag : vod.getFlags()) { - if (flag == null) continue; - if (!TextUtils.isEmpty(preferred) && preferred.equals(flag.getFlag())) { - preferredFlag = flag; - continue; - } - int count = validCount(flag, trackedSeason); - if (count > fallbackCount) { - fallback = flag; - fallbackCount = count; - } - } - if (preferredFlag != null && validCount(preferredFlag, trackedSeason) > 0) return preferredFlag; - return fallbackCount > 0 ? fallback : null; - } - - private static int validCount(Flag flag, int trackedSeason) { - int count = 0; - for (Episode episode : flag.getEpisodes()) { - if (episode == null || TextUtils.isEmpty(episode.getUrl())) continue; - if (trackedSeason >= 0 && !matchesSeason(episode, trackedSeason)) continue; - count++; - } - return count; - } - - static int episodeNumber(Episode episode) { - TmdbEpisode tmdb = episode == null ? null : episode.getTmdbEpisode(); - if (tmdb != null && tmdb.getNumber() > 0) return tmdb.getNumber(); - return episode == null ? 0 : episode.getNumber(); - } - - static boolean matchesSeason(Episode episode, int trackedSeason) { - TmdbEpisode tmdb = episode == null ? null : episode.getTmdbEpisode(); - return tmdb == null || tmdb.getSeasonNumber() < 0 || tmdb.getSeasonNumber() == trackedSeason; - } - - public static FollowingMetadataSnapshot metadata(Following item, FollowingSource source, long now) { - FollowingMetadataSnapshot snapshot = new FollowingMetadataSnapshot(); - snapshot.source = "source-probe"; - snapshot.status = FollowingMetadataSnapshot.UNKNOWN; - snapshot.latestReleasedSeason = Math.max(0, item.trackedSeason); - snapshot.latestReleasedEpisode = Math.max(0, source.playableEpisode); - snapshot.seasonTotalEpisodes = Math.max(0, source.playableCount); - snapshot.seasonReleasedEpisodes = Math.max(0, source.playableEpisode); - snapshot.fetchedAt = now; - return snapshot; - } - - public static final class FollowingSourceSnapshot { - public FollowingSource source; - public int playableEpisode; - public int playableCount; - public long probedAt; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingStore.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingStore.java deleted file mode 100644 index 5605e560af..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingStore.java +++ /dev/null @@ -1,295 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.text.TextUtils; - -import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.bean.Episode; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.db.AppDatabase; - -import java.util.List; -import java.util.Collection; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public final class FollowingStore { - - private static final Object MIGRATION_LOCK = new Object(); - private static final ExecutorService PROJECTOR = Executors.newSingleThreadExecutor(runnable -> { - Thread thread = new Thread(runnable, "following-projector"); - thread.setDaemon(true); - return thread; - }); - - private FollowingStore() { - } - - public static FollowingDatabase database() { - return FollowingDatabase.get(); - } - - public static List list() { - return database().getFollowingDao().findAll(); - } - - public static List sources(String identityKey) { - return database().getFollowingSourceDao().findForFollowing(identityKey); - } - - public static FollowingSource preferredSource(String identityKey) { - return database().getFollowingSourceDao().findPreferred(identityKey); - } - - public static Following find(String identityKey) { - return TextUtils.isEmpty(identityKey) ? null : database().getFollowingDao().find(identityKey); - } - - public static Following findByTmdb(TmdbItem item, int season) { - if (item == null || item.getTmdbId() <= 0) return null; - return database().getFollowingDao().findByTmdb(item.getTmdbId(), FollowingIdentity.normalizeMediaType(item.getMediaType()), season); - } - - public static Following findBySource(int cid, String siteKey, String vodId, int season) { - return database().getFollowingDao().findBySource(cid, FollowingIdentity.normalize(siteKey), FollowingIdentity.normalize(vodId), season); - } - - public static Following resolveTmdb(TmdbItem tmdb, int season, int cid, String siteKey, String vodId, - FollowingMetadataSnapshot snapshot) { - if (tmdb == null || tmdb.getTmdbId() <= 0 || !"tv".equals(FollowingIdentity.normalizeMediaType(tmdb.getMediaType()))) return null; - String identityKey = FollowingIdentity.identityKey(tmdb, season); - synchronized (MIGRATION_LOCK) { - return runInTransaction(() -> { - Following target = database().getFollowingDao().find(identityKey); - List sources = database().getFollowingDao().findUnmatchedBySource( - cid, FollowingIdentity.normalize(siteKey), FollowingIdentity.normalize(vodId)); - Following source = selectMigrationSource(sources, season); - if (source == null) return target; - return migrateSourceToTmdb(source, tmdb, season, snapshot); - }); - } - } - - private static Following selectMigrationSource(List sources, int season) { - if (sources == null || sources.isEmpty()) return null; - for (Following item : sources) if (item.trackedSeason == season) return item; - for (Following item : sources) if (item.trackedSeason <= 0) return item; - return sources.get(0); - } - - private static Following migrateSourceToTmdb(Following source, TmdbItem tmdb, int season, - FollowingMetadataSnapshot snapshot) { - long now = System.currentTimeMillis(); - String identityKey = FollowingIdentity.identityKey(tmdb, season); - Following migrated = source.copy(); - boolean fallbackSeason = migrated.trackedSeason <= 0 && season > 0; - migrated.identityKey = identityKey; - migrated.seriesKey = FollowingIdentity.seriesKey(tmdb); - migrated.mediaType = FollowingIdentity.normalizeMediaType(tmdb.getMediaType()); - migrated.tmdbId = tmdb.getTmdbId(); - migrated.trackedSeason = season; - if (fallbackSeason) { - if (migrated.watchedEpisode > 0) migrated.watchedSeason = season; - if (migrated.lastNotifiedEpisode > 0 || migrated.readWatermarkEpisode > 0) migrated.watchedSeason = season; - } - - Following target = database().getFollowingDao().find(identityKey); - Following result = target == null ? migrated : FollowingMergePolicy.mergeOne(target, migrated); - if (snapshot != null) FollowingUpdatePolicy.applyMetadata(result, snapshot, now); - else FollowingUpdatePolicy.refreshDerived(result, now); - result.nextCheckAt = FollowingSchedulePolicy.nextCheckAt(now, result.officialStatus, result.nextAirAt); - database().getFollowingDao().insertOrUpdate(result); - - List bindings = database().getFollowingSourceDao().findForFollowing(source.identityKey); - for (FollowingSource binding : bindings) { - if (binding == null) continue; - binding.followingKey = identityKey; - if (binding.playableSeason <= 0) binding.playableSeason = season; - database().getFollowingSourceDao().insertOrUpdate(binding); - } - database().getFollowingSourceDao().deleteForFollowing(source.identityKey); - database().getFollowingDao().delete(source.identityKey); - return result; - } - - public static int unreadCount() { - try { - return database().getFollowingDao().unreadCount(); - } catch (Throwable e) { - return 0; - } - } - - public static void saveNew(Following item, FollowingSource source) { - if (item == null || TextUtils.isEmpty(item.identityKey)) throw new IllegalArgumentException("following identity is empty"); - runInTransaction(() -> { - database().getFollowingDao().insertOrUpdate(item); - if (source != null) { - source.followingKey = item.identityKey; - database().getFollowingSourceDao().insertOrUpdate(source); - } - return null; - }); - } - - public static void update(Following item) { - if (item == null || TextUtils.isEmpty(item.identityKey)) return; - database().getFollowingDao().insertOrUpdate(item); - } - - public static void updateSource(FollowingSource source) { - if (source == null || TextUtils.isEmpty(source.followingKey)) return; - database().getFollowingSourceDao().insertOrUpdate(source); - } - - public static void markRead(String identityKey) { - Following item = find(identityKey); - if (item == null) return; - FollowingUpdatePolicy.markRead(item, System.currentTimeMillis()); - update(item); - } - - public static void markReadAll(Collection identityKeys) { - if (identityKeys == null || identityKeys.isEmpty()) return; - long now = System.currentTimeMillis(); - runInTransaction(() -> { - for (String identityKey : identityKeys) { - Following item = find(identityKey); - if (item == null || !item.hasUpdate) continue; - FollowingUpdatePolicy.markRead(item, now); - update(item); - } - return null; - }); - } - - public static void setNotifyEnabled(String identityKey, boolean enabled) { - database().getFollowingDao().setNotifyEnabled(identityKey, enabled, System.currentTimeMillis()); - } - - public static void setEnabled(String identityKey, boolean enabled) { - database().getFollowingDao().setEnabled(identityKey, enabled, System.currentTimeMillis()); - } - - public static void delete(String identityKey) { - runInTransaction(() -> { - database().getFollowingSourceDao().deleteForFollowing(identityKey); - database().getFollowingDao().delete(identityKey); - return null; - }); - } - - public static void project(History history) { - if (history == null || !FollowingSettings.isEnabled()) return; - History snapshot = history.copy(); - PROJECTOR.execute(() -> { - try { - projectHistory(snapshot); - } catch (Throwable ignored) { - // Projection is best effort and must never affect playback history writes. - } - }); - } - - public static void reconcile(Following item) { - if (item == null) return; - if (item.tmdbId > 0) { - List histories = AppDatabase.get().getHistoryDao().findByTmdbIdentity( - item.cid, FollowingIdentity.normalizeMediaType(item.mediaType), item.tmdbId); - History latest = null; - for (History history : histories) { - if (history.getTmdbSeasonNumber() != item.trackedSeason) continue; - if (latest == null || history.getCreateTime() > latest.getCreateTime()) latest = history; - } - if (latest != null) projectHistory(latest); - } else { - History history = History.find(item.cid, sourceHistoryKey(item.siteKey, item.vodId)); - if (history != null) projectHistory(history); - } - } - - public static History historyFor(Following item, FollowingSource source) { - if (item == null) return null; - if (item.tmdbId > 0) { - List histories = AppDatabase.get().getHistoryDao().findByTmdbIdentity( - item.cid, FollowingIdentity.normalizeMediaType(item.mediaType), item.tmdbId); - History preferred = null; - History anySource = null; - for (History history : histories) { - if (history.getTmdbSeasonNumber() != item.trackedSeason) continue; - if (anySource == null || history.getCreateTime() > anySource.getCreateTime()) anySource = history; - if (source == null || TextUtils.equals(history.getSiteKey(), source.siteKey)) { - if (preferred == null || history.getCreateTime() > preferred.getCreateTime()) preferred = history; - } - } - if (preferred != null) return preferred; - if (anySource != null) return anySource; - } - if (source != null) return History.find(item.cid, sourceHistoryKey(source.siteKey, source.vodId)); - return History.find(item.cid, sourceHistoryKey(item.siteKey, item.vodId)); - } - - public static T runInTransaction(Callable action) { - return database().runInTransaction(action); - } - - public static void replaceAll(List following, List sources) { - runInTransaction(() -> { - database().getFollowingSourceDao().deleteAll(); - database().getFollowingDao().deleteAll(); - if (following != null && !following.isEmpty()) database().getFollowingDao().insertOrUpdate(following); - if (sources != null && !sources.isEmpty()) database().getFollowingSourceDao().insertOrUpdate(sources); - return null; - }); - } - - public static void mergeAll(List remoteFollowing, List remoteSources) { - runInTransaction(() -> { - List merged = FollowingMergePolicy.mergeFollowing(list(), remoteFollowing); - List sources = FollowingMergePolicy.mergeSources( - database().getFollowingSourceDao().findAll(), remoteSources); - database().getFollowingSourceDao().deleteAll(); - database().getFollowingDao().deleteAll(); - if (!merged.isEmpty()) database().getFollowingDao().insertOrUpdate(merged); - if (!sources.isEmpty()) database().getFollowingSourceDao().insertOrUpdate(sources); - return null; - }); - } - - private static void projectHistory(History history) { - if (history == null) return; - int episodeNumber = history.getTmdbEpisodeNumber(); - if (episodeNumber <= 0) episodeNumber = Episode.create(history.getVodRemarks(), history.getEpisodeUrl()).getNumber(); - if (episodeNumber <= 0) return; - Following item = null; - if (history.getTmdbId() > 0 && "tv".equals(FollowingIdentity.normalizeMediaType(history.getMediaType()))) { - item = database().getFollowingDao().findByTmdb( - history.getTmdbId(), FollowingIdentity.normalizeMediaType(history.getMediaType()), - Math.max(0, history.getTmdbSeasonNumber())); - } - if (item == null) { - item = database().getFollowingDao().findBySource( - history.getCid(), FollowingIdentity.normalize(history.getSiteKey()), - FollowingIdentity.normalize(history.getVodId()), Math.max(0, history.getTmdbSeasonNumber())); - } - if (item == null) return; - boolean changed = episodeNumber > item.watchedEpisode; - if (episodeNumber == item.watchedEpisode) { - changed = history.getPosition() != item.position || history.getDuration() != item.duration; - } - if (!changed) return; - item.watchedSeason = Math.max(0, history.getTmdbSeasonNumber()); - item.watchedEpisode = episodeNumber; - item.position = Math.max(0, history.getPosition()); - item.duration = Math.max(0, history.getDuration()); - item.trackedSeason = Math.max(0, history.getTmdbSeasonNumber()); - if (item.trackedEpisode <= 0) item.trackedEpisode = item.watchedEpisode; - FollowingUpdatePolicy.refreshDerived(item, System.currentTimeMillis()); - database().getFollowingDao().insertOrUpdate(item); - } - - public static String sourceHistoryKey(String siteKey, String vodId) { - if (TextUtils.isEmpty(siteKey) || TextUtils.isEmpty(vodId)) return ""; - return siteKey + AppDatabase.SYMBOL + vodId; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingUpdateCoordinator.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingUpdateCoordinator.java deleted file mode 100644 index 3e20d2b687..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingUpdateCoordinator.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.following.FollowingSourceProbe.FollowingSourceSnapshot; - -import java.io.IOException; -import java.util.List; - -public class FollowingUpdateCoordinator { - - private final FollowingMetadataClient metadataClient; - private final FollowingSourceProbe sourceProbe; - - public FollowingUpdateCoordinator() { - this(new FollowingMetadataClient(), new FollowingSourceProbe()); - } - - FollowingUpdateCoordinator(FollowingMetadataClient metadataClient, FollowingSourceProbe sourceProbe) { - this.metadataClient = metadataClient; - this.sourceProbe = sourceProbe; - } - - public int checkDue(long now) throws IOException { - if (!FollowingSettings.isEnabled()) return 0; - List due = FollowingStore.database().getFollowingDao().findDue(now, 5); - int checked = 0; - for (Following item : due) { - if (check(item, false)) checked++; - } - return checked; - } - - public boolean checkNow(String identityKey, boolean manual) { - Following item = FollowingStore.find(identityKey); - return item != null && check(item, manual); - } - - boolean check(Following item, boolean manual) { - long now = System.currentTimeMillis(); - try { - FollowingSource source = FollowingStore.preferredSource(item.identityKey); - FollowingMetadataSnapshot metadata = null; - boolean metadataChanged = false; - boolean sourceAlreadyProbed = false; - Throwable metadataError = null; - if (item.tmdbId > 0) { - try { - metadata = metadataClient.fetch(item, manual); - } catch (Throwable error) { - // TMDB is the preferred official provider, but an unavailable/rate-limited - // provider must not make the whole record fail when the bound source is - // still usable. Preserve the last official snapshot and probe the source. - metadataError = error; - } - } - if (metadata != null) { - metadataChanged = FollowingUpdatePolicy.applyMetadata(item, metadata, now); - } else { - FollowingSourceSnapshot sourceSnapshot = probeBestEffort(item, source, manual, false); - sourceAlreadyProbed = sourceSnapshot != null; - if (sourceSnapshot == null || sourceSnapshot.source == null) { - Throwable failure = metadataError != null - ? metadataError : new IllegalStateException("来源检查失败"); - FollowingUpdatePolicy.markFailure(item, failure, now); - FollowingStore.update(item); - FollowingScheduler.scheduleNext(App.get(), item); - return false; - } - source = sourceSnapshot.source; - } - FollowingStore.reconcile(item); - FollowingUpdatePolicy.refreshDerived(item, now); - if (!sourceAlreadyProbed && FollowingSettings.shouldProbe(manual, metadataChanged, source)) { - FollowingSourceSnapshot probed = probeBestEffort(item, source, manual, false); - if (probed != null) source = probed.source; - } - item.lastCheckedAt = now; - item.failureCount = 0; - item.lastError = metadataError == null ? "" : "TMDB不可用,已回退原站"; - item.nextCheckAt = FollowingSchedulePolicy.nextCheckAt(now, item.officialStatus, item.nextAirAt); - item.updatedAt = now; - FollowingStore.update(item); - if (source != null) FollowingStore.updateSource(source); - if (FollowingNotifier.notifyUpdate(item, source)) FollowingStore.update(item); - FollowingScheduler.scheduleNext(App.get(), item); - return true; - } catch (Throwable error) { - FollowingUpdatePolicy.markFailure(item, error, now); - FollowingStore.update(item); - FollowingScheduler.scheduleNext(App.get(), item); - return false; - } - } - - private FollowingSourceSnapshot probeBestEffort(Following item, FollowingSource source, boolean manual, boolean required) { - if (source == null) source = fallbackSource(item); - if (source == null || !FollowingSettings.shouldProbe(manual, false, source)) return null; - try { - FollowingSourceSnapshot result = sourceProbe.probe(item, source); - FollowingStore.updateSource(source); - return result; - } catch (Throwable error) { - source.lastError = error.getMessage() == null ? "来源检查失败" : error.getMessage(); - source.lastProbeAt = System.currentTimeMillis(); - FollowingStore.updateSource(source); - if (required) throw error instanceof RuntimeException ? (RuntimeException) error : new RuntimeException(error); - return null; - } - } - - private static FollowingSource fallbackSource(Following item) { - if (item == null || item.siteKey == null || item.siteKey.isBlank() || item.vodId == null || item.vodId.isBlank()) return null; - FollowingSource source = new FollowingSource(); - source.followingKey = item.identityKey; - source.cid = item.cid; - source.siteKey = item.siteKey; - source.vodId = item.vodId; - source.vodName = item.vodName; - source.vodPic = item.vodPic; - source.preferred = true; - return source; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingUpdatePolicy.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingUpdatePolicy.java deleted file mode 100644 index 74d74cebf7..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingUpdatePolicy.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.fongmi.android.tv.following; - -public final class FollowingUpdatePolicy { - - private FollowingUpdatePolicy() { - } - - public static int releasedEpisode(Following item) { - if (item == null) return 0; - int released = item.latestReleasedSeason == item.trackedSeason - ? item.latestReleasedEpisode : item.seasonReleasedEpisodes; - return Math.max(0, released); - } - - public static void initializeNew(Following item, int releasedEpisode, long now) { - if (item == null) return; - item.readWatermarkEpisode = Math.max(0, releasedEpisode); - item.lastNotifiedEpisode = Math.max(0, releasedEpisode); - item.lastNotifiedAt = now; - item.lastObservedEpisode = Math.max(0, releasedEpisode); - item.createdAt = item.createdAt > 0 ? item.createdAt : now; - item.updatedAt = now; - refreshDerived(item, now); - } - - public static boolean applyMetadata(Following item, FollowingMetadataSnapshot snapshot, long now) { - if (item == null || snapshot == null) return false; - boolean firstMetadata = item.metadataUpdatedAt <= 0 - && item.readWatermarkEpisode <= 0 - && item.lastNotifiedEpisode <= 0; - int before = releasedEpisode(item); - int previousLatestSeason = Math.max(0, item.latestReleasedSeason); - int previousLatestEpisode = Math.max(0, item.latestReleasedEpisode); - item.officialStatus = FollowingMetadataSnapshot.normalizeStatus(snapshot.status); - int latestSeason = Math.max(0, snapshot.latestReleasedSeason); - int latestEpisode = Math.max(0, snapshot.latestReleasedEpisode); - if (latestSeason > previousLatestSeason) { - item.latestReleasedSeason = latestSeason; - item.latestReleasedEpisode = latestEpisode; - } else if (latestSeason == previousLatestSeason) { - item.latestReleasedSeason = previousLatestSeason; - item.latestReleasedEpisode = Math.max(previousLatestEpisode, latestEpisode); - } else { - item.latestReleasedSeason = previousLatestSeason; - item.latestReleasedEpisode = previousLatestEpisode; - } - item.seasonTotalEpisodes = Math.max(0, snapshot.seasonTotalEpisodes); - item.seasonReleasedEpisodes = Math.max(Math.max(0, item.seasonReleasedEpisodes), Math.max(0, snapshot.seasonReleasedEpisodes)); - item.seriesTotalEpisodes = Math.max(0, snapshot.seriesTotalEpisodes); - item.nextAirSeason = Math.max(0, snapshot.nextAirSeason); - item.nextAirEpisode = Math.max(0, snapshot.nextAirEpisode); - item.nextAirAt = Math.max(0, snapshot.nextAirAt); - item.metadataUpdatedAt = snapshot.fetchedAt > 0 ? snapshot.fetchedAt : now; - item.lastObservedEpisode = Math.max(item.lastObservedEpisode, releasedEpisode(item)); - item.failureCount = 0; - item.lastError = ""; - item.lastCheckedAt = now; - if (firstMetadata) { - item.readWatermarkEpisode = Math.max(item.readWatermarkEpisode, releasedEpisode(item)); - item.lastNotifiedEpisode = Math.max(item.lastNotifiedEpisode, releasedEpisode(item)); - item.lastNotifiedAt = now; - } - item.updatedAt = now; - refreshDerived(item, now); - return releasedEpisode(item) > before; - } - - public static void refreshDerived(Following item, long now) { - if (item == null) return; - int released = releasedEpisode(item); - int watched = Math.max(0, item.watchedEpisode); - int read = Math.max(0, item.readWatermarkEpisode); - item.unwatchedCount = Math.max(0, released - watched); - item.hasUpdate = released > Math.max(watched, read); - if (now > 0) item.updatedAt = now; - } - - public static boolean shouldNotify(Following item) { - if (item == null || !item.enabled || !item.notifyEnabled) return false; - int released = releasedEpisode(item); - return released > Math.max(Math.max(0, item.readWatermarkEpisode), Math.max(0, item.lastNotifiedEpisode)); - } - - public static void markNotified(Following item, long now) { - if (item == null) return; - item.lastNotifiedEpisode = releasedEpisode(item); - item.lastNotifiedAt = now; - item.updatedAt = now; - } - - public static void markRead(Following item, long now) { - if (item == null) return; - item.readWatermarkEpisode = Math.max(item.readWatermarkEpisode, releasedEpisode(item)); - refreshDerived(item, now); - } - - public static void markFailure(Following item, Throwable error, long now) { - if (item == null) return; - item.failureCount = Math.max(0, item.failureCount) + 1; - item.lastError = shortError(error); - item.lastCheckedAt = now; - item.nextCheckAt = FollowingSchedulePolicy.backoffAt(now, item.failureCount); - item.updatedAt = now; - } - - private static String shortError(Throwable error) { - String message = error == null || error.getMessage() == null ? "检查失败" : error.getMessage().trim(); - return message.length() <= 160 ? message : message.substring(0, 160); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/following/FollowingUpdateWorker.java b/app/src/main/java/com/fongmi/android/tv/following/FollowingUpdateWorker.java deleted file mode 100644 index 1f8d967454..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/following/FollowingUpdateWorker.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.work.Worker; -import androidx.work.WorkerParameters; - -import java.io.IOException; - -public class FollowingUpdateWorker extends Worker { - - public FollowingUpdateWorker(@NonNull Context context, @NonNull WorkerParameters params) { - super(context, params); - } - - @NonNull - @Override - public Result doWork() { - if (!FollowingSettings.isEnabled()) return Result.success(); - try { - new FollowingUpdateCoordinator().checkDue(System.currentTimeMillis()); - return Result.success(); - } catch (IOException e) { - return Result.retry(); - } catch (Throwable e) { - return Result.success(); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/gitcloud/secure/GitCloudTokenStore.java b/app/src/main/java/com/fongmi/android/tv/gitcloud/secure/GitCloudTokenStore.java index 91b05bbe41..d2e4b7ae59 100644 --- a/app/src/main/java/com/fongmi/android/tv/gitcloud/secure/GitCloudTokenStore.java +++ b/app/src/main/java/com/fongmi/android/tv/gitcloud/secure/GitCloudTokenStore.java @@ -5,38 +5,26 @@ import android.content.SharedPreferences; import android.security.keystore.KeyGenParameterSpec; import android.security.keystore.KeyProperties; import android.text.TextUtils; -import android.util.AtomicFile; import android.util.Base64; import com.fongmi.android.tv.App; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; import java.nio.charset.StandardCharsets; import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.SecureRandom; -import java.security.Security; -import java.util.Arrays; import java.util.Map; import javax.crypto.Cipher; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; public final class GitCloudTokenStore { private static final String PREF = "git_cloud_tokens"; private static final String ALIAS = "webhtv_git_cloud_token"; private static final String ANDROID_KEYSTORE = "AndroidKeyStore"; - private static final String LOCAL_PREFIX = "local-v1:"; - private static final String LOCAL_KEY_FILE = "git_cloud_token.key"; - private static final int KEY_BYTES = 32; private static final int IV_BYTES = 12; private static final int TAG_BITS = 128; @@ -45,13 +33,13 @@ public final class GitCloudTokenStore { public static void put(String key, String token) throws Exception { if (TextUtils.isEmpty(key)) return; - prefs().edit().putString(key, encrypt(App.get(), token == null ? "" : token)).apply(); + prefs().edit().putString(key, encrypt(token == null ? "" : token)).apply(); } public static String get(String key) throws Exception { if (TextUtils.isEmpty(key)) return ""; String value = prefs().getString(key, ""); - return TextUtils.isEmpty(value) ? "" : decrypt(App.get(), value); + return TextUtils.isEmpty(value) ? "" : decrypt(value); } public static void remove(String key) { @@ -64,7 +52,7 @@ public final class GitCloudTokenStore { for (Map.Entry entry : prefs().getAll().entrySet()) { if (TextUtils.isEmpty(entry.getKey()) || !(entry.getValue() instanceof String)) continue; try { - String token = decrypt(App.get(), (String) entry.getValue()); + String token = decrypt((String) entry.getValue()); if (!TextUtils.isEmpty(token)) object.addProperty(entry.getKey(), token); } catch (Throwable ignored) { } @@ -92,70 +80,30 @@ public final class GitCloudTokenStore { return App.get().getSharedPreferences(PREF, Context.MODE_PRIVATE); } - static String encrypt(Context context, String text) throws Exception { - SecretKey secret; - String prefix = ""; - try { - secret = key(); - } catch (KeyStoreException error) { - // A missing OEM provider is different from an existing, unusable system key. - if (Security.getProvider(ANDROID_KEYSTORE) != null) throw error; - secret = localKey(context, true); - prefix = LOCAL_PREFIX; - } + private static String encrypt(String text) throws Exception { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init(Cipher.ENCRYPT_MODE, secret); + cipher.init(Cipher.ENCRYPT_MODE, key()); byte[] iv = cipher.getIV(); byte[] body = cipher.doFinal(text.getBytes(StandardCharsets.UTF_8)); byte[] out = new byte[iv.length + body.length]; System.arraycopy(iv, 0, out, 0, iv.length); System.arraycopy(body, 0, out, iv.length, body.length); - return prefix + Base64.encodeToString(out, Base64.NO_WRAP); + return Base64.encodeToString(out, Base64.NO_WRAP); } - static String decrypt(Context context, String value) throws Exception { - boolean local = value.startsWith(LOCAL_PREFIX); - byte[] input = Base64.decode(local ? value.substring(LOCAL_PREFIX.length()) : value, Base64.DEFAULT); + private static String decrypt(String value) throws Exception { + byte[] input = Base64.decode(value, Base64.DEFAULT); if (input.length <= IV_BYTES) return ""; byte[] iv = new byte[IV_BYTES]; byte[] body = new byte[input.length - IV_BYTES]; System.arraycopy(input, 0, iv, 0, IV_BYTES); System.arraycopy(input, IV_BYTES, body, 0, body.length); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init(Cipher.DECRYPT_MODE, local ? localKey(context, false) : key(), new GCMParameterSpec(TAG_BITS, iv)); + cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(TAG_BITS, iv)); return new String(cipher.doFinal(body), StandardCharsets.UTF_8); } - private static synchronized SecretKey localKey(Context context, boolean create) throws Exception { - File path = new File(context.getNoBackupFilesDir(), LOCAL_KEY_FILE); - AtomicFile file = new AtomicFile(path); - byte[] bytes; - try { - bytes = file.readFully(); - } catch (FileNotFoundException error) { - if (!create || path.exists() || new File(path + ".bak").exists()) { - throw new KeyStoreException("Token 本地密钥不可用,请重新添加账号", error); - } - bytes = new byte[KEY_BYTES]; - new SecureRandom().nextBytes(bytes); - FileOutputStream output = file.startWrite(); - try { - output.write(bytes); - output.getFD().sync(); - file.finishWrite(output); - } catch (Exception failure) { - file.failWrite(output); - throw failure; - } - // AtomicFile reports some rename failures only through logs. Never save a token - // until its key is confirmed on disk. - if (!Arrays.equals(bytes, file.readFully())) throw new KeyStoreException("Token 本地密钥保存失败"); - } - if (bytes.length != KEY_BYTES) throw new KeyStoreException("Token 本地密钥损坏,请重新添加账号"); - return new SecretKeySpec(bytes, "AES"); - } - - private static synchronized SecretKey key() throws Exception { + private static SecretKey key() throws Exception { KeyStore store = KeyStore.getInstance(ANDROID_KEYSTORE); store.load(null); if (store.containsAlias(ALIAS)) return (SecretKey) store.getKey(ALIAS, null); diff --git a/app/src/main/java/com/fongmi/android/tv/lab/LabActions.java b/app/src/main/java/com/fongmi/android/tv/lab/LabActions.java index f49b6e9c7a..0dbd257d24 100644 --- a/app/src/main/java/com/fongmi/android/tv/lab/LabActions.java +++ b/app/src/main/java/com/fongmi/android/tv/lab/LabActions.java @@ -87,9 +87,9 @@ public final class LabActions { } private static void startInstallWithDialog(FragmentActivity activity, LabModels.Item item, DoneCallback done) { - LabModels.Download download = LabEnv.findDownload(item); + LabModels.Download download = pickDownload(item); if (download == null) { - Notify.show(LabEnv.downloadError(item)); + Notify.show("当前架构不支持: " + LabEnv.arch()); return; } View root = activity.getLayoutInflater().inflate(R.layout.dialog_lab_download, null, false); @@ -214,6 +214,15 @@ public final class LabActions { if (close != null) close.setVisibility(View.VISIBLE); } + private static LabModels.Download pickDownload(LabModels.Item item) { + if (item.downloads == null || item.downloads.isEmpty()) return null; + String arch = LabEnv.arch(); + for (LabModels.Download download : item.downloads) { + if (arch.equals(download.arch)) return download; + } + return item.downloads.get(0); + } + private static void startInstall(FragmentActivity activity, LabModels.Item item, DoneCallback done) { TextView progress = new TextView(activity); progress.setTextColor(Color.WHITE); diff --git a/app/src/main/java/com/fongmi/android/tv/lab/LabDetailActivity.java b/app/src/main/java/com/fongmi/android/tv/lab/LabDetailActivity.java index 7979094133..36026e468d 100644 --- a/app/src/main/java/com/fongmi/android/tv/lab/LabDetailActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/lab/LabDetailActivity.java @@ -130,7 +130,14 @@ public class LabDetailActivity extends AppCompatActivity implements LabCommandAd } private String displayVersion() { - return LabEnv.displayVersion(item); + if (item.downloads != null) { + for (LabModels.Download download : item.downloads) { + if (LabEnv.arch().equals(download.arch) && download.version != null && !download.version.isEmpty()) { + return download.version; + } + } + } + return item.version == null ? "" : item.version; } private void updateButtons() { diff --git a/app/src/main/java/com/fongmi/android/tv/lab/LabEnv.java b/app/src/main/java/com/fongmi/android/tv/lab/LabEnv.java index 68ce1faa75..b1d4aa8248 100644 --- a/app/src/main/java/com/fongmi/android/tv/lab/LabEnv.java +++ b/app/src/main/java/com/fongmi/android/tv/lab/LabEnv.java @@ -6,7 +6,6 @@ import android.os.Build; import android.text.TextUtils; import com.fongmi.android.tv.App; -import com.fongmi.android.tv.BuildConfig; import com.fongmi.android.tv.utils.Notify; import com.google.gson.Gson; @@ -47,8 +46,6 @@ import java.util.zip.ZipFile; public final class LabEnv { private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); - private static final String ABI_ARM64 = "arm64-v8a"; - private static final String ABI_ARMV7 = "armeabi-v7a"; private LabEnv() { } @@ -284,72 +281,14 @@ public final class LabEnv { return files != null && files.length > 0; } - /** - * 实验室运行时必须与 APK 内实际打包的 native 资产一致。 - * - *

不能只看 {@link Build#SUPPORTED_ABIS}:设备可能同时支持 arm64 和 armv7, - * 但当前 APK 只携带其中一个 flavor 的 7zz/proot。优先使用 APK flavor,只有 - * flavor 未提供时才回退到设备 ABI 列表;未知架构返回空串,不能默认伪装成 arm64。 - */ public static String arch() { - return resolveArch(BuildConfig.FLAVOR_abi, Build.SUPPORTED_ABIS); - } - - static String resolveArch(String packagedAbi, String[] supportedAbis) { - String packaged = normalizeAbi(packagedAbi); - if (isSupportedArch(packaged)) return packaged; - if (!TextUtils.isEmpty(packagedAbi)) return ""; - if (supportedAbis != null) { - for (String abi : supportedAbis) { - String normalized = normalizeAbi(abi); - if (isSupportedArch(normalized)) return normalized; + if (Build.SUPPORTED_ABIS != null) { + for (String abi : Build.SUPPORTED_ABIS) { + if (abi.contains("arm64")) return "arm64-v8a"; + if (abi.contains("armeabi-v7a") || abi.contains("armeabi")) return "armeabi-v7a"; } } - return ""; - } - - static String normalizeAbi(String abi) { - if (TextUtils.isEmpty(abi)) return ""; - String normalized = abi.trim().toLowerCase(Locale.ROOT).replace('_', '-'); - if (ABI_ARM64.equals(normalized) || "arm64".equals(normalized) || "aarch64".equals(normalized)) { - return ABI_ARM64; - } - if (ABI_ARMV7.equals(normalized) - || "armeabi".equals(normalized) - || "armv7".equals(normalized) - || "armv7-a".equals(normalized) - || "armv7a".equals(normalized)) { - return ABI_ARMV7; - } - return normalized; - } - - private static boolean isSupportedArch(String arch) { - return ABI_ARM64.equals(arch) || ABI_ARMV7.equals(arch); - } - - static LabModels.Download findDownload(LabModels.Item item) { - return findDownload(item, arch()); - } - - static LabModels.Download findDownload(LabModels.Item item, String selectedArch) { - if (item == null || item.downloads == null || item.downloads.isEmpty()) return null; - String normalizedArch = normalizeAbi(selectedArch); - if (!isSupportedArch(normalizedArch)) return null; - for (LabModels.Download download : item.downloads) { - if (download != null && normalizedArch.equals(normalizeAbi(download.arch))) return download; - } - return null; - } - - static String downloadError(LabModels.Item item) { - if (item == null || item.downloads == null || item.downloads.isEmpty()) { - return "当前环境没有配置下载包"; - } - String currentArch = arch(); - return TextUtils.isEmpty(currentArch) - ? "当前 APK/设备架构不支持实验室环境" - : "当前环境没有适配 " + currentArch + " 的下载包"; + return "arm64-v8a"; } public interface InstallCallback { @@ -388,8 +327,8 @@ public final class LabEnv { public static void install(Context context, LabModels.Item item, String mirror, InstallCallback callback) { EXECUTOR.execute(() -> { try { - LabModels.Download download = findDownload(item); - if (download == null) throw new IOException(downloadError(item)); + LabModels.Download download = pick(item, mirror); + if (download == null) throw new IOException("没有可用下载地址"); File packageDir = packageRoot(context, item); packageDir.mkdirs(); for (String dir : item.mkdirList()) { @@ -605,9 +544,23 @@ public final class LabEnv { return out.toByteArray(); } + private static LabModels.Download pick(LabModels.Item item, String mirror) { + if (item.downloads == null || item.downloads.isEmpty()) return null; + String arch = arch(); + for (LabModels.Download d : item.downloads) { + if (arch.equals(d.arch)) return d; + } + return item.downloads.get(0); + } + public static String displayVersion(LabModels.Item item) { - LabModels.Download download = findDownload(item); - if (download != null && !TextUtils.isEmpty(download.version)) return download.version; + if (item.downloads != null) { + for (LabModels.Download download : item.downloads) { + if (arch().equals(download.arch) && download.version != null && !download.version.isEmpty()) { + return download.version; + } + } + } return item.version == null ? "" : item.version; } diff --git a/app/src/main/java/com/fongmi/android/tv/lab/LabPackageAdapter.java b/app/src/main/java/com/fongmi/android/tv/lab/LabPackageAdapter.java index 02f4751961..82b86b63e0 100644 --- a/app/src/main/java/com/fongmi/android/tv/lab/LabPackageAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/lab/LabPackageAdapter.java @@ -100,12 +100,23 @@ public final class LabPackageAdapter extends RecyclerView.Adapter taskIds; private final List> searchFuture; private final ListeningExecutorService playerExecutor; - private final ListeningExecutorService isolatedPlayerExecutor; private final AtomicInteger searchEpoch; private final Object searchLock; - private final Object isolatedPlayerLock; - private final AtomicInteger isolatedPlayerId; private ListeningExecutorService searchExecutor; - private ListenableFuture isolatedPlayerFuture; private KaraokeResult karaokeResult; private int karaokeResultAction; @@ -70,11 +66,6 @@ public class SiteViewModel extends ViewModel { // Player spiders can share a loopback proxy and may ignore interruption. // Keep resolutions serial so a canceled source fully exits before the next starts. playerExecutor = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); - // The queue resolver must not share the foreground PLAYER slot: a late next-item - // result must never replace or cancel the item the user explicitly selected. - isolatedPlayerExecutor = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); - isolatedPlayerLock = new Object(); - isolatedPlayerId = new AtomicInteger(0); futures = new EnumMap<>(TaskType.class); taskIds = new EnumMap<>(TaskType.class); for (TaskType type : TaskType.values()) taskIds.put(type, new AtomicInteger(0)); @@ -167,74 +158,6 @@ public class SiteViewModel extends ViewModel { futures.remove(TaskType.PLAYER); } - /** - * Resolves one future episode without touching the foreground PLAYER LiveData/future. - * - *

There is deliberately only one isolated request at a time. Spider implementations - * may share a loopback proxy and may not stop immediately on interruption, so a dedicated - * serial executor gives cancellation a safe boundary without allowing it to interfere with - * the user's current selection.

- */ - public ListenableFuture playerContentIsolated( - String key, - String flag, - String id, - int playerType, - Consumer onSuccess, - Consumer onError) { - int requestId = isolatedPlayerId.incrementAndGet(); - cancelIsolatedFuture(); - FluentFuture future = FluentFuture.from(isolatedPlayerExecutor.submit( - () -> SiteApi.playerContentIsolated(key, flag, id, playerType))) - .withTimeout(Constant.TIMEOUT_VOD, TimeUnit.MILLISECONDS, Task.scheduler()); - synchronized (isolatedPlayerLock) { - isolatedPlayerFuture = future; - } - future.addCallback(Task.callback( - value -> App.post(() -> { - if (!isCurrentIsolatedRequest(requestId, future)) return; - if (onSuccess != null) onSuccess.accept(value); - clearIsolatedFuture(requestId, future); - }), - error -> { - if (error instanceof CancellationException) return; - App.post(() -> { - if (!isCurrentIsolatedRequest(requestId, future)) return; - if (onError != null) onError.accept(error); - clearIsolatedFuture(requestId, future); - }); - } - ), MoreExecutors.directExecutor()); - return future; - } - - /** Cancels only the outstanding next-item resolver, leaving the foreground PLAYER request intact. */ - public void cancelPlayerContentIsolated() { - isolatedPlayerId.incrementAndGet(); - cancelIsolatedFuture(); - } - - private void cancelIsolatedFuture() { - ListenableFuture future; - synchronized (isolatedPlayerLock) { - future = isolatedPlayerFuture; - isolatedPlayerFuture = null; - } - if (future != null) future.cancel(true); - } - - private boolean isCurrentIsolatedRequest(int requestId, ListenableFuture future) { - synchronized (isolatedPlayerLock) { - return isolatedPlayerId.get() == requestId && isolatedPlayerFuture == future; - } - } - - private void clearIsolatedFuture(int requestId, ListenableFuture future) { - synchronized (isolatedPlayerLock) { - if (isolatedPlayerId.get() == requestId && isolatedPlayerFuture == future) isolatedPlayerFuture = null; - } - } - public void searchContent(Site site, String keyword, boolean quick, String page) { long start = System.currentTimeMillis(); execute(TaskType.RESULT, result, SearchTask.create(site, keyword, quick, page), @@ -356,9 +279,7 @@ public class SiteViewModel extends ViewModel { super.onCleared(); stopSearch(); futures.values().forEach(future -> future.cancel(true)); - cancelPlayerContentIsolated(); playerExecutor.shutdownNow(); - isolatedPlayerExecutor.shutdownNow(); } private enum TaskType {RESULT, PLAYER, ACTION} diff --git a/app/src/main/java/com/fongmi/android/tv/playback/PlaybackProgressWriter.java b/app/src/main/java/com/fongmi/android/tv/playback/PlaybackProgressWriter.java index eb95e98e49..d78b3aa910 100644 --- a/app/src/main/java/com/fongmi/android/tv/playback/PlaybackProgressWriter.java +++ b/app/src/main/java/com/fongmi/android/tv/playback/PlaybackProgressWriter.java @@ -10,7 +10,6 @@ import com.fongmi.android.tv.bean.TmdbSeasonProgress; import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.db.dao.HistoryDao; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.following.FollowingStore; import com.fongmi.android.tv.setting.Setting; import java.util.ArrayList; @@ -236,7 +235,6 @@ public final class PlaybackProgressWriter { TmdbSeasonProgressStore.write(history); return null; }); - FollowingStore.project(history); RefreshEvent.history(); return local == null ? PlaybackProgressApplyResult.created(input, history.getKey()) : PlaybackProgressApplyResult.updated(input, history.getKey()); } diff --git a/app/src/main/java/com/fongmi/android/tv/playback/PlaybackRuntime.java b/app/src/main/java/com/fongmi/android/tv/playback/PlaybackRuntime.java index 306e96661f..5c3854a191 100644 --- a/app/src/main/java/com/fongmi/android/tv/playback/PlaybackRuntime.java +++ b/app/src/main/java/com/fongmi/android/tv/playback/PlaybackRuntime.java @@ -4,15 +4,13 @@ import android.text.TextUtils; import androidx.annotation.Nullable; -import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.player.PlayerManager; import java.lang.ref.WeakReference; import java.util.UUID; -public final class PlaybackRuntime { +final class PlaybackRuntime { private static volatile History currentHistory; private static volatile WeakReference currentPlayer = new WeakReference<>(null); @@ -49,34 +47,6 @@ public final class PlaybackRuntime { return history; } - public static SiteIdentity currentSiteIdentity(String fallbackDomain) { - History history = currentHistory(); - if (history != null) { - String siteKey = safe(history.getSiteKey()).trim(); - if (!siteKey.isEmpty()) { - try { - Site site = VodConfig.get().getSite(siteKey); - String siteName = site == null || site.isEmpty() ? siteKey : site.getDisplayName(); - return new SiteIdentity(siteKey, siteName, safe(fallbackDomain)); - } catch (Throwable ignored) { - return new SiteIdentity(siteKey, siteKey, safe(fallbackDomain)); - } - } - } - return new SiteIdentity("", "未知站点", safe(fallbackDomain)); - } - - public static PlaybackIdentity currentPlaybackIdentity() { - History history = currentHistory(); - if (history == null) return new PlaybackIdentity("", "", ""); - return new PlaybackIdentity(safe(history.getVodName()), safe(history.getVodFlag()), - safe(history.getVodRemarks())); - } - - public record SiteIdentity(String siteKey, String siteName, String siteDomain) {} - - public record PlaybackIdentity(String vodName, String lineName, String episodeName) {} - static synchronized String ensureSession(History history) { String signature = signature(history); if (TextUtils.isEmpty(sessionId) || !TextUtils.equals(sessionSignature, signature)) { diff --git a/app/src/main/java/com/fongmi/android/tv/playback/SubtitleRestoreCoordinator.java b/app/src/main/java/com/fongmi/android/tv/playback/SubtitleRestoreCoordinator.java deleted file mode 100644 index 25d74786ed..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/playback/SubtitleRestoreCoordinator.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.fongmi.android.tv.playback; - -import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.Sub; -import com.fongmi.android.tv.player.PlayerManager; -import com.fongmi.android.tv.setting.Setting; - -import java.util.ArrayList; -import java.util.List; - -/** - * 外挂字幕来源在 History 与播放器之间的搬运工。 - * - *

三个宿主(mobile / leanback VideoActivity、TmdbDetailActivity)各自持有 - * 自己的 History,但记录和恢复的规则是同一套,放这里避免写三遍。 - */ -public final class SubtitleRestoreCoordinator { - - private SubtitleRestoreCoordinator() { - } - - /** - * 记录用户刚选中的外挂字幕。 - * - * @return true 表示 history 被改动,调用方需要落盘 - */ - public static boolean remember(History history, Sub sub) { - if (history == null || Setting.isIncognito()) return false; - SubtitleSource source = SubtitleSource.of(sub, history.getEpisodeUrl()); - if (source == null) return false; - history.setSubtitleSourceObject(source); - return true; - } - - /** - * 起播前把该恢复的字幕交给播放器。 - * - *

必须在 {@code startPlayer()} 之前调用:注入点在 {@code setMediaItem} 之前, - * 晚了就得靠 {@code setSub()} 重启一次,画面会闪。 - * - * @param result 本次起播的解析结果,用来让自动匹配看见这条字幕 - * @return true 表示 history 里那条记录已失效并被清空,调用方需要落盘 - */ - public static boolean restore(History history, PlayerManager player, Result result) { - if (player == null) return false; - player.setPendingRestoreSub(null); - if (history == null || Setting.isIncognito()) return false; - SubtitleSource source = history.getSubtitleSourceObject(); - SubtitleRestorePolicy.Decision decision = SubtitleRestorePolicy.decide( - source, history.getEpisodeUrl(), history.isCrossSourcePlayback()); - if (decision.restore()) { - Sub sub = source.toSub(); - player.setPendingRestoreSub(sub); - // 也写进 Result:SubtitlePlaybackSession 用 result.getSubs() 判断「已有字幕」 - // 来跳过自动匹配。不写的话自动匹配会照跑,下载完再 setSub() 一次,既重启 - // 一遍播放又把用户记住的选择顶掉。setSubs() 自带「已有字幕就不覆盖」语义, - // 所以源站自带字幕时这里是空操作。 - if (result != null) { - List subs = new ArrayList<>(); - subs.add(sub); - result.setSubs(subs); - } - } - if (!decision.clear()) return false; - // 失效记录必须写回,否则每次起播都白跑一次文件存在性检查。 - history.setSubtitleSource(""); - return true; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/playback/SubtitleRestorePolicy.java b/app/src/main/java/com/fongmi/android/tv/playback/SubtitleRestorePolicy.java deleted file mode 100644 index 752550138b..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/playback/SubtitleRestorePolicy.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.fongmi.android.tv.playback; - -import java.io.File; -import java.util.function.Predicate; - -/** - * 决定一条持久化的外挂字幕来源能否在本次起播时挂回去。 - * - *

抽成不依赖 Android 的纯判定,是因为这里的每个分支都对应一种「挂错字幕」的 - * 用户可见故障:换集挂上一集的 srt 会让时间轴完全错位,缓存被清后挂死路径会 - * 每次起播白跑一次文件检查。判定必须可单测。 - */ -public final class SubtitleRestorePolicy { - - /** - * 判定结果:是否注入、是否该忘掉这条记录。 - * - *

{@code clear} 只对「记录本身已经死了」成立,也就是文件没了。不该注入的其他 - * 情形(换集、跨源)记录仍然有效,只是这一次不适用——清掉等于替用户丢掉他下次 - * 回到那一集时想要的字幕。 - */ - public record Decision(boolean restore, boolean clear, String reason) { - - static Decision inject() { - return new Decision(true, false, "restore"); - } - - static Decision drop(String reason) { - return new Decision(false, true, reason); - } - - static Decision skip(String reason) { - return new Decision(false, false, reason); - } - } - - private SubtitleRestorePolicy() { - } - - public static Decision decide(SubtitleSource source, String episodeUrl, boolean crossSource) { - return decide(source, episodeUrl, crossSource, file -> new File(file).isFile()); - } - - /** - * @param episodeUrl 本次要播的集地址 - * @param crossSource 跨源续播。目标源的集数编排可能不同,旧字幕不能直接用, - * 但原源的偏好要留着——所以不注入也不清空。 - * @param exists 文件存在性判定,单测注入替身 - */ - static Decision decide(SubtitleSource source, String episodeUrl, boolean crossSource, - Predicate exists) { - if (source == null || !source.isUsable()) return Decision.skip("absent"); - if (crossSource) return Decision.skip("cross-source"); - // 记录时没有集地址的旧数据永远无法判断是不是同一集,属于死数据,清掉。 - if (source.getEpisodeUrl().isEmpty()) return Decision.drop("episode-unknown"); - // 换集只是「这一次不适用」,记录本身没死。清掉的话用户切走再切回来就没字幕了, - // 而宿主在起播前就把 episodeUrl 改成了新集(mobile VideoActivity.java:5452 等), - // 所以每次换集都会走到这里——清空等于换一集就把偏好丢一次。 - if (!source.getEpisodeUrl().equals(episodeUrl == null ? "" : episodeUrl)) { - return Decision.skip("episode-changed"); - } - // 远端字幕不做预检:网络探测会拖慢起播,而字幕加载失败对播放是非致命的。 - if (source.isRemote()) return Decision.inject(); - return exists.test(source.getUrl()) ? Decision.inject() : Decision.drop("file-missing"); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/playback/SubtitleSource.java b/app/src/main/java/com/fongmi/android/tv/playback/SubtitleSource.java deleted file mode 100644 index 99922e7018..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/playback/SubtitleSource.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.fongmi.android.tv.playback; - -import android.text.TextUtils; - -import com.fongmi.android.tv.bean.Sub; -import com.google.gson.Gson; -import com.google.gson.JsonSyntaxException; -import com.google.gson.annotations.SerializedName; - -/** - * 一次外挂字幕选择的来源标识。 - * - *

保存来源而不是 mpv 的 sid:外挂字幕的轨道是运行时 {@code sub-add} 出来的, - * 退出播放器后那条轨道就不存在了,下次起播时同一个 sid 会落到别的轨道上。 - * 来源(路径或 URL)是跨会话稳定的,而且三个内核都能直接消费——恢复时走 - * {@code PlaySpec.setSub()},和用户手动加载完全同一条链路。 - * - *

字段命名与 {@code docs/unified-media-identity-cross-site-resume.md} 里设计的 - * {@code SubtitleSnapshot} 对齐,将来落地跨站旁路表时可以平移。 - */ -public final class SubtitleSource { - - public static final String MODE_EXTERNAL = "external"; - public static final String MODE_DISABLED = "disabled"; - - private static final Gson GSON = new Gson(); - - @SerializedName("mode") - private String mode; - @SerializedName("url") - private String url; - @SerializedName("name") - private String name; - @SerializedName("lang") - private String lang; - @SerializedName("format") - private String format; - @SerializedName("episodeUrl") - private String episodeUrl; - @SerializedName("time") - private long time; - - public SubtitleSource() { - } - - /** - * 从一次字幕选择构造来源记录。 - * - * @param episodeUrl 当前集的播放地址,恢复时用它挡住「上一集的字幕挂到下一集」 - */ - public static SubtitleSource of(Sub sub, String episodeUrl) { - if (sub == null || TextUtils.isEmpty(sub.getUrl())) return null; - SubtitleSource source = new SubtitleSource(); - source.mode = MODE_EXTERNAL; - source.url = sub.getUrl(); - source.name = sub.getName(); - source.lang = sub.getLang(); - source.format = sub.getFormat(); - source.episodeUrl = episodeUrl == null ? "" : episodeUrl; - source.time = System.currentTimeMillis(); - return source; - } - - /** 解析失败返回 null——一条脏数据不能让整条历史不可用。 */ - public static SubtitleSource decode(String json) { - if (TextUtils.isEmpty(json)) return null; - try { - SubtitleSource source = GSON.fromJson(json, SubtitleSource.class); - return source != null && source.isUsable() ? source : null; - } catch (JsonSyntaxException | IllegalStateException e) { - return null; - } - } - - public static String encode(SubtitleSource source) { - return source == null || !source.isUsable() ? "" : GSON.toJson(source); - } - - public Sub toSub() { - return isUsable() ? Sub.create(getName(), getUrl(), getLang(), getFormat()) : null; - } - - public boolean isUsable() { - return isExternal() && !TextUtils.isEmpty(url); - } - - public boolean isExternal() { - return !MODE_DISABLED.equals(mode); - } - - /** - * 远端字幕每次起播都能重新取,本地文件却可能已被删除或随缓存清理消失。 - * 恢复时据此决定是否要先确认文件还在。 - */ - public boolean isRemote() { - return getUrl().contains("://"); - } - - public String getMode() { - return mode == null ? MODE_EXTERNAL : mode; - } - - public String getUrl() { - return url == null ? "" : url; - } - - public String getName() { - return name == null ? "" : name; - } - - public String getLang() { - return lang == null ? "" : lang; - } - - public String getFormat() { - return format == null ? "" : format; - } - - public String getEpisodeUrl() { - return episodeUrl == null ? "" : episodeUrl; - } - - public long getTime() { - return time; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/AudioPlaybackDiagnostics.java b/app/src/main/java/com/fongmi/android/tv/player/AudioPlaybackDiagnostics.java deleted file mode 100644 index 44b58839c1..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/AudioPlaybackDiagnostics.java +++ /dev/null @@ -1,449 +0,0 @@ -package com.fongmi.android.tv.player; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.PlaybackException; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; - -public final class AudioPlaybackDiagnostics { - - private AudioPlaybackDiagnostics() { - } - - public enum DecodeMode { - HARDWARE, - SOFTWARE, - NONE, - UNKNOWN - } - - public enum OutputMode { - PASSTHROUGH, - PCM, - COMPRESSED_DIRECT, - OFFLOAD, - UNKNOWN - } - - /** The strategy actually selected for the current audio path. */ - public enum DecisionLevel { - EXACT_PASSTHROUGH, - COMPRESSED_OFFLOAD, - HARDWARE_PCM, - SAME_TRACK_COMPATIBLE, - SOFTWARE_PCM, - LANGUAGE_OR_STEREO_FALLBACK - } - - public enum RuntimeState { - UNKNOWN, - PENDING, - ACTIVE, - FAILED - } - - /** Stable diagnostic codes. Do not expose exception class names as protocol values. */ - public enum FailureReason { - NONE(""), - UNKNOWN("unknown"), - DIRECT_OUTPUT_INIT("direct-output-init"), - DIRECT_OUTPUT_WRITE("direct-output-write"), - OFFLOAD_INIT("offload-init"), - OFFLOAD_WRITE("offload-write"), - DECODER_INIT("decoder-init"), - DECODER_RUNTIME("decoder-runtime"), - CHANNEL_LAYOUT_UNSUPPORTED("channel-layout-unsupported"), - SAME_TRACK_COMPATIBLE("same-track-compatible"), - SAME_LANGUAGE_STEREO("same-language-stereo"), - MANUAL_TRACK_SELECTION("manual-track-selection"); - - private final String code; - - FailureReason(String code) { - this.code = code; - } - - public String code() { - return code; - } - } - - public record Track(String codec, int channels, int sampleRate, - String language, int bitrate) { - - public Track { - codec = clean(codec); - language = clean(language); - channels = Math.max(0, channels); - sampleRate = Math.max(0, sampleRate); - bitrate = Math.max(0, bitrate); - } - - public Track withCodec(String codec) { - return new Track(codec, channels, sampleRate, language, bitrate); - } - - public boolean available() { - return !codec.isEmpty() || channels > 0 || sampleRate > 0; - } - - public static Track empty() { - return new Track("", 0, 0, "", 0); - } - } - - public record Snapshot(Track originalTrack, Track activeTrack, - DecodeMode decodeMode, String decoderName, - OutputMode outputMode, int outputChannels, - int outputSampleRate, boolean tunneling, - String downgradeReason, DecisionLevel decisionLevel, - RuntimeState runtimeState, FailureReason failureReason) { - - public Snapshot(Track originalTrack, Track activeTrack, - DecodeMode decodeMode, String decoderName, - OutputMode outputMode, int outputChannels, - int outputSampleRate, boolean tunneling, - String downgradeReason) { - this(originalTrack, activeTrack, decodeMode, decoderName, outputMode, - outputChannels, outputSampleRate, tunneling, downgradeReason, - AudioPlaybackDiagnostics.decisionLevel( - outputMode, decodeMode, downgradeReason), - AudioPlaybackDiagnostics.runtimeState( - originalTrack, activeTrack, outputMode, false), - AudioPlaybackDiagnostics.failureReason(downgradeReason)); - } - - public Snapshot { - originalTrack = originalTrack == null ? Track.empty() : originalTrack; - activeTrack = activeTrack == null ? Track.empty() : activeTrack; - decodeMode = decodeMode == null ? DecodeMode.UNKNOWN : decodeMode; - decoderName = clean(decoderName); - outputMode = outputMode == null ? OutputMode.UNKNOWN : outputMode; - outputChannels = Math.max(0, outputChannels); - outputSampleRate = Math.max(0, outputSampleRate); - downgradeReason = clean(downgradeReason); - decisionLevel = decisionLevel == null - ? AudioPlaybackDiagnostics.decisionLevel( - outputMode, decodeMode, downgradeReason) - : decisionLevel; - runtimeState = runtimeState == null - ? AudioPlaybackDiagnostics.runtimeState( - originalTrack, activeTrack, outputMode, false) - : runtimeState; - failureReason = failureReason == null - ? AudioPlaybackDiagnostics.failureReason(downgradeReason) - : failureReason; - } - - public boolean available() { - return originalTrack.available() || activeTrack.available() - || outputMode != OutputMode.UNKNOWN - || runtimeState != RuntimeState.UNKNOWN; - } - - public boolean downgraded() { - return !downgradeReason.isEmpty() && originalTrack.available() - && activeTrack.available(); - } - - public static Snapshot empty() { - return new Snapshot(Track.empty(), Track.empty(), DecodeMode.UNKNOWN, - "", OutputMode.UNKNOWN, 0, 0, false, "", null, - RuntimeState.UNKNOWN, FailureReason.NONE); - } - } - - public static RuntimeState runtimeState(Track originalTrack, Track activeTrack, - OutputMode outputMode, boolean failed) { - if (failed) return RuntimeState.FAILED; - boolean trackKnown = (originalTrack != null && originalTrack.available()) - || (activeTrack != null && activeTrack.available()); - if (!trackKnown) return RuntimeState.UNKNOWN; - return outputMode == null || outputMode == OutputMode.UNKNOWN - ? RuntimeState.PENDING : RuntimeState.ACTIVE; - } - - public static DecisionLevel decisionLevel(OutputMode outputMode, - DecodeMode decodeMode, - String downgradeReason) { - FailureReason reason = failureReason(downgradeReason); - if (reason == FailureReason.SAME_TRACK_COMPATIBLE) { - return DecisionLevel.SAME_TRACK_COMPATIBLE; - } - if (reason == FailureReason.SAME_LANGUAGE_STEREO) { - return DecisionLevel.LANGUAGE_OR_STEREO_FALLBACK; - } - OutputMode mode = outputMode == null ? OutputMode.UNKNOWN : outputMode; - return switch (mode) { - case PASSTHROUGH, COMPRESSED_DIRECT -> DecisionLevel.EXACT_PASSTHROUGH; - case OFFLOAD -> DecisionLevel.COMPRESSED_OFFLOAD; - case PCM -> decodeMode == DecodeMode.HARDWARE - ? DecisionLevel.HARDWARE_PCM : DecisionLevel.SOFTWARE_PCM; - case UNKNOWN -> null; - }; - } - - public static DecisionLevel lastAttemptLevel(FailureReason failureReason, - OutputMode outputMode, - DecodeMode decodeMode) { - FailureReason reason = failureReason == null ? FailureReason.UNKNOWN - : failureReason; - return switch (reason) { - case OFFLOAD_INIT, OFFLOAD_WRITE -> DecisionLevel.COMPRESSED_OFFLOAD; - case DIRECT_OUTPUT_INIT, DIRECT_OUTPUT_WRITE -> DecisionLevel.EXACT_PASSTHROUGH; - case DECODER_INIT, DECODER_RUNTIME, CHANNEL_LAYOUT_UNSUPPORTED -> - decodeMode == DecodeMode.HARDWARE - ? DecisionLevel.HARDWARE_PCM : DecisionLevel.SOFTWARE_PCM; - case SAME_TRACK_COMPATIBLE -> DecisionLevel.SAME_TRACK_COMPATIBLE; - case SAME_LANGUAGE_STEREO -> DecisionLevel.LANGUAGE_OR_STEREO_FALLBACK; - case MANUAL_TRACK_SELECTION, NONE, UNKNOWN -> - decisionLevel(outputMode, decodeMode, ""); - }; - } - - public static FailureReason failureReason(String value) { - String reason = lower(value); - if (reason.isEmpty()) return FailureReason.NONE; - if (reason.contains("dts-hd-core") || reason.contains("same-track-compatible")) { - return FailureReason.SAME_TRACK_COMPATIBLE; - } - if (reason.contains("same-language-stereo") - || reason.contains("same-language-efficient")) { - return FailureReason.SAME_LANGUAGE_STEREO; - } - for (FailureReason candidate : FailureReason.values()) { - if (!candidate.code.isEmpty() && candidate.code.equals(reason)) return candidate; - } - return FailureReason.UNKNOWN; - } - - public static FailureReason failureReason(int errorCode) { - return switch (errorCode) { - case PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED -> - FailureReason.DIRECT_OUTPUT_INIT; - case PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED -> - FailureReason.DIRECT_OUTPUT_WRITE; - case PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_INIT_FAILED -> - FailureReason.OFFLOAD_INIT; - case PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_WRITE_FAILED -> - FailureReason.OFFLOAD_WRITE; - case PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, - PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> - FailureReason.DECODER_INIT; - case PlaybackException.ERROR_CODE_DECODING_FAILED, - PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, - PlaybackException.ERROR_CODE_DECODING_RESOURCES_RECLAIMED, - PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> - FailureReason.DECODER_RUNTIME; - default -> FailureReason.UNKNOWN; - }; - } - - public static Track track(Format format, String profile) { - if (format == null) return Track.empty(); - return track(format.codecs, format.label, profile, format.sampleMimeType, - format.channelCount, format.sampleRate, format.language, - Math.max(format.averageBitrate, format.bitrate)); - } - - public static Track track(String codec, String title, String profile, - String mimeType, int channels, int sampleRate, - String language, int bitrate) { - return new Track(codecLabel(codec, title, profile, mimeType), channels, - sampleRate, language, bitrate); - } - - public static Track passthroughTrack(Track source, String outputFormat) { - Track base = source == null ? Track.empty() : source; - String format = lower(outputFormat); - if (format.contains("spdif-dts") && !format.contains("dtshd")) { - return base.withCodec("DTS Core"); - } - if (format.contains("spdif-dtshd")) { - String codec = lower(base.codec()); - return codec.contains("dts-hd") || codec.contains("dts:x") - ? base : base.withCodec("DTS-HD"); - } - if (format.contains("spdif-truehd")) return base.withCodec("Dolby TrueHD"); - if (format.contains("spdif-eac3")) return base.withCodec("E-AC-3"); - if (format.contains("spdif-ac3")) return base.withCodec("AC-3"); - return base; - } - - public static Track encodedTrack(Track source, int encoding) { - Track base = source == null ? Track.empty() : source; - return switch (encoding) { - case C.ENCODING_AC3 -> base.withCodec("AC-3"); - case C.ENCODING_E_AC3 -> base.withCodec("E-AC-3"); - case C.ENCODING_E_AC3_JOC -> base.withCodec("E-AC-3 JOC"); - case C.ENCODING_DTS -> base.withCodec("DTS Core"); - case C.ENCODING_DTS_HD -> base.withCodec("DTS-HD"); - case C.ENCODING_DTS_HD_MA -> base.withCodec("DTS-HD MA"); - case C.ENCODING_DOLBY_TRUEHD -> base.withCodec("Dolby TrueHD"); - default -> base; - }; - } - - public static OutputMode mpvOutputMode(String outputFormat) { - return mpvOutputMode(outputFormat, ""); - } - - public static OutputMode mpvOutputMode(String outputFormat, String outputMode) { - String observed = lower(outputMode); - if (observed.equals("passthrough")) return OutputMode.PASSTHROUGH; - if (observed.equals("offload")) return OutputMode.OFFLOAD; - if (observed.equals("compressed-direct")) return OutputMode.COMPRESSED_DIRECT; - if (observed.equals("pcm")) return OutputMode.PCM; - String value = lower(outputFormat); - if (value.isEmpty()) return OutputMode.UNKNOWN; - if (value.equals("aac") || value.equals("mp3") - || value.equals("encoded-aac") || value.equals("encoded-mp3")) { - return OutputMode.COMPRESSED_DIRECT; - } - return value.startsWith("spdif-") ? OutputMode.PASSTHROUGH : OutputMode.PCM; - } - - public static String format(Snapshot snapshot) { - if (snapshot == null || !snapshot.available()) return ""; - Track active = snapshot.activeTrack().available() - ? snapshot.activeTrack() : snapshot.originalTrack(); - String track = formatTrack(active); - if (snapshot.downgraded()) { - track = formatTrack(snapshot.originalTrack()) + "/降级" + formatTrack(active); - } - List parts = new ArrayList<>(); - add(parts, track); - if (snapshot.runtimeState() == RuntimeState.FAILED) { - add(parts, "输出失败"); - add(parts, snapshot.failureReason().code()); - } else if (snapshot.runtimeState() == RuntimeState.PENDING) { - add(parts, "输出初始化中"); - } else switch (snapshot.outputMode()) { - case PASSTHROUGH -> add(parts, "直通"); - case COMPRESSED_DIRECT -> { - add(parts, "硬解"); - add(parts, "直出"); - } - case OFFLOAD -> { - add(parts, "硬解"); - add(parts, "卸载"); - } - case PCM -> { - add(parts, decodeText(snapshot.decodeMode())); - add(parts, "PCM" + channelSuffix(snapshot.outputChannels())); - } - case UNKNOWN -> { - add(parts, decodeText(snapshot.decodeMode())); - add(parts, "输出待确认"); - } - } - if (snapshot.tunneling()) add(parts, "隧道"); - int sampleRate = snapshot.outputSampleRate() > 0 - ? snapshot.outputSampleRate() : active.sampleRate(); - if (sampleRate > 0) add(parts, sampleRateText(sampleRate)); - if (active.bitrate() > 0) add(parts, bitrateText(active.bitrate())); - return String.join(" · ", parts); - } - - public static String channelLabel(int channels) { - return switch (channels) { - case 1 -> "1.0"; - case 2 -> "2.0"; - case 3 -> "3.0"; - case 4 -> "4.0"; - case 5 -> "5.0"; - case 6 -> "5.1"; - case 7 -> "6.1"; - case 8 -> "7.1"; - default -> channels > 0 ? channels + "ch" : ""; - }; - } - - public static String codecLabel(String codec, String title, String profile, - String mimeType) { - String value = lower(join(codec, title, profile, mimeType)); - if (value.contains("dts:x") || value.contains("dtsx")) return "DTS:X"; - if (containsAny(value, "dts-hd ma", "dts hd ma", "master audio")) return "DTS-HD MA"; - if (containsAny(value, "dts-hd hra", "dts hd hra", "high resolution")) return "DTS-HD HRA"; - if (containsAny(value, "dts-hd", "dts_hd", "dtshd", "dts hd")) return "DTS-HD"; - if (value.contains("truehd") || value.contains("true hd")) { - return value.contains("atmos") ? "Dolby TrueHD Atmos" : "Dolby TrueHD"; - } - if (containsAny(value, "eac3-joc", "e-ac-3 joc", "eac3_joc")) return "E-AC-3 JOC"; - if (containsAny(value, "eac3", "e-ac-3", "e-ac3")) return "E-AC-3"; - if (containsAny(value, "ac3", "ac-3")) return "AC-3"; - if (value.contains("dts")) return "DTS"; - if (value.contains("aac")) return "AAC"; - if (value.contains("opus")) return "Opus"; - if (value.contains("vorbis")) return "Vorbis"; - if (value.contains("flac")) return "FLAC"; - if (value.contains("alac")) return "ALAC"; - if (containsAny(value, "mp3", "mpeg audio", "audio/mpeg")) return "MP3"; - if (containsAny(value, "pcm", "audio/raw")) return "PCM"; - String fallback = clean(codec); - if (fallback.isEmpty()) fallback = clean(mimeType); - return fallback.isEmpty() ? "音频" : fallback.toUpperCase(Locale.US); - } - - private static String formatTrack(Track track) { - if (track == null || !track.available()) return "音频"; - return clean(track.codec()) + channelSuffix(track.channels()); - } - - private static String decodeText(DecodeMode mode) { - return switch (mode) { - case HARDWARE -> "硬解"; - case SOFTWARE -> "软解"; - case NONE -> ""; - case UNKNOWN -> "解码待确认"; - }; - } - - private static String channelSuffix(int channels) { - String value = channelLabel(channels); - return value.isEmpty() ? "" : " " + value; - } - - private static String sampleRateText(int sampleRate) { - if (sampleRate % 1000 == 0) return sampleRate / 1000 + "kHz"; - return String.format(Locale.US, "%.1fkHz", sampleRate / 1000f); - } - - private static String bitrateText(int bitrate) { - if (bitrate >= 1_000_000) { - return String.format(Locale.US, "%.1fMbps", bitrate / 1_000_000f); - } - return Math.max(1, bitrate / 1000) + "kbps"; - } - - private static void add(List parts, String value) { - if (value != null && !value.isBlank()) parts.add(value); - } - - private static boolean containsAny(String value, String... needles) { - for (String needle : needles) if (value.contains(needle)) return true; - return false; - } - - private static String join(String... values) { - StringBuilder builder = new StringBuilder(); - for (String value : values) { - String clean = clean(value); - if (clean.isEmpty()) continue; - if (builder.length() > 0) builder.append(' '); - builder.append(clean); - } - return builder.toString(); - } - - private static String lower(String value) { - return clean(value).toLowerCase(Locale.US); - } - - private static String clean(String value) { - return value == null ? "" : value.trim(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/DiagnosticControls.java b/app/src/main/java/com/fongmi/android/tv/player/DiagnosticControls.java deleted file mode 100644 index fb3c74026c..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/DiagnosticControls.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.fongmi.android.tv.player; - -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticAccess; -import com.github.catvod.crawler.diagnostics.DiagnosticCapture; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; -import com.google.gson.JsonObject; -import java.lang.ref.WeakReference; -import java.util.Set; - -/** Product controls publish requests only; exporters never invoke a player. */ -public final class DiagnosticControls { - public static final DiagnosticAccess ACCESS = new DiagnosticAccess(); - public static final String[] SYMPTOMS = {"黑屏", "画面不动", "无声", "断音", "音画不同步", "其他"}; - private static volatile WeakReference latest = new WeakReference<>(null); - private static volatile WeakReference felProbe = new WeakReference<>(null); - private DiagnosticControls() {} - - static void playback(PlaybackDiagnosticCollector collector) { - FelBindProbeControl probe = felProbe.get(); - if (probe != null && !probe.owner().equals(collector.instanceId())) probe.cancel(); - latest = new WeakReference<>(collector); - DiagnosticCapture.Session active = DiagnosticCapture.current(); - PlaybackDiagnosticCollector.Context owner = collector.context(); - if (active != null && (!active.instance().equals(collector.instanceId()) || active.attempt() != owner.attempt())) DiagnosticCapture.stop("playback-changed"); - } - - public static void mark(String symptom) { - if (!Set.of(SYMPTOMS).contains(symptom)) throw new IllegalArgumentException("请选择一种故障现象"); - if (!DebugLogStore.isEnabled()) throw new IllegalStateException("请先开启调试日志"); - publish("diag.user-mark", e -> e.userReported().observed("symptom", symptom) - .observed("captureId", PlaybackDiagnosticCollector.id("incident")) - .requested("requestedBeforeMs", 30_000).requested("requestedAfterMs", 15_000).pin("last-user-mark")); - } - - public static synchronized DiagnosticCapture.Session startDepth(int seconds) { - FelBindProbeControl probe = felProbe.get(); - if (probe != null && probe.active()) throw new IllegalStateException("请先结束 FEL 绑定对照"); - PlaybackDiagnosticCollector collector = latest.get(); - if (collector == null || collector.ended()) throw new IllegalStateException("请先开始播放"); - PlaybackDiagnosticCollector.Context owner = collector.context(); - return DiagnosticCapture.start(owner.trace(), collector.instanceId(), owner.generation(), owner.attempt(), seconds, collector.protectedMedia()); - } - - public static void registerFelBindProbe(FelBindProbeControl probe) { - felProbe = new WeakReference<>(probe); - } - - public static synchronized void startFelBindProbe() { - if (!DebugLogStore.isEnabled()) throw new IllegalStateException("请先开启调试日志"); - if (!DebugLogStore.categoryEnabled(com.github.catvod.crawler.diagnostics.DiagnosticCategories - .nativePrefix("vo/gpu-next/aimagereader"))) throw new IllegalStateException("请先开启视频诊断日志分类"); - if (DiagnosticCapture.current() != null) throw new IllegalStateException("请先停止深度统计"); - PlaybackDiagnosticCollector collector = latest.get(); - FelBindProbeControl probe = felProbe.get(); - if (collector == null || collector.ended() || collector.protectedMedia() - || !"mpv".equals(collector.engine()) || probe == null - || !probe.owner().equals(collector.instanceId())) - throw new IllegalStateException("当前播放不支持 FEL 绑定对照"); - probe.start(); - publish("diag.capture", e -> e.userReported().requested("operation", "fel-bind-start") - .requested("durationMs", 30_000).observed("metricScope", "record-only; no pixel access")); - } - - public static void stopFelBindProbe() { - FelBindProbeControl probe = felProbe.get(); - if (probe != null) probe.cancel(); - } - - public static void forTrace(String trace, String name, String source, java.util.function.Consumer facts) { - if (!DebugLogStore.acceptsEvent(name)) return; - PlaybackDiagnosticCollector collector = latest.get(); - if (collector != null && collector.context().trace().equals(trace)) collector.emit(collector.context(), name, source, "monitor-trace-context", facts); - else { - DiagnosticEvent event = new DiagnosticEvent(name, trace, "monitor", 0, 0); - facts.accept(event); DebugLogStore.event(event); - } - } - - private static void publish(String event, java.util.function.Consumer facts) { - PlaybackDiagnosticCollector collector = latest.get(); - if (collector != null) collector.emit(collector.context(), event, "user-control", "selected-playback-context", facts); - else { - DiagnosticEvent value = new DiagnosticEvent(event, "none", "user", 0, 0); facts.accept(value); DebugLogStore.event(value); - } - } - - public static JsonObject status() { - JsonObject result = new JsonObject(); - PlaybackDiagnosticCollector collector = latest.get(); - result.addProperty("playingContext", collector != null && !collector.ended()); - if (collector != null) { - PlaybackDiagnosticCollector.Context owner = collector.context(); - result.addProperty("trace", owner.trace()); result.addProperty("attempt", owner.attempt()); - result.addProperty("engine", collector.engine()); result.addProperty("protectedMedia", collector.protectedMedia()); - } - DiagnosticCapture.Session capture = DiagnosticCapture.current(); - result.addProperty("deepRemainingMs", capture == null ? 0 : capture.remainingMs()); - FelBindProbeControl probe = felProbe.get(); - boolean ownsProbe = collector != null && probe != null && !collector.ended() - && probe.owner().equals(collector.instanceId()); - result.addProperty("felBindProbeState", ownsProbe ? probe.state() : "unavailable"); - result.addProperty("felBindProbeActive", ownsProbe && probe.active()); - result.addProperty("categories", com.github.catvod.crawler.diagnostics.DiagnosticCategories.summary(DebugLogStore.categories())); - return result; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/FelBindProbeControl.java b/app/src/main/java/com/fongmi/android/tv/player/FelBindProbeControl.java deleted file mode 100644 index b8895a7ac4..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/FelBindProbeControl.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.fongmi.android.tv.player; - -import java.util.Set; -import java.util.concurrent.Executor; -import java.util.function.Predicate; - -/** A per-player request mailbox. Web status reads never call native code. */ -public final class FelBindProbeControl { - private static final Set STATES = Set.of("unavailable", "ready", "armed", "quiescing", - "running", "completed", "cancelled", "failed", "timed-out"); - private static final Set ACTIVE = Set.of("queued", "armed", "quiescing", "running", "cancelling"); - private final String owner; - private Executor executor; - private Predicate command; - private long epoch, serial, awaitingSerial = -1; - private boolean attached, startSent; - private String state = "unavailable"; - - public FelBindProbeControl(String owner) { this.owner = owner; } - public String owner() { return owner; } - - public synchronized void connect(Executor executor, Predicate command) { - this.executor = executor; - this.command = command; - } - - public synchronized void begin() { - epoch++; attached = true; startSent = false; serial = 0; awaitingSerial = -1; - state = "unavailable"; - } - public synchronized void end() { epoch++; attached = false; state = "unavailable"; } - public synchronized String state() { return state; } - public synchronized boolean active() { - return ACTIVE.contains(state); - } - - public synchronized void observed(String value) { - if (!attached || value == null) return; - int colon = value.indexOf(':'); - if (colon < 1) return; - long received; - try { received = Long.parseLong(value.substring(0, colon)); } - catch (NumberFormatException ignored) { return; } - String next = value.substring(colon + 1); - if (received < 0 || !STATES.contains(next)) return; - // Native serials distinguish a previous run's queued notifications. - if (awaitingSerial >= 0 && received < awaitingSerial) return; - serial = received; - state = next; - if (!active()) awaitingSerial = -1; - } - - public synchronized void start() { - if (!attached || executor == null || state.equals("unavailable")) - throw new IllegalStateException("请先使用 MPV 播放 Vulkan FEL 视频"); - if (active()) throw new IllegalStateException("绑定对照已在运行"); - state = "queued"; - startSent = false; - awaitingSerial = serial + 1; - enqueue("start"); - } - - public synchronized void cancel() { - if (!attached || !active() || state.equals("cancelling")) return; - if (!startSent && state.equals("queued")) { - epoch++; // Invalidate a start still queued on the player thread. - state = "cancelled"; - awaitingSerial = -1; - return; - } - state = "cancelling"; - enqueue("cancel"); - } - - private void enqueue(String action) { - long requestedEpoch = epoch; - executor.execute(() -> { - Predicate sink; - synchronized (this) { - if (!attached || requestedEpoch != epoch) return; - // Cancellation before the posted start must not run a probe. - if (action.equals("start") && state.equals("cancelling")) return; - if (action.equals("start")) startSent = true; - sink = command; - } - boolean accepted; - try { accepted = sink != null && sink.test(action); } - catch (RuntimeException ignored) { accepted = false; } - synchronized (this) { - if (attached && requestedEpoch == epoch && !accepted) state = "failed"; - // A successful cancel only requests cleanup. Remain exclusive - // until native publishes its terminal state after cleanup. - } - }); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/IntroSkipPlayback.java b/app/src/main/java/com/fongmi/android/tv/player/IntroSkipPlayback.java index 5dea2f671d..2d7fe6ccc4 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/IntroSkipPlayback.java +++ b/app/src/main/java/com/fongmi/android/tv/player/IntroSkipPlayback.java @@ -10,7 +10,6 @@ import com.github.catvod.crawler.SpiderDebug; import java.util.HashSet; import java.util.Set; -import java.util.WeakHashMap; public class IntroSkipPlayback { @@ -44,18 +43,6 @@ public class IntroSkipPlayback { private static final long MIN_SKIP_DELTA_MS = 1500; /** 时长归一粒度:HLS 的时长会随 manifest 精化抖动,别为几百毫秒反复重解析。 */ private static final long DURATION_BUCKET_MS = 2000; - private static final Object CONFIRMATION_LOCK = new Object(); - private static final WeakHashMap CONFIRMATIONS = new WeakHashMap<>(); - - private static final class ConfirmationLease { - final IntroSkipPlayback owner; - final String id; - - ConfirmationLease(IntroSkipPlayback owner, String id) { - this.owner = owner; - this.id = id; - } - } private final IntroSkipService service = new IntroSkipService(); private final Set skipped = new HashSet<>(); @@ -67,8 +54,6 @@ public class IntroSkipPlayback { private long resumeMs; private boolean suppressOpening; private boolean suppressEnding; - private String pendingConfirmationId = ""; - private Object pendingSession; private SkipConfirmListener skipConfirmListener; private Runnable skipConfirmDismisser; private SkipNoticeListener skipNoticeListener; @@ -84,7 +69,6 @@ public class IntroSkipPlayback { suppressOpening = false; suppressEnding = false; if (skipConfirmDismisser != null) skipConfirmDismisser.run(); - releaseConfirmationState(); } /** @@ -124,87 +108,6 @@ public class IntroSkipPlayback { this.skipNoticeListener = listener; } - /** - * 开始询问一个片段;同一时间只允许一个确认框占用状态。 - * - *

确认状态按底层播放器会话共享,而不是只属于当前 Activity。播放页切换或同一播放器同时 - * 被多个页面绑定时,每个页面都有自己的 {@link IntroSkipPlayback} 实例;若状态仅实例内可见, - * 它们会在同一秒的进度回调里各弹一次框。共享租约保证只有首个实例能真正显示确认框。 - */ - public boolean beginConfirmation(Segment segment) { - return beginConfirmation(this, segment); - } - - boolean beginConfirmation(Object session, Segment segment) { - if (session == null) return false; - String id = id(segment); - if (id.isEmpty() || skipped.contains(id)) return false; - synchronized (CONFIRMATION_LOCK) { - ConfirmationLease current = CONFIRMATIONS.get(session); - if (current != null) return current.owner == this && current.id.equals(id); - CONFIRMATIONS.put(session, new ConfirmationLease(this, id)); - } - pendingSession = session; - pendingConfirmationId = id; - return true; - } - - boolean hasConfirmation(Object session) { - if (session == null) return false; - synchronized (CONFIRMATION_LOCK) { - return CONFIRMATIONS.containsKey(session); - } - } - - public boolean isConfirmationPending(Segment segment) { - String id = id(segment); - return !id.isEmpty() && id.equals(pendingConfirmationId); - } - - public boolean isSegmentHandled(Segment segment) { - String id = id(segment); - return !id.isEmpty() && skipped.contains(id); - } - - /** 取消、关闭或过期的确认不应使片段永久失效。 */ - public void cancelConfirmation(Segment segment) { - releaseConfirmation(segment); - } - - /** 用户明确拒绝本段后,本集内不再重复询问;下一集 reset 后恢复。 */ - public void declineConfirmation(Segment segment) { - String id = id(segment); - if (id.isEmpty()) return; - skipped.add(id); - releaseConfirmation(segment); - } - - /** 只有实际执行了跳转/换集后才把片段记为已处理。 */ - public void completeConfirmation(Segment segment) { - String id = id(segment); - if (id.isEmpty()) return; - skipped.add(id); - releaseConfirmation(segment); - } - - private void releaseConfirmation(Segment segment) { - String id = id(segment); - if (!id.equals(pendingConfirmationId)) return; - releaseConfirmationState(); - } - - private void releaseConfirmationState() { - Object session = pendingSession; - String id = pendingConfirmationId; - pendingSession = null; - pendingConfirmationId = ""; - if (session == null || id.isEmpty()) return; - synchronized (CONFIRMATION_LOCK) { - ConfirmationLease current = CONFIRMATIONS.get(session); - if (current != null && current.owner == this && current.id.equals(id)) CONFIRMATIONS.remove(session); - } - } - /** * 探测到的片头落点(正片从这里继续),无数据返回 -1。 * @@ -215,7 +118,6 @@ public class IntroSkipPlayback { if (suppressOpening) return -1; for (Segment segment : plan.getOpenings()) { // 续播已越过的段不会再触发,显示它等于报一个不会发生的时间 - if (isSegmentHandled(segment)) continue; if (segment.getEndMs() > 0 && isKindEnabled(segment) && !passedOnResume(segment, 0)) return segment.getEndMs(); } return -1; @@ -230,7 +132,6 @@ public class IntroSkipPlayback { if (suppressEnding || durationMs <= 0) return -1; for (Segment segment : plan.getEndings()) { long start = segment.getStartMs(); - if (isSegmentHandled(segment)) continue; if (start > 0 && start < durationMs && isKindEnabled(segment) && !passedOnResume(segment, durationMs)) return durationMs - start; } return -1; @@ -263,13 +164,11 @@ public class IntroSkipPlayback { loading = true; loadingKey = key; Task.execute(() -> { - IntroSkipService.LoadResult result = service.loadResult(query); - IntroSkipPlan loaded = result.getPlan(); + IntroSkipPlan loaded = service.load(query); App.post(() -> { if (current != generation || !key.equals(loadingKey)) return; loading = false; - // 不完整的 provider 响应不能把本次 key 标成最终结果,否则同一集后续不会重试。 - loadedKey = result.isCacheable() ? key : ""; + loadedKey = key; // 无条件采纳,空计划也要。解析层给出空,是「这些段对不上本集时间轴」的明确判定; // 留着上一次按 duration=0 算出的旧计划,会拿错误的落点去跳(把正片中段当片尾)。 // skipped 不清:id 不含时间边界,重解析后仍能命中,已跳过的段不会重来。 @@ -285,10 +184,9 @@ public class IntroSkipPlayback { * 「用户是否开了这一类」和「有没有可 seek 的落点」,不再按片头/片尾分两套逻辑。 */ public boolean apply(PlayerManager player, EndingAction onEnding) { - if (player == null || player.isReleased() || plan == null || plan.isEmpty()) return false; + if (player == null || plan == null || plan.isEmpty()) return false; int mode = Setting.getIntroSkipMode(); if (mode == Setting.INTRO_SKIP_OFF) return false; - if (mode == Setting.INTRO_SKIP_CONFIRM && hasConfirmation(player)) return true; long position = player.getPosition(); long duration = player.getDuration(); if (position < 0) return false; @@ -322,32 +220,17 @@ public class IntroSkipPlayback { SpiderDebug.log("intro-skip", "hit kind=%s provider=%s from=%d start=%d end=%d openEnded=%s target=%d canEnd=%s duration=%d mode=%d", segment.getKind(), segment.getProvider(), position, start, segment.getEndMs(), segment.isOpenEnded(), target, canEnd, duration, mode); if (mode == Setting.INTRO_SKIP_AUTO) { - int expectedGeneration = generation; - if (runSkip(player, segment, target, canEnd, onEnding)) { - if (expectedGeneration == generation) skipped.add(id); - return true; - } - continue; + skipped.add(id); + runSkip(player, segment, target, canEnd, onEnding); + return true; } if (mode == Setting.INTRO_SKIP_CONFIRM && skipConfirmListener != null) { int current = generation; // 只有确认框真的弹出来了才算已处理;被别的框挡住时留着下个 tick 再问 - if (isConfirmationPending(segment)) return true; - if (!beginConfirmation(player, segment)) continue; - boolean shown; - try { - shown = skipConfirmListener.onSkipConfirm(segment, - () -> confirmSkip(current, player, segment, onEnding)); - } catch (Throwable ignored) { - shown = false; - } - if (!shown) { - cancelConfirmation(segment); - continue; - } + if (!skipConfirmListener.onSkipConfirm(segment, () -> confirmSkip(current, player, segment, onEnding))) continue; + skipped.add(id); return true; } - cancelConfirmation(segment); return false; } return false; @@ -357,37 +240,23 @@ public class IntroSkipPlayback { * 用户点确认时才决定怎么做。确认框可能挂很久:期间位置已推进,沿用检测时算好的落点会 * 往回 seek;更要紧的是可能已经切集,此时按上一集的段落动作会把用户带到别处。 */ - private boolean confirmSkip(int expectedGeneration, PlayerManager player, Segment segment, EndingAction onEnding) { - if (!isConfirmationPending(segment)) return false; - if (expectedGeneration != generation || player == null || player.isReleased()) { - cancelConfirmation(segment); - return false; // 已切集/换源,这个确认过期了 - } + private void confirmSkip(int expectedGeneration, PlayerManager player, Segment segment, EndingAction onEnding) { + if (expectedGeneration != generation) return; // 已切集/换源,这个确认过期了 + if (player.isReleased()) return; long position = player.getPosition(); long duration = player.getDuration(); long target = seekTarget(segment, position, duration); boolean canEnd = target <= 0 && endsWithFile(segment, duration); - if (target <= 0 && !canEnd) { - cancelConfirmation(segment); - return false; // 已经放过去了,无事可做 - } - boolean success = runSkip(player, segment, target, canEnd, onEnding); - if (success && expectedGeneration == generation) completeConfirmation(segment); - else if (!success) cancelConfirmation(segment); - return success; + if (target <= 0 && !canEnd) return; // 已经放过去了,无事可做 + runSkip(player, segment, target, canEnd, onEnding); } - private boolean runSkip(PlayerManager player, Segment segment, long target, boolean canEnd, EndingAction onEnding) { + private void runSkip(PlayerManager player, Segment segment, long target, boolean canEnd, EndingAction onEnding) { boolean seeked = target > 0; - if (player == null || player.isReleased()) return false; if (seeked) player.seekTo(target); - // 末集也算「本次已处理」:EndingAction 会自行提示无下一集,若返回 false 仍继续留在 - // 当前时间点,下一次进度回调会不断重复触发同一个动作。 - boolean endingAttempted = !seeked && canEnd && onEnding != null; - boolean advanced = endingAttempted && onEnding.run(); + boolean advanced = !seeked && canEnd && onEnding != null && onEnding.run(); // 切不动(末集/电影)时既没跳也没换集,此时提示「已跳过」是假话 if (skipNoticeListener != null && (seeked || advanced)) skipNoticeListener.onSkipped(segment, seeked); - return seeked || endingAttempted; } /** @@ -395,12 +264,12 @@ public class IntroSkipPlayback { * *

片尾之后还剩内容(彩蛋、预告)时不能按看完处理,那会把剩下的正片一起扔掉。 */ - static boolean endsWithFile(Segment segment, long duration) { - if (segment == null || !segment.isEnding() || duration <= 0) return false; + private boolean endsWithFile(Segment segment, long duration) { + if (!segment.isEnding()) return false; if (segment.isOpenEnded()) return true; long end = segment.getEndMs(); if (end <= 0) return true; - return end >= duration - TOLERANCE_MS; + return duration > 0 && end >= duration - TOLERANCE_MS; } /** @@ -456,24 +325,11 @@ public class IntroSkipPlayback { } /** - * 段落身份。同一 provider 内两个字段名可能指同一段(例如 intro 与 intro#0),去掉首项序号 - * 让它们共享一次性状态;后续数组项保留序号,避免同一 provider 的多个片段互相吞掉。身份不含 - * 本地折算后的时间边界,所以本集时长小幅抖动不会换出新 id。 - * - *

带上 provider:跨 provider 不归一。别名映射(credits→outro 等)只在同一家内部 - * 消解字段名差异,一旦把两家折进同一个 id,两家对同一类片段给出的不同边界就会共享 - * {@code skipped}——去重时若因起点相差过大判为两段而各自保留,跳过其中一段会让另一段被 - * {@code isSegmentHandled} 永久吞掉,用户再也跳不到真正的片尾。同段合并由服务层 - * {@code addDeduped}/{@code overlaps} 按时间轴负责,那里才有边界信息。 + * 段落身份。刻意不含时间边界:尾部段会随本集时长折算而平移,把边界写进 id 会让同一段 + * 在时长抖动后换出新 id,逃出 skipped 再触发一次,表现为重复跳过 / 重复换集。 + * 同类同源在一集里只会有一段(去重已保证),所以类型 + 数据源足够唯一。 */ private String id(Segment segment) { - if (segment == null) return ""; - String identity = segment.getIdentity(); - if (identity.endsWith("#0")) identity = identity.substring(0, identity.length() - 2); - if (identity.startsWith("credits")) identity = "outro" + identity.substring("credits".length()); - if (identity.startsWith("trailer")) identity = "preview" + identity.substring("trailer".length()); - if (identity.startsWith("next_episode")) identity = "preview" + identity.substring("next_episode".length()); - if (identity.startsWith("next_preview")) identity = "preview" + identity.substring("next_preview".length()); - return segment.getKind() + "|" + segment.getProvider() + "|" + identity; + return segment.getKind() + "|" + segment.getProvider(); } } diff --git a/app/src/main/java/com/fongmi/android/tv/player/LiveSourceFallbackPolicy.java b/app/src/main/java/com/fongmi/android/tv/player/LiveSourceFallbackPolicy.java deleted file mode 100644 index 25f9eaf536..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/LiveSourceFallbackPolicy.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.fongmi.android.tv.player; - -public final class LiveSourceFallbackPolicy { - - public enum Action { - NONE, - NEXT_LINE, - NEXT_SOURCE - } - - private LiveSourceFallbackPolicy() { - } - - public static Action decide(boolean changeLineEnabled, boolean sourceFallbackEnabled, boolean channelPresent, boolean lastLine, boolean onlyLine, boolean nextSourceAvailable) { - if (channelPresent && changeLineEnabled && !lastLine) return Action.NEXT_LINE; - if (!sourceFallbackEnabled) return Action.NONE; - if (nextSourceAvailable) return Action.NEXT_SOURCE; - if (channelPresent && !onlyLine) return Action.NEXT_LINE; - return Action.NONE; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/NativeLibraryDiagnostics.java b/app/src/main/java/com/fongmi/android/tv/player/NativeLibraryDiagnostics.java deleted file mode 100644 index 8623cd092e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/NativeLibraryDiagnostics.java +++ /dev/null @@ -1,204 +0,0 @@ -package com.fongmi.android.tv.player; - -import com.fongmi.android.tv.App; -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.io.InputStream; -import java.io.RandomAccessFile; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -/** Reads only this process's loaded code, off the player/UI thread. Never loads a library. */ -public final class NativeLibraryDiagnostics { - private static final AtomicBoolean pending = new AtomicBoolean(); - private static final java.util.concurrent.ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(r -> { - Thread thread = new Thread(r, "diagnostic-library-inventory"); thread.setDaemon(true); return thread; - }); - private static final LinkedHashMap identities = new LinkedHashMap<>(); - private record Mapping(File file, long offset) {} - private record Library(File file, String name, String source, long offset, long size, String asset) {} - private record Identity(String abi, String buildId, String actual, String expected) {} - private NativeLibraryDiagnostics() {} - - public static void request() { - if (!DebugLogStore.acceptsEvent("env.native") || !pending.compareAndSet(false, true)) return; - worker.schedule(() -> { - try { - android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND); - if (DebugLogStore.acceptsEvent("env.native")) capture(); - } catch (Exception ignored) { DebugLogStore.collectorFailure(); } - finally { pending.set(false); } - }, 1500, TimeUnit.MILLISECONDS); - } - - public static void failure(String name, Throwable error) { - if (!DebugLogStore.acceptsEvent("env.native")) return; - DebugLogStore.event(new DiagnosticEvent("env.native", "none", "process", 0, 0).severity("error") - .observed("library", name).observed("loadResult", "failed").observed("javaClass", error.getClass().getName()).message(error.getMessage())); - } - - private static void capture() throws Exception { - android.content.pm.ApplicationInfo info = App.get().getApplicationInfo(); - Set apks = new LinkedHashSet<>(); apks.add(info.sourceDir); - if (info.splitSourceDirs != null) java.util.Collections.addAll(apks, info.splitSourceDirs); - List mappings = new ArrayList<>(); Set files = new LinkedHashSet<>(); - List libraries = new ArrayList<>(); - try (BufferedReader reader = new BufferedReader(new FileReader("/proc/self/maps"))) { - String line; int lines = 0; - while ((line = reader.readLine()) != null && ++lines <= 32768) { - String[] fields = line.split("\\s+", 6); - if (fields.length < 6 || !fields[1].contains("x")) continue; - String path = fields[5]; - if (apks.contains(path)) mappings.add(new Mapping(new File(path), Long.parseLong(fields[2], 16))); - else if (path.endsWith(".so") && (path.startsWith(info.nativeLibraryDir + "/") || path.startsWith(info.dataDir + "/app_mpv-libs/")) && files.add(path)) { - File file = new File(path); - String asset = path.startsWith(info.dataDir + "/app_mpv-libs/") ? "mpv-libs/" + file.getParentFile().getName() + "/" + file.getName() : null; - libraries.add(new Library(file, file.getName(), asset == null ? "nativeLibraryDir" : "extracted-mpv-asset", 0, file.length(), asset)); - } - } - } - for (String path : apks) apkLibraries(new File(path), mappings, libraries); - int count = 0; - for (Library library : libraries) { - if (!DebugLogStore.acceptsEvent("env.native") || count++ >= 128) break; - String key = library.file + ":" + library.offset + ":" + library.size + ":" + library.file.lastModified(); - Identity identity = identities.get(key); - try { - if (identity == null) { - identity = identify(library); - if (identities.size() >= 128) identities.remove(identities.keySet().iterator().next()); - identities.put(key, identity); - } - DiagnosticEvent event = new DiagnosticEvent("env.native", "none", "process", 0, 0) - .observed("library", library.name).observed("source", library.source).observed("loadResult", "executable-mapping-observed") - .observed("abi", identity.abi).observed("buildId", identity.buildId).observed("actualDigest", identity.actual) - .observed("expectedDigest", identity.expected).observed("manifestMatch", identity.expected == null ? null : identity.expected.equals(identity.actual)) - .observed("config", library.asset == null ? null : com.fongmi.android.tv.BuildConfig.MPV_NATIVE_SOURCES) - .observed("metricScope", "own process executable mappings and backing ELF/package bytes; not memory-content hashing") - .pin("library-" + library.name); - DebugLogStore.event(event); - } catch (Exception error) { - DebugLogStore.event(new DiagnosticEvent("env.native", "none", "process", 0, 0).observed("library", library.name) - .observed("loadResult", "executable-mapping-observed").unknown("buildId", DiagnosticEvent.Status.READ_ERROR)); - } - } - if (libraries.isEmpty()) DebugLogStore.event(new DiagnosticEvent("env.native", "none", "process", 0, 0) - .observed("count", 0).observed("reason", "no app-owned executable library mapping at capture")); - } - - /** Android maps uncompressed APK entries directly. Match executable offsets, never package presence. */ - private static void apkLibraries(File apk, List mappings, List libraries) throws Exception { - if (mappings.stream().noneMatch(mapping -> mapping.file.equals(apk))) return; - try (RandomAccessFile file = new RandomAccessFile(apk, "r")) { - int tailLength = (int) Math.min(65557, file.length()); - byte[] tail = new byte[tailLength]; file.seek(file.length() - tailLength); file.readFully(tail); - ByteBuffer end = ByteBuffer.wrap(tail).order(ByteOrder.LITTLE_ENDIAN); int offset = tailLength - 22; - while (offset >= 0 && end.getInt(offset) != 0x06054b50) offset--; - if (offset < 0) return; - int entries = Short.toUnsignedInt(end.getShort(offset + 10)); - long cursor = Integer.toUnsignedLong(end.getInt(offset + 16)); - for (int i = 0; i < entries && i < 65535; i++) { - if (cursor < 0 || cursor + 46 > file.length()) break; - file.seek(cursor); byte[] header = new byte[46]; file.readFully(header); - ByteBuffer h = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN); - if (h.getInt(0) != 0x02014b50) break; - int nameLength = Short.toUnsignedInt(h.getShort(28)), extra = Short.toUnsignedInt(h.getShort(30)), comment = Short.toUnsignedInt(h.getShort(32)); - byte[] nameBytes = new byte[nameLength]; file.readFully(nameBytes); - String name = new String(nameBytes, java.nio.charset.StandardCharsets.UTF_8); - cursor += 46L + nameLength + extra + comment; - if (h.getShort(10) != 0 || !name.startsWith("lib/") || !name.endsWith(".so")) continue; - long local = Integer.toUnsignedLong(h.getInt(42)); - if (local + 30 > file.length()) continue; - file.seek(local); byte[] localHeader = new byte[30]; file.readFully(localHeader); - ByteBuffer l = ByteBuffer.wrap(localHeader).order(ByteOrder.LITTLE_ENDIAN); - if (l.getInt(0) != 0x04034b50) continue; - long start = local + 30 + Short.toUnsignedInt(l.getShort(26)) + Short.toUnsignedInt(l.getShort(28)); - long size = Integer.toUnsignedLong(h.getInt(24)); - if (size <= 0 || size > (128L << 20) || start + size > file.length()) continue; - if (mappings.stream().anyMatch(mapping -> mapping.file.equals(apk) && mapping.offset >= start && mapping.offset < start + size)) - libraries.add(new Library(apk, name.substring(name.lastIndexOf('/') + 1), "mapped-apk-entry:" + name, start, size, null)); - } - } - } - - private static Identity identify(Library library) throws Exception { - if (library.size < 64 || library.size > (128L << 20)) throw new java.io.IOException("ELF budget"); - try (RandomAccessFile file = new RandomAccessFile(library.file, "r")) { - file.seek(library.offset); byte[] bytes = new byte[64]; file.readFully(bytes); - if (bytes[0] != 0x7f || bytes[1] != 'E' || bytes[2] != 'L' || bytes[3] != 'F') throw new java.io.IOException("ELF magic"); - ByteBuffer header = ByteBuffer.wrap(bytes).order(bytes[5] == 2 ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN); - boolean wide = bytes[4] == 2; int machine = Short.toUnsignedInt(header.getShort(18)); - String abi = machine == 183 ? "arm64-v8a" : machine == 40 ? "armeabi-v7a" : "elf-machine-" + machine; - long phoff = wide ? header.getLong(32) : Integer.toUnsignedLong(header.getInt(28)); - int phsize = Short.toUnsignedInt(header.getShort(wide ? 54 : 42)), count = Short.toUnsignedInt(header.getShort(wide ? 56 : 44)); - String buildId = null; - if (phsize >= (wide ? 56 : 32) && phsize <= 256 && count <= 256 && phoff >= 0 && phoff + (long) phsize * count <= library.size) - for (int i = 0; i < count; i++) { - file.seek(library.offset + phoff + (long) i * phsize); byte[] ph = new byte[phsize]; file.readFully(ph); - ByteBuffer p = ByteBuffer.wrap(ph).order(header.order()); if (p.getInt() != 4) continue; - long noteOffset = wide ? p.getLong(8) : Integer.toUnsignedLong(p.getInt(4)); - long size = wide ? p.getLong(32) : Integer.toUnsignedLong(p.getInt(16)); - if (size < 12 || size > 65536 || noteOffset < 0 || noteOffset + size > library.size) continue; - byte[] note = new byte[(int) size]; file.seek(library.offset + noteOffset); file.readFully(note); - ByteBuffer n = ByteBuffer.wrap(note).order(header.order()); - for (int at = 0; at + 12 <= note.length;) { - int names = n.getInt(at), data = n.getInt(at + 4), type = n.getInt(at + 8); - if (names < 0 || names > 256 || data < 0 || data > 4096) break; - int value = at + 12 + ((names + 3) & ~3), next = value + ((data + 3) & ~3); - if (next > note.length) break; - if (type == 3 && names == 4 && data > 0 && data <= 64 && note[at + 12] == 'G' && note[at + 13] == 'N' && note[at + 14] == 'U') - buildId = hex(java.util.Arrays.copyOfRange(note, value, value + data)); - at = next; - } - } - MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] buffer = new byte[16384]; long remaining = library.size; - file.seek(library.offset); - while (remaining > 0) { int length = file.read(buffer, 0, (int) Math.min(buffer.length, remaining)); if (length < 0) throw new java.io.EOFException(); digest.update(buffer, 0, length); remaining -= length; } - String actual = hex(digest.digest()), expected = null; - if (library.asset != null) try (InputStream input = App.get().getAssets().open(library.asset)) { - digest.reset(); int length; long size = 0; - while ((length = input.read(buffer)) >= 0) { if ((size += length) > (128L << 20)) throw new java.io.IOException("asset budget"); digest.update(buffer, 0, length); } - expected = hex(digest.digest()); - } - else if (library.source.startsWith("mapped-apk-entry:")) { - // The executable map points into this exact packaged ELF; no extracted copy exists. - expected = actual; - } else { - android.content.pm.ApplicationInfo info = App.get().getApplicationInfo(); - List packages = new ArrayList<>(); packages.add(info.sourceDir); - if (info.splitSourceDirs != null) java.util.Collections.addAll(packages, info.splitSourceDirs); - for (String path : packages) try (java.util.zip.ZipFile archive = new java.util.zip.ZipFile(path)) { - java.util.zip.ZipEntry entry = archive.getEntry("lib/" + abi + "/" + library.name); - if (entry == null || entry.getSize() != library.size) continue; - digest.reset(); - try (InputStream input = archive.getInputStream(entry)) { - int length; long size = 0; - while ((length = input.read(buffer)) >= 0) { - if ((size += length) > (128L << 20)) throw new java.io.IOException("package budget"); - digest.update(buffer, 0, length); - } - } - expected = hex(digest.digest()); break; - } - } - return new Identity(abi, buildId, actual, expected); - } - } - public static String digestText(String value) { - try { return hex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); } - catch (java.security.NoSuchAlgorithmException error) { return "unavailable"; } - } - private static String hex(byte[] bytes) { StringBuilder value = new StringBuilder(bytes.length * 2); for (byte item : bytes) value.append(Character.forDigit((item >>> 4) & 15, 16)).append(Character.forDigit(item & 15, 16)); return value.toString(); } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/OutputProgressDiagnostic.java b/app/src/main/java/com/fongmi/android/tv/player/OutputProgressDiagnostic.java deleted file mode 100644 index 173c656fb8..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/OutputProgressDiagnostic.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.fongmi.android.tv.player; - -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; -import java.util.Objects; - -/** Conservative observation over existing samples. Never requests recovery or changes playback. */ -public final class OutputProgressDiagnostic { - private final boolean video; - private PlaybackDiagnosticCollector.Context owner; - private long epoch, capture = -1, unchangedSince; - private double previous = Double.NaN; - private int unchangedSamples; - private boolean reported; - private String lastGate; - - public OutputProgressDiagnostic(boolean video) { this.video = video; } - - public void sample(PlaybackDiagnosticCollector log, PlaybackDiagnosticCollector.Context context, - long currentEpoch, long nowMs, double progress, String gate, String metric) { - if (!DebugLogStore.acceptsEvent(video ? "video.output.summary" : "audio.output.playhead") || context == null) { - previous = Double.NaN; lastGate = null; return; - } - long generation = DebugLogStore.captureGeneration(); - if (owner != context || epoch != currentEpoch || capture != generation) { - owner = context; epoch = currentEpoch; capture = generation; - previous = Double.NaN; lastGate = null; reported = false; - } - if (gate == null && !Double.isFinite(progress)) gate = "output-metric-unavailable"; - if (gate != null) { - previous = Double.NaN; unchangedSamples = 0; reported = false; - if (!Objects.equals(lastGate, gate)) { - String reason = gate; - log.emit(context, "diag.observation", "output-progress-gate", "sample-owner", e -> e - .observed("video", video).observed("phase", "gated").observed("reason", reason).observed("metricScope", metric)); - } - lastGate = gate; return; - } - lastGate = null; - if (!Double.isFinite(previous) || Double.compare(progress, previous) != 0) { - previous = progress; unchangedSince = nowMs; unchangedSamples = 0; reported = false; return; - } - unchangedSamples++; - long duration = nowMs - unchangedSince; - if (reported || unchangedSamples < 3 || duration < 15_000) return; - reported = true; - log.emit(context, "diag.observation", "output-progress-samples", "sample-owner", e -> e.inferred() - .observed("video", video).observed("phase", "observation").observed("reason", "no-output-progress-observed") - .observed("windowMs", duration).observed("count", unchangedSamples).observed("value", progress) - .observed("epoch", currentEpoch).observed("metricScope", metric) - .unknown(video ? "physicalVideo" : "audibility", DiagnosticEvent.Status.NOT_OBSERVABLE) - .observed("nextStep", "correlate input, buffering, surface/route and user symptom; this is not a decoder failure") - .pin(log.instanceId() + (video ? "-video-observation" : "-audio-observation"))); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/PcmDiagnosticProbe.java b/app/src/main/java/com/fongmi/android/tv/player/PcmDiagnosticProbe.java deleted file mode 100644 index 9f50d40df9..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/PcmDiagnosticProbe.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.fongmi.android.tv.player; - -import androidx.media3.common.C; -import com.github.catvod.crawler.diagnostics.DiagnosticCapture; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; - -/** At most three 4096-frame windows per checkpoint/capture; no buffer is retained or modified. */ -public final class PcmDiagnosticProbe { - private String captureId; - private int windows; - private long nextNs; - - public void sample(PlaybackDiagnosticCollector log, PlaybackDiagnosticCollector.Context owner, - ByteBuffer data, int start, int end, int encoding, int channels, String checkpoint, boolean protectedMedia) { - if (!com.github.catvod.crawler.DebugLogStore.categoryEnabled(com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category.AUDIO) - || protectedMedia || owner == null || channels < 1 || channels > 32 || end <= start) return; - DiagnosticCapture.Session capture = DiagnosticCapture.current(owner.trace(), owner.generation(), owner.attempt()); - if (capture == null || !capture.instance().equals(log.instanceId())) return; - if (!capture.id().equals(captureId)) { captureId = capture.id(); windows = 0; nextNs = 0; } - long now = System.nanoTime(); - if (windows >= 3 || now < nextNs) return; - int width = switch (encoding) { - case C.ENCODING_PCM_8BIT -> 1; - case C.ENCODING_PCM_16BIT -> 2; - case C.ENCODING_PCM_24BIT -> 3; - case C.ENCODING_PCM_32BIT, C.ENCODING_PCM_FLOAT -> 4; - default -> 0; - }; - if (width == 0) return; - int frames = Math.min(4096, (end - start) / (width * channels)); - if (frames == 0) return; - int index = windows++; nextNs = now + 1_000_000_000; - ByteBuffer buffer = data.duplicate().order(ByteOrder.LITTLE_ENDIAN); buffer.position(start); - double[] squares = new double[channels], peaks = new double[channels]; - int[] zeros = new int[channels], clipped = new int[channels], invalid = new int[channels]; - for (int frame = 0; frame < frames; frame++) for (int channel = 0; channel < channels; channel++) { - double value = switch (encoding) { - case C.ENCODING_PCM_8BIT -> ((buffer.get() & 255) - 128) / 128.0; - case C.ENCODING_PCM_16BIT -> buffer.getShort() / 32768.0; - case C.ENCODING_PCM_24BIT -> ((buffer.get() & 255) | ((buffer.get() & 255) << 8) | (buffer.get() << 16)) / 8388608.0; - case C.ENCODING_PCM_32BIT -> buffer.getInt() / 2147483648.0; - default -> buffer.getFloat(); - }; - if (!Double.isFinite(value)) { invalid[channel]++; continue; } - squares[channel] += value * value; peaks[channel] = Math.max(peaks[channel], Math.abs(value)); - if (value == 0) zeros[channel]++; - if (Math.abs(value) >= 0.9999) clipped[channel]++; - } - for (int channel = 0; channel < channels; channel++) { - int c = channel; - log.emit(owner, "audio.pcm-probe", checkpoint, "capture-owner", e -> e.observed("captureId", capture.id()) - .observed("probeIndex", index).observed("channel", c).observed("channels", channels).observed("encoding", encoding) - .observed("frames", frames).observed("rms", Math.sqrt(squares[c] / frames)).observed("peak", peaks[c]) - .observed("zeroRatio", (double) zeros[c] / frames).observed("clipped", clipped[c]).observed("nonFinite", invalid[c]) - .observed("metricScope", "bounded PCM statistics; no raw samples retained")); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/PixelDiagnosticProbe.java b/app/src/main/java/com/fongmi/android/tv/player/PixelDiagnosticProbe.java deleted file mode 100644 index 17dead3325..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/PixelDiagnosticProbe.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.fongmi.android.tv.player; - -import android.app.Activity; -import android.content.Context; -import android.content.ContextWrapper; -import android.graphics.Bitmap; -import android.graphics.Rect; -import android.os.Build; -import android.os.Handler; -import android.os.Looper; -import android.view.PixelCopy; -import android.view.SurfaceView; -import android.view.View; -import android.view.WindowManager; -import androidx.media3.ui.PlayerView; -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticCapture; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; -import static com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.*; - -/** Three 64x36 statistics only. PixelCopy never certifies physical presentation. */ -final class PixelDiagnosticProbe { - private final Handler handler = new Handler(Looper.getMainLooper()); - private String lastCapture; - private int[] previous; - - void maybeSample(PlayerView view, String trace) { - DiagnosticCapture.Session capture = DiagnosticCapture.current(); - if (!DebugLogStore.categoryEnabled(com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category.VIDEO) - || capture == null || !capture.trace().equals(trace) || capture.id().equals(lastCapture)) return; - lastCapture = capture.id(); previous = null; - sample(view, capture); - } - - private void sample(PlayerView view, DiagnosticCapture.Session capture) { - if (!DebugLogStore.categoryEnabled(com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category.VIDEO) - || DiagnosticCapture.current() != capture || !view.isAttachedToWindow()) return; - int index = DiagnosticCapture.claimPixel(capture); - if (index < 0) return; - View target = view.getVideoSurfaceView(); - Activity activity = activity(view.getContext()); - boolean secure = activity == null || (activity.getWindow().getAttributes().flags & WindowManager.LayoutParams.FLAG_SECURE) != 0; - if (view.getPlayer() != null) for (androidx.media3.common.Tracks.Group group : view.getPlayer().getCurrentTracks().getGroups()) - for (int i = 0; i < group.length; i++) if (group.isTrackSelected(i) && group.getTrackFormat(i).drmInitData != null) secure = true; - boolean protectedTarget = secure; - if (secure || Build.VERSION.SDK_INT < 24 || target == null || target.getWidth() <= 0 || target.getHeight() <= 0) { - emit(capture, index, target, e -> e.unknown("result", protectedTarget ? PERMISSION_DENIED : Build.VERSION.SDK_INT < 24 ? NOT_SUPPORTED : UNAVAILABLE)); - return; - } - Bitmap bitmap = Bitmap.createBitmap(64, 36, Bitmap.Config.ARGB_8888); - long start = android.os.SystemClock.elapsedRealtime(); - PixelCopy.OnPixelCopyFinishedListener callback = result -> { - try { - if (DiagnosticCapture.current() != capture) return; - emit(capture, index, target, e -> { - e.observed("errorCode", result).observed("durationMs", android.os.SystemClock.elapsedRealtime() - start); - if (result != PixelCopy.SUCCESS) { e.unknown("luminance", UNAVAILABLE); return; } - int[] pixels = new int[64 * 36]; bitmap.getPixels(pixels, 0, 64, 0, 0, 64, 36); - double sum = 0; int black = 0, changed = 0; - for (int i = 0; i < pixels.length; i++) { - int pixel = pixels[i]; - double luma = (0.2126 * ((pixel >> 16) & 255) + 0.7152 * ((pixel >> 8) & 255) + 0.0722 * (pixel & 255)) / 255; - sum += luma; if (luma < 0.02) black++; - if (previous != null && pixel != previous[i]) changed++; - } - e.observed("pixelCount", pixels.length).observed("luminance", sum / pixels.length) - .observed("blackRatio", (double) black / pixels.length); - if (previous == null) e.unknown("changeRatio", NOT_COLLECTED); else e.observed("changeRatio", (double) changed / pixels.length); - previous = pixels; - }); - } finally { - bitmap.recycle(); - if (DiagnosticCapture.current() == capture && index < 2) handler.postDelayed(() -> sample(view, capture), 500); - else previous = null; - } - }; - try { - if (target instanceof SurfaceView surface) PixelCopy.request(surface, bitmap, callback, handler); - else if (Build.VERSION.SDK_INT >= 26) { - int[] xy = new int[2]; target.getLocationInWindow(xy); - PixelCopy.request(activity.getWindow(), new Rect(xy[0], xy[1], xy[0] + target.getWidth(), xy[1] + target.getHeight()), bitmap, callback, handler); - } else { bitmap.recycle(); emit(capture, index, target, e -> e.unknown("result", NOT_SUPPORTED)); } - } catch (RuntimeException error) { - bitmap.recycle(); emit(capture, index, target, e -> e.unknown("result", READ_ERROR).observed("javaClass", error.getClass().getSimpleName())); - } - } - - private void emit(DiagnosticCapture.Session capture, int index, View target, java.util.function.Consumer facts) { - DiagnosticEvent event = new DiagnosticEvent("video.pixel-probe", capture.trace(), capture.instance(), capture.generation(), capture.attempt()) - .source("ui", "pixelcopy", "foreground", "PixelCopy", "capture-owner", null, null, index, android.os.SystemClock.elapsedRealtimeNanos()) - .observed("captureId", capture.id()).observed("probeIndex", index) - .observed("surfaceType", target instanceof SurfaceView ? "video-SurfaceView" : "window-region; may include UI") - .unknown("physicalDisplay", NOT_OBSERVABLE).observed("metricScope", "queued buffer statistics; black/static content can be valid"); - facts.accept(event); DebugLogStore.event(event); - } - - private static Activity activity(Context context) { - for (int i = 0; i < 16 && context instanceof ContextWrapper wrapper; i++) { - if (context instanceof Activity activity) return activity; - Context base = wrapper.getBaseContext(); if (base == context) break; context = base; - } - return context instanceof Activity activity ? activity : null; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlaybackDiagnosticCollector.java b/app/src/main/java/com/fongmi/android/tv/player/PlaybackDiagnosticCollector.java deleted file mode 100644 index 3fec0b18e9..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/PlaybackDiagnosticCollector.java +++ /dev/null @@ -1,154 +0,0 @@ -package com.fongmi.android.tv.player; - -import android.media.MediaCodec; -import android.os.Build; -import android.os.SystemClock; - -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; -import com.github.catvod.crawler.diagnostics.DiagnosticText; - -import java.util.Collections; -import java.util.IdentityHashMap; -import java.util.Set; -import java.util.WeakHashMap; -import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Consumer; - -import static com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.*; - -/** Per-engine evidence. Contexts are immutable and may safely outlive the current media. */ -public final class PlaybackDiagnosticCollector { - public record Context(String trace, long generation, long attempt, String mediaId, String role) {} - private static final AtomicLong IDS = new AtomicLong(); - private static final WeakHashMap OBJECT_IDS = new WeakHashMap<>(); - private final String instance = id("player"); - private final String engine, version; - private final AtomicLong sequence = new AtomicLong(); - private volatile Context current = new Context("none", 0, 0, null, "unresolved"); - private volatile int videoLevel = -1, audioLevel = -1; - private volatile String firstError, lastError; - private volatile boolean protectedMedia; - private long captureGeneration = -1; - private volatile boolean ended; - - public PlaybackDiagnosticCollector(String engine, String version) { this.engine = engine; this.version = version; } - public static boolean enabled() { return DebugLogStore.isEnabled(); } - public static String id(String kind) { return kind + "-" + IDS.incrementAndGet(); } - public static synchronized String objectId(Object object, String kind) { - return object == null ? "none" : OBJECT_IDS.computeIfAbsent(object, key -> id(kind)); - } - public Context context() { return current; } - public String instanceId() { return instance; } - public String engine() { return engine; } - public boolean ended() { return ended; } - public boolean protectedMedia() { return protectedMedia; } - public void protectedMedia(boolean value) { protectedMedia = value; } - - public synchronized Context begin(String trace, String role) { - if (current.attempt() > 0) end("reprepare"); - boolean same = current.trace().equals(trace); - current = new Context(PlaybackTrace.normalize(trace), current.generation() + (same ? 0 : 1), - current.attempt() + 1, id("media"), role); - videoLevel = audioLevel = -1; firstError = lastError = null; captureGeneration = -1; ended = false; - if ("foreground".equals(role)) DiagnosticControls.playback(this); - baseline(false); - return current; - } - - public synchronized void baseline() { - baseline(true); - } - - private void baseline(boolean late) { - if (!enabled() || captureGeneration == DebugLogStore.captureGeneration()) return; - if (captureGeneration != -1) { videoLevel = audioLevel = -1; firstError = lastError = null; } - captureGeneration = DebugLogStore.captureGeneration(); - NativeLibraryDiagnostics.request(); - emit(current, "diag.session.begin", "engine-collector", "engine-context", e -> e - .observed("mode", "standard").observed("captureStartedLate", late) - .observed("engineInstance", instance).observed("captureGeneration", captureGeneration) - .unknown("physicalVideo", NOT_OBSERVABLE).unknown("audibility", NOT_OBSERVABLE) - .pin(instance + "-baseline")); - } - - public void emit(Context owner, String name, String source, String association, Consumer facts) { - emit(owner, name, source, association, null, facts); - } - - public void emitNative(Context owner, String name, String source, String association, String prefix, Consumer facts) { - emit(owner, name, source, association, prefix == null ? "" : prefix, facts); - } - - private void emit(Context owner, String name, String source, String association, String nativePrefix, Consumer facts) { - if (nativePrefix == null ? !DebugLogStore.acceptsEvent(name) : !DebugLogStore.isEnabled() - || !DebugLogStore.categoryEnabled(com.github.catvod.crawler.diagnostics.DiagnosticCategories.nativePrefix(nativePrefix))) return; - try { - Context ctx = owner == null ? new Context("none", 0, 0, null, "unresolved") : owner; - DiagnosticEvent event = new DiagnosticEvent(name, ctx.trace(), instance, ctx.generation(), ctx.attempt()) - .source(engine, version, ctx.role(), source, association, ctx.mediaId(), current.mediaId(), - sequence.incrementAndGet(), SystemClock.elapsedRealtimeNanos()); - facts.accept(event); - DebugLogStore.event(event); - } catch (RuntimeException ignored) { DebugLogStore.collectorFailure(); } - } - - public void emit(String name, String source, Consumer facts) { - emit(current, name, source, "engine-context", facts); - } - - public void evidence(Context owner, boolean video, int level) { - if (owner != current || !enabled()) return; - if (video) videoLevel = Math.max(videoLevel, level); else audioLevel = Math.max(audioLevel, level); - } - - public void error(Context owner, String domain, String stage, Throwable error) { - if (!enabled() || error == null) return; - String errorId = id("error"); - if (owner == current) { if (firstError == null) firstError = errorId; lastError = errorId; } - if (domain.startsWith("video") || domain.startsWith("audio")) - emit(owner, domain.startsWith("video") ? "video.error" : "audio.error", domain, "exception-chain", e -> e - .severity("error").observed("errorId", errorId).observed("stage", stage) - .observed("javaClass", error.getClass().getName()).message(error.getMessage())); - Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); - Throwable cause = error; - for (int depth = 0; cause != null && depth < 8 && visited.add(cause); depth++, cause = cause.getCause()) { - Throwable item = cause; - int index = depth; - emit(owner, "diag.error", domain, "exception-chain", e -> { - e.severity("error").observed("errorId", errorId).observed("domain", domain).observed("stage", stage) - .observed("chunk", index).observed("javaClass", item.getClass().getName()) - .observed("message", String.valueOf(item.getMessage())).pin(instance + "-error"); - if (Build.VERSION.SDK_INT >= 21 && item instanceof MediaCodec.CodecException codec) { - e.observed("diagnosticInfo", codec.getDiagnosticInfo()).observed("recoverable", codec.isRecoverable()) - .observed("transient", codec.isTransient()); - if (Build.VERSION.SDK_INT >= 23) e.observed("errorCode", codec.getErrorCode()); - } - }); - } - String detail = DiagnosticText.throwable(error); - int limit = Math.min(detail.length(), 2640), chunks = (limit + 219) / 220; - for (int i = 0; i < chunks; i++) { - int chunk = i; - String part = detail.substring(i * 220, Math.min(limit, (i + 1) * 220)); - emit(owner, "diag.error", domain, "exception-detail", e -> e.severity("error") - .observed("errorId", errorId).observed("chunk", chunk).observed("chunkCount", chunks) - .observed("message", part).observed("truncated", detail.length() > limit)); - } - } - - public synchronized void end(String reason) { - if (ended) return; - ended = true; - Context owner = current; - com.github.catvod.crawler.diagnostics.DiagnosticCapture.Session depth = com.github.catvod.crawler.diagnostics.DiagnosticCapture.current(owner.trace(), owner.generation(), owner.attempt()); - if (depth != null && depth.instance().equals(instance)) com.github.catvod.crawler.diagnostics.DiagnosticCapture.stop("playback-ended"); - emit(owner, "play.attempt.end", "engine-collector", "engine-context", e -> e - .observed("reason", reason).observed("closed", true) - .observed("videoEvidenceLevel", videoLevel < 0 ? null : videoLevel).observed("audioEvidenceLevel", audioLevel < 0 ? null : audioLevel) - .observed("firstError", firstError).observed("lastError", lastError) - .unknown("physicalVideo", NOT_OBSERVABLE).unknown("audibility", NOT_OBSERVABLE) - .observed("missingEvidence", "physical presentation; per-collector unavailable fields") - .pin(instance + "-end")); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlaybackDiagnosticSession.java b/app/src/main/java/com/fongmi/android/tv/player/PlaybackDiagnosticSession.java deleted file mode 100644 index 892b8b3357..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/PlaybackDiagnosticSession.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.fongmi.android.tv.player; - -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; - -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicLong; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.*; - -/** Controller evidence only. Decoder, output and physical presentation are separate collectors. */ -public final class PlaybackDiagnosticSession { - interface Sink { - boolean enabled(); - long generation(); - void emit(DiagnosticEvent event); - } - private static final AtomicLong INSTANCES = new AtomicLong(); - private static final List> ACTIVE = new ArrayList<>(); - private static final Pattern DETAIL = Pattern.compile("(?:^|\\s)(player|decode|headers|source)=([^\\s]+)"); - private static final Sink LIVE = new Sink() { - @Override public boolean enabled() { return DebugLogStore.isEnabled(); } - @Override public long generation() { return DebugLogStore.captureGeneration(); } - @Override public void emit(DiagnosticEvent event) { DebugLogStore.event(event); } - }; - - private final Sink sink; - private final String instance = "controller-" + INSTANCES.incrementAndGet(); - private String trace = "none", lastStage = "none"; - private long mediaGeneration, attempt, capturedGeneration = -1, startedAtMs; - private Integer playerType, decode; - private boolean active; - - public PlaybackDiagnosticSession() { - this(LIVE); - synchronized (ACTIVE) { - ACTIVE.removeIf(reference -> reference.get() == null); - if (ACTIVE.size() >= 64) ACTIVE.remove(0); - ACTIVE.add(new WeakReference<>(this)); - } - } - - PlaybackDiagnosticSession(Sink sink) { this.sink = sink; } - - public static void captureActive(long nowMs) { - List sessions = new ArrayList<>(); - synchronized (ACTIVE) { - for (WeakReference reference : ACTIVE) { - PlaybackDiagnosticSession session = reference.get(); - if (session != null) sessions.add(session); - } - } - for (PlaybackDiagnosticSession session : sessions) session.captureLate(nowMs); - } - - public synchronized void begin(String trace, long nowMs) { - end("replaced"); - this.trace = trace; mediaGeneration++; attempt = 0; - startedAtMs = nowMs; active = true; lastStage = "request-pending"; - playerType = decode = null; capturedGeneration = -1; - if (sink.enabled()) capture(false, nowMs); - } - - private synchronized void captureLate(long nowMs) { - if (active && sink.enabled() && capturedGeneration != sink.generation()) capture(true, nowMs); - } - - private void capture(boolean late, long nowMs) { - capturedGeneration = sink.generation(); - DiagnosticEvent event = event("diag.session.begin") - .observed("mode", "standard").observed("captureStartedLate", late) - .observed("association", "controller-current; source callback media is not collected") - .unknown("engineInstance", NOT_COLLECTED).unknown("role", UNKNOWN) - .coverage("session", KNOWN).coverage("deviceInfo", KNOWN).coverage("config", KNOWN) - .coverage("request", KNOWN).coverage("lifecycle", KNOWN).coverage("attemptEnd", KNOWN).coverage("health", KNOWN); - for (String missing : List.of("nativeLibraries", "display", "configChanges", "input", "inputRoute", "container", "tracks", - "drm", "clock", "resources", "decoder", "surface", "audioOutput")) event.coverage(missing, NOT_COLLECTED); - sink.emit(event.pin(trace + "-session")); - if (late) sink.emit(event("play.lifecycle").observed("reason", "capture-started-late") - .unknown("controllerStage", NOT_COLLECTED).unknown("video", NOT_COLLECTED).unknown("audio", NOT_COLLECTED) - .unknown("physicalVideo", NOT_OBSERVABLE).unknown("audibility", NOT_OBSERVABLE).pin(trace + "-late")); - } - - public synchronized void stage(String stage, String detail, long nowMs) { - if (!active || !sink.enabled()) return; - if (capturedGeneration != sink.generation()) capture(true, nowMs); - Integer oldPlayer = playerType, oldDecode = decode; - String signalSource = null; - Integer headers = null; - Matcher matcher = DETAIL.matcher(detail == null ? "" : detail); - while (matcher.find()) { - String name = matcher.group(1), value = matcher.group(2); - if ("source".equals(name)) signalSource = value; - else { - try { - int number = Integer.parseInt(value); - if (number < 0) continue; - if ("player".equals(name)) playerType = number; - else if ("decode".equals(name)) decode = number; - else headers = number; - } catch (NumberFormatException ignored) {} - } - } - if ("prepare".equals(stage)) { - if (attempt > 0) endAttempt("controller-reprepare"); - attempt++; - sink.emit(event("config.snapshot").requested("playerType", playerType).requested("decode", decode) - .unknown("decoder", NOT_COLLECTED).unknown("audioOutput", NOT_COLLECTED).pin(trace + "-config-" + attempt)); - } - if (!java.util.Objects.equals(oldPlayer, playerType) || !java.util.Objects.equals(oldDecode, decode)) { - sink.emit(event("config.change").observed("oldValue", "player=" + oldPlayer + ",decode=" + oldDecode) - .requested("newValue", "player=" + playerType + ",decode=" + decode) - .observed("reason", stage).observed("phase", "controller-request") - .unknown("result", PENDING_CALLBACK)); - } - lastStage = stage; - String name = switch (stage) { - case "request" -> "play.request"; - case "parse-complete" -> "resolve.result"; - case "tracks" -> "media.tracks"; - default -> "play.lifecycle"; - }; - DiagnosticEvent event = event(name).observed("controllerStage", stage).observed("elapsedMs", Math.max(0, nowMs - startedAtMs)); - if (playerType != null) event.requested("playerType", playerType); - if (headers != null) event.observed("headersCount", headers); - if (signalSource != null) event.observed("signalSource", signalSource); - if ("tracks".equals(stage)) event.observed("tracksSummary", detail == null ? "" : detail); - event.unknown("video", NOT_COLLECTED).unknown("audio", NOT_COLLECTED) - .unknown("physicalVideo", NOT_OBSERVABLE).unknown("audibility", NOT_OBSERVABLE); - sink.emit(event.pin(trace + "-" + stage)); - } - - public synchronized void end(String reason) { - if (!active) return; - if (sink.enabled()) { - if (capturedGeneration != sink.generation()) capture(true, startedAtMs); - endAttempt(reason); - } - active = false; - } - - private void endAttempt(String reason) { - sink.emit(event("play.attempt.end").observed("closed", true).observed("reason", reason) - .observed("lastNormalLayer", "controller-signal:" + lastStage) - .unknown("outcome", UNKNOWN).unknown("video", NOT_COLLECTED).unknown("audio", NOT_COLLECTED) - .unknown("physicalVideo", NOT_OBSERVABLE).unknown("audibility", NOT_OBSERVABLE) - .observed("missingEvidence", "decoder / video submission / audio output / user observation") - .observed("nextStep", "Collect the missing output layer during one reproduction (D1/D2/D3 collectors).") - .pin(trace + "-end-" + attempt)); - } - - private DiagnosticEvent event(String event) { return new DiagnosticEvent(event, trace, instance, mediaGeneration, attempt); } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlaybackErrorClassifier.java b/app/src/main/java/com/fongmi/android/tv/player/PlaybackErrorClassifier.java index b9f5b49565..a59d9c74fa 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/PlaybackErrorClassifier.java +++ b/app/src/main/java/com/fongmi/android/tv/player/PlaybackErrorClassifier.java @@ -1,7 +1,6 @@ package com.fongmi.android.tv.player; import androidx.media3.common.PlaybackException; -import androidx.media3.common.util.StuckPlayerException; import androidx.media3.mpvplayer.MpvPlayer; import java.io.IOException; @@ -23,13 +22,6 @@ public final class PlaybackErrorClassifier { Failure marked = classifyStableMarker(message, error.errorCode, resolution); if (marked != null) return marked; - // The player can time out while data is already buffered. Its stuck detector - // alone identifies neither network I/O nor a particular audio/video renderer. - if (hasCause(error, StuckPlayerException.class)) { - return failure(Stage.UNKNOWN, Confidence.CONFIRMED, "playback-stuck", - error.errorCode, resolution); - } - if (isLocalConnectFailure(error, resolution)) { String evidence = hasCause(error, ConnectException.class) || hasCause(error, NoRouteToHostException.class) ? "local-connect-exception" @@ -60,8 +52,6 @@ public final class PlaybackErrorClassifier { private static Stage exactStage(int errorCode) { return switch (errorCode) { - // IJK also maps its native timed-out errors to this legacy code. Keep - // that contract; typed Media3 playback stalls are handled above. case PlaybackException.ERROR_CODE_TIMEOUT, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlaybackExperimentPolicy.java b/app/src/main/java/com/fongmi/android/tv/player/PlaybackExperimentPolicy.java index f26c3b1e55..908334d915 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/PlaybackExperimentPolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/player/PlaybackExperimentPolicy.java @@ -122,8 +122,6 @@ public final class PlaybackExperimentPolicy { Risk.STABLE_BASELINE, 1), EXO_AUTO_PRELOAD("exo.auto-preload", Domain.EXO, Risk.AUTOMATIC_OPTIMIZATION, 1), - EXO_SHORT_DRAMA_QUEUE("exo.short-drama-queue", Domain.EXO, - Risk.AUTOMATIC_OPTIMIZATION, 2), EXO_NETWORK_SPEED("exo.network-speed", Domain.EXO, Risk.AUTOMATIC_OPTIMIZATION, 1), EXO_RTSP_RECOVERY("exo.rtsp-recovery", Domain.EXO, diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlaybackMemoryMonitor.java b/app/src/main/java/com/fongmi/android/tv/player/PlaybackMemoryMonitor.java index 6aad6b9489..e856368d29 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/PlaybackMemoryMonitor.java +++ b/app/src/main/java/com/fongmi/android/tv/player/PlaybackMemoryMonitor.java @@ -123,10 +123,6 @@ public final class PlaybackMemoryMonitor { system.processImportance(), nativeHeapBytes); coordinator.publish(session, snapshot, pssBytes, Build.VERSION.SDK_INT, sampledAt); - DiagnosticControls.forTrace(session.traceId(), "play.resources", "existing-memory-monitor", e -> e - .observed("javaBytes", javaHeap.usedBytes()).observed("nativeBytes", nativeHeapBytes) - .observed("lowRam", system.lowRamDevice()).observed("trimLevel", trimLevel).observed("reason", trigger.name()) - .observed("metricScope", "existing monitor; no additional proc/PSS polling")); } private boolean shouldSamplePss(long nowElapsedMs) { diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlaybackResourceClassifier.java b/app/src/main/java/com/fongmi/android/tv/player/PlaybackResourceClassifier.java index b66d9d2009..fddb6a52ae 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/PlaybackResourceClassifier.java +++ b/app/src/main/java/com/fongmi/android/tv/player/PlaybackResourceClassifier.java @@ -32,13 +32,6 @@ public final class PlaybackResourceClassifier { private PlaybackResourceClassifier() { } - public static boolean isHlsUrl(String url) { - String lower = lower(url); - if (lower.contains("m3u8") || lower.contains("type=hls") || lower.contains("format=hls")) return true; - String path = urlPath(lower); - return path.endsWith("/live.php") || path.contains("/live/"); - } - public static Classification classifyRequest(String playerUri, String mimeType, String format) { return classify(playerUri, null, mimeType, format, Map.of(), null); } @@ -722,20 +715,6 @@ public final class PlaybackResourceClassifier { return value == null ? "" : value.toLowerCase(Locale.US); } - private static String urlPath(String value) { - try { - String path = URI.create(value).getPath(); - if (path != null) return path; - } catch (IllegalArgumentException ignored) { - } - int end = value.length(); - int query = value.indexOf('?'); - int fragment = value.indexOf('#'); - if (query >= 0) end = Math.min(end, query); - if (fragment >= 0) end = Math.min(end, fragment); - return value.substring(0, end); - } - private static boolean hasProgressiveExtension(String value) { String token = lower(value); int query = token.indexOf('?'); diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlaybackStartupPolicy.java b/app/src/main/java/com/fongmi/android/tv/player/PlaybackStartupPolicy.java index a7b969427b..257e913883 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/PlaybackStartupPolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/player/PlaybackStartupPolicy.java @@ -5,9 +5,9 @@ public final class PlaybackStartupPolicy { private PlaybackStartupPolicy() { } - public static Completion resolve(boolean ready, boolean hasVideoFrameEvidence, boolean hasVideo, boolean hasAudio) { + public static Completion resolve(boolean ready, boolean readySignalsFirstVideoFrame, boolean hasVideo, boolean hasAudio) { if (!ready) return Completion.NONE; - if (hasVideo && hasVideoFrameEvidence) return Completion.FIRST_FRAME; + if (hasVideo && readySignalsFirstVideoFrame) return Completion.FIRST_FRAME; if (!hasVideo && hasAudio) return Completion.AUDIO_PLAYABLE; return Completion.NONE; } diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlaybackSystemConditionMonitor.java b/app/src/main/java/com/fongmi/android/tv/player/PlaybackSystemConditionMonitor.java index 830a9b9cdf..0aa5ab78d0 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/PlaybackSystemConditionMonitor.java +++ b/app/src/main/java/com/fongmi/android/tv/player/PlaybackSystemConditionMonitor.java @@ -181,8 +181,6 @@ public final class PlaybackSystemConditionMonitor { Integer thermalStatus = readThermalStatus(session, powerManager); coordinator.publish(session, trigger, network, powerSave, thermalStatus, Build.VERSION.SDK_INT, sampledAt); - DiagnosticControls.forTrace(session.traceId(), "play.resources", "existing-system-monitor", e -> e - .observed("powerSave", powerSave).observed("thermalStatus", thermalStatus).observed("reason", trigger.name())); } private void registerListenersLocked(PlaybackAutoContext.SessionToken session) { diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlaybackTrace.java b/app/src/main/java/com/fongmi/android/tv/player/PlaybackTrace.java index 47af0065c4..00ca6a88a3 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/PlaybackTrace.java +++ b/app/src/main/java/com/fongmi/android/tv/player/PlaybackTrace.java @@ -29,7 +29,6 @@ public final class PlaybackTrace { Stage.READY, }; - private final PlaybackDiagnosticSession diagnostics = new PlaybackDiagnosticSession(); private final EnumMap stageTimes = new EnumMap<>(Stage.class); private String traceId = ""; private long startedAtMs = -1; @@ -64,7 +63,6 @@ public final class PlaybackTrace { startedAtMs = Math.max(0, elapsedRealtimeMs); lastStageAtMs = startedAtMs; stageTimes.clear(); - diagnostics.begin(traceId, startedAtMs); return traceId; } @@ -81,7 +79,6 @@ public final class PlaybackTrace { } public synchronized void clear() { - diagnostics.end("controller-cleared"); traceId = ""; startedAtMs = -1; lastStageAtMs = -1; @@ -93,10 +90,7 @@ public final class PlaybackTrace { } synchronized boolean mark(Stage stage, long elapsedRealtimeMs, String detail) { - if (traceId.isEmpty() || stage == null) return false; - // Repeated PREPARE is a new diagnostic attempt; preserve the original startup timer semantics. - if (!stageTimes.containsKey(stage) || stage == Stage.PREPARE) diagnostics.stage(stage.label(), detail, elapsedRealtimeMs); - if (stageTimes.containsKey(stage)) return false; + if (traceId.isEmpty() || stage == null || stageTimes.containsKey(stage)) return false; long now = Math.max(startedAtMs, elapsedRealtimeMs); long elapsedMs = Math.max(0, now - startedAtMs); long deltaMs = Math.max(0, now - lastStageAtMs); diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java b/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java index 6a1955c916..45998ca586 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java +++ b/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java @@ -52,7 +52,6 @@ import com.fongmi.android.tv.bean.Track; import com.fongmi.android.tv.impl.ParseCallback; import com.fongmi.android.tv.player.codec.CodecCapabilityInspector; import com.fongmi.android.tv.player.engine.ExoPlayerEngine; -import com.fongmi.android.tv.player.exo.ass.ExoAssSession; import com.fongmi.android.tv.player.engine.IjkPlayerEngine; import com.fongmi.android.tv.player.engine.MpvPlayerEngine; import com.fongmi.android.tv.player.engine.PlaySpec; @@ -197,7 +196,7 @@ public class PlayerManager implements ParseCallback { private static final long DISK_RANGE_GAP_TOLERANCE_MS = 2000L; private static final long BUFFERING_STALL_POLL_INTERVAL_MS = 1000L; private static final long LUT_PREVIEW_FRAME_INTERVAL_MS = 16L; - private static final float[] SPEED_PRESETS = new float[]{0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, 2.5f, 3f, 5f}; + private static final float[] SPEED_PRESETS = new float[]{0.5f, 0.75f, 1f, 1.2f, 1.25f, 1.5f, 1.75f, 2f, 2.5f, 3f, 5f}; private static final DecimalFormat SPEED_FORMAT = new DecimalFormat("0.##x"); private static final Pattern HTTP_STATUS = Pattern.compile("(?i)(?:response code|http status|http error)\\D+(\\d{3})"); @@ -214,8 +213,6 @@ public class PlayerManager implements ParseCallback { private final PlaybackMediaSessionController mediaSession = new PlaybackMediaSessionController(mediaSignals, mediaClock); private final AdAudioRuntimeController adAudioRuntime; - private final AdAudioRuntimeController.SpeechAdPlaybackHealth speechAdPlaybackHealth = - new AdAudioRuntimeController.SpeechAdPlaybackHealth(); private final DynamicLutEffect dynamicLutEffect; private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; private final BroadcastReceiver noisyReceiver; @@ -279,13 +276,6 @@ public class PlayerManager implements ParseCallback { private volatile boolean liveDanmakuPlaybackActive; private long pendingSwitchPositionMs = C.TIME_UNSET; private long pendingInitialStartPositionMs = C.TIME_UNSET; - /** - * 宿主从 History 里恢复出来的外挂字幕,等本次起播前注入。 - * - *

不让宿主直接改 spec:spec 在 {@code parse()} 路径里是 PlayerManager 自己 - * 构造的,宿主拿不到。取用一次即清,避免换集时复用上一集的字幕。 - */ - private Sub pendingRestoreSub; private float pendingSwitchSpeed = 1f; private boolean danmakuLoadInProgress; private boolean danmakuForeground = true; @@ -339,6 +329,9 @@ public class PlayerManager implements ParseCallback { private int lutApplySeq; private long parseHealthStartedAt; private boolean[] playerFallbackTried; + private boolean[] ffmpegModeFallbackTried; + private boolean ffmpegModeEngineRefreshPending; + private int ffmpegModeEngine = PlayerSetting.NONE; private int lutWarmupRecoveredErrors; private int mpvOutputEvaluationSeq; private int mpvAutoOutputProbeAttempts; @@ -418,6 +411,7 @@ public class PlayerManager implements ParseCallback { this.playerType = PlayerSetting.getActivePlayer(); PlayerSetting.putActivePlayer(this.playerType); this.playerFallbackTried = new boolean[PLAYER_COUNT]; + clearFfmpegModeFallbackState(); this.adAudioRuntime = new AdAudioRuntimeController( mediaSignals, mediaClock, new PrioritizedAdAudioRuleSource(AdAudioRuleStore.get(), ProbeRuleStore.get()), @@ -437,6 +431,8 @@ public class PlayerManager implements ParseCallback { prepareSeq++; exoSpeedRestoreState.clear(); lutApplySeq++; + clearFfmpegModeFallbackState(); + ffmpegModeEngine = PlayerSetting.NONE; prepareTerminalRelease(); resetNetworkProtectionSession("release"); clearExoDecoderResourceRecovery(true); @@ -547,14 +543,6 @@ public class PlayerManager implements ParseCallback { return player; } - public ExoAssSession getAssSession() { - return engine instanceof ExoPlayerEngine exo ? exo.getAssSession() : null; - } - - public com.fongmi.android.tv.player.exo.subtitle.ExoSubtitleSession getSubtitleSession() { - return engine instanceof ExoPlayerEngine exo ? exo.getSubtitleSession() : null; - } - public Tracks getCurrentTracks() { return engine.getCurrentTracks(); } @@ -612,32 +600,6 @@ public class PlayerManager implements ParseCallback { return spec != null ? spec.getKey() : null; } - public boolean supportsPlaylistQueue() { - return engine != null && engine.supportsPlaylistQueue(); - } - - public boolean appendPlaylistItem(PlaySpec nextSpec, String mediaId) { - return engine != null && engine.appendPlaylistItem(nextSpec, mediaId); - } - - public boolean removePlaylistItemsAfterCurrent() { - return engine != null && engine.removePlaylistItemsAfterCurrent(); - } - - public boolean setPlaylistPreloadDurationMs(long durationMs) { - return engine != null && engine.setPlaylistPreloadDurationMs(durationMs); - } - - /** Commits a Media3 natural transition without replacing the active playlist/player. */ - public boolean commitPlaylistTransition(PlaySpec nextSpec) { - if (engine == null || nextSpec == null) return false; - if (spec != null) nextSpec.setPlaybackTraceId(spec.getPlaybackTraceId()); - if (!engine.commitPlaylistTransition(nextSpec)) return false; - spec = nextSpec; - bindPlaybackTrace(); - return true; - } - public List getDanmakus() { return spec != null ? spec.getDanmakus() : null; } @@ -799,6 +761,16 @@ public class PlayerManager implements ParseCallback { adAudioRuntime.unbindUi(); } + /** + * 跳过用户框选的广告区间。区间为媒体时间,不依赖音频指纹功能开关。 + * + * @return 是否真的执行了 seek。false 表示已记录但未跳过(如终点已播过)。 + */ + public boolean skipUserAdInterval(long startMs, long endMs, String feedbackId) { + if (isReleased()) return false; + return adAudioRuntime.skipUserInterval(startMs, endMs, feedbackId); + } + public void reloadAdAudioRules() { reloadAdAudioSettings(); } @@ -934,28 +906,16 @@ public class PlayerManager implements ParseCallback { : engine.getVideoPlaybackDetails(); } - public AudioPlaybackDiagnostics.Snapshot getAudioPlaybackDiagnostics() { - return engine == null - ? AudioPlaybackDiagnostics.Snapshot.empty() - : engine.getAudioPlaybackDiagnostics(); - } - public long getDroppedFrames() { return engine == null ? 0 : engine.getDroppedFrames(); } public int getRebufferCount() { - long discCount = player instanceof MpvPlayer mpv ? mpv.getDiscRebufferCount() : 0; - return (int) Math.min(Integer.MAX_VALUE, playbackBufferingTracker.getRebufferCount() + discCount); + return playbackBufferingTracker.getRebufferCount(); } public long getRebufferTotalMs() { - return getRebufferTotalMs(SystemClock.elapsedRealtime()); - } - - private long getRebufferTotalMs(long nowMs) { - long discMs = player instanceof MpvPlayer mpv ? mpv.getDiscRebufferTotalMs(nowMs) : 0; - return playbackBufferingTracker.getRebufferTotalMs(nowMs) + discMs; + return playbackBufferingTracker.getRebufferTotalMs(); } public boolean supportsSubtitleStyle() { @@ -970,10 +930,6 @@ public class PlayerManager implements ParseCallback { return engine != null && engine.isSecondarySubtitleSelected(format); } - public boolean isPrimarySubtitleSelected(Format format) { - return engine != null && engine.isPrimarySubtitleSelected(format); - } - public String getAudioPassThroughText() { if (!PlayerSetting.isAudioPassThrough(playerType)) return "关"; if (!isMpv()) return "开"; @@ -1184,11 +1140,6 @@ public class PlayerManager implements ParseCallback { return playerType == PlayerSetting.MPV; } - public boolean sendMpvCustomButton(String id, boolean longPress) { - if (!isMpv() || TextUtils.isEmpty(id) || !(engine instanceof MpvPlayerEngine mpv)) return false; - return mpv.sendScriptMessage(MpvConfigStore.CUSTOM_BUTTON_MESSAGE, id, longPress ? "long" : "short"); - } - public boolean isMpvSurfaceDirect() { return engine instanceof MpvPlayerEngine mpv && mpv.isSurfaceDirect(); } @@ -1258,10 +1209,6 @@ public class PlayerManager implements ParseCallback { Track.delete(getKey(), C.TRACK_TYPE_TEXT); engine.resetTrack(C.TRACK_TYPE_TEXT); spec.setSub(sub); - // 这里是本地文件、在线搜索、AI 翻译、局域网推送四条路径的唯一收口, - // 一处上报就够,不需要在各 UI 入口分别接线。集地址由宿主从自己的 - // History 里取,PlayerManager 不掺和历史记录的事。 - callback.onSubtitleSelected(sub); boolean automaticOutput = MpvPerformanceSetting.getOutputMode() == MpvPerformanceSetting.OUTPUT_AUTO; if (MpvAutoOutputPolicy.shouldLeaveSurfaceDirectForSubtitle(automaticOutput, isMpvSurfaceDirect(), true, false)) { resetMpvOutputEvaluationState(); @@ -1271,16 +1218,6 @@ public class PlayerManager implements ParseCallback { } } - /** - * 登记一条待恢复的外挂字幕,下一次起播时注入。 - * - *

必须在 {@code start()} / {@code parse()} 之前调用。是否该恢复由宿主用 - * {@code SubtitleRestorePolicy} 判定,这里只负责搬运。 - */ - public void setPendingRestoreSub(Sub sub) { - pendingRestoreSub = sub; - } - public void setFormat(String format) { if (spec != null) spec.setFormat(format); setMediaItem(); @@ -1449,8 +1386,6 @@ public class PlayerManager implements ParseCallback { } private ExoNetworkGuardEligibility.Decision getNetworkProtectionEligibility() { - AudioPlaybackDiagnostics.OutputMode audioOutputMode = getAudioPlaybackDiagnostics() - .outputMode(); return ExoNetworkGuardEligibility.resolve(new ExoNetworkGuardEligibility.Request( ExoPerformanceSetting.isNetworkProtectionEnabled() && experimentAllowed( @@ -1460,19 +1395,16 @@ public class PlayerManager implements ParseCallback { Math.abs(userPlaybackSpeed - 1f) < 0.001f, player != null && player.isCommandAvailable(Player.COMMAND_SET_SPEED_AND_PITCH), PlayerSetting.isTunnel(), - audioOutputMode)); + PlayerSetting.isAudioPassThrough(PlayerSetting.EXO))); } private void scheduleNetworkProtection(long delayMs) { App.removeCallbacks(networkProtectionRunnable); ExoNetworkGuardEligibility.Decision eligibility = getNetworkProtectionEligibility(); - AudioPlaybackDiagnostics.OutputMode audioOutputMode = getAudioPlaybackDiagnostics() - .outputMode(); logNetworkGuard("schedule delay=" + delayMs + " eligible=" + eligibility.eligible() + " reason=" + eligibility.reason() + " exo=" + isExo() + " vod=" + isVod() + " userSpeed=" + userPlaybackSpeed + " tunnel=" + PlayerSetting.isTunnel() - + " configuredPassthrough=" + PlayerSetting.isAudioPassThrough(PlayerSetting.EXO) - + " audioOutput=" + audioOutputMode + + " passthrough=" + PlayerSetting.isAudioPassThrough(PlayerSetting.EXO) + " state=" + (player == null ? -1 : player.getPlaybackState()) + " playing=" + (player != null && player.isPlaying())); if (!eligibility.eligible()) { @@ -1780,8 +1712,7 @@ public class PlayerManager implements ParseCallback { ijkRealtimeRecoveryController.onUserSeek(playbackAutoSession, now); ijkDecodePressureController.onUserSeek(playbackAutoSession, now); resetNetworkProtectionSession("user-seek"); - if (isExo() && adAudioRuntime.isSpeechConfigured() - && !adAudioRuntime.isSpeechSuppressed()) { + if (isExo()) { PlaybackAnalyticsListener.onUserSeekRequested( player.getCurrentPosition(), time, @@ -1792,8 +1723,11 @@ public class PlayerManager implements ParseCallback { player.isPlaying()); } player.seekTo(time); + // A seek has no timeout of its own; without this the session can sit in + // BUFFERING indefinitely waiting on the rebuffer threshold. Arm after the + // seek so the baseline is the new position, not the old one. cancelBufferingStallWatchdog(); - if (isExo()) armBufferingStallWatchdog(); + armBufferingStallWatchdog(); } public long getTextOffsetMs() { @@ -1838,10 +1772,6 @@ public class PlayerManager implements ParseCallback { resetMpvOutputRuntime(); closeMultiThreadProxyRegistration(); spec = null; - // 待恢复字幕是绑定「下一次起播」的一次性登记。若那次起播被提前拦下 - // (DRM 不支持、阅读器路由、地址为空),登记会留在这里;不清掉的话 - // 会被后续某次无关起播(例如音频迷你播放器)取走并挂上。 - pendingRestoreSub = null; clearPendingSwitchRestore(); clearDanmaku("clear"); lutAppliedForItem = false; @@ -2043,7 +1973,6 @@ public void resetTrack(int type) { beginIjkRuntimeManualOverride(); beginPlaybackTrace("switch-player-fresh"); type = PlayerSetting.sanitizePlayer(type); - boolean resetVideoSurface = type != playerType; boolean wasPlayWhenReady = player.getPlayWhenReady(); int decode = engine.getDecode(); prepareSeq++; @@ -2058,7 +1987,7 @@ public void resetTrack(int type) { if (SpiderDebug.isEnabled()) SpiderDebug.log("player", "switch player fresh type=%d position=%d spec=%s", type, position, debugSpec()); engine = buildEngine(playerType, decode); player = engine.getPlayer(); - callback.onPlayerRebuild(player, resetVideoSurface); + callback.onPlayerRebuild(player, false); setMediaItem(Constant.TIMEOUT_PLAY); if (position > 0) seekTo(position); if (speed != 1f) setSpeed(speed); @@ -2071,7 +2000,6 @@ public void resetTrack(int type) { beginPlaybackTrace("switch-player-result"); type = PlayerSetting.sanitizePlayer(type); manualPlayerSwitchPending = true; - boolean resetVideoSurface = type != playerType; boolean wasPlayWhenReady = player.getPlayWhenReady(); int decode = engine.getDecode(); prepareSeq++; @@ -2084,7 +2012,7 @@ public void resetTrack(int type) { engine = buildEngine(playerType, decode); player = engine.getPlayer(); playWhenReady = wasPlayWhenReady; - callback.onPlayerRebuild(player, resetVideoSurface); + callback.onPlayerRebuild(player, false); if (result.needParse() || useParse) { pendingSwitchRestore = true; pendingSwitchPositionMs = position; @@ -2145,9 +2073,7 @@ public void resetTrack(int type) { if (SpiderDebug.isEnabled()) SpiderDebug.log("player", "switch player type=%d chosen=%s position=%d spec=%s", type, chosen, position, debugSpec()); engine = buildEngine(playerType, sanitizeDecode(decode)); player = engine.getPlayer(); - // A Surface used by a CPU renderer stays connected after decoder release. - // A different player needs a new Surface before its GPU/codec can connect. - callback.onPlayerRebuild(player, true); + callback.onPlayerRebuild(player, false); if (spec == null || spec.getUrl() == null) return; this.playWhenReady = wasPlayWhenReady; if (reparseForPlayerSwitch(position, speed, repeat)) return; @@ -2165,6 +2091,8 @@ public void resetTrack(int type) { private void rebuildPlayer(boolean resetVideoSurface) { stopNativeAudioSession(); player = engine.rebuild(listener); + ffmpegModeEngine = playerType == PlayerSetting.EXO ? PlayerSetting.getEffectiveFFmpegMode() : PlayerSetting.NONE; + ffmpegModeEngineRefreshPending = false; restoreIjkStagedBufferConfig(); videoEffectsActive = false; videoEffectsDirty = false; @@ -3837,7 +3765,7 @@ public void resetTrack(int type) { int rebufferCount = observation != null && observation.rebufferCount().known() ? Math.max(0, observation.rebufferCount().value()) - : getRebufferCount(); + : playbackBufferingTracker.getRebufferCount(); boolean droppedFramesUsable = observation != null && observation.droppedFrames().known(); long droppedFrames = droppedFramesUsable @@ -5349,15 +5277,12 @@ public void resetTrack(int type) { } if (!isMpv() || spec == null || TextUtils.isEmpty(spec.getUrl()) || !(engine instanceof MpvPlayerEngine mpv)) return; resetMpvOutputEvaluationState(); - mpvSurfaceFallbackTried = false; // An explicit settings change permits a fresh attempt. mpvAutoVulkanPinnedForItem = false; mpvAutoVulkanDisabledForItem = false; mpv.setSurfaceDirectOverride(null); mpv.clearHwdecOverride(); mpv.setVulkanRenderOverride(null); mpv.resetDv7HandlingForNewItem(); - mpv.resetDv8HandlingForNewItem(); - prepareMpvFelOutput(mpv); rebuildAndRestartMpv(null, "performance-settings-changed"); } @@ -5398,7 +5323,7 @@ public void resetTrack(int type) { App.removeCallbacks(runnable); Boolean effectiveSurfaceDirectOverride = surfaceDirectOverride; if (effectiveSurfaceDirectOverride == null - && (mpvAutoGpuPinnedForSession || mpvSurfaceFallbackTried) + && mpvAutoGpuPinnedForSession && MpvPerformanceSetting.getOutputMode() == MpvPerformanceSetting.OUTPUT_AUTO) { effectiveSurfaceDirectOverride = false; } @@ -5422,11 +5347,6 @@ public void resetTrack(int type) { private void prepareMpvOutputForNewItem() { resetMpvOutputEvaluationState(); - // 必须在 instanceof 早退之前注入:Exo 和 IJK 也要恢复外挂字幕。 - // 也必须在下面算 externalSubtitleActive 之前,否则 MPV 的输出模式判定 - // 会漏掉这条刚挂上的字幕。 - restorePendingSubtitle(); - mpvSurfaceFallbackTried = false; List persistedTracks = Track.find(getKey()); Track persistedSubtitle = findRequestedSubtitle(persistedTracks); mpvExplicitSubtitlePreference = persistedSubtitle != null; @@ -5434,9 +5354,7 @@ public void resetTrack(int type) { boolean hwdecOverrideCleared = mpv.clearHwdecOverride(); mpv.prepareSubtitleForNewItem(persistedSubtitle); boolean dv7HandlingChanged = mpv.resetDv7HandlingForNewItem(); - boolean dv8HandlingChanged = mpv.resetDv8HandlingForNewItem(); - boolean clearAutoVulkanRenderer = mpvAutoVulkanPinnedForItem - && !mpv.isDv7FelOutputEnabled(); + boolean clearAutoVulkanRenderer = mpvAutoVulkanPinnedForItem; mpvAutoVulkanPinnedForItem = false; mpvAutoVulkanDisabledForItem = false; mpv.setVulkanRenderOverride(null); @@ -5460,37 +5378,20 @@ public void resetTrack(int type) { automaticOutput, mpv.isSurfaceDirect(), externalSubtitleActive, mpvExplicitSubtitlePreference); if (leaveForSubtitle) shouldStartDirect = false; if (automaticOutput && MpvPerformanceSetting.isAutoSurfaceDirectEnabled() - && mpv.isConfiguredSurfaceDirect() && shouldStartDirect) { + && mpv.isSurfaceDirect() && shouldStartDirect) { if (SpiderDebug.isEnabled()) SpiderDebug.log("mpv-output", "preserve direct output for new item reason=auto-sticky"); return; } - if (mpv.isConfiguredSurfaceDirect() == shouldStartDirect + if (mpv.isSurfaceDirect() == shouldStartDirect && !hwdecOverrideCleared - && !clearAutoVulkanRenderer && !dv7HandlingChanged && !dv8HandlingChanged) return; - if (SpiderDebug.isEnabled()) SpiderDebug.log("mpv-output", "prepare new item rebuild currentDirect=%s desiredDirect=%s mode=%s hwdecOverrideCleared=%s clearAutoVulkan=%s dv7HandlingChanged=%s dv8HandlingChanged=%s", mpv.isSurfaceDirect(), shouldStartDirect, MpvPerformanceSetting.getOutputModeText(), hwdecOverrideCleared, clearAutoVulkanRenderer, dv7HandlingChanged, dv8HandlingChanged); + && !clearAutoVulkanRenderer && !dv7HandlingChanged) return; + if (SpiderDebug.isEnabled()) SpiderDebug.log("mpv-output", "prepare new item rebuild currentDirect=%s desiredDirect=%s mode=%s hwdecOverrideCleared=%s clearAutoVulkan=%s dv7HandlingChanged=%s", mpv.isSurfaceDirect(), shouldStartDirect, MpvPerformanceSetting.getOutputModeText(), hwdecOverrideCleared, clearAutoVulkanRenderer, dv7HandlingChanged); mpv.setSurfaceDirectOverride(shouldStartDirect); rebuildPlayer(); } - /** - * 把宿主放在 spec 上的待恢复字幕挂进字幕列表。 - * - *

发生在 {@code setMediaItem} 之前,所以走的是正常起播路径——不会触发 - * {@link #setSub(Sub)} 里的重启分支,用户也就看不到画面闪一下。三个内核 - * 统一从 {@code PlaySpec.subs} 取字幕,因此这里不区分内核。 - */ - private void restorePendingSubtitle() { - Sub sub = pendingRestoreSub; - pendingRestoreSub = null; - if (spec == null || sub == null) return; - // setSub() 内部会先 remove 再插首位,源站自带同一个 url 时天然幂等。 - spec.setSub(sub); - if (SpiderDebug.isEnabled()) SpiderDebug.log("subtitle-restore", "inject persisted subtitle name=%s key=%s", sub.getName(), getKey()); - } - private void resetMpvOutputRuntime() { resetMpvOutputEvaluationState(); - mpvSurfaceFallbackTried = false; mpvAutoGpuPinnedForSession = false; mpvAutoVulkanPinnedForItem = false; mpvAutoVulkanDisabledForItem = false; @@ -5508,6 +5409,7 @@ public void resetTrack(int type) { mpvAutoOutputEvaluationScheduled = false; mpvAutoOutputProbeGaveUp = false; mpvAutoOutputProbeAttempts = 0; + mpvSurfaceFallbackTried = false; mpvVulkanFallbackTried = false; mpvCopyFallbackTried = false; mpvOutputEvaluationSeq++; @@ -5515,7 +5417,8 @@ public void resetTrack(int type) { private void scheduleMpvAutoOutputEvaluation() { if (!isMpv() - || !shouldEvaluateMpvOutput()) return; + || MpvPerformanceSetting.getOutputMode() + != MpvPerformanceSetting.OUTPUT_AUTO) return; if (mpvAutoOutputEvaluated || mpvAutoOutputEvaluationScheduled) return; mpvAutoOutputEvaluationScheduled = true; int seq = ++mpvOutputEvaluationSeq; @@ -5549,30 +5452,13 @@ public void resetTrack(int type) { if (!isMpv() || mpvAutoOutputEvaluated || !(engine instanceof MpvPlayerEngine mpv)) return true; if (mpvHlsManagedReload) return false; - if (mpv.isDv7FelRequested() && !mpv.isNativeOutputSelectionReady()) return false; - prepareMpvFelOutput(mpv); - if (mpv.isDv7FelOutputEnabled()) { - // Already selected by native before decoder initialization. A - // second App-level transition would reopen the same media. - mpvAutoOutputEvaluated = true; - return true; - } Tracks tracks = engine.getCurrentTracks(); boolean tracksReady = tracks != null && !tracks.isEmpty(); if (!tracksReady) return false; - boolean automaticOutput = MpvPerformanceSetting.getOutputMode() - == MpvPerformanceSetting.OUTPUT_AUTO; Format format = tracksReady ? engine.getVideoFormat() : null; PlayerEngine.VideoPlaybackDetails videoDetails = engine.getVideoPlaybackDetails(); boolean dolbyVision = videoDetails != null && videoDetails.hasDolbyVisionSource(); - // P8 HDR10 compatibility is also required with an explicit output - // backend. Leave other explicit-mode content on its existing path. - if (!automaticOutput && (!mpv.isHard() || !dolbyVision - || videoDetails.dolbyVisionProfile() != 8)) { - mpvAutoOutputEvaluated = true; - return true; - } if (!dolbyVision && (format == null || !tracks.containsType(C.TRACK_TYPE_VIDEO))) { return false; } @@ -5603,23 +5489,6 @@ public void resetTrack(int type) { boolean dv7HandlingChanged = dolbyVision && videoDetails.dolbyVisionProfile() == 7 && mpv.updateDv7Handling(dolbyVisionSupport, profile81Support); - MpvAutoOutputPolicy.DolbyVisionSupport hevcHdr10Support = - dolbyVision && videoDetails.dolbyVisionProfile() == 8 - ? CodecCapabilityInspector.hevcHdr10Support( - App.get(), format, width, height) - : MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN; - boolean dv8HandlingChanged = dolbyVision - && videoDetails.dolbyVisionProfile() == 8 - && mpv.updateDv8Handling(dolbyVisionSupport, hevcHdr10Support); - if (!automaticOutput) { - mpvAutoOutputEvaluated = true; - if (dv8HandlingChanged) { - if (SpiderDebug.isEnabled()) SpiderDebug.log("mpv-output", "P8 compatibility handling=%s dvSupport=%s hdr10Support=%s explicitOutput=%d", mpv.getDv8HandlingOption(), dolbyVisionSupport, hevcHdr10Support, MpvPerformanceSetting.getOutputMode()); - // Preserve the chosen output/render backend and decode mode. - rebuildAndRestartMpv(null, "compat-dv8-" + mpv.getDv8HandlingOption()); - } - return true; - } dv7Hdr10FallbackEnabled = dolbyVision && videoDetails.dolbyVisionProfile() == 7 && mpv.isDv7Hdr10Active(); @@ -5628,11 +5497,7 @@ public void resetTrack(int type) { Util.isLeanback(), lutOrFilterActive, customGpuProcessing, dolbyVisionSupport, dolbyVision ? videoDetails.dolbyVisionProfile() : C.INDEX_UNSET, - dv7Hdr10FallbackEnabled, - hevcHdr10Support); - decision = MpvAutoOutputPolicy.afterSurfaceFailure(decision, mpvSurfaceFallbackTried); - decision = MpvAutoOutputPolicy.forFelReconstruction(decision, - mpv.isDv7FelOutputEnabled()); + dv7Hdr10FallbackEnabled); int dolbyVisionProfile = dolbyVision ? videoDetails.dolbyVisionProfile() : C.INDEX_UNSET; boolean currentlyVulkan = mpv.isVulkanRenderer(); @@ -5642,8 +5507,7 @@ public void resetTrack(int type) { engine.isHard(), dolbyVisionProfile, dolbyVisionSupport, MPVLib.isBundledVulkanEnabled(App.get()), MPVLib.isDeviceVulkan13Capable(App.get()), - currentlyVulkan, mpvAutoVulkanDisabledForItem, - mpv.isDv7FelOutputEnabled()); + currentlyVulkan, mpvAutoVulkanDisabledForItem); boolean enableAutoVulkan = renderDecision.action() == MpvAutoRenderPolicy.Action.ENABLE_VULKAN; if (enableAutoVulkan) { @@ -5657,21 +5521,19 @@ public void resetTrack(int type) { boolean currentlyDirect = isMpvSurfaceDirect(); boolean effectiveEligible = MpvPerformanceSetting.isAutoSurfaceDirectEnabled() && decision.eligible(); MpvAutoOutputPolicy.Transition transition = MpvAutoOutputPolicy.transition(effectiveEligible, currentlyDirect); - if (SpiderDebug.isEnabled()) SpiderDebug.log("mpv-output", "auto decision eligible=%s effectiveEligible=%s transition=%s reason=%s renderAction=%s renderReason=%s size=%dx%d tracksReady=%s early=%s subtitle=%s lutOrFilter=%s customGpu=%s dvProfile=%d dvSupport=%s direct=%s gpuPinned=%s autoVulkan=%s attempts=%d", decision.eligible(), effectiveEligible, transition, decision.reason(), renderDecision.action(), renderDecision.reason(), width, height, tracksReady, earlyEvaluation, subtitleActive, lutOrFilterActive, customGpuProcessing, dolbyVisionProfile, dolbyVisionSupport, currentlyDirect, mpvAutoGpuPinnedForSession, mpvAutoVulkanPinnedForItem, mpvAutoOutputProbeAttempts); - boolean transitionRequested = dv7HandlingChanged || dv8HandlingChanged || enableAutoVulkan + if (SpiderDebug.isEnabled()) SpiderDebug.log("mpv-output", "auto decision eligible=%s effectiveEligible=%s transition=%s reason=%s size=%dx%d tracksReady=%s early=%s subtitle=%s lutOrFilter=%s customGpu=%s dvProfile=%d dvSupport=%s direct=%s gpuPinned=%s attempts=%d", decision.eligible(), effectiveEligible, transition, decision.reason(), width, height, tracksReady, earlyEvaluation, subtitleActive, lutOrFilterActive, customGpuProcessing, dolbyVision ? videoDetails.dolbyVisionProfile() : C.INDEX_UNSET, dolbyVisionSupport, currentlyDirect, mpvAutoGpuPinnedForSession, mpvAutoOutputProbeAttempts); + boolean transitionRequested = dv7HandlingChanged || enableAutoVulkan || transition == MpvAutoOutputPolicy.Transition.ENTER_SURFACE_DIRECT || transition == MpvAutoOutputPolicy.Transition.LEAVE_SURFACE_DIRECT; boolean requestAccepted = true; - if (dv7HandlingChanged || dv8HandlingChanged) { + if (dv7HandlingChanged) { Boolean outputOverride = enableAutoVulkan || transition == MpvAutoOutputPolicy.Transition.LEAVE_SURFACE_DIRECT - ? Boolean.FALSE + ? false : transition == MpvAutoOutputPolicy.Transition.ENTER_SURFACE_DIRECT - ? Boolean.TRUE : null; - String reason = dv7HandlingChanged - ? "auto-dv7-" + mpv.getDv7HandlingOption() - : "auto-dv8-" + mpv.getDv8HandlingOption(); - requestAccepted = rebuildAndRestartMpv(outputOverride, reason); + ? true : null; + requestAccepted = rebuildAndRestartMpv(outputOverride, + "auto-dv7-" + mpv.getDv7HandlingOption()); } else if (enableAutoVulkan) { requestAccepted = rebuildAndRestartMpv(false, "auto-" + renderDecision.reason()); @@ -5737,7 +5599,8 @@ public void resetTrack(int type) { private void onMpvVideoSizeProbed(Integer width, Integer height) { if (width == null || height == null || width <= 0 || height <= 0) return; if (!isMpv() - || !shouldEvaluateMpvOutput() + || MpvPerformanceSetting.getOutputMode() + != MpvPerformanceSetting.OUTPUT_AUTO || mpvAutoOutputEvaluated) return; if (SpiderDebug.isEnabled()) SpiderDebug.log("mpv-output", "auto size probe size=%dx%d attempts=%d", width, height, mpvAutoOutputProbeAttempts); mpvAutoOutputEvaluationScheduled = false; @@ -5745,27 +5608,6 @@ public void resetTrack(int type) { if (!evaluateMpvAutoOutput()) scheduleMpvAutoOutputEvaluation(); } - private boolean shouldEvaluateMpvOutput() { - return MpvPerformanceSetting.getOutputMode() == MpvPerformanceSetting.OUTPUT_AUTO - || engine instanceof MpvPlayerEngine mpv - && (mpv.isDv7FelRequested() || mpv.isHard()); - } - - private boolean prepareMpvFelOutput(MpvPlayerEngine mpv) { - if (!mpv.updateDv7FelOutputForCurrentItem()) return false; - if (mpv.isDv7FelOutputEnabled() && mpv.isVulkanRenderer() - && engine.isHard() && PlaybackPerformanceSetting.isAuto( - PlayerSetting.MPV, PlaybackPerformanceCatalog.MPV_RENDER)) { - // Retain runtime failure recovery without changing the configured - // renderer: native owns this per-source automatic choice. - mpvAutoVulkanPinnedForItem = true; - } - PlaybackTrace.log("mpv-dv", playbackTrace.current(), - "native FEL selection: active=%s single-load=1", - mpv.isDv7FelOutputEnabled()); - return true; - } - private boolean retryMpvDv7P81Failure(PlaybackException error) { if (error == null || !(engine instanceof MpvPlayerEngine mpv) || !mpv.isDv7P81Active()) return false; @@ -5921,7 +5763,7 @@ public void resetTrack(int type) { mpv.setVulkanRenderOverride(false); mpv.setVulkanBackendOverride(null); PlaybackTrace.log("mpv-vulkan", playbackTrace.current(), - "automatic DV GPU Vulkan failed; fallback OpenGL once reason=%s", + "automatic DV5 Vulkan failed; fallback OpenGL once reason=%s", reason); return rebuildAndRestartMpv(false, reason); } @@ -5948,8 +5790,25 @@ public void resetTrack(int type) { case PlayerSetting.IJK -> new IjkPlayerEngine(decode, listener); case PlayerSetting.SYSTEM -> new SystemPlayerEngine(decode, listener); case PlayerSetting.MPV -> new MpvPlayerEngine(decode, lutAllowed, listener, this::onMpvVideoSizeProbed); - default -> new ExoPlayerEngine(decode, listener, mediaSignals, mediaClock); + default -> new ExoPlayerEngine(decode, listener, new ExoPlayerEngine.PrepareListener() { + @Override + public void onPrepareStarted(int generation) { + prepareExoSpeedForMediaItem(generation); + } + + @Override + public void onPrepareReady(int generation) { + restoreExoSpeedAfterPrepare(generation); + } + + @Override + public void onPrepareCanceled(int generation) { + cancelExoSpeedPrepare(generation); + } + }, mediaSignals, mediaClock); }; + ffmpegModeEngine = type == PlayerSetting.EXO ? PlayerSetting.getEffectiveFFmpegMode() : PlayerSetting.NONE; + ffmpegModeEngineRefreshPending = false; return next; } @@ -6036,6 +5895,7 @@ public void resetTrack(int type) { manualPlayerSwitchPending = false; localProxyRetry = 0; resetPlayerFallback(); + refreshFfmpegModeEngineIfNeeded(); hardDecodeSwitchRetryArmed = false; setMediaItem(timeout); } @@ -6068,6 +5928,7 @@ public void resetTrack(int type) { parseHealthStartedAt = System.currentTimeMillis(); parseHealthRecorded = false; resetPlayerFallback(); + refreshFfmpegModeEngineIfNeeded(); hardDecodeSwitchRetryArmed = false; parseJob = ParseJob.create(this).start(result, useParse); } @@ -6258,6 +6119,10 @@ public void resetTrack(int type) { if (SpiderDebug.isEnabled()) SpiderDebug.log("player", "setMediaItem timeout=%d notify=%s spec=%s", timeout, notifyPrepare, debugSpec()); resetNetworkProtectionSession("new-media"); App.removeCallbacks(runnable); + // Same responsibility as the line above: a new media item invalidates the previous + // episode's baseline. This is the funnel every start path reaches, including the async + // awaitLocalProxyAndSetMediaItem route, so cancelling once here is sufficient. + cancelBufferingStallWatchdog(); setDanmakus(spec.getDanmakus()); prepareLutPipeline(); initTrack = false; @@ -6381,11 +6246,10 @@ public void resetTrack(int type) { if (manager == null || audioFocusHeld) return; int result; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioFocusRequest = AudioFocusApi26.request(manager, audioFocusChangeListener, raw -> diagnosticNativeFocus("request", raw, 0)); + audioFocusRequest = AudioFocusApi26.request(manager, audioFocusChangeListener); result = audioFocusRequest == null ? AudioManager.AUDIOFOCUS_REQUEST_FAILED : AudioManager.AUDIOFOCUS_REQUEST_GRANTED; } else { result = manager.requestAudioFocus(audioFocusChangeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); - diagnosticNativeFocus("request", result, 0); } audioFocusHeld = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; if (!audioFocusHeld && SpiderDebug.isEnabled()) SpiderDebug.log("player", "native audio focus request denied type=%d", playerType); @@ -6395,8 +6259,8 @@ public void resetTrack(int type) { if (!audioFocusHeld) return; AudioManager manager = audioManager(); if (manager != null) { - int result = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? AudioFocusApi26.abandon(manager, audioFocusRequest) : manager.abandonAudioFocus(audioFocusChangeListener); - diagnosticNativeFocus("abandon", result, 0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) AudioFocusApi26.abandon(manager, audioFocusRequest); + else manager.abandonAudioFocus(audioFocusChangeListener); } audioFocusRequest = null; audioFocusHeld = false; @@ -6451,17 +6315,6 @@ public void resetTrack(int type) { } } if (SpiderDebug.isEnabled()) SpiderDebug.log("player", "native audio focus changed type=%d change=%d resume=%s", playerType, focusChange, resumeOnAudioFocusGain); - diagnosticNativeFocus("callback", 0, focusChange); - } - - private void diagnosticNativeFocus(String action, int result, int change) { - if (!com.github.catvod.crawler.DebugLogStore.isEnabled()) return; - com.github.catvod.crawler.DebugLogStore.event(new com.github.catvod.crawler.diagnostics.DiagnosticEvent("audio.focus", getPlaybackTraceId(), - "native-focus-controller", 0, 0).observed("focusOwner", "App/native-player").observed("focusAction", action) - .observed("focusGain", AudioManager.AUDIOFOCUS_GAIN).observed("focusResult", result).observed("focusChange", change) - .observed("usage", android.media.AudioAttributes.USAGE_MEDIA).observed("contentType", android.media.AudioAttributes.CONTENT_TYPE_MOVIE) - .observed("playWhenReady", player != null && player.getPlayWhenReady()).observed("active", audioFocusHeld) - .observed("metricScope", "controller focus session; callback result is a focus change, not a new request")); } private AudioManager audioManager() { @@ -6470,7 +6323,7 @@ public void resetTrack(int type) { private static final class AudioFocusApi26 { - private static Object request(AudioManager manager, AudioManager.OnAudioFocusChangeListener listener, java.util.function.IntConsumer observe) { + private static Object request(AudioManager manager, AudioManager.OnAudioFocusChangeListener listener) { android.media.AudioAttributes attributes = new android.media.AudioAttributes.Builder() .setUsage(android.media.AudioAttributes.USAGE_MEDIA) .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MOVIE) @@ -6481,12 +6334,11 @@ public void resetTrack(int type) { .setAcceptsDelayedFocusGain(false) .setWillPauseWhenDucked(true) .build(); - int result = manager.requestAudioFocus(request); observe.accept(result); - return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED ? request : null; + return manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED ? request : null; } - private static int abandon(AudioManager manager, Object request) { - return request instanceof android.media.AudioFocusRequest ? manager.abandonAudioFocusRequest((android.media.AudioFocusRequest) request) : AudioManager.AUDIOFOCUS_REQUEST_FAILED; + private static void abandon(AudioManager manager, Object request) { + if (request instanceof android.media.AudioFocusRequest) manager.abandonAudioFocusRequest((android.media.AudioFocusRequest) request); } } @@ -7237,7 +7089,6 @@ public void resetTrack(int type) { clearExoDecoderResourceRecovery(true); lastIjkTimelinePublicationKey = null; playbackTrace.begin(); - speechAdPlaybackHealth.reset(); long now = SystemClock.elapsedRealtime(); playbackAutoSession = playbackAutoContextStore.beginSession(playbackTrace.current(), now); rtspLiveLagController.beginSession(playbackAutoSession); @@ -7544,7 +7395,7 @@ public void resetTrack(int type) { runtime.underrunCount(), rebufferMetric.known() ? Math.max(0, rebufferMetric.value()) - : getRebufferCount(), + : playbackBufferingTracker.getRebufferCount(), buffering, bufferedMetric.known(), bufferedMetric.known() @@ -7878,9 +7729,9 @@ public void resetTrack(int type) { mediaBitrate, renderedFrameRate, droppedFrames, - PlaybackTelemetry.Metric.of(getRebufferCount(), + PlaybackTelemetry.Metric.of(playbackBufferingTracker.getRebufferCount(), PlaybackAutoContext.ValueSource.PLAYER_MANAGER, PlaybackAutoContext.Confidence.HIGH), - PlaybackTelemetry.Metric.of(getRebufferTotalMs(now), + PlaybackTelemetry.Metric.of(playbackBufferingTracker.getRebufferTotalMs(now), PlaybackAutoContext.ValueSource.PLAYER_MANAGER, PlaybackAutoContext.Confidence.HIGH), firstFrame, liveLag); @@ -8224,10 +8075,6 @@ public void resetTrack(int type) { if (traceId.equals(lastLoggedRouteTraceId)) return; PlaybackRoute.Resolution resolution = spec.getPlaybackRoute(); PlaybackTrace.log("playback-route", traceId, "%s", resolution.logSummary()); - DiagnosticControls.forTrace(traceId, "input.route", "resolved-playback-route", e -> e - .observed("inputRoute", resolution.route().name()).observed("source", resolution.owner().name()) - .observed("reason", resolution.evidence().name()).observed("input", resolution.logSummary()) - .observed("metricScope", "resolved local route; external proxy upstream is not observed")); lastLoggedRouteTraceId = traceId; } @@ -8257,12 +8104,10 @@ public void resetTrack(int type) { if (tracks == null) return; boolean hasVideo = tracks.containsType(C.TRACK_TYPE_VIDEO); boolean hasAudio = tracks.containsType(C.TRACK_TYPE_AUDIO); - boolean videoSubmitted = engine instanceof MpvPlayerEngine mpv - && mpv.hasSubmittedVideoFrame(); - PlaybackStartupPolicy.Completion completion = PlaybackStartupPolicy.resolve(ready, videoSubmitted, hasVideo, hasAudio); + PlaybackStartupPolicy.Completion completion = PlaybackStartupPolicy.resolve(ready, playerType == PlayerSetting.MPV, hasVideo, hasAudio); if (completion == PlaybackStartupPolicy.Completion.FIRST_FRAME) { if (playbackTrace.hasStage(PlaybackTrace.Stage.FIRST_FRAME)) return; - playbackTrace.mark(PlaybackTrace.Stage.FIRST_FRAME, "source=mpv-vo-submitted player=" + playerType); + playbackTrace.mark(PlaybackTrace.Stage.FIRST_FRAME, "source=mpv-playback-restart player=" + playerType); onIjkRuntimeFirstFrame(SystemClock.elapsedRealtime()); } else if (completion == PlaybackStartupPolicy.Completion.AUDIO_PLAYABLE) { playbackTrace.mark(PlaybackTrace.Stage.AUDIO_PLAYABLE, "source=ready player=" + playerType); @@ -8270,7 +8115,6 @@ public void resetTrack(int type) { } private void completeMpvDirectFirstFrame(int state) { - if (!(engine instanceof MpvPlayerEngine mpv) || !mpv.hasSubmittedVideoFrame()) return; if (!MpvAutoOutputPolicy.canRevealDirectFrame( MpvPerformanceSetting.getOutputMode() == MpvPerformanceSetting.OUTPUT_AUTO, mpvAutoOutputEvaluated, @@ -8282,7 +8126,7 @@ public void resetTrack(int type) { callback.onPlayerOutputReady(); if (!playbackTrace.hasStage(PlaybackTrace.Stage.FIRST_FRAME)) { playbackTrace.mark(PlaybackTrace.Stage.FIRST_FRAME, - "source=mpv-vo-submitted-direct player=" + playerType); + "source=mpv-playback-restart-direct player=" + playerType); onIjkRuntimeFirstFrame(SystemClock.elapsedRealtime()); } PlaybackTrace.log("mpv-output", playbackTrace.current(), @@ -8521,13 +8365,6 @@ public void resetTrack(int type) { default void onExoFirstFrame() { } - /** - * 用户选中了一个外挂字幕。宿主负责写进自己的 History——PlayerManager - * 不持有 History 引用,也不该持有。 - */ - default void onSubtitleSelected(Sub sub) { - } - void onPlayerRebuild(Player newPlayer, boolean resetVideoSurface); } @@ -8561,10 +8398,18 @@ public void resetTrack(int type) { @Override public void onPlaybackStateChanged(int state) { if (state != Player.STATE_IDLE) App.removeCallbacks(runnable); + // Entering BUFFERING disarms the startup timeout above, which would + // otherwise leave a stalled session with no guard at all. Hand it to + // the stall watchdog, which only fires when neither the position nor + // the buffered end advances, so a slow-but-progressing source is safe. + if (state == Player.STATE_BUFFERING) { + if (!bufferingStallWatchdog.isArmed()) armBufferingStallWatchdog(); + } else { + cancelBufferingStallWatchdog(); + } if (SpiderDebug.isEnabled()) SpiderDebug.log("player", "state=%s spec=%s", stateName(state), debugSpec()); publishPlaybackAutoContext(state != Player.STATE_IDLE); if (state == Player.STATE_READY) { - cancelBufferingStallWatchdog(); completeMpvDirectFirstFrame(state); manualPlayerSwitchPending = false; App.post(PlayerManager.this::refreshAdAudioRuntime); @@ -8584,7 +8429,6 @@ public void resetTrack(int type) { applyLutForCurrentItem(); scheduleNetworkProtection(0); } else if (state == Player.STATE_BUFFERING) { - if (isExo()) armBufferingStallWatchdog(); App.removeCallbacks(networkProtectionRunnable); // Do not reset/disrupt the network guard here. BUFFERING is transient and // clearing its trend makes repeated stalls permanently outrun the 10 s @@ -8592,7 +8436,6 @@ public void resetTrack(int type) { // retained pre-stall trend plus the newly recorded rebuffer count. networkProtectionReason = "buffering-hold"; } else { - cancelBufferingStallWatchdog(); resetNetworkProtectionSession(state == Player.STATE_ENDED ? "ended" : "inactive"); } recordBufferingState(state); @@ -8672,7 +8515,10 @@ public void resetTrack(int type) { if (isExo()) scheduleNetworkProtection(0); if (!tracks.isEmpty() && !initTrack) { playbackTrace.mark(PlaybackTrace.Stage.TRACKS, trackSummary(tracks)); - restoreTrackSelection(Track.find(getKey())); + List savedTracks = Track.find(getKey()); + setTrack(savedTracks); + if (RealtimeSubtitleController.get().isEnabled()) disableSubtitleTrackForRealtime(); + if (PlayerSetting.isPreferAAC(playerType) && !TrackUtil.hasTrack(player, savedTracks, C.TRACK_TYPE_AUDIO)) TrackUtil.preferAAC(player); if (engine instanceof MpvPlayerEngine mpv) { mpv.completeInitialSubtitleTrackRestore(); } @@ -8690,14 +8536,15 @@ public void resetTrack(int type) { // A rendered video frame proves that the media and video decoder are // working even if a slow audio track keeps Exo in BUFFERING briefly. // Do not let the generic startup timer turn that valid playback into - // a false connection-timeout error. - if (isExo()) App.removeCallbacks(runnable); - playbackTrace.mark(PlaybackTrace.Stage.FIRST_FRAME, - "source=" + (isMpv() ? "mpv-vo-submitted" : "media3") + " player=" + playerType); - if (isMpv()) { - mpvAutoOutputFrameReady = true; - callback.onPlayerOutputReady(); + // a false connection-timeout error. Hand the session to the stall + // watchdog instead of leaving it unguarded: a first frame is not + // STATE_READY, and without a guard a session that never reaches READY + // would buffer forever with no error and no fallback. + if (isExo() && player != null && player.getPlaybackState() != Player.STATE_READY) { + App.removeCallbacks(runnable); + armBufferingStallWatchdog(); } + playbackTrace.mark(PlaybackTrace.Stage.FIRST_FRAME, "source=media3 player=" + playerType); publishPlaybackAutoContext(true); onIjkRuntimeFirstFrame(SystemClock.elapsedRealtime()); ijkFirstFrameWatchdog.onFirstFrame(playbackAutoSession); @@ -8776,8 +8623,25 @@ public void resetTrack(int type) { } }; + /** + * Arms the stall watchdog for a session that is buffering, or that has shown a + * first frame without reaching {@link Player#STATE_READY} yet. This is the + * replacement guard for the startup timeout: a rendered frame proves the decoder + * works, but it does not prove playback can proceed, so the session must stay + * under some watch until READY actually arrives. + * + *

Deliberately not gated on the kernel. E-SP3 makes this watchdog a trigger of the + * decode/kernel fallback chain, and that chain covers every kernel, so restricting it to + * Exo would leave MPV and Ijk with the very "spins forever, never falls back" gap it was + * added to close. MPV publishes both signals the criterion reads through {@code + * SimpleBasePlayer.State} — {@code setIsLoading} mirrors {@code paused-for-cache} and the + * buffered end comes from the demuxer-cache observers — so they do advance there; a stalled + * MPV session simply keeps {@code isLoading()} true and gets the longer loading ceiling. + * False positives while paused are handled by the {@code playWhenReady} guard in + * {@link #checkBufferingStall()}, not by excluding a kernel. + */ private void armBufferingStallWatchdog() { - if (!isExo() || player == null || spec == null) return; + if (player == null || spec == null) return; bufferingStallWatchdog.arm( SystemClock.elapsedRealtime(), Math.max(0, player.getCurrentPosition()), @@ -8790,12 +8654,17 @@ public void resetTrack(int type) { App.removeCallbacks(bufferingStallRunnable); } + /** + * The stall criterion must read the raw Exo buffered position. {@code + * getEffectiveBufferedPosition()} folds in completed disk ranges, which would + * keep a stalled session looking like it were still making progress. + */ private long nativeBufferedPosition() { return player == null ? 0 : Math.max(0, player.getBufferedPosition()); } private void checkBufferingStall() { - if (!isExo() || player == null || spec == null) { + if (player == null || spec == null) { cancelBufferingStallWatchdog(); return; } @@ -8805,6 +8674,14 @@ public void resetTrack(int type) { return; } long now = SystemClock.elapsedRealtime(); + // A paused session is not a stalled one: pausing while still buffering freezes the + // position by design, and once the buffer stops growing the criterion would fire and + // switch decoder or engine behind a user who only pressed pause. + // + // Keep polling and start a fresh episode every tick instead of cancelling. Resuming + // then gets a full window with no dependency on a resume callback — note that + // onIsPlayingChanged does NOT fire here, since isPlaying() requires STATE_READY and + // stays false in both directions while buffering. if (!player.getPlayWhenReady()) { bufferingStallWatchdog.arm(now, Math.max(0, player.getCurrentPosition()), nativeBufferedPosition()); @@ -8813,18 +8690,35 @@ public void resetTrack(int type) { } long position = Math.max(0, player.getCurrentPosition()); long buffered = nativeBufferedPosition(); - bufferingStallWatchdog.observe(now, position, buffered); if (bufferingStallWatchdog.shouldTimeout(now, position, buffered, player.isLoading())) { onBufferingStall(position, buffered); return; } + bufferingStallWatchdog.observe(now, position, buffered); App.post(bufferingStallRunnable, BUFFERING_STALL_POLL_INTERVAL_MS); } + /** + * Deliberately narrower than {@link #onPlaybackTimeout()}: the startup-only + * retries there (DV7 first-frame fallback, LUT warmup refresh, Ijk managed + * reload) must not run again for a session that already started playing. + */ private void onBufferingStall(long positionMs, long bufferedPositionMs) { cancelBufferingStallWatchdog(); PlaybackTrace.log("buffering-stall", playbackTrace.current(), "stalled position=%d buffered=%d player=%d", positionMs, bufferedPositionMs, playerType); + if (SpiderDebug.isEnabled()) { + SpiderDebug.log("player", "buffering stall position=%d buffered=%d spec=%s", + positionMs, bufferedPositionMs, debugSpec()); + } + // A stall right after the user picked a kernel must report that choice's failure rather + // than silently walking the fallback chain away from it, matching onPlaybackTimeout. + if (manualPlayerSwitchPending) { + finishPlaybackProfileAbSession( + "manual-switch-stall", SystemClock.elapsedRealtime()); + callback.onError(ResUtil.getString(R.string.error_play_timeout)); + return; + } PlaybackException e = new PlaybackException( ResUtil.getString(R.string.error_play_timeout), null, PlaybackException.ERROR_CODE_TIMEOUT); if (fallbackPlayback(e)) return; @@ -9270,6 +9164,7 @@ public void resetTrack(int type) { private boolean fallbackPlayback(PlaybackException e) { if (engine == null) return false; if (retryMpvGpuCopyFailure(e)) return true; + if (fallbackFfmpegMode(e)) return true; return switch (nextFallbackAction(PlayerSetting.getFailureFallback(), engine.getDecode())) { case FALLBACK_DECODE -> fallbackDecode(e); case FALLBACK_PLAYER -> fallbackPlayer(e); @@ -9277,12 +9172,51 @@ public void resetTrack(int type) { }; } + // AUTO 模式:失败时在当前解码下遍历下一个未试过的 FFmpeg 模式,用尽后交回解码/内核降级链。 + private boolean fallbackFfmpegMode(PlaybackException e) { + if (spec == null || spec.getUrl() == null || engine == null) return false; + if (playerType != PlayerSetting.EXO || !PlayerSetting.isAutoFFmpegMode()) return false; + int current = ffmpegModeEngine; + markFfmpegModeTried(current); + int next = nextUntriedFfmpegMode(); + if (next == PlayerSetting.NONE) return false; + markFfmpegModeTried(next); + PlayerSetting.setFFmpegModeOverride(next); + SpiderDebug.log("player", "fallback ffmpeg-mode from=%d to=%d decode=%d spec=%s cause=%s", current, next, engine.getDecode(), debugSpec(), causeChain(e)); + App.removeCallbacks(runnable); + localProxyRetry = 0; + hardDecodeSwitchRetryArmed = false; + switchEngine(playerType, false, true, true, engine.getDecode()); + return true; + } + + private void markFfmpegModeTried(int mode) { + for (int i = 0; i < PlayerSetting.FFMPEG_AUTO_ORDER.length; i++) { + if (PlayerSetting.FFMPEG_AUTO_ORDER[i] == mode) { + ffmpegModeFallbackTried[i] = true; + return; + } + } + } + + private int nextUntriedFfmpegMode() { + return nextUntriedFfmpegMode(PlayerSetting.FFMPEG_AUTO_ORDER, ffmpegModeFallbackTried); + } + + static int nextUntriedFfmpegMode(int[] order, boolean[] tried) { + for (int i = 0; i < order.length; i++) { + if (!tried[i]) return order[i]; + } + return PlayerSetting.NONE; + } + private boolean fallbackDecode(PlaybackException e) { if (spec == null || spec.getUrl() == null || engine == null || !engine.isHard()) return false; SpiderDebug.log("player", "fallback decode player=%s from=hard to=soft spec=%s cause=%s", getPlayerText(playerType), debugSpec(), causeChain(e)); App.removeCallbacks(runnable); localProxyRetry = 0; hardDecodeSwitchRetryArmed = false; + resetFfmpegModeFallback(); switchEngine(playerType, false, true, true, PlayerEngine.SOFT); return true; } @@ -9374,12 +9308,31 @@ public void resetTrack(int type) { private void resetPlayerFallback() { playerFallbackTried = new boolean[PLAYER_COUNT]; + resetFfmpegModeFallback(); } private void markPlayerFallbackTried(int type) { if (type >= 0 && type < playerFallbackTried.length) playerFallbackTried[type] = true; } + private void resetFfmpegModeFallback() { + ffmpegModeFallbackTried = new boolean[PlayerSetting.FFMPEG_AUTO_ORDER.length]; + PlayerSetting.clearFFmpegModeOverride(); + ffmpegModeEngineRefreshPending = playerType == PlayerSetting.EXO && ffmpegModeEngine != PlayerSetting.getEffectiveFFmpegMode(); + } + + private void refreshFfmpegModeEngineIfNeeded() { + if (!ffmpegModeEngineRefreshPending || playerType != PlayerSetting.EXO || engine == null || player == null) return; + SpiderDebug.log("player", "refresh ffmpeg-mode engine from=%d to=%d decode=%d", ffmpegModeEngine, PlayerSetting.getEffectiveFFmpegMode(), engine.getDecode()); + rebuildPlayer(); + } + + private void clearFfmpegModeFallbackState() { + ffmpegModeFallbackTried = new boolean[PlayerSetting.FFMPEG_AUTO_ORDER.length]; + ffmpegModeEngineRefreshPending = false; + PlayerSetting.clearFFmpegModeOverride(); + } + static boolean shouldStopOnManualSwitchFailure(boolean manualSwitchPending, PlayerEngine.ErrorAction action) { return manualSwitchPending && action != PlayerEngine.ErrorAction.RECOVERED; } diff --git a/app/src/main/java/com/fongmi/android/tv/player/SurfaceDiagnosticCollector.java b/app/src/main/java/com/fongmi/android/tv/player/SurfaceDiagnosticCollector.java deleted file mode 100644 index 6ac6cf6575..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/SurfaceDiagnosticCollector.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.fongmi.android.tv.player; - -import android.graphics.Rect; -import android.os.Handler; -import android.os.Looper; -import android.view.SurfaceHolder; -import android.view.SurfaceView; -import android.view.View; -import android.view.ViewParent; -import android.widget.ImageView; - -import androidx.media3.common.C; -import androidx.media3.common.Player; -import androidx.media3.ui.PlayerView; - -import com.github.catvod.crawler.DebugLogStore; - -import static com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.*; - -/** Adds SurfaceHolder observers without replacing the player-owned callbacks/listeners. */ -public final class SurfaceDiagnosticCollector implements SurfaceHolder.Callback { - private final PlaybackDiagnosticCollector log = new PlaybackDiagnosticCollector("ui", "surface-v1"); - private final Handler handler = new Handler(Looper.getMainLooper()); - private PlayerView playerView; - private View surfaceView; - private String trace = "none", resizeOperation = "none"; - private final PixelDiagnosticProbe pixelProbe = new PixelDiagnosticProbe(); - private final Runnable tick = new Runnable() { - @Override public void run() { snapshot("periodic"); if (playerView != null) handler.postDelayed(this, 5000); } - }; - - public void bind(PlayerView view, String trace) { - if (!this.trace.equals(trace)) { this.trace = trace; log.begin(trace, "foreground-window"); } - View target = view.getVideoSurfaceView(); - if (playerView == view && target == surfaceView) return; - unbind(); playerView = view; surfaceView = target; - if (target instanceof SurfaceView sv) { - sv.getHolder().addCallback(this); - state(sv.getHolder(), "surface.bind", "existing-holder"); - } - snapshot("bind"); handler.postDelayed(tick, 5000); - } - - public void unbind() { - if (surfaceView instanceof SurfaceView sv) { - state(sv.getHolder(), "surface.bind", "detach-observer"); sv.getHolder().removeCallback(this); - } - handler.removeCallbacks(tick); playerView = null; surfaceView = null; - } - - public void resize(String mode, int width, int height) { - if (!PlaybackDiagnosticCollector.enabled()) return; - resizeOperation = PlaybackDiagnosticCollector.id("surface-resize"); - log.emit("surface.resize", "player-view-request", e -> e.observed("operationId", resizeOperation) - .requested("resizeMode", mode).requested("requestedWidth", width < 0 ? null : width) - .requested("requestedHeight", height < 0 ? null : height) - .unknown("holderWidth", PENDING_CALLBACK).unknown("holderHeight", PENDING_CALLBACK)); - } - - public void display(com.fongmi.android.tv.player.exo.ExoOutputModeManager.Result result) { - log.emit("display.change", "display-mode-manager", e -> { - e.observed("reason", result.reason()).observed("result", result.applied() ? "request-applied" : "observed"); - if (result.requestedMode() != null) e.requested("modeId", result.requestedMode().id()) - .requested("refreshHz", result.requestedMode().refreshRateMilliHz() / 1000f); - if (result.currentMode() != null) e.observed("modeId", result.currentMode().id()) - .observed("refreshHz", result.currentMode().refreshRateMilliHz() / 1000f); - }); - } - - @Override public void surfaceCreated(SurfaceHolder holder) { state(holder, "surface.created", "holder-callback"); } - @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { - log.emit("surface.changed", "SurfaceHolder.Callback", e -> e.observed("surfaceId", PlaybackDiagnosticCollector.objectId(holder.getSurface(), "surface")) - .observed("operationId", resizeOperation).observed("holderWidth", width).observed("holderHeight", height) - .observed("format", format).observed("valid", holder.getSurface().isValid())); - snapshot("surface-changed"); - } - @Override public void surfaceDestroyed(SurfaceHolder holder) { state(holder, "surface.destroyed", "holder-callback"); } - private void state(SurfaceHolder holder, String event, String reason) { - log.emit(event, "SurfaceHolder", e -> { - Rect rect = holder.getSurfaceFrame(); - e.observed("surfaceId", PlaybackDiagnosticCollector.objectId(holder.getSurface(), "surface")) - .observed("valid", holder.getSurface().isValid()).observed("holderWidth", rect.width()) - .observed("holderHeight", rect.height()).observed("reason", reason).observed("surfaceType", "SurfaceView"); - }); - } - - public void snapshot(String reason) { - if (!PlaybackDiagnosticCollector.enabled() || playerView == null) return; - try { - log.baseline(); - pixelProbe.maybeSample(playerView, trace); - View target = surfaceView; - if (target != null) log.emit("surface.visibility", "view-hierarchy", e -> { - float alpha = target.getAlpha(); ViewParent parent = target.getParent(); int depth = 0; - while (parent instanceof View view && depth++ < 32) { alpha *= view.getAlpha(); parent = view.getParent(); } - e.observed("reason", reason).observed("width", target.getWidth()).observed("height", target.getHeight()) - .observed("surfaceType", target.getClass().getSimpleName()).observed("attached", target.isAttachedToWindow()) - .observed("shown", target.isShown()).observed("visibility", target.getVisibility()) - .observed("windowVisibility", target.getWindowVisibility()).observed("alpha", target.getAlpha()) - .observed("ancestorAlpha", alpha).observed("rotation", target.getRotation()); - if (target instanceof SurfaceView sv) e.observed("surfaceId", PlaybackDiagnosticCollector.objectId(sv.getHolder().getSurface(), "surface")); - else e.unknown("surfaceId", NOT_OBSERVABLE); - }); - Player player = playerView.getPlayer(); - View shutter = playerView.findViewById(androidx.media3.ui.R.id.exo_shutter); - ImageView artwork = playerView.findViewById(androidx.media3.ui.R.id.exo_artwork); - boolean shutterShown = shutter != null && shutter.isShown() && shutter.getAlpha() > 0; - boolean artworkShown = artwork != null && artwork.isShown() && artwork.getDrawable() != null; - log.emit("video.presentation.source", "PlayerView", e -> { - e.observed("shutterVisible", shutterShown).observed("artworkVisible", artworkShown) - .observed("presentation", artworkShown ? "artwork" : shutterShown ? "shutter" : "video-surface-exposed") - .unknown("physicalDisplay", NOT_OBSERVABLE).observed("reason", reason); - if (player != null) e.observed("videoSelected", player.getCurrentTracks().isTypeSelected(C.TRACK_TYPE_VIDEO)); - else e.unknown("videoSelected", UNAVAILABLE); - }); - android.view.Display display = playerView.getDisplay(); - if (display != null) log.emit("env.display", "View.getDisplay", e -> e.observed("displayId", display.getDisplayId()) - .observed("refreshHz", display.getRefreshRate()).observed("rotation", display.getRotation())); - } catch (RuntimeException ignored) { DebugLogStore.collectorFailure(); } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/SystemAudioDiagnosticCollector.java b/app/src/main/java/com/fongmi/android/tv/player/SystemAudioDiagnosticCollector.java deleted file mode 100644 index 39da2d14d0..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/SystemAudioDiagnosticCollector.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.fongmi.android.tv.player; - -import android.content.Context; -import android.media.AudioDeviceInfo; -import android.media.AudioManager; -import android.os.Build; - -import com.fongmi.android.tv.App; -import com.github.catvod.crawler.DebugLogStore; - -import static com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.*; - -/** Snapshot only: never requests focus or changes volume/routing. Called on the owning player looper. */ -public final class SystemAudioDiagnosticCollector { - private SystemAudioDiagnosticCollector() {} - - public static void snapshot(PlaybackDiagnosticCollector log, PlaybackDiagnosticCollector.Context owner) { - if (!PlaybackDiagnosticCollector.enabled()) return; - try { - AudioManager manager = (AudioManager) App.get().getSystemService(Context.AUDIO_SERVICE); - if (manager == null) return; - log.emit(owner, "audio.volume", "AudioManager", "system-stream-not-output-route", e -> { - e.observed("systemVolume", manager.getStreamVolume(AudioManager.STREAM_MUSIC)) - .observed("systemMaxVolume", manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)) - .observed("fixedVolume", manager.isVolumeFixed()).unknown("audibility", NOT_OBSERVABLE); - if (Build.VERSION.SDK_INT >= 23) e.observed("muted", manager.isStreamMute(AudioManager.STREAM_MUSIC)); - else e.unknown("muted", NOT_SUPPORTED); - if (Build.VERSION.SDK_INT >= 28) e.observed("systemMinVolume", manager.getStreamMinVolume(AudioManager.STREAM_MUSIC)); - else e.unknown("systemMinVolume", NOT_SUPPORTED); - }); - if (Build.VERSION.SDK_INT >= 23) for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { - log.emit(owner, "audio.route", "AudioManager.getDevices", "advertised-only", e -> e - .observed("routeRole", "discovered").observed("routeId", "device-" + device.getId()) - .observed("routeType", device.getType()).observed("encoding", java.util.Arrays.toString(device.getEncodings())) - .observed("channelMask", java.util.Arrays.toString(device.getChannelMasks())) - .observed("sampleRate", java.util.Arrays.toString(device.getSampleRates()))); - } - } catch (RuntimeException ignored) { DebugLogStore.collectorFailure(); } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/codec/CodecCapabilityInspector.java b/app/src/main/java/com/fongmi/android/tv/player/codec/CodecCapabilityInspector.java index 2cb33b6f56..d8a7f55bdb 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/codec/CodecCapabilityInspector.java +++ b/app/src/main/java/com/fongmi/android/tv/player/codec/CodecCapabilityInspector.java @@ -58,10 +58,10 @@ public final class CodecCapabilityInspector { builder.append(text); } if (matched == 0) { - if (total == 0) return type == TYPE_AUDIO ? "未发现硬件音频解码器" : "未发现匹配类型的硬件解码器"; + if (total == 0) return type == TYPE_AUDIO ? "未发现系统音频解码器" : "未发现匹配类型的硬件解码器"; return TextUtils.isEmpty(query) ? "未发现解码能力" : "没有匹配关键词的解码能力"; } - String title = type == TYPE_AUDIO ? "硬件音频解码器 " : type == TYPE_VIDEO ? "硬件视频解码器 " : "硬件解码器 "; + String title = type == TYPE_AUDIO ? "系统音频解码器 " : type == TYPE_VIDEO ? "硬件视频解码器 " : "解码器 "; return title + matched + "/" + total + "\n\n" + builder; } @@ -166,43 +166,6 @@ public final class CodecCapabilityInspector { } } - /** - * Checks a regular HEVC Main10 decoder for the dimensions/rate of a - * Dolby Vision Profile 8.1 base layer. This query is intentionally - * separate from the Dolby Vision MIME query: a vendor may advertise - * HEVC Main10 while rejecting the Dolby Vision profile or its RPU. - */ - public static MpvAutoOutputPolicy.DolbyVisionSupport hevcHdr10Support( - Context context, Format current, int width, int height) { - if (context == null) return MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN; - String codecs = width >= 3840 || height >= 2160 - ? "hvc1.2.4.L153.B0" : "hvc1.2.4.L120.B0"; - Format.Builder builder = new Format.Builder() - .setSampleMimeType(MimeTypes.VIDEO_H265) - .setCodecs(codecs); - if (current != null) { - if (current.frameRate > 0) builder.setFrameRate(current.frameRate); - if (current.averageBitrate > 0) builder.setAverageBitrate(current.averageBitrate); - if (current.peakBitrate > 0) builder.setPeakBitrate(current.peakBitrate); - } - if (width > 0) builder.setWidth(width); - if (height > 0) builder.setHeight(height); - Format format = builder.build(); - try { - for (androidx.media3.exoplayer.mediacodec.MediaCodecInfo info - : MediaCodecSelector.DEFAULT.getDecoderInfos( - MimeTypes.VIDEO_H265, false, false)) { - if (!info.hardwareAccelerated) continue; - if (info.isFormatSupported(context, format)) { - return MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED; - } - } - return MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED; - } catch (Throwable ignored) { - return MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN; - } - } - private static boolean codecMatchesProfile(String codecs, int profile) { if (TextUtils.isEmpty(codecs)) return false; String expected = String.format(Locale.US, ".%02d.", profile); @@ -235,7 +198,7 @@ public final class CodecCapabilityInspector { boolean video = mime != null && mime.startsWith("video/"); boolean audio = mime != null && mime.startsWith("audio/"); if (!video && !audio) return null; - if (!isHardwareCodec(info)) return null; + if (video && !isHardwareCodec(info)) return null; String text = video ? videoEntry(info, mime, caps) : audioEntry(info, mime, caps); return new CodecEntry(video ? TYPE_VIDEO : TYPE_AUDIO, video ? "视频" : "音频", info.getName(), mime, text, normalize(text)); } catch (Throwable ignored) { @@ -409,10 +372,11 @@ public final class CodecCapabilityInspector { } private static List decoderNames(Context context, String mime, Format format) { + boolean audio = mime.startsWith("audio/"); List names = new ArrayList<>(); try { for (androidx.media3.exoplayer.mediacodec.MediaCodecInfo info : MediaCodecSelector.DEFAULT.getDecoderInfos(mime, false, false)) { - if (!isHardwareCodec(info)) continue; + if (!audio && !info.hardwareAccelerated) continue; if (info.isFormatSupported(context, format)) names.add(info.name); else if (!names.contains(info.name)) names.add(info.name); } @@ -425,44 +389,21 @@ public final class CodecCapabilityInspector { String mime = getSampleMimeType(format); if (TextUtils.isEmpty(mime)) return "无法识别 MIME,codecs=" + empty(format == null ? null : format.codecs); boolean audio = mime.startsWith("audio/"); - List hardwareSupported = new ArrayList<>(); - List softwareSupported = new ArrayList<>(); - List hardwareCandidates = new ArrayList<>(); - List softwareCandidates = new ArrayList<>(); + List supported = new ArrayList<>(); + List candidates = new ArrayList<>(); try { for (androidx.media3.exoplayer.mediacodec.MediaCodecInfo info : MediaCodecSelector.DEFAULT.getDecoderInfos(mime, false, false)) { - boolean hardware = isHardwareCodec(info); - if (!audio && !hardware) continue; - List candidates = hardware - ? hardwareCandidates : softwareCandidates; - List supported = hardware - ? hardwareSupported : softwareSupported; + if (!audio && !info.hardwareAccelerated) continue; candidates.add(info.name); - if (info.isFormatSupported(context, format)) { - supported.add(info.name); - } + if (info.isFormatSupported(context, format)) supported.add(info.name); } } catch (MediaCodecUtil.DecoderQueryException e) { return "查询失败 " + e.getClass().getSimpleName() + ": " + e.getMessage(); } catch (Throwable e) { return "查询失败 " + e.getClass().getSimpleName(); } - if (!hardwareSupported.isEmpty()) { - return "当前规格可硬解 / " - + TextUtils.join(", ", hardwareSupported); - } - if (audio && !softwareSupported.isEmpty()) { - return "当前规格仅有系统软件解码 / " - + TextUtils.join(", ", softwareSupported); - } - if (!hardwareCandidates.isEmpty()) { - return "有硬件 decoder 支持该 MIME,但当前规格未声明可硬解 / 候选 " - + TextUtils.join(", ", hardwareCandidates); - } - if (audio && !softwareCandidates.isEmpty()) { - return "没有硬件 decoder;有系统软件候选 / " - + TextUtils.join(", ", softwareCandidates); - } + if (!supported.isEmpty()) return audio ? "当前音频可由系统解码 / " + TextUtils.join(", ", supported) : "当前规格可硬解 / " + TextUtils.join(", ", supported); + if (!candidates.isEmpty()) return audio ? "有系统音频 decoder,但当前规格未声明支持 / 候选 " + TextUtils.join(", ", candidates) : "有硬解器支持该 MIME,但当前规格未声明可硬解 / 候选 " + TextUtils.join(", ", candidates); return audio ? "没有该 MIME 的系统音频 decoder" : "没有该 MIME 的硬件 video decoder"; } @@ -521,18 +462,11 @@ public final class CodecCapabilityInspector { } private static boolean isHardwareCodec(MediaCodecInfo info) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return info.isHardwareAccelerated() && !info.isSoftwareOnly(); - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return info.isHardwareAccelerated(); String name = info.getName().toLowerCase(Locale.US); return !name.contains("google") && !name.contains("android") && !name.contains("ffmpeg") && !name.contains("software") && !name.startsWith("c2.android"); } - private static boolean isHardwareCodec( - androidx.media3.exoplayer.mediacodec.MediaCodecInfo info) { - return info.hardwareAccelerated && !info.softwareOnly; - } - private static String codecClass(MediaCodecInfo info) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (info.isHardwareAccelerated()) return "硬件"; diff --git a/app/src/main/java/com/fongmi/android/tv/player/engine/ExoPlayerEngine.java b/app/src/main/java/com/fongmi/android/tv/player/engine/ExoPlayerEngine.java index b1473e9263..17fcd941c2 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/engine/ExoPlayerEngine.java +++ b/app/src/main/java/com/fongmi/android/tv/player/engine/ExoPlayerEngine.java @@ -1,9 +1,5 @@ package com.fongmi.android.tv.player.engine; -import android.media.MediaCodecInfo; -import android.media.MediaCodecList; -import android.os.Build; - import androidx.media3.common.C; import androidx.media3.common.Effect; import androidx.media3.common.Format; @@ -15,29 +11,22 @@ import androidx.media3.common.PlaybackException; import androidx.media3.common.Player; import androidx.media3.common.Timeline; import androidx.media3.common.Tracks; -import androidx.media3.exoplayer.ExoPlaybackException; import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.exoplayer.source.MediaSource; import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Track; -import com.fongmi.android.tv.player.AudioPlaybackDiagnostics; import com.fongmi.android.tv.player.PlaybackTrace; import com.fongmi.android.tv.player.PlaybackResourceClassifier; import com.fongmi.android.tv.player.audio.PlaybackMediaClock; import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; import com.fongmi.android.tv.player.exo.ErrorMsgProvider; -import com.fongmi.android.tv.player.exo.ExoAudioOutputState; import com.fongmi.android.tv.player.exo.ExoDecoderRuntimeProfiles; import com.fongmi.android.tv.player.exo.ExoDecoderRuntimeSession; -import com.fongmi.android.tv.player.exo.ExoCompressedAudioDirectPolicy; import com.fongmi.android.tv.player.exo.ExoDolbyVisionPlaybackState; import com.fongmi.android.tv.player.exo.ExoFrameSchedulingPlayerSettings; import com.fongmi.android.tv.player.exo.ExoFrameSchedulingSessionLock; import com.fongmi.android.tv.player.exo.ExoUtil; -import com.fongmi.android.tv.player.exo.ass.ExoAssSession; -import com.fongmi.android.tv.player.exo.subtitle.ExoSubtitleSession; import com.fongmi.android.tv.player.exo.ExoTunnelingProgressWatchdog; import com.fongmi.android.tv.player.exo.ExoTunnelingRuntimeState; import com.fongmi.android.tv.player.exo.ExoTunnelingWatchdog; @@ -52,40 +41,48 @@ import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.utils.ResUtil; import com.github.catvod.crawler.SpiderDebug; -import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicInteger; public class ExoPlayerEngine implements PlayerEngine { + private static final AtomicInteger PREPARE_GENERATION = new AtomicInteger(); + + public interface PrepareListener { + + PrepareListener NONE = new PrepareListener() { + }; + + default void onPrepareStarted(int generation) { + } + + default void onPrepareReady(int generation) { + } + + default void onPrepareCanceled(int generation) { + } + } + private final ErrorMsgProvider provider; private final PreCache preCache; private final Set attemptedFormats; + private final PrepareListener prepareListener; private final ExoDecoderRuntimeSession decoderRuntimeSession; - private final ExoCompressedAudioDirectPolicy compressedAudioDirectPolicy; private final ExoDolbyVisionPlaybackState dolbyVisionPlaybackState; private final ExoFrameSchedulingSessionLock frameSchedulingSessionLock; private final PlaybackMediaSignalHub mediaSignals; private final PlaybackMediaClock mediaClock; - private final AtomicBoolean pcmFallbackInProgress = new AtomicBoolean(); - private final AtomicLong pcmFallbackRequestSeq = new AtomicLong(); - private final AtomicLong pcmFallbackScheduledAttempt = - new AtomicLong(Long.MIN_VALUE); - private volatile long audioOutputAttemptGeneration; private PlaySpec spec; - private PlaySpec queuedSpec; - private String queuedMediaId; private String activeFormat; private ExoPlayer player; - private ExoAssSession assSession; - private ExoSubtitleSession subtitleSession; + private Player.Listener prepareReadyListener; private int decode; + private int pendingPrepareGeneration = -1; private boolean playWhenReady; private boolean cacheSessionActive; private boolean tunnelingFallbackAttempted; @@ -120,7 +117,6 @@ public class ExoPlayerEngine implements PlayerEngine { @Override public void onIsPlayingChanged(boolean isPlaying) { - if (isPlaying) compressedAudioDirectPolicy.setSelectedAudioFormat(player.getAudioFormat()); if (isPlaying && firstFrameRendered) { armTunnelingProgressWatchdog(); armDecoderRuntimeStableWindow(); @@ -130,11 +126,6 @@ public class ExoPlayerEngine implements PlayerEngine { } } - @Override - public void onTracksChanged(Tracks tracks) { - compressedAudioDirectPolicy.setSelectedAudioFormat(player.getAudioFormat()); - } - @Override public void onPlaybackStateChanged(int state) { if (state == Player.STATE_READY && player.isPlaying() && firstFrameRendered) { @@ -161,20 +152,18 @@ public class ExoPlayerEngine implements PlayerEngine { }; public ExoPlayerEngine(int decode, Player.Listener listener) { - this(decode, listener, null, null); + this(decode, listener, PrepareListener.NONE, null, null); } - public ExoPlayerEngine( - int decode, - Player.Listener listener, - PlaybackMediaSignalHub mediaSignals, - PlaybackMediaClock mediaClock) { + public ExoPlayerEngine(int decode, Player.Listener listener, PrepareListener prepareListener) { + this(decode, listener, prepareListener, null, null); + } + + public ExoPlayerEngine(int decode, Player.Listener listener, PrepareListener prepareListener, + PlaybackMediaSignalHub mediaSignals, PlaybackMediaClock mediaClock) { this.mediaSignals = mediaSignals; this.mediaClock = mediaClock; this.decoderRuntimeSession = ExoDecoderRuntimeProfiles.process().newSession(); - this.compressedAudioDirectPolicy = new ExoCompressedAudioDirectPolicy(App.get()); - this.compressedAudioDirectPolicy.setInitializationFailureListener( - this::onVendorDirectInitializationFailure); this.dolbyVisionPlaybackState = new ExoDolbyVisionPlaybackState(); this.decoderRuntimeEnabledForPlayer = PlaybackPerformanceSetting.isAuto(PlayerSetting.EXO); @@ -188,8 +177,6 @@ public class ExoPlayerEngine implements PlayerEngine { this.frameSchedulingOutput = ExoDecoderRuntimeProfiles.currentOutput( ExoUtil.isTunnelingEnabled(decode, false)); MediaSourceFactory.acquireCacheSession(); - this.assSession = ExoAssSession.createIfEnabled(App.get(), ExoUtil.isTunnelingEnabled(decode, false)); - this.subtitleSession = new ExoSubtitleSession(); try { this.player = ExoUtil.buildPlayer( decode, @@ -198,14 +185,9 @@ public class ExoPlayerEngine implements PlayerEngine { decoderRuntimeSession, frameSchedulingSettings, dolbyVisionPlaybackState, - compressedAudioDirectPolicy, - assSession, - subtitleSession, mediaSignals, mediaClock); } catch (RuntimeException | Error e) { - if (assSession != null) assSession.release(); - subtitleSession.release(); MediaSourceFactory.releaseCacheSession(); throw e; } @@ -213,6 +195,7 @@ public class ExoPlayerEngine implements PlayerEngine { this.provider = new ErrorMsgProvider(); this.preCache = new PreCache(); this.attemptedFormats = new HashSet<>(); + this.prepareListener = prepareListener == null ? PrepareListener.NONE : prepareListener; this.decode = decode; this.tunnelingEnabledForSession = ExoUtil.isTunnelingEnabled(decode, false); this.firstFrameRendered = false; @@ -224,18 +207,9 @@ public class ExoPlayerEngine implements PlayerEngine { return player; } - public ExoAssSession getAssSession() { - return assSession; - } - - public ExoSubtitleSession getSubtitleSession() { - return subtitleSession; - } - @Override public void release() { - audioOutputAttemptGeneration++; - compressedAudioDirectPolicy.resetOutputProgress(); + cancelPendingPrepare(); Runnable cacheRelease = null; if (cacheSessionActive) { cacheSessionActive = false; @@ -251,17 +225,14 @@ public class ExoPlayerEngine implements PlayerEngine { dolbyVisionP81RuntimeFailureObserved = false; dolbyVisionFallbackPreparedForNextStart = false; dolbyVisionFallbackSpec = null; - if (assSession != null) assSession.release(); - subtitleSession.release(); player.release(); } @Override public Player rebuild(Player.Listener listener) { + cancelPendingPrepare(); ExoFrameSchedulingPlayerSettings schedulingSettings = settingsForRebuild(); - audioOutputAttemptGeneration++; - compressedAudioDirectPolicy.resetOutputProgress(); preCache.stop("engine-rebuild"); cancelTunnelingWatchdog(); cancelTunnelingProgressWatchdog(); @@ -269,8 +240,6 @@ public class ExoPlayerEngine implements PlayerEngine { finishDecoderRuntimeAttempt(); PlaybackAnalyticsListener.finishSession(player.getCurrentPosition()); dolbyVisionPlaybackState.resetAttempt(); - if (assSession != null) assSession.release(); - subtitleSession.release(); player.release(); PlaybackTrace.log("player-engine", getPlaybackTraceId(), "rebuild decode=%d", decode); tunnelingEnabledForSession = ExoUtil.isTunnelingEnabled(decode, tunnelingFallbackAttempted); @@ -280,8 +249,6 @@ public class ExoPlayerEngine implements PlayerEngine { PlaybackPerformanceSetting.isDv7Hdr10FallbackEnabled(); frameSchedulingOutput = ExoDecoderRuntimeProfiles.currentOutput( tunnelingEnabledForSession); - assSession = ExoAssSession.createIfEnabled(App.get(), tunnelingEnabledForSession); - subtitleSession = new ExoSubtitleSession(); player = ExoUtil.buildPlayer( decode, listener, @@ -289,9 +256,6 @@ public class ExoPlayerEngine implements PlayerEngine { decoderRuntimeSession, schedulingSettings, dolbyVisionPlaybackState, - compressedAudioDirectPolicy, - assSession, - subtitleSession, mediaSignals, mediaClock); frameSchedulingSettings = schedulingSettings; @@ -552,101 +516,6 @@ public class ExoPlayerEngine implements PlayerEngine { startInternal(position, playWhenReady); } - @Override - public boolean supportsPlaylistQueue() { - return player != null && PreCache.isPlaylistPreloadEnabled() - && ExoUtil.supportsPlaylistPreload(player) - && !player.isCurrentMediaItemLive() - && !player.getShuffleModeEnabled() - && player.getRepeatMode() == Player.REPEAT_MODE_OFF - && !dolbyVisionPlaybackState.snapshot().hdr10FallbackActive() - && !dolbyVisionPlaybackState.snapshot().p81ConversionActive() - && !dolbyVisionPlaybackState.isHdr10FallbackRequested() - && !dolbyVisionPlaybackState.isP81ConversionAttempted(); - } - - @Override - public boolean appendPlaylistItem(PlaySpec queuedSpec, String mediaId) { - if (!supportsPlaylistQueue() || queuedSpec == null || mediaId == null || mediaId.isBlank()) return false; - // Only an empty next slot is appendable. Consumed items are removed at commit, - // never by a later append (which could transiently grow the playlist to three). - if (queuedMediaId != null || player.getCurrentMediaItemIndex() != 0 - || player.getMediaItemCount() != 1 - || mediaId.equals(player.getCurrentMediaItem().mediaId) - || queuedSpec.getDrm() != null || queuedSpec.isParseSource()) return false; - try { - MediaItem item = ExoUtil.getMediaItem(queuedSpec, decode, mediaId); - // The future extractor must not mutate the active renderer's DV state. - MediaSource source = ExoUtil.createMediaSource(item, new ExoDolbyVisionPlaybackState()); - // Queue success is intentionally only this append. Do not call stop, clear, - // setMediaItem, setMediaSource, prepare, or rebuild the active player here. - player.addMediaSource(source); - this.queuedSpec = queuedSpec.copyWithFormat(queuedSpec.getFormat()); - this.queuedMediaId = mediaId; - return true; - } catch (RuntimeException error) { - PlaybackTrace.log( - "player-engine", - getPlaybackTraceId(), - "playlist append failed mediaId=%s error=%s", - mediaId, - error.getClass().getSimpleName()); - return false; - } - } - - @Override - public boolean removePlaylistItemsAfterCurrent() { - if (player == null) return false; - preCache.setPlaylistPreloadDurationMs(player, 0); - queuedSpec = null; - queuedMediaId = null; - int currentIndex = player.getCurrentMediaItemIndex(); - int itemCount = player.getMediaItemCount(); - if (currentIndex < 0 || currentIndex >= itemCount) return false; - if (currentIndex + 1 < itemCount) player.removeMediaItems(currentIndex + 1, itemCount); - return true; - } - - @Override - public boolean setPlaylistPreloadDurationMs(long durationMs) { - if (player == null) return false; - if (durationMs > 0 && (!supportsPlaylistQueue() || queuedMediaId == null)) return false; - return preCache.setPlaylistPreloadDurationMs(player, durationMs); - } - - @Override - public boolean commitPlaylistTransition(PlaySpec nextSpec) { - if (player == null || nextSpec == null || queuedSpec == null - || player.getCurrentMediaItem() == null - || !Objects.equals(queuedMediaId, player.getCurrentMediaItem().mediaId) - || !Objects.equals(queuedSpec.getKey(), nextSpec.getKey()) - || !Objects.equals(queuedSpec.getUrl(), nextSpec.getUrl()) - || !Objects.equals(queuedSpec.getHeaders(), nextSpec.getHeaders()) - || player.getCurrentMediaItemIndex() != 1 - || player.getMediaItemCount() != 2) return false; - preCache.setPlaylistPreloadDurationMs(player, 0); - spec = nextSpec; - queuedSpec = null; - queuedMediaId = null; - attemptedFormats.clear(); - attemptedFormats.add(nextSpec.getFormat()); - prepareDolbyVisionForStart(nextSpec); - activeFormat = nextSpec.getFormat(); - playWhenReady = player.getPlayWhenReady(); - resourceClassification = PlaybackResourceClassifier.classifyRequest( - nextSpec.getUrl(), nextSpec.getFormat(), nextSpec.getFormat()); - preCache.onMediaItemTransition( - player, - player.getCurrentMediaItem(), - nextSpec.getPlaybackTraceId(), - nextSpec.getPlaybackRoute()); - // This is a playlist mutation, not a new playback start. It also makes the - // slot available before the UI can enqueue the following episode. - player.removeMediaItems(0, 1); - return true; - } - @Override public void restart(PlaySpec spec, long position, boolean playWhenReady) { prepareDolbyVisionForStart(spec); @@ -660,6 +529,7 @@ public class ExoPlayerEngine implements PlayerEngine { } resetAttemptedFormats(); PlaybackTrace.log("player-engine", getPlaybackTraceId(), "restart decode=%d format=%s position=%d play=%s headers=%s urlLen=%d", decode, spec.getFormat(), position, playWhenReady, spec.getHeaders() == null ? 0 : spec.getHeaders().size(), spec.getUrl() == null ? 0 : spec.getUrl().length()); + cancelPendingPrepare(); preCache.stop("engine-restart"); player.stop(); startInternal(position, playWhenReady); @@ -676,13 +546,11 @@ public class ExoPlayerEngine implements PlayerEngine { @Override public void stop() { - audioOutputAttemptGeneration++; - compressedAudioDirectPolicy.resetOutputProgress(); + cancelPendingPrepare(); preCache.stop("player-stop"); cancelDecoderRuntimeStableWindow(); finishDecoderRuntimeAttempt(); PlaybackAnalyticsListener.finishSession(player.getCurrentPosition()); - subtitleSession.reset(); player.stop(); } @@ -722,39 +590,17 @@ public class ExoPlayerEngine implements PlayerEngine { @Override public void setTrack(List tracks) { - List allowed = new ArrayList<>(tracks.size()); - for (Track track : tracks) { - if (subtitleSession.canSelectPrimary(player, track)) allowed.add(track); - } - if (!allowed.isEmpty()) TrackUtil.setTrackSelection(player, allowed); + TrackUtil.setTrackSelection(player, tracks); } @Override public void resetTrack() { - subtitleSession.reset(); TrackUtil.reset(player); } @Override - public boolean supportsSecondarySubtitle() { - return true; - } - - @Override - public boolean isPrimarySubtitleSelected(Format format) { - return subtitleSession.isPrimarySelected(format); - } - - @Override - public boolean isSecondarySubtitleSelected(Format format) { - return subtitleSession.isSecondarySelected(format); - } - - @Override - public void setSecondarySubtitleTrack(Track track) { - boolean selected = subtitleSession.selectSecondary(player, track); - PlaybackTrace.log("exo-subtitle", getPlaybackTraceId(), "role=secondary accepted=%s disabled=%s", - selected, track == null || track.isDisabled()); +public void resetTrack(int type) { + TrackUtil.reset(player, type); } @Override @@ -788,7 +634,6 @@ public class ExoPlayerEngine implements PlayerEngine { format == null ? "unknown" : format.width + "x" + format.height + "/" + format.sampleMimeType); } player.setVideoEffects(effects); - com.fongmi.android.tv.player.exo.ExoDiagnosticCollector.effects(player, effects); } @Override @@ -821,86 +666,6 @@ public class ExoPlayerEngine implements PlayerEngine { currentAnalyticsSession ? tunnelingEnabledForSession : null); } - @Override - public AudioPlaybackDiagnostics.Snapshot getAudioPlaybackDiagnostics() { - PlaybackAnalyticsListener.Snapshot analytics = - PlaybackAnalyticsListener.getSnapshot(); - boolean currentAnalyticsSession = !PlaybackTrace.NONE.equals( - getPlaybackTraceId()) && getPlaybackTraceId().equals( - PlaybackAnalyticsListener.getPlaybackTraceId()); - Format selected = currentAnalyticsSession && analytics.audioFormat() != null - ? analytics.audioFormat() - : TrackUtil.explicitlySelectedFormat(getCurrentTracks(), C.TRACK_TYPE_AUDIO); - AudioPlaybackDiagnostics.Track original = - AudioPlaybackDiagnostics.track(selected, ""); - ExoAudioOutputState.Snapshot output = - currentAnalyticsSession - ? compressedAudioDirectPolicy.getAudioOutputSnapshot() - : ExoAudioOutputState.Snapshot.empty(); - String decoderName = currentAnalyticsSession - ? analytics.audioDecoderName() : ""; - PlaybackException error = player == null ? null : player.getPlayerError(); - if (isAudioDiagnosticsFailure(error, selected)) { - AudioPlaybackDiagnostics.FailureReason failureReason = - AudioPlaybackDiagnostics.failureReason(error.errorCode); - AudioPlaybackDiagnostics.DecodeMode attemptedDecode = audioDecodeMode( - original, decoderName); - AudioPlaybackDiagnostics.OutputMode attemptedOutput = output.offload() - ? AudioPlaybackDiagnostics.OutputMode.OFFLOAD - : AudioPlaybackDiagnostics.OutputMode.UNKNOWN; - return new AudioPlaybackDiagnostics.Snapshot(original, original, - attemptedDecode, decoderName, attemptedOutput, 0, 0, false, - "", AudioPlaybackDiagnostics.lastAttemptLevel( - failureReason, attemptedOutput, attemptedDecode), - AudioPlaybackDiagnostics.RuntimeState.FAILED, failureReason); - } - if (!output.initialized()) { - return new AudioPlaybackDiagnostics.Snapshot(original, original, - AudioPlaybackDiagnostics.DecodeMode.UNKNOWN, - decoderName, - AudioPlaybackDiagnostics.OutputMode.UNKNOWN, - 0, 0, false, ""); - } - AudioPlaybackDiagnostics.OutputMode outputMode = output.offload() - ? AudioPlaybackDiagnostics.OutputMode.OFFLOAD - : androidx.media3.common.util.Util.isEncodingLinearPcm(output.encoding()) - ? AudioPlaybackDiagnostics.OutputMode.PCM - : AudioPlaybackDiagnostics.OutputMode.PASSTHROUGH; - AudioPlaybackDiagnostics.Track active = outputMode - == AudioPlaybackDiagnostics.OutputMode.PCM - ? original - : AudioPlaybackDiagnostics.encodedTrack(original, output.encoding()); - String reason = isDtsCoreDowngrade(original, active) - ? "dts-hd-core" : ""; - AudioPlaybackDiagnostics.DecodeMode decodeMode = outputMode - == AudioPlaybackDiagnostics.OutputMode.PCM - ? audioDecodeMode(active, decoderName) - : AudioPlaybackDiagnostics.DecodeMode.NONE; - return new AudioPlaybackDiagnostics.Snapshot(original, active, - decodeMode, decoderName, outputMode, output.channels(), - output.sampleRate(), output.tunneling(), reason); - } - - private boolean isAudioDiagnosticsFailure(PlaybackException error, Format selectedAudio) { - if (error == null) return false; - if (isAudioOutputFailure(error)) return true; - AudioPlaybackDiagnostics.FailureReason reason = - AudioPlaybackDiagnostics.failureReason(error.errorCode); - if (reason != AudioPlaybackDiagnostics.FailureReason.DECODER_INIT - && reason != AudioPlaybackDiagnostics.FailureReason.DECODER_RUNTIME) { - return false; - } - if (error instanceof ExoPlaybackException exoError - && exoError.type == ExoPlaybackException.TYPE_RENDERER) { - if (exoError.rendererFormat != null) { - return MimeTypes.isAudio(exoError.rendererFormat.sampleMimeType); - } - return TrackUtil.explicitlySelectedFormat( - getCurrentTracks(), C.TRACK_TYPE_VIDEO) == null; - } - return selectedAudio != null && MimeTypes.isAudio(selectedAudio.sampleMimeType); - } - @Override public VideoPlaybackDetails getVideoPlaybackDetails() { PlaybackAnalyticsListener.Snapshot analytics = @@ -975,21 +740,6 @@ public class ExoPlayerEngine implements PlayerEngine { @Override public ErrorAction handleError(PlaybackException e) { - boolean directAudioFailure = isAudioOutputFailure(e) - || compressedAudioDirectPolicy.requestPcmFallbackForStuckPlayback(e); - boolean requestedFallback = directAudioFailure - && compressedAudioDirectPolicy.consumePcmFallbackRequest(); - boolean fallbackAlreadyPending = pcmFallbackInProgress.get() - || pcmFallbackScheduledAttempt.get() == audioOutputAttemptGeneration; - if (directAudioFailure && (requestedFallback || fallbackAlreadyPending)) { - if (requestPcmFallback()) { - PlaybackTrace.log( - "player-engine", - getPlaybackTraceId(), - "audio output direct failed; restarted current item with PCM"); - return ErrorAction.RECOVERED; - } - } ErrorAction action = switch (e.errorCode) { case PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> seekToDefaultPosition(); case PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED, PlaybackException.ERROR_CODE_DECODING_FAILED -> ErrorAction.DECODE; @@ -1000,66 +750,6 @@ public class ExoPlayerEngine implements PlayerEngine { return action; } - private void onVendorDirectInitializationFailure() { - compressedAudioDirectPolicy.consumePcmFallbackRequest(); - long attempt = audioOutputAttemptGeneration; - long request = pcmFallbackRequestSeq.incrementAndGet(); - pcmFallbackScheduledAttempt.set(attempt); - App.post(() -> { - if (request != pcmFallbackRequestSeq.get() - || attempt != audioOutputAttemptGeneration - || pcmFallbackScheduledAttempt.get() != attempt) return; - pcmFallbackScheduledAttempt.compareAndSet(attempt, Long.MIN_VALUE); - retryAudioOutputWithPcm(); - }); - } - - private boolean requestPcmFallback() { - if (pcmFallbackInProgress.get() - || pcmFallbackScheduledAttempt.get() == audioOutputAttemptGeneration) { - return true; - } - return retryAudioOutputWithPcm(); - } - - private boolean retryAudioOutputWithPcm() { - if (!pcmFallbackInProgress.compareAndSet(false, true)) return false; - try { - if (player == null || spec == null) return false; - long position = Math.max(0, player.getCurrentPosition()); - boolean shouldPlay = playWhenReady; - preCache.stop("audio-output-pcm-fallback"); - startInternal(position, shouldPlay, true); - if (SpiderDebug.isEnabled()) { - SpiderDebug.log( - "exo-audio-direct", - "fallback=pcm position=%d play=%s format=%s", - position, - shouldPlay, - activeFormat); - } - return true; - } catch (RuntimeException error) { - PlaybackTrace.log( - "player-engine", - getPlaybackTraceId(), - "audio output PCM fallback failed type=%s message=%s", - error.getClass().getSimpleName(), - error.getMessage()); - return false; - } finally { - pcmFallbackInProgress.set(false); - } - } - - private static boolean isAudioOutputFailure(PlaybackException error) { - if (error == null) return false; - return error.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED - || error.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED - || error.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_INIT_FAILED - || error.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_WRITE_FAILED; - } - public boolean observeDecoderRuntimeFailure(PlaybackException error) { if (!isHard() || error == null) return false; cancelDecoderRuntimeStableWindow(); @@ -1128,7 +818,7 @@ public class ExoPlayerEngine implements PlayerEngine { PlaybackTrace.log("exo-rtsp-live", getPlaybackTraceId(), "action=seek-live-edge"); player.seekToDefaultPosition(); - player.prepare(); + preparePlayer(); return true; } @@ -1141,15 +831,6 @@ public class ExoPlayerEngine implements PlayerEngine { } private void startInternal(long position, boolean playWhenReady) { - startInternal(position, playWhenReady, false); - } - - private void startInternal(long position, boolean playWhenReady, boolean pcmRetry) { - audioOutputAttemptGeneration++; - compressedAudioDirectPolicy.prepareForPlayback(spec.getUrl(), pcmRetry); - preCache.setPlaylistPreloadDurationMs(player, 0); - queuedSpec = null; - queuedMediaId = null; this.playWhenReady = playWhenReady; firstFrameRendered = false; cancelTunnelingProgressWatchdog(); @@ -1173,14 +854,48 @@ public class ExoPlayerEngine implements PlayerEngine { ExoPerformanceSetting.beginAutoSession(); if (!playWhenReady) player.pause(); MediaItem item = ExoUtil.getMediaItem(spec.copyWithFormat(activeFormat), decode); - item = com.fongmi.android.tv.player.exo.ExoDiagnosticCollector.prepare(player, item, spec.getPlaybackTraceId()); - subtitleSession.reset(); player.setMediaItem(item, position); preCache.start(player, item, spec.getPlaybackTraceId(), spec.getPlaybackRoute()); - player.prepare(); + preparePlayer(); if (playWhenReady) player.play(); } + private void preparePlayer() { + int generation = beginPrepare(); + prepareListener.onPrepareStarted(generation); + player.prepare(); + } + + private int beginPrepare() { + cancelPendingPrepare(); + int generation = PREPARE_GENERATION.incrementAndGet(); + pendingPrepareGeneration = generation; + Player.Listener readyListener = new Player.Listener() { + @Override + public void onPlaybackStateChanged(int state) { + if (state != Player.STATE_READY || generation != pendingPrepareGeneration || prepareReadyListener != this) return; + player.removeListener(this); + prepareReadyListener = null; + pendingPrepareGeneration = -1; + prepareListener.onPrepareReady(generation); + } + }; + prepareReadyListener = readyListener; + player.addListener(readyListener); + return generation; + } + + @Override + public void cancelPendingPrepare() { + int generation = pendingPrepareGeneration; + if (generation < 0) return; + pendingPrepareGeneration = -1; + Player.Listener readyListener = prepareReadyListener; + prepareReadyListener = null; + if (readyListener != null) player.removeListener(readyListener); + prepareListener.onPrepareCanceled(generation); + } + private void finishDecoderRuntimeAttempt() { if (!decoderRuntimeEnabledForPlayer) return; decoderRuntimeSession.finishAttempt( @@ -1250,49 +965,6 @@ public class ExoPlayerEngine implements PlayerEngine { || lower.endsWith("-secure"); } - private AudioPlaybackDiagnostics.DecodeMode audioDecodeMode( - AudioPlaybackDiagnostics.Track track, String decoderName) { - if (track != null && "PCM".equalsIgnoreCase(track.codec())) { - return AudioPlaybackDiagnostics.DecodeMode.NONE; - } - if (decoderName == null || decoderName.isBlank()) { - return AudioPlaybackDiagnostics.DecodeMode.UNKNOWN; - } - String lower = decoderName.toLowerCase(Locale.US); - if (lower.startsWith("omx.google.") || lower.startsWith("c2.android.") - || lower.contains("ffmpeg") || lower.contains("software") - || lower.contains("libopus") || lower.contains("libflac")) { - return AudioPlaybackDiagnostics.DecodeMode.SOFTWARE; - } - try { - for (MediaCodecInfo info : new MediaCodecList( - MediaCodecList.ALL_CODECS).getCodecInfos()) { - if (!info.getName().equalsIgnoreCase(decoderName)) continue; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return info.isHardwareAccelerated() - ? AudioPlaybackDiagnostics.DecodeMode.HARDWARE - : AudioPlaybackDiagnostics.DecodeMode.SOFTWARE; - } - return lower.contains("google") || lower.contains("android") - ? AudioPlaybackDiagnostics.DecodeMode.SOFTWARE - : AudioPlaybackDiagnostics.DecodeMode.HARDWARE; - } - } catch (Throwable ignored) { - } - if (lower.startsWith("omx.") || lower.startsWith("c2.")) { - return AudioPlaybackDiagnostics.DecodeMode.HARDWARE; - } - return AudioPlaybackDiagnostics.DecodeMode.UNKNOWN; - } - - private boolean isDtsCoreDowngrade(AudioPlaybackDiagnostics.Track original, - AudioPlaybackDiagnostics.Track active) { - if (original == null || active == null) return false; - String source = original.codec().toLowerCase(Locale.US); - return "DTS Core".equals(active.codec()) - && (source.contains("dts-hd") || source.contains("dts:x")); - } - private static int[] dolbyVisionProfileLevel(Format format) { if (format == null) return new int[]{C.INDEX_UNSET, C.INDEX_UNSET}; String codecs = format.codecs == null ? "" : format.codecs.trim(); @@ -1322,7 +994,7 @@ public class ExoPlayerEngine implements PlayerEngine { private ErrorAction seekToDefaultPosition() { player.seekToDefaultPosition(); - player.prepare(); + preparePlayer(); return ErrorAction.RECOVERED; } diff --git a/app/src/main/java/com/fongmi/android/tv/player/engine/IjkPlayerEngine.java b/app/src/main/java/com/fongmi/android/tv/player/engine/IjkPlayerEngine.java index 4ce28d9f19..85ba20f369 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/engine/IjkPlayerEngine.java +++ b/app/src/main/java/com/fongmi/android/tv/player/engine/IjkPlayerEngine.java @@ -11,7 +11,6 @@ import androidx.media3.common.util.UnstableApi; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Track; -import com.fongmi.android.tv.player.AudioPlaybackDiagnostics; import com.fongmi.android.tv.player.PlaybackTrace; import com.fongmi.android.tv.player.PlaybackResourceClassifier; import com.fongmi.android.tv.player.PlaybackRoute; @@ -89,7 +88,6 @@ public class IjkPlayerEngine implements PlayerEngine { @Override public void start(PlaySpec spec, long position, boolean playWhenReady) { this.spec = spec; - player.setDiagnosticTrace(spec.getPlaybackTraceId()); PlaybackTrace.log("player-engine", getPlaybackTraceId(), "start ijk decode=%d position=%d play=%s urlLen=%d headers=%d", decode, position, playWhenReady, spec.getUrl() == null ? 0 : spec.getUrl().length(), spec.getHeaders() == null ? 0 : spec.getHeaders().size()); MediaItem item = ExoUtil.getMediaItem(spec, decode); if (position > 0) player.setMediaItem(item, position); @@ -216,39 +214,6 @@ public class IjkPlayerEngine implements PlayerEngine { null); } - @Override - public AudioPlaybackDiagnostics.Snapshot getAudioPlaybackDiagnostics() { - Format format = player.getSelectedAudioFormatSnapshot(); - AudioPlaybackDiagnostics.Track track = - AudioPlaybackDiagnostics.track(format, ""); - String decoderName = player.getAudioCodecInfoSnapshot(); - AudioPlaybackDiagnostics.DecodeMode decodeMode = "PCM".equalsIgnoreCase( - track.codec()) ? AudioPlaybackDiagnostics.DecodeMode.NONE - : decoderName == null || decoderName.isBlank() - ? AudioPlaybackDiagnostics.DecodeMode.UNKNOWN - : AudioPlaybackDiagnostics.DecodeMode.SOFTWARE; - AudioPlaybackDiagnostics.OutputMode outputMode = decoderName == null - || decoderName.isBlank() - ? AudioPlaybackDiagnostics.OutputMode.UNKNOWN - : AudioPlaybackDiagnostics.OutputMode.PCM; - ErrorSnapshot error = player.getLastErrorSnapshot(); - boolean decoderFailed = error != null - && error.stage() != null - && error.stage().ordinal() >= OpenStage.COMPONENT_OPENED.ordinal(); - if (decoderFailed) { - AudioPlaybackDiagnostics.FailureReason failureReason = error.prepared() - ? AudioPlaybackDiagnostics.FailureReason.DECODER_RUNTIME - : AudioPlaybackDiagnostics.FailureReason.DECODER_INIT; - return new AudioPlaybackDiagnostics.Snapshot(track, track, decodeMode, - decoderName, outputMode, 0, 0, false, "", - AudioPlaybackDiagnostics.lastAttemptLevel( - failureReason, outputMode, decodeMode), - AudioPlaybackDiagnostics.RuntimeState.FAILED, failureReason); - } - return new AudioPlaybackDiagnostics.Snapshot(track, track, decodeMode, - decoderName, outputMode, 0, 0, false, ""); - } - @Override public RuntimeMetrics getRuntimeMetrics() { long tcpBytesPerSecond = player.getTcpSpeedSnapshot(); diff --git a/app/src/main/java/com/fongmi/android/tv/player/engine/IjkSimplePlayer.java b/app/src/main/java/com/fongmi/android/tv/player/engine/IjkSimplePlayer.java index a223e42195..884385b59f 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/engine/IjkSimplePlayer.java +++ b/app/src/main/java/com/fongmi/android/tv/player/engine/IjkSimplePlayer.java @@ -130,20 +130,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener private boolean newlyRenderedFirstFrame; private boolean renderedFirstFrameSeen; private float volume; - private final com.fongmi.android.tv.player.PlaybackDiagnosticCollector diagnostics = - new com.fongmi.android.tv.player.PlaybackDiagnosticCollector("ijk", "packaged-IjkMediaPlayer"); - private String diagnosticTrace = "none"; - private long lastDiagnosticMs; - - void setDiagnosticTrace(String trace) { diagnosticTrace = trace; } - - private void diagnosticEvent(String stage, int what, int extra, boolean error) { - diagnostics.emit(diagnostics.context(), "ijk.event", "IMediaPlayer.Listener", "ijk-instance; callback-media-unconfirmed", e -> e - .severity(error ? "error" : "info").observed("stage", stage).observed("value", what).observed("errorCode", extra) - .observed("state", playbackState).observed("playWhenReady", playWhenReady) - .unknown("physicalVideo", com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.NOT_OBSERVABLE) - .unknown("audibility", com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.NOT_OBSERVABLE)); - } IjkSimplePlayer(int decode) { super(Looper.getMainLooper()); @@ -454,20 +440,17 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener else ijk.pause(); } if (!playWhenReady) requestPreload(Math.max(0, position())); - diagnosticEvent("play-when-ready", playWhenReady ? 1 : 0, 0, false); return Futures.immediateVoidFuture(); } @Override protected ListenableFuture handleStop() { - diagnostics.end("stop-request"); stopInternal(true); return Futures.immediateVoidFuture(); } @Override protected ListenableFuture handleRelease() { - diagnostics.end("release-request"); if (currentDash) { releaseDashSafely(); return Futures.immediateVoidFuture(); @@ -564,7 +547,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener @Override public void onPrepared(IMediaPlayer mp) { - diagnosticEvent("prepared; not physical output", 0, 0, false); prepared = true; advanceOpenStage(IjkPlayerEngine.OpenStage.PREPARED); playbackState = Player.STATE_READY; @@ -586,7 +568,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener @Override public void onCompletion(IMediaPlayer mp) { - diagnostics.end("completion"); setPendingSeek(C.TIME_UNSET); playbackState = Player.STATE_ENDED; loading = false; @@ -597,7 +578,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener @Override public boolean onError(IMediaPlayer mp, int what, int extra) { - diagnosticEvent(openStage.label(), what, extra, true); lastErrorSnapshot = new IjkPlayerEngine.ErrorSnapshot( what, extra, @@ -639,10 +619,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener @Override public void onInfo(IMediaPlayer mp, int what, int extra) { - diagnosticEvent(what == IMediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START ? "video-rendering-start" - : what == IMediaPlayer.MEDIA_INFO_AUDIO_RENDERING_START ? "audio-rendering-start" : "info", what, extra, false); - if (what == IMediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) diagnostics.evidence(diagnostics.context(), true, 3); - if (what == IMediaPlayer.MEDIA_INFO_AUDIO_RENDERING_START) diagnostics.evidence(diagnostics.context(), false, 3); if (what == IMediaPlayer.MEDIA_INFO_OPEN_INPUT) { advanceOpenStage(IjkPlayerEngine.OpenStage.INPUT_OPENED); } else if (what == IMediaPlayer.MEDIA_INFO_FIND_STREAM_INFO) { @@ -703,9 +679,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener lastNativeOffset = offset; } if (SpiderDebug.isEnabled()) { - diagnostics.emit("input.open", "ijk-native-invoke", e -> e.observed("value", event) - .observed("stage", openStage.label()).observed("httpCode", httpStatus > 0 ? httpStatus : null) - .observed("errorCode", nativeError).observed("rangeStart", offset < 0 ? null : offset)); SpiderDebug.log("ijk", "native event=%d stage=%s http=%d error=%d offset=%d retry=%d", event, @@ -732,7 +705,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener @Override public void onVideoSizeChanged(IMediaPlayer mp, int width, int height, int sarNum, int sarDen) { - diagnostics.emit("video.output.format", "ijk-video-size", e -> e.observed("width", width).observed("height", height)); videoSize = new VideoSize(width, height); refreshTracks(); invalidateState(); @@ -744,8 +716,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener private void openCurrent() { if (mediaItem == null || mediaItem.localConfiguration == null) return; - diagnostics.begin(diagnosticTrace, "foreground"); - lastDiagnosticMs = 0; try { playbackState = Player.STATE_BUFFERING; loading = true; @@ -810,7 +780,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener invalidateState(); startStateRefresh(); } catch (Throwable e) { - diagnostics.error(diagnostics.context(), "ijk", "open:" + openStage.label(), e); playerError = new PlaybackException( "IJK open failed", e, @@ -884,7 +853,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener updateCurrentCues(position()); requestPreload(Math.max(0, position())); invalidateState(); - diagnosticSnapshot(); startStateRefresh(); } @@ -922,32 +890,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener return true; } - private void diagnosticSnapshot() { - if (!com.fongmi.android.tv.player.PlaybackDiagnosticCollector.enabled()) return; - long now = SystemClock.elapsedRealtime(); - if (now - lastDiagnosticMs < 5000) return; - lastDiagnosticMs = now; - diagnostics.baseline(); - diagnostics.emit("ijk.runtime", "ijk-public-properties", e -> { - e.observed("state", playbackState).observed("positionMs", position()).observed("durationMs", duration() == C.TIME_UNSET ? null : duration()) - .observed("playWhenReady", playWhenReady).observed("volume", volume).observed("speed", playbackParameters.speed) - .observed("videoSelected", prepared ? selectedVideoFormat != null : null) - .observed("audioSelected", prepared ? selectedAudioFormat != null : null) - .unknown("audioOutput", com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.NOT_OBSERVABLE) - .observed("nativeHook", "native-hook-required: IJK AudioTrack/OpenSL write/head/actual route"); - if (prepared) e.observed("decoderName", ijk.getVideoCodecInfo()).observed("decode", ijk.getVideoDecoder()); - }); - if (prepared) { - diagnostics.emit("video.output.summary", "ijk-public-properties", e -> e - .observed("frameRate", ijk.getVideoOutputFramesPerSecond()).observed("metricScope", "IJK video-output-fps; not display refresh") - .observed("bytes", ijk.getVideoCachedBytes())); - diagnostics.emit("audio.decoder.output", "ijk-public-properties", e -> e.observed("decoderName", ijk.getAudioCodecInfo()) - .observed("bytes", ijk.getAudioCachedBytes()).observed("metricScope", "cached input bytes; not accepted output") - .unknown("audibility", com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.NOT_OBSERVABLE)); - } - com.fongmi.android.tv.player.SystemAudioDiagnosticCollector.snapshot(diagnostics, diagnostics.context()); - } - private void requestPreload(long positionMs) { if (playWhenReady) hlsProxy.preloadAround(positionMs); else hlsProxy.preloadWhilePaused(positionMs); @@ -1339,17 +1281,6 @@ class IjkSimplePlayer extends SimpleBasePlayer implements IMediaPlayer.Listener else if (type == C.TRACK_TYPE_TEXT) selectedText = true; } Format format = buildFormat(info, type, ++index); - if (SpiderDebug.isEnabled()) { - boolean actualSelected = type == C.TRACK_TYPE_VIDEO ? info.getStreamIndex() == selectedVideoStream - : type == C.TRACK_TYPE_AUDIO && info.getStreamIndex() == selectedAudioStream; - diagnostics.emit("media.tracks", "ijk-track-info", e -> e.observed("trackId", info.getStreamIndex()) - .observed("trackType", type).observed("selected", type == C.TRACK_TYPE_TEXT ? null : actualSelected) - .observed("mime", format.sampleMimeType).observed("codecs", format.codecs) - .observed("sampleRate", format.sampleRate > 0 ? format.sampleRate : null) - .observed("channels", format.channelCount > 0 ? format.channelCount : null)); - if (type == C.TRACK_TYPE_VIDEO) diagnostics.evidence(diagnostics.context(), true, 0); - if (type == C.TRACK_TYPE_AUDIO) diagnostics.evidence(diagnostics.context(), false, 0); - } if (type == C.TRACK_TYPE_VIDEO && info.getStreamIndex() == selectedVideoStream) actualVideo = format; if (type == C.TRACK_TYPE_AUDIO && info.getStreamIndex() == selectedAudioStream) actualAudio = format; TrackGroup group = new TrackGroup("ijk:" + type + ":" + index, format); diff --git a/app/src/main/java/com/fongmi/android/tv/player/engine/MpvAudioCapabilities.java b/app/src/main/java/com/fongmi/android/tv/player/engine/MpvAudioCapabilities.java index b7f2eb0bc3..56dc37ac68 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/engine/MpvAudioCapabilities.java +++ b/app/src/main/java/com/fongmi/android/tv/player/engine/MpvAudioCapabilities.java @@ -40,63 +40,15 @@ final class MpvAudioCapabilities { for (String codec : advertised) { if (supportsMpvCarrier(codec)) carrierCodecs.add(codec); } - boolean passthroughRoute = hasPassthroughOutputDevice(manager); - addCompressedCodecsIfRouted( - carrierCodecs, getAudioCompressedCodecs(appContext), passthroughRoute); String value = String.join(",", carrierCodecs); if (SpiderDebug.isEnabled()) { SpiderDebug.log("mpv-audio", "spdif codecs=%s media3=%s devices=%s carrier=%s route=%s", value, media3Codecs, describeDevices(manager), carrierCodecs, - passthroughRoute); + hasPassthroughOutputDevice(manager)); } return value; } - static void addCompressedCodecsIfRouted(Set target, Set compressed, - boolean passthroughRoute) { - if (passthroughRoute && target != null && compressed != null) { - target.addAll(compressed); - } - } - - static Set getAudioCompressedCodecs(Context context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return Set.of(); - Context appContext = context.getApplicationContext(); - AudioManager manager = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE); - AudioAttributes attributes = new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) - .build(); - Set codecs = new LinkedHashSet<>(); - if (supportsDirect(manager, attributes, AudioFormat.ENCODING_AAC_LC, - 48000, AudioFormat.CHANNEL_OUT_STEREO)) codecs.add("aac"); - if (supportsDirect(manager, attributes, AudioFormat.ENCODING_MP3, - 44100, AudioFormat.CHANNEL_OUT_STEREO)) codecs.add("mp3"); - if (SpiderDebug.isEnabled()) { - SpiderDebug.log("mpv-audio", "compressed direct codecs=%s", codecs); - } - return codecs; - } - - private static boolean supportsDirect(AudioManager manager, - AudioAttributes attributes, int encoding, - int sampleRate, int channelMask) { - try { - AudioFormat format = new AudioFormat.Builder() - .setEncoding(encoding) - .setSampleRate(sampleRate) - .setChannelMask(channelMask) - .build(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && manager != null) { - return manager.getDirectPlaybackSupport(format, attributes) - != AudioManager.DIRECT_PLAYBACK_NOT_SUPPORTED; - } - return AudioTrack.isDirectPlaybackSupported(format, attributes); - } catch (Throwable ignored) { - return false; - } - } - static String getAudioSpdifCodecs(IntPredicate supportsEncoding) { Set codecs = new LinkedHashSet<>(); if (supportsEncoding.test(C.ENCODING_AC3)) codecs.add("ac3"); diff --git a/app/src/main/java/com/fongmi/android/tv/player/engine/MpvPlayerEngine.java b/app/src/main/java/com/fongmi/android/tv/player/engine/MpvPlayerEngine.java index 6748b2260d..035e57921a 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/engine/MpvPlayerEngine.java +++ b/app/src/main/java/com/fongmi/android/tv/player/engine/MpvPlayerEngine.java @@ -1,7 +1,5 @@ package com.fongmi.android.tv.player.engine; -import android.text.TextUtils; - import androidx.media3.common.MimeTypes; import androidx.media3.common.C; import androidx.media3.common.Format; @@ -20,7 +18,6 @@ import androidx.annotation.Nullable; import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Track; -import com.fongmi.android.tv.player.AudioPlaybackDiagnostics; import com.fongmi.android.tv.player.PlayerHelper; import com.fongmi.android.tv.player.PlaybackRoute; import com.fongmi.android.tv.player.PlaybackResourceClassifier; @@ -55,11 +52,6 @@ public class MpvPlayerEngine implements PlayerEngine { static final String DV7_PRESERVE = "preserve"; static final String DV7_P81 = "p81"; static final String DV7_HDR10 = "hdr10"; - static final String DV7_FEL = "fel"; - static final String DV8_PRESERVE = "preserve"; - static final String DV8_HDR10 = "hdr10"; - static final String HWDEC_SOFTWARE_FALLBACK_OPTION = "hwdec-software-fallback"; - static final String HWDEC_SOFTWARE_FALLBACK_DISABLED = "no"; private MpvPlayer player; private PlaySpec spec; @@ -75,9 +67,7 @@ public class MpvPlayerEngine implements PlayerEngine { private String hwdecOverride; private String configuredHwdec = "no"; private String dv7HandlingOption; - private String dv8HandlingOption = DV8_PRESERVE; private boolean dv7P81FallbackTried; - private boolean dv7FelOutput; private boolean initialSubtitleSurfaceRequested; private String initialSubtitleTrackId; private final BiConsumer videoSizeProbeListener; @@ -88,7 +78,6 @@ public class MpvPlayerEngine implements PlayerEngine { this.lutAllowed = lutAllowed; this.videoSizeProbeListener = videoSizeProbeListener; resetDv7HandlingForNewItem(); - resetDv8HandlingForNewItem(); this.player = buildPlayer(listener); } @@ -157,12 +146,6 @@ public class MpvPlayerEngine implements PlayerEngine { return decode == HARD; } - static String hardwareDecodeSoftwareFallbackOption(int decode) { - // mpv also consults this option when hwdec=no. Explicit software mode - // must be allowed to open its decoder; hardware mode keeps its policy. - return decode == HARD ? HWDEC_SOFTWARE_FALLBACK_DISABLED : "yes"; - } - @Override public String getDecodeText() { return ResUtil.getStringArray(R.array.select_decode)[decode]; @@ -324,28 +307,13 @@ public class MpvPlayerEngine implements PlayerEngine { @Override public boolean supportsNativeLut() { - return !isSurfaceDirect(); + return !surfaceDirect; } public boolean isSurfaceDirect() { - if (player.isAndroidFelActive()) return false; - String observed = player.getObservedCurrentVideoOutput(); - if (!observed.isEmpty()) return "mediacodec_embed".equals(observed); return surfaceDirect; } - public boolean isConfiguredSurfaceDirect() { - return surfaceDirect; - } - - public boolean hasSubmittedVideoFrame() { - return player.hasSubmittedVideoFrame(); - } - - public boolean isNativeOutputSelectionReady() { - return player.isNativeOutputSelectionReady(); - } - public void setSurfaceDirectOverride(@Nullable Boolean value) { surfaceDirectOverride = value; } @@ -366,12 +334,11 @@ public class MpvPlayerEngine implements PlayerEngine { } public boolean isVulkanRenderer() { - if (player.isAndroidFelActive()) return player.isObservedVulkanRenderer(); return vulkanRenderer; } public boolean shouldFallbackVulkanToStable() { - return isVulkanRenderer() && MpvVulkanBackendPolicy.isAutomaticConfig() + return vulkanRenderer && MpvVulkanBackendPolicy.isAutomaticConfig() && !MpvVulkanBackendPolicy.STABLE.equals(vulkanBackend); } @@ -430,10 +397,6 @@ public class MpvPlayerEngine implements PlayerEngine { player.clearAutoHlsBitrate(); } - public boolean sendScriptMessage(String message, String... args) { - return player.sendScriptMessage(message, args); - } - /** Cached track/proxy HLS state; this method never performs a native query. */ public MpvPlayer.AutoHlsRuntimeSnapshot getAutoHlsRuntimeSnapshot() { return player.getAutoHlsRuntimeSnapshot(); @@ -497,55 +460,32 @@ public class MpvPlayerEngine implements PlayerEngine { } } String currentVo = player.getObservedCurrentVideoOutput(); - int reportedProfile = details.dolbyVisionProfile() > 0 - ? details.dolbyVisionProfile() : details.sourceDolbyVisionProfile(); - int reportedLevel = details.dolbyVisionLevel() > 0 - ? details.dolbyVisionLevel() : details.sourceDolbyVisionLevel(); boolean fallbackConfigured = isConfiguredDv7Hdr10Fallback( - details, isHard(), isDv7Hdr10Active()) - || isConfiguredDv8Hdr10Fallback(details, isHard(), isDv8Hdr10Active()); + details, isHard(), isDv7Hdr10Active()); return new VideoPlaybackDetails( details.sourceCodecs(), - reportedProfile, - reportedLevel, + details.dolbyVisionProfile(), + details.dolbyVisionLevel(), details.decodedCodec(), details.decoderName(), player.getObservedHwdecCurrent(), details.outputColorInfo(), - isDolbyVisionHdr10Fallback(details, currentVo) && !isDv7FelOutputEnabled() + isDolbyVisionHdr10Fallback(details, currentVo) || fallbackConfigured, details.sourceDolbyVisionProfile() == 7 && isDv7P81Active()); } - @Override - public AudioPlaybackDiagnostics.Snapshot getAudioPlaybackDiagnostics() { - return player.getAudioPlaybackDiagnostics(); - } - static boolean isConfiguredDv7Hdr10Fallback( MpvPlayer.VideoTrackDiagnostics details, boolean hardDecode, boolean fallbackEnabled) { - return details != null && sourceDolbyVisionProfile(details) == 7 + return details != null && details.dolbyVisionProfile() == 7 && hardDecode && fallbackEnabled; } - static boolean isConfiguredDv8Hdr10Fallback( - MpvPlayer.VideoTrackDiagnostics details, - boolean hardDecode, - boolean fallbackEnabled) { - return details != null && sourceDolbyVisionProfile(details) == 8 - && hardDecode && fallbackEnabled; - } - - private static int sourceDolbyVisionProfile(MpvPlayer.VideoTrackDiagnostics details) { - return details.sourceDolbyVisionProfile() > 0 - ? details.sourceDolbyVisionProfile() : details.dolbyVisionProfile(); - } - static boolean isDolbyVisionHdr10Fallback( MpvPlayer.VideoTrackDiagnostics details, String currentVo) { - if (details == null || sourceDolbyVisionProfile(details) != 7 + if (details == null || details.dolbyVisionProfile() != 7 || currentVo == null) return false; String output = currentVo.trim().toLowerCase(java.util.Locale.US); return output.equals("gpu") || output.startsWith("gpu-next"); @@ -553,46 +493,21 @@ public class MpvPlayerEngine implements PlayerEngine { public boolean resetDv7HandlingForNewItem() { String previous = dv7HandlingOption; - dv7FelOutput = false; - int preferredMode = PlaybackPerformanceSetting.getMpvDv7HandlingMode(); MpvAutoOutputPolicy.DolbyVisionSupport nativeDv7 = - preferredMode == PlaybackPerformanceSetting.DV7_HANDLING_FEL - ? MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN - : CodecCapabilityInspector.dolbyVisionProfileSupport( + CodecCapabilityInspector.dolbyVisionProfileSupport( App.get(), 7, 6, null, null, 0, 0); MpvAutoOutputPolicy.DolbyVisionSupport profile81 = - preferredMode == PlaybackPerformanceSetting.DV7_HANDLING_P81 + PlaybackPerformanceSetting.getMpvDv7HandlingMode() + == PlaybackPerformanceSetting.DV7_HANDLING_P81 ? CodecCapabilityInspector.dolbyVisionProfileSupport( App.get(), 8, 6, null, null, 0, 0) : MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN; - dv7HandlingOption = selectDv7Handling(nativeDv7, profile81, preferredMode); + dv7HandlingOption = selectDv7Handling(nativeDv7, profile81, + PlaybackPerformanceSetting.getMpvDv7HandlingMode()); dv7P81FallbackTried = false; return !dv7HandlingOption.equals(previous); } - public boolean resetDv8HandlingForNewItem() { - String previous = dv8HandlingOption; - dv8HandlingOption = DV8_PRESERVE; - return !dv8HandlingOption.equals(previous); - } - - public boolean updateDv8Handling( - MpvAutoOutputPolicy.DolbyVisionSupport nativeDv8, - MpvAutoOutputPolicy.DolbyVisionSupport hevcHdr10) { - String selected = selectDv8Handling(nativeDv8, hevcHdr10); - if (selected.equals(dv8HandlingOption)) return false; - dv8HandlingOption = selected; - return true; - } - - static String selectDv8Handling( - MpvAutoOutputPolicy.DolbyVisionSupport nativeDv8, - MpvAutoOutputPolicy.DolbyVisionSupport hevcHdr10) { - return nativeDv8 == MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED - && hevcHdr10 == MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED - ? DV8_HDR10 : DV8_PRESERVE; - } - public boolean updateDv7Handling( MpvAutoOutputPolicy.DolbyVisionSupport nativeDv7, MpvAutoOutputPolicy.DolbyVisionSupport profile81) { @@ -608,9 +523,6 @@ public class MpvPlayerEngine implements PlayerEngine { MpvAutoOutputPolicy.DolbyVisionSupport nativeDv7, MpvAutoOutputPolicy.DolbyVisionSupport profile81, int preferredMode) { - if (preferredMode == PlaybackPerformanceSetting.DV7_HANDLING_FEL) { - return DV7_FEL; - } if (nativeDv7 == MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED) { return DV7_PRESERVE; } @@ -642,35 +554,6 @@ public class MpvPlayerEngine implements PlayerEngine { return DV7_HDR10.equals(dv7HandlingOption); } - public boolean isDv7FelRequested() { - return DV7_FEL.equals(dv7HandlingOption); - } - - public boolean isDv7FelOutputEnabled() { - return player.isAndroidFelActive(); - } - - /** Native selects the actual track before VO/decoder creation. Never guess - * from an available track or rebuild the player after this observation. */ - public boolean updateDv7FelOutputForCurrentItem() { - boolean enabled = player.isAndroidFelActive(); - if (dv7FelOutput == enabled) return false; - dv7FelOutput = enabled; - return true; - } - - static String dv7DemuxerOption(String handling) { - return DV7_FEL.equals(handling) ? DV7_PRESERVE : handling; - } - - public boolean isDv8Hdr10Active() { - return DV8_HDR10.equals(dv8HandlingOption); - } - - public String getDv8HandlingOption() { - return dv8HandlingOption; - } - public String getDv7HandlingOption() { return dv7HandlingOption; } @@ -714,7 +597,7 @@ public class MpvPlayerEngine implements PlayerEngine { @Override public boolean supportsSecondarySubtitle() { - return !isSurfaceDirect(); + return !surfaceDirect; } @Override @@ -985,29 +868,21 @@ public class MpvPlayerEngine implements PlayerEngine { private MpvPlayerConfig buildConfig() { MpvConfigStore.ensureReady(); - MpvConfigStore.ensureCustomButtonScript(); boolean zeroCopyBlocked = MpvPerformanceSetting.isZeroCopyBlocked(); boolean autoDirectEligible = !zeroCopyBlocked && MpvAutoOutputPolicy.canStartSurfaceDirect( decode == HARD, Util.isLeanback(), MpvPerformanceSetting.isInterpolation() || lutAllowed && LutSetting.isEnabled(), MpvConfigStore.hasGpuVideoProcessing()); - surfaceDirect = (surfaceDirectOverride == null + surfaceDirect = surfaceDirectOverride == null ? MpvPerformanceSetting.shouldUseSurfaceDirect(autoDirectEligible, Util.isLeanback(), decode == HARD) - : surfaceDirectOverride && decode == HARD && !zeroCopyBlocked); + : surfaceDirectOverride && decode == HARD && !zeroCopyBlocked; boolean requestVulkan = vulkanRenderOverride != null ? vulkanRenderOverride : PlayerSetting.getMpvRender() == PlayerSetting.MPV_RENDER_VULKAN; boolean nativeVulkan = MPVLib.isBundledVulkanEnabled(App.get()); boolean deviceVulkan = MPVLib.isDeviceVulkan13Capable(App.get()); boolean useVulkan = !surfaceDirect && requestVulkan && nativeVulkan && deviceVulkan; - boolean felVulkan = nativeVulkan && deviceVulkan && (requestVulkan - || vulkanRenderOverride == null && decode == HARD - && PlaybackPerformanceSetting.isAuto( - PlayerSetting.MPV, PlaybackPerformanceCatalog.MPV_RENDER)); - boolean felAutoVulkan = vulkanRenderOverride == null && decode == HARD - && PlaybackPerformanceSetting.isAuto( - PlayerSetting.MPV, PlaybackPerformanceCatalog.MPV_RENDER); vulkanRenderer = useVulkan; String configuredBackend = MpvVulkanBackendPolicy.configuredBackend(); String appBackendOverride = MpvVulkanBackendPolicy.appOverride(); @@ -1022,15 +897,12 @@ public class MpvPlayerEngine implements PlayerEngine { if (requestVulkan && !surfaceDirect && !useVulkan) SpiderDebug.log("player-engine", "mpv render requested=vulkan but unavailable native=%s device=%s; fallback=opengl", nativeVulkan, deviceVulkan); String hwdec = surfaceDirect ? "mediacodec" : resolveGpuHwdec(zeroCopyBlocked); configuredHwdec = hwdec; - SpiderDebug.log("player-engine", "mpv output mode=%s direct=%s zeroCopyBlocked=%s hwdec=%s render requested=%s nativeVulkan=%s deviceVulkan=%s decode=%s actual=%s/%s fel=%s", MpvPerformanceSetting.getOutputModeText(), surfaceDirect, zeroCopyBlocked, hwdec, requestVulkan ? "vulkan" : "opengl", nativeVulkan, deviceVulkan, decode == HARD ? "hard" : "soft", surfaceDirect ? "surface" : useVulkan ? "vulkan" : "opengl", surfaceDirect ? "mediacodec_embed" : useGpuNext ? "gpu-next" : "gpu", dv7FelOutput); + SpiderDebug.log("player-engine", "mpv output mode=%s direct=%s zeroCopyBlocked=%s hwdec=%s render requested=%s nativeVulkan=%s deviceVulkan=%s decode=%s actual=%s/%s", MpvPerformanceSetting.getOutputModeText(), surfaceDirect, zeroCopyBlocked, hwdec, requestVulkan ? "vulkan" : "opengl", nativeVulkan, deviceVulkan, decode == HARD ? "hard" : "soft", surfaceDirect ? "surface" : useVulkan ? "vulkan" : "opengl", surfaceDirect ? "mediacodec_embed" : useGpuNext ? "gpu-next" : "gpu"); MpvPlayerConfig.Builder builder = MpvPlayerConfig.builder(App.get()) .configDir(MpvConfigStore.configDir()) .hwdec(hwdec) - .option(HWDEC_SOFTWARE_FALLBACK_OPTION, - hardwareDecodeSoftwareFallbackOption(decode)) .audioSpdif(resolveAudioSpdifCodecs()) - .multichannelPcm(MpvPerformanceSetting.isMultichannelPcm()) - .logLevel("all=warn") // Diagnostic verbosity follows DebugLogStore; mpv.conf remains a separate user baseline. + .logLevel(MpvPerformanceSetting.isVerboseLog() ? "all=v" : "all=warn") .demuxerMaxBytes(getDemuxerMaxBytes()) .demuxerMaxBackBytes(getDemuxerMaxBackBytes()) .cacheSeconds(getCacheTargetSeconds()) @@ -1045,25 +917,17 @@ public class MpvPlayerEngine implements PlayerEngine { PlayerSetting.MPV, PlaybackPerformanceCatalog.MPV_HLS_BITRATE)) .deferStartupTrackRefresh(DV7_P81.equals(getDv7HandlingOption())) - .restoreFelAutomaticSubtitles(isDv7FelRequested() - && TextUtils.isEmpty(MpvConfigStore.getOptionValue("sid"))) .option("framedrop", MpvPerformanceSetting.getFrameDropOption()) .option("video-sync", MpvPerformanceSetting.getSyncOption()) .option("interpolation", MpvPerformanceSetting.isInterpolation() ? "yes" : "no") .option("hls-bitrate", MpvPerformanceSetting.getHlsBitrateOption()) .option("demuxer-dovi-profile7", - dv7DemuxerOption(getDv7HandlingOption())) - .option("android-dovi-fel", isDv7FelRequested() ? "yes" : "no") - .option("android-dovi-fel-vulkan", felVulkan ? felAutoVulkan ? "auto" : "yes" : "no") - .option("demuxer-dovi-profile8", - getDv8HandlingOption()); - if ((useVulkan || isDv7FelRequested() && felVulkan) && !appBackendOverride.isEmpty()) { + getDv7HandlingOption()); + if (useVulkan && !appBackendOverride.isEmpty()) { builder.option(MpvVulkanBackendPolicy.OPTION, appBackendOverride); - } else if ((useVulkan || isDv7FelRequested() && felVulkan) && automaticBackend && !automaticOverride.isEmpty()) { + } else if (useVulkan && automaticBackend && !automaticOverride.isEmpty()) { builder.option(MpvVulkanBackendPolicy.OPTION, automaticOverride); } - // Native enforces faithful decoding only on the selected FEL chain. - // Keep the ordinary content preferences intact in a reused player. applySoftDecodeOptions(builder); if (surfaceDirect) { builder.vo("mediacodec_embed") @@ -1079,7 +943,6 @@ public class MpvPlayerEngine implements PlayerEngine { // The legacy gpu renderer restores the original pre-Dolby-Vision // color representation. Software-decoded Profile 5 frames need // gpu-next/libplacebo to apply their per-frame DOVI mapping. - // FEL additionally needs its enhancement-layer upload/NLQ path. builder.vo("gpu-next") .gpuContext("android") .gpuApi("opengl") @@ -1114,9 +977,8 @@ public class MpvPlayerEngine implements PlayerEngine { private String resolveAudioSpdifCodecs() { boolean enabled = PlayerSetting.isAudioPassThrough(PlayerSetting.MPV); - String codecs = enabled ? MpvAudioCapabilities.getAudioSpdifCodecs(App.get()) - : String.join(",", MpvAudioCapabilities.getAudioCompressedCodecs(App.get())); - SpiderDebug.log("mpv-audio", "configured passthrough=%s codecs=%s", + String codecs = enabled ? MpvAudioCapabilities.getAudioSpdifCodecs(App.get()) : ""; + SpiderDebug.log("mpv-audio", "configured enabled=%s codecs=%s", enabled, codecs.isEmpty() ? "pcm" : codecs); return codecs; } diff --git a/app/src/main/java/com/fongmi/android/tv/player/engine/PlaySpec.java b/app/src/main/java/com/fongmi/android/tv/player/engine/PlaySpec.java index 442700bd1a..76e2a465af 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/engine/PlaySpec.java +++ b/app/src/main/java/com/fongmi/android/tv/player/engine/PlaySpec.java @@ -207,6 +207,5 @@ public class PlaySpec { public void addDanmaku(Danmaku item) { if (danmakus == null) danmakus = new ArrayList<>(); if (!item.isEmpty() && !danmakus.contains(item)) danmakus.add(item); - danmakus.forEach(danmaku -> danmaku.setSelected(danmaku.getUrl().equals(item.getUrl()))); } } diff --git a/app/src/main/java/com/fongmi/android/tv/player/engine/PlayerEngine.java b/app/src/main/java/com/fongmi/android/tv/player/engine/PlayerEngine.java index 3dfafb9bdc..9e6fd51eaa 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/engine/PlayerEngine.java +++ b/app/src/main/java/com/fongmi/android/tv/player/engine/PlayerEngine.java @@ -11,7 +11,6 @@ import androidx.media3.common.Player; import androidx.media3.common.Tracks; import com.fongmi.android.tv.bean.Track; -import com.fongmi.android.tv.player.AudioPlaybackDiagnostics; import com.fongmi.android.tv.player.PlaybackRoute; import com.fongmi.android.tv.player.PlaybackResourceClassifier; import com.fongmi.android.tv.player.PlaybackTrace; @@ -53,34 +52,6 @@ public interface PlayerEngine { start(spec, position, playWhenReady); } - /** - * Whether this engine can append one already-resolved VOD item without replacing playback. - * Native and system engines remain unsupported unless they implement the full contract. - */ - default boolean supportsPlaylistQueue() { - return false; - } - - /** Appends one prepared source to the current playlist without starting or preparing it. */ - default boolean appendPlaylistItem(PlaySpec spec, String mediaId) { - return false; - } - - /** Removes only media items after the currently playing item. */ - default boolean removePlaylistItemsAfterCurrent() { - return false; - } - - /** Configures bounded playlist preloading; unsupported engines keep the old path. */ - default boolean setPlaylistPreloadDurationMs(long durationMs) { - return false; - } - - /** Commits business/diagnostic context after a natural transition to a queued item. */ - default boolean commitPlaylistTransition(PlaySpec spec) { - return false; - } - default void stop() { getPlayer().stop(); } @@ -168,11 +139,6 @@ default void resetTrack(int type) { return VideoPlaybackDetails.empty(); } - /** Actual runtime audio decode and AudioTrack/AO output facts. */ - default AudioPlaybackDiagnostics.Snapshot getAudioPlaybackDiagnostics() { - return AudioPlaybackDiagnostics.Snapshot.empty(); - } - default long getDroppedFrames() { return 0; } @@ -201,17 +167,6 @@ default void resetTrack(int type) { return false; } - default boolean isPrimarySubtitleSelected(Format format) { - if (format == null || isSecondarySubtitleSelected(format)) return false; - for (Tracks.Group group : getCurrentTracks().getGroups()) { - if (group.getType() != C.TRACK_TYPE_TEXT) continue; - for (int i = 0; i < group.length; i++) { - if (group.isTrackSelected(i) && format.equals(group.getTrackFormat(i))) return true; - } - } - return false; - } - default boolean isSecondarySubtitleSelected(Format format) { return false; } diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ApeExtractor.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ApeExtractor.java deleted file mode 100644 index 4327c205dd..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ApeExtractor.java +++ /dev/null @@ -1,505 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import androidx.annotation.NonNull; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.util.ParsableByteArray; -import androidx.media3.extractor.Extractor; -import androidx.media3.extractor.ExtractorInput; -import androidx.media3.extractor.ExtractorOutput; -import androidx.media3.extractor.PositionHolder; -import androidx.media3.extractor.SeekMap; -import androidx.media3.extractor.SeekPoint; -import androidx.media3.extractor.TrackOutput; - -import java.io.EOFException; -import java.io.IOException; -import java.util.Collections; - -/** Extracts Monkey's Audio frames in the packet layout expected by FFmpeg's APE decoder. */ -final class ApeExtractor implements Extractor { - - static final String MIME_TYPE = "audio/ape"; - - private static final int MAGIC = 0x2043414d; // "MAC " in little-endian form. - private static final int MIN_VERSION = 3800; - private static final int MAX_VERSION = 3990; - private static final int NEW_DESCRIPTOR_VERSION = 3980; - private static final int NEW_DESCRIPTOR_SIZE = 52; - private static final int NEW_HEADER_SIZE = 24; - private static final int OLD_HEADER_SIZE = 32; - private static final int APE_EXTRADATA_SIZE = 6; - private static final int PACKET_PREFIX_SIZE = 8; - private static final int FLAG_8_BIT = 1; - private static final int FLAG_HAS_PEAK_LEVEL = 4; - private static final int FLAG_24_BIT = 8; - private static final int FLAG_HAS_SEEK_ELEMENTS = 16; - private static final int FLAG_CREATE_WAV_HEADER = 32; - private static final int MAX_FRAME_COUNT = 1_000_000; - private static final long MAX_METADATA_BYTES = 64L * 1024 * 1024; - private static final long MAX_FRAME_SIZE = 64L * 1024 * 1024; - - private final byte[] sniffBuffer = new byte[6]; - private final byte[] integerBuffer = new byte[8]; - - private ExtractorOutput extractorOutput; - private TrackOutput trackOutput; - private Header header; - private int currentFrame; - - @Override - public boolean sniff(ExtractorInput input) throws IOException { - boolean available = input.peekFully(sniffBuffer, 0, sniffBuffer.length, true); - input.resetPeekPosition(); - if (!available) return false; - return readIntLittleEndian(sniffBuffer, 0) == MAGIC - && readUnsignedShortLittleEndian(sniffBuffer, 4) >= MIN_VERSION - && readUnsignedShortLittleEndian(sniffBuffer, 4) <= MAX_VERSION; - } - - @Override - public void init(ExtractorOutput output) { - extractorOutput = output; - trackOutput = output.track(0, C.TRACK_TYPE_AUDIO); - output.endTracks(); - } - - @Override - public @ReadResult int read( - @NonNull ExtractorInput input, @NonNull PositionHolder seekPosition) throws IOException { - if (header == null) parseHeader(input); - if (currentFrame >= header.totalFrames) return RESULT_END_OF_INPUT; - - long framePosition = header.framePositions[currentFrame]; - if (input.getPosition() != framePosition) { - seekPosition.position = framePosition; - return RESULT_SEEK; - } - - int frameSize = header.frameSizes[currentFrame]; - int packetSize = checkedPacketSize(frameSize); - byte[] packet = new byte[packetSize]; - writeLittleEndianInt(packet, 0, header.frameBlocks(currentFrame)); - writeLittleEndianInt(packet, 4, header.frameSkips[currentFrame]); - input.readFully(packet, PACKET_PREFIX_SIZE, frameSize); - trackOutput.sampleData(new ParsableByteArray(packet), packet.length); - trackOutput.sampleMetadata( - header.frameTimesUs[currentFrame], C.BUFFER_FLAG_KEY_FRAME, packet.length, 0, null); - currentFrame++; - return RESULT_CONTINUE; - } - - @Override - public void seek(long position, long timeUs) { - if (header == null) { - currentFrame = 0; - return; - } - currentFrame = header.frameIndexForPosition(position); - } - - @Override - public void release() { - extractorOutput = null; - trackOutput = null; - header = null; - currentFrame = 0; - } - - private void parseHeader(ExtractorInput input) throws IOException { - long startPosition = input.getPosition(); - byte[] base = new byte[NEW_DESCRIPTOR_SIZE]; - input.readFully(base, 0, OLD_HEADER_SIZE); - int magic = readIntLittleEndian(base, 0); - int version = readUnsignedShortLittleEndian(base, 4); - if (magic != MAGIC || version < MIN_VERSION || version > MAX_VERSION) { - throw new IOException("Invalid APE header"); - } - - long descriptorLength; - long headerLength; - long seekTableLength; - long wavHeaderLength; - long wavTailLength; - int compressionType; - int formatFlags; - long blocksPerFrame; - long finalFrameBlocks; - long totalFramesLong; - int bitsPerSample; - int channels; - long sampleRateLong; - boolean legacy = version < NEW_DESCRIPTOR_VERSION; - - if (!legacy) { - input.readFully(base, OLD_HEADER_SIZE, NEW_DESCRIPTOR_SIZE - OLD_HEADER_SIZE); - descriptorLength = readUnsignedIntLittleEndian(base, 8); - headerLength = readUnsignedIntLittleEndian(base, 12); - seekTableLength = readUnsignedIntLittleEndian(base, 16); - wavHeaderLength = readUnsignedIntLittleEndian(base, 20); - wavTailLength = readUnsignedIntLittleEndian(base, 32); - if (descriptorLength < NEW_DESCRIPTOR_SIZE || descriptorLength > MAX_METADATA_BYTES - || headerLength < NEW_HEADER_SIZE || headerLength > MAX_METADATA_BYTES - || seekTableLength > MAX_METADATA_BYTES) { - throw new IOException("Invalid APE descriptor lengths"); - } - skipFully(input, descriptorLength - NEW_DESCRIPTOR_SIZE); - byte[] audioHeader = new byte[NEW_HEADER_SIZE]; - input.readFully(audioHeader, 0, audioHeader.length); - compressionType = readUnsignedShortLittleEndian(audioHeader, 0); - formatFlags = readUnsignedShortLittleEndian(audioHeader, 2); - blocksPerFrame = readUnsignedIntLittleEndian(audioHeader, 4); - finalFrameBlocks = readUnsignedIntLittleEndian(audioHeader, 8); - totalFramesLong = readUnsignedIntLittleEndian(audioHeader, 12); - bitsPerSample = readUnsignedShortLittleEndian(audioHeader, 16); - channels = readUnsignedShortLittleEndian(audioHeader, 18); - sampleRateLong = readUnsignedIntLittleEndian(audioHeader, 20); - skipFully(input, headerLength - NEW_HEADER_SIZE); - } else { - descriptorLength = 0; - headerLength = OLD_HEADER_SIZE; - compressionType = readUnsignedShortLittleEndian(base, 6); - formatFlags = readUnsignedShortLittleEndian(base, 8); - channels = readUnsignedShortLittleEndian(base, 10); - sampleRateLong = readUnsignedIntLittleEndian(base, 12); - wavHeaderLength = readUnsignedIntLittleEndian(base, 16); - wavTailLength = readUnsignedIntLittleEndian(base, 20); - totalFramesLong = readUnsignedIntLittleEndian(base, 24); - finalFrameBlocks = readUnsignedIntLittleEndian(base, 28); - if ((formatFlags & FLAG_HAS_PEAK_LEVEL) != 0) { - readUnsignedInt(input); - headerLength += 4; - } - long seekElements = totalFramesLong; - if ((formatFlags & FLAG_HAS_SEEK_ELEMENTS) != 0) { - seekElements = readUnsignedInt(input); - headerLength += 4; - } - seekTableLength = checkedMultiply(seekElements, 4, "seek table length"); - bitsPerSample = (formatFlags & FLAG_8_BIT) != 0 - ? 8 : (formatFlags & FLAG_24_BIT) != 0 ? 24 : 16; - if (version >= 3950) { - blocksPerFrame = 73728L * 4; - } else if (version >= 3900 || (version >= 3800 && compressionType >= 4000)) { - blocksPerFrame = 73728; - } else { - blocksPerFrame = 9216; - } - if ((formatFlags & FLAG_CREATE_WAV_HEADER) == 0) { - skipFully(input, wavHeaderLength); - } else if (wavHeaderLength != 0) { - throw new IOException("APE generated WAV header has non-zero stored length"); - } - } - - if (totalFramesLong <= 0 || totalFramesLong > MAX_FRAME_COUNT - || blocksPerFrame <= 0 || blocksPerFrame > Integer.MAX_VALUE - || finalFrameBlocks <= 0 || finalFrameBlocks > Integer.MAX_VALUE - || sampleRateLong <= 0 || sampleRateLong > Integer.MAX_VALUE - || channels <= 0 || channels > 2 - || (bitsPerSample != 8 && bitsPerSample != 16 && bitsPerSample != 24) - || seekTableLength < checkedMultiply(totalFramesLong, 4, "seek table minimum")) { - throw new IOException("Unsupported or invalid APE stream parameters"); - } - - int totalFrames = (int) totalFramesLong; - long[] rawPositions = new long[totalFrames]; - long firstFrame = checkedAdd( - startPosition, - checkedAdd( - checkedAdd(descriptorLength, headerLength, "APE header"), - checkedAdd( - checkedAdd(seekTableLength, wavHeaderLength, "APE data offset"), - legacy && version < 3810 ? totalFramesLong : 0, - "APE legacy data offset"), - "APE data offset"), - "APE data offset"); - for (int i = 0; i < totalFrames; i++) { - long entry = readUnsignedInt(input); - if (i == 0) { - rawPositions[i] = firstFrame; - } else { - rawPositions[i] = entry + startPosition; - if (rawPositions[i] <= rawPositions[i - 1]) { - throw new IOException("APE seek table is not increasing"); - } - } - } - long extraSeekEntries = (seekTableLength / 4) - totalFrames; - skipFully(input, checkedMultiply(extraSeekEntries, 4, "APE extra seek entries")); - if (!legacy) skipFully(input, wavHeaderLength); - - int[] legacyBits = null; - if (legacy && version < 3810) { - legacyBits = new int[totalFrames]; - for (int i = 0; i < totalFrames; i++) legacyBits[i] = readUnsignedByte(input); - } - - long[] rawSizes = new long[totalFrames]; - for (int i = 0; i + 1 < totalFrames; i++) { - rawSizes[i] = rawPositions[i + 1] - rawPositions[i]; - } - long fileLength = input.getLength(); - long finalSize = fileLength > 0 - ? fileLength - rawPositions[totalFrames - 1] - wavTailLength - : -1; - if (finalSize <= 0) finalSize = finalFrameBlocks * 8L; - finalSize -= finalSize & 3; - rawSizes[totalFrames - 1] = finalSize; - - long[] framePositions = new long[totalFrames]; - int[] frameSizes = new int[totalFrames]; - int[] frameSkips = new int[totalFrames]; - long[] frameTimesUs = new long[totalFrames]; - long totalBlocks = 0; - long maxFrameSize = 0; - for (int i = 0; i < totalFrames; i++) { - long skip = (rawPositions[i] - firstFrame) & 3; - long position = rawPositions[i] - skip; - long size = checkedAdd(rawSizes[i], skip, "APE frame size"); - size = (size + 3) & ~3L; - if (legacyBits != null) { - if (i > 0 && legacyBits[i] != 0) { - size = checkedAdd(size, 4, "APE legacy frame size"); - } - skip = checkedAdd(skip * 8, legacyBits[i], "APE legacy bit offset"); - } - if (size <= 0 || size > MAX_FRAME_SIZE || size > Integer.MAX_VALUE - PACKET_PREFIX_SIZE) { - throw new IOException("APE frame exceeds supported size"); - } - framePositions[i] = position; - frameSizes[i] = (int) size; - frameSkips[i] = (int) skip; - frameTimesUs[i] = toTimeUs(totalBlocks, (int) sampleRateLong); - totalBlocks = checkedAdd( - totalBlocks, - i + 1 == totalFrames ? finalFrameBlocks : blocksPerFrame, - "APE sample count"); - maxFrameSize = Math.max(maxFrameSize, size); - } - long durationUs = toTimeUs(totalBlocks, (int) sampleRateLong); - byte[] extraData = new byte[APE_EXTRADATA_SIZE]; - writeLittleEndianShort(extraData, 0, version); - writeLittleEndianShort(extraData, 2, compressionType); - writeLittleEndianShort(extraData, 4, formatFlags); - header = new Header( - version, - compressionType, - formatFlags, - (int) blocksPerFrame, - (int) finalFrameBlocks, - totalFrames, - bitsPerSample, - channels, - (int) sampleRateLong, - durationUs, - framePositions, - frameSizes, - frameSkips, - frameTimesUs, - extraData, - (int) Math.min(Integer.MAX_VALUE, maxFrameSize + PACKET_PREFIX_SIZE)); - trackOutput.format( - new Format.Builder() - .setContainerMimeType(MIME_TYPE) - .setSampleMimeType(MIME_TYPE) - .setChannelCount(channels) - .setSampleRate((int) sampleRateLong) - .setAverageBitrate( - (int) Math.min(Integer.MAX_VALUE, (fileLength > 0 - ? fileLength * 8L * sampleRateLong / Math.max(1, totalBlocks) - : 0))) - .setMaxInputSize(header.maxInputSize) - .setInitializationData(Collections.singletonList(extraData)) - .build()); - trackOutput.durationUs(durationUs); - extractorOutput.seekMap(new ApeSeekMap(durationUs, framePositions, frameTimesUs)); - } - - private int readUnsignedByte(ExtractorInput input) throws IOException { - int read = input.read(integerBuffer, 0, 1); - if (read != 1) throw new EOFException("Truncated APE header"); - return integerBuffer[0] & 0xFF; - } - - private long readUnsignedInt(ExtractorInput input) throws IOException { - input.readFully(integerBuffer, 0, 4); - return readUnsignedIntLittleEndian(integerBuffer, 0); - } - - private static int checkedPacketSize(int frameSize) throws IOException { - if (frameSize < 0 || frameSize > Integer.MAX_VALUE - PACKET_PREFIX_SIZE) { - throw new IOException("Invalid APE packet size"); - } - return frameSize + PACKET_PREFIX_SIZE; - } - - private static void skipFully(ExtractorInput input, long length) throws IOException { - while (length > 0) { - int chunk = (int) Math.min(length, Integer.MAX_VALUE); - input.skipFully(chunk); - length -= chunk; - } - } - - private static long checkedAdd(long left, long right, String what) throws IOException { - if (right < 0 || left > Long.MAX_VALUE - right) throw new IOException("Overflow in " + what); - return left + right; - } - - private static long checkedMultiply(long left, long right, String what) throws IOException { - if (left < 0 || right < 0 || (left != 0 && right > Long.MAX_VALUE / left)) { - throw new IOException("Overflow in " + what); - } - return left * right; - } - - private static long toTimeUs(long blocks, int sampleRate) throws IOException { - if (blocks < 0 || blocks > Long.MAX_VALUE / C.MICROS_PER_SECOND) { - throw new IOException("APE duration overflow"); - } - return blocks * C.MICROS_PER_SECOND / sampleRate; - } - - private static int readIntLittleEndian(byte[] data, int offset) { - return (data[offset] & 0xFF) - | ((data[offset + 1] & 0xFF) << 8) - | ((data[offset + 2] & 0xFF) << 16) - | ((data[offset + 3] & 0xFF) << 24); - } - - private static long readUnsignedIntLittleEndian(byte[] data, int offset) { - return ((long) data[offset] & 0xFF) - | (((long) data[offset + 1] & 0xFF) << 8) - | (((long) data[offset + 2] & 0xFF) << 16) - | (((long) data[offset + 3] & 0xFF) << 24); - } - - private static int readUnsignedShortLittleEndian(byte[] data, int offset) { - return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8); - } - - private static void writeLittleEndianInt(byte[] data, int offset, long value) { - data[offset] = (byte) value; - data[offset + 1] = (byte) (value >>> 8); - data[offset + 2] = (byte) (value >>> 16); - data[offset + 3] = (byte) (value >>> 24); - } - - private static void writeLittleEndianShort(byte[] data, int offset, int value) { - data[offset] = (byte) value; - data[offset + 1] = (byte) (value >>> 8); - } - - static final class Header { - final int fileVersion; - final int compressionType; - final int formatFlags; - final int blocksPerFrame; - final int finalFrameBlocks; - final int totalFrames; - final int bitsPerSample; - final int channels; - final int sampleRate; - final long durationUs; - final long[] framePositions; - final int[] frameSizes; - final int[] frameSkips; - final long[] frameTimesUs; - final byte[] extraData; - final int maxInputSize; - - Header( - int fileVersion, - int compressionType, - int formatFlags, - int blocksPerFrame, - int finalFrameBlocks, - int totalFrames, - int bitsPerSample, - int channels, - int sampleRate, - long durationUs, - long[] framePositions, - int[] frameSizes, - int[] frameSkips, - long[] frameTimesUs, - byte[] extraData, - int maxInputSize) { - this.fileVersion = fileVersion; - this.compressionType = compressionType; - this.formatFlags = formatFlags; - this.blocksPerFrame = blocksPerFrame; - this.finalFrameBlocks = finalFrameBlocks; - this.totalFrames = totalFrames; - this.bitsPerSample = bitsPerSample; - this.channels = channels; - this.sampleRate = sampleRate; - this.durationUs = durationUs; - this.framePositions = framePositions; - this.frameSizes = frameSizes; - this.frameSkips = frameSkips; - this.frameTimesUs = frameTimesUs; - this.extraData = extraData; - this.maxInputSize = maxInputSize; - } - - long frameBlocks(int index) { - return index + 1 == totalFrames ? finalFrameBlocks : blocksPerFrame; - } - - int frameIndexForPosition(long position) { - int low = 0; - int high = framePositions.length - 1; - while (low < high) { - int middle = (low + high + 1) >>> 1; - if (framePositions[middle] <= position) low = middle; - else high = middle - 1; - } - return low; - } - } - - private static final class ApeSeekMap implements SeekMap { - private final long durationUs; - private final long[] positions; - private final long[] timesUs; - - ApeSeekMap(long durationUs, long[] positions, long[] timesUs) { - this.durationUs = durationUs; - this.positions = positions; - this.timesUs = timesUs; - } - - @Override - public boolean isSeekable() { - return positions.length > 0; - } - - @Override - public long getDurationUs() { - return durationUs; - } - - @Override - public SeekPoints getSeekPoints(long timeUs) { - int index = 0; - int low = 0; - int high = timesUs.length - 1; - while (low <= high) { - int middle = (low + high) >>> 1; - if (timesUs[middle] <= timeUs) { - index = middle; - low = middle + 1; - } else { - high = middle - 1; - } - } - SeekPoint first = new SeekPoint(timesUs[index], positions[index]); - if (index == timesUs.length - 1 || timesUs[index] == timeUs) { - return new SeekPoints(first); - } - return new SeekPoints(first, new SeekPoint(timesUs[index + 1], positions[index + 1])); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/DolbyVisionP81ExtractorsFactory.java b/app/src/main/java/com/fongmi/android/tv/player/exo/DolbyVisionP81ExtractorsFactory.java index 5f28054048..e311bcb1e2 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/DolbyVisionP81ExtractorsFactory.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/DolbyVisionP81ExtractorsFactory.java @@ -25,8 +25,6 @@ import androidx.media3.extractor.mkv.MatroskaExtractor; import androidx.media3.extractor.text.DefaultSubtitleParserFactory; import com.fongmi.android.tv.App; -import com.fongmi.android.tv.player.exo.ass.AssFontMatroskaExtractor; -import com.fongmi.android.tv.player.exo.ass.AssFontSet; import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; import com.github.catvod.crawler.SpiderDebug; import com.suyashbelekar.exoplayerhdrutils.libdovi.FrameInfo; @@ -49,6 +47,8 @@ import java.util.Map; final class DolbyVisionP81ExtractorsFactory implements ExtractorsFactory { private static final int TRANSFORM_GROWTH_BYTES = 10 * 1024; + static final int INITIAL_BUFFER_BYTES = 64 * 1024; + static final int INITIAL_SCRATCH_BYTES = 16 * 1024; private static final TransformStrategy P81_STRATEGY = new TransformStrategy( DoviStrategy.CONVERT_TO_P8, DoviStrategy.CONVERT_TO_P8, @@ -61,7 +61,6 @@ final class DolbyVisionP81ExtractorsFactory implements ExtractorsFactory { private final ExtractorsFactory delegate; @Nullable private final ExoDolbyVisionPlaybackState playbackState; - @Nullable private final AssFontSet assFonts; DolbyVisionP81ExtractorsFactory(ExtractorsFactory delegate) { this(delegate, null); @@ -70,14 +69,8 @@ final class DolbyVisionP81ExtractorsFactory implements ExtractorsFactory { DolbyVisionP81ExtractorsFactory( ExtractorsFactory delegate, @Nullable ExoDolbyVisionPlaybackState playbackState) { - this(delegate, playbackState, null); - } - - DolbyVisionP81ExtractorsFactory(ExtractorsFactory delegate, - @Nullable ExoDolbyVisionPlaybackState playbackState, @Nullable AssFontSet assFonts) { this.delegate = delegate; this.playbackState = playbackState; - this.assFonts = assFonts; } @Override @@ -88,7 +81,9 @@ final class DolbyVisionP81ExtractorsFactory implements ExtractorsFactory { @Override public Extractor[] createExtractors( Uri uri, Map> responseHeaders) { - return wrap(delegate.createExtractors(uri, responseHeaders), isRemoteUri(uri)); + return wrap( + delegate.createExtractors(uri, responseHeaders), + isRemoteUri(uri) && PlaybackPerformanceSetting.isDeferredCuesEnabled()); } private Extractor[] wrap(Extractor[] extractors, boolean deferSeekForCues) { @@ -97,12 +92,10 @@ final class DolbyVisionP81ExtractorsFactory implements ExtractorsFactory { for (int i = 0; i < extractors.length; i++) { Extractor extractor = extractors[i]; if (extractor instanceof MatroskaExtractor - && (deferSeekForCues || dv7P81Enabled || assFonts != null)) { + && (deferSeekForCues || dv7P81Enabled)) { int flags = MatroskaExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA | (deferSeekForCues ? MatroskaExtractor.FLAG_DEFER_SEEK_FOR_CUES : 0); - extractor = assFonts != null - ? new AssFontMatroskaExtractor(flags, dv7P81Enabled, assFonts) - : new MatroskaExtractor( + extractor = new MatroskaExtractor( new DefaultSubtitleParserFactory(), flags, dv7P81Enabled); @@ -369,9 +362,15 @@ final class DolbyVisionP81ExtractorsFactory implements ExtractorsFactory { private final ParsableByteArray outputData = new ParsableByteArray(); @Nullable private final ExoDolbyVisionPlaybackState playbackState; - private ByteBuffer pending = ByteBuffer.allocateDirect(1024 * 1024); - private byte[] inputScratch = new byte[16 * 1024]; - private byte[] outputScratch = new byte[1024 * 1024]; + // Sized small on purpose. One of these is constructed for every video track of every + // media item, DV7 or not, so a megabyte each was paid unconditionally by sources that + // never convert anything. ensureCapacity doubles on demand, so a converting session + // still reaches whatever it needs after a few one-time growths, while a non-DV session + // never grows them at all. Not lazily null: both ensureCapacity overloads dereference + // their argument, so null would add an NPE surface at every call site for no gain. + private ByteBuffer pending = ByteBuffer.allocateDirect(INITIAL_BUFFER_BYTES); + private byte[] inputScratch = new byte[INITIAL_SCRATCH_BYTES]; + private byte[] outputScratch = new byte[INITIAL_BUFFER_BYTES]; @Nullable private HevcFrameTransformer transformer; @Nullable private LibDovi validator; @Nullable private Format sourceFormat; diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAudioCodecSelector.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAudioCodecSelector.java deleted file mode 100644 index 56c3b38fe6..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAudioCodecSelector.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import androidx.media3.common.MimeTypes; -import androidx.media3.exoplayer.mediacodec.MediaCodecInfo; -import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; -import androidx.media3.exoplayer.mediacodec.MediaCodecUtil; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -final class ExoAudioCodecSelector implements MediaCodecSelector { - - private static final ExoAudioCodecSelector DEFAULT = - new ExoAudioCodecSelector(MediaCodecSelector.DEFAULT); - - private final MediaCodecSelector delegate; - private final Map> cache = new HashMap<>(); - - ExoAudioCodecSelector(MediaCodecSelector delegate) { - this.delegate = Objects.requireNonNull(delegate); - } - - static MediaCodecSelector hardwareFirst(MediaCodecSelector delegate) { - if (delegate instanceof ExoAudioCodecSelector) return delegate; - return delegate == MediaCodecSelector.DEFAULT - ? DEFAULT - : new ExoAudioCodecSelector(delegate); - } - - @Override - public List getDecoderInfos( - String mimeType, - boolean requiresSecureDecoder, - boolean requiresTunnelingDecoder) - throws MediaCodecUtil.DecoderQueryException { - if (!isAudio(mimeType)) { - return delegate.getDecoderInfos( - mimeType, requiresSecureDecoder, requiresTunnelingDecoder); - } - Query query = new Query( - mimeType, requiresSecureDecoder, requiresTunnelingDecoder); - synchronized (cache) { - List cached = cache.get(query); - if (cached != null) return cached; - List ordered = requiresFfmpeg(mimeType) - ? List.of() - : orderHardwareFirst(delegate.getDecoderInfos( - mimeType, - requiresSecureDecoder, - requiresTunnelingDecoder)); - cache.put(query, ordered); - return ordered; - } - } - - static boolean requiresFfmpeg(String mimeType) { - // The target vendor ALAC codec can report READY without emitting PCM. Keep ALAC on the - // bundled FFmpeg renderer so decoder progress and AudioTrack initialization are coupled. - return MimeTypes.AUDIO_ALAC.equals(mimeType); - } - - static List orderHardwareFirst(List infos) { - if (infos.size() < 2) return List.copyOf(infos); - List ordered = new ArrayList<>(infos); - ordered.sort(Comparator.comparingInt(ExoAudioCodecSelector::priority)); - return List.copyOf(ordered); - } - - private static int priority(MediaCodecInfo info) { - if (info.hardwareAccelerated && !info.softwareOnly) return 0; - if (!info.softwareOnly) return 1; - return 2; - } - - private static boolean isAudio(String mimeType) { - return mimeType != null && mimeType.startsWith("audio/"); - } - - private record Query( - String mimeType, - boolean requiresSecureDecoder, - boolean requiresTunnelingDecoder) { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAudioDirectFailureMemory.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAudioDirectFailureMemory.java deleted file mode 100644 index 0cf04313c8..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAudioDirectFailureMemory.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import androidx.media3.common.AudioAttributes; -import androidx.media3.common.Format; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.Map; - -/** Short-lived, process-only evidence that one exact direct path recovered with PCM. */ -final class ExoAudioDirectFailureMemory { - - static final int MAX_ENTRIES = 32; - static final long TTL_MS = 10 * 60_000; - private static final ExoAudioDirectFailureMemory PROCESS = new ExoAudioDirectFailureMemory(); - - // No player, Context, AudioTrack, URL, header, or codec initialization bytes are retained. - record Route(int id, int type, int encodings, int sampleRates, int channelMasks) {} - record Key(String media, String format, AudioAttributes attributes, Route route) {} - - private final LinkedHashMap confirmed = new LinkedHashMap<>(); - private long generation; - - static ExoAudioDirectFailureMemory process() { - return PROCESS; - } - - synchronized long generation() { - return generation; - } - - synchronized boolean hasMedia(String media, long nowMs) { - expire(nowMs); - if (media == null) return false; - for (Key key : confirmed.keySet()) { - if (key.media.equals(media)) return true; - } - return false; - } - - synchronized boolean contains(Key key, long nowMs) { - expire(nowMs); - return key != null && confirmed.containsKey(key); - } - - synchronized boolean confirm(Key key, long expectedGeneration, long nowMs) { - if (key == null || key.media == null || key.route == null - || expectedGeneration != generation) return false; - expire(nowMs); - confirmed.remove(key); - confirmed.put(key, nowMs); - while (confirmed.size() > MAX_ENTRIES) { - confirmed.remove(confirmed.keySet().iterator().next()); - } - return true; - } - - synchronized void invalidate() { - confirmed.clear(); - generation++; - } - - private void expire(long nowMs) { - Iterator> entries = confirmed.entrySet().iterator(); - while (entries.hasNext()) { - long confirmedAtMs = entries.next().getValue(); - if (nowMs < confirmedAtMs || nowMs - confirmedAtMs >= TTL_MS) entries.remove(); - } - } - - static String mediaId(String url) { - if (url == null || url.isEmpty()) return null; - MessageDigest digest = newDigest(); - update(digest, url); - return hex(digest.digest()); - } - - static String formatId(Format format) { - if (format == null) return null; - MessageDigest digest = newDigest(); - update(digest, format.id); - update(digest, format.sampleMimeType); - update(digest, format.codecs); - update(digest, format.sampleRate); - update(digest, format.channelCount); - update(digest, format.initializationData.size()); - for (byte[] data : format.initializationData) { - update(digest, data.length); - digest.update(data); - } - return hex(digest.digest()); - } - - private static MessageDigest newDigest() { - try { - return MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException impossible) { - throw new AssertionError(impossible); - } - } - - private static void update(MessageDigest digest, String value) { - if (value == null) { - update(digest, -1); - } else { - byte[] bytes = value.getBytes(StandardCharsets.UTF_8); - update(digest, bytes.length); - digest.update(bytes); - } - } - - private static void update(MessageDigest digest, int value) { - digest.update((byte) (value >>> 24)); - digest.update((byte) (value >>> 16)); - digest.update((byte) (value >>> 8)); - digest.update((byte) value); - } - - private static String hex(byte[] bytes) { - char[] chars = new char[bytes.length * 2]; - char[] digits = "0123456789abcdef".toCharArray(); - for (int i = 0; i < bytes.length; i++) { - chars[i * 2] = digits[(bytes[i] & 0xff) >>> 4]; - chars[i * 2 + 1] = digits[bytes[i] & 15]; - } - return new String(chars); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAudioOutputState.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAudioOutputState.java deleted file mode 100644 index f8f2ff2ea9..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAudioOutputState.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import androidx.media3.common.C; -import androidx.media3.exoplayer.audio.AudioOutput; -import androidx.media3.exoplayer.audio.AudioOutputProvider; -import androidx.media3.exoplayer.audio.ForwardingAudioOutput; - -import java.util.concurrent.atomic.AtomicReference; - -/** The actual output owned by one player, independent of delayed analytics callbacks. */ -public final class ExoAudioOutputState { - - private final AtomicReference current = - new AtomicReference<>(Snapshot.empty()); - - AudioOutput track(AudioOutput output, AudioOutputProvider.OutputConfig config) { - // A fresh snapshot is also the output's identity, even for identical configurations. - Snapshot owned = new Snapshot(config.encoding, config.sampleRate, - Integer.bitCount(config.channelMask), config.isTunneling, - config.isOffload, true); - AudioOutput tracked = new ForwardingAudioOutput(output) { - @Override - public void release() { - // Invalidate on release request. The platform's completion callback may arrive - // after another AudioTrack has already initialized, or after a player rebuild. - current.compareAndSet(owned, Snapshot.empty()); - super.release(); - } - }; - current.set(owned); - return tracked; - } - - public Snapshot snapshot() { - return current.get(); - } - - public record Snapshot(int encoding, int sampleRate, int channels, - boolean tunneling, boolean offload, boolean initialized) { - private static final Snapshot EMPTY = - new Snapshot(C.ENCODING_INVALID, 0, 0, false, false, false); - - public static Snapshot empty() { - return EMPTY; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAutomaticVideoConstraintPolicy.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAutomaticVideoConstraintPolicy.java index 4f621f4258..a742e7496d 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAutomaticVideoConstraintPolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoAutomaticVideoConstraintPolicy.java @@ -322,7 +322,10 @@ final class ExoAutomaticVideoConstraintPolicy { enum Fault { NONE("none"), DROPPED_FRAMES("dropped-frames"), - CODEC_ERROR("codec-error"); + CODEC_ERROR("codec-error"), + // 起播固定选约束内最高画质后,原生 ABR 不再提供吞吐保护,带宽不足只能表现为持续重缓冲。 + // 解码器本身没有压力,掉帧和编解码错误都不会触发,因此需要独立的吞吐不足证据来降档。 + THROUGHPUT("throughput"); private final String label; diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicy.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicy.java deleted file mode 100644 index f4f968e062..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicy.java +++ /dev/null @@ -1,1176 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import android.content.Context; -import android.media.AudioDeviceCallback; -import android.media.AudioDeviceInfo; -import android.media.AudioFormat; -import android.media.AudioManager; -import android.media.AudioTrack; -import android.os.Build; - -import androidx.media3.common.AudioAttributes; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.PlaybackException; -import androidx.media3.common.util.Clock; -import androidx.media3.common.util.StuckPlayerException; -import androidx.media3.common.util.Util; -import androidx.media3.exoplayer.audio.AudioOffloadSupport; -import androidx.media3.exoplayer.audio.AudioOutput; -import androidx.media3.exoplayer.audio.AudioOutputProvider; -import androidx.media3.exoplayer.audio.AudioTrackAudioOutput; -import androidx.media3.exoplayer.audio.DefaultAudioOffloadSupportProvider; -import androidx.media3.exoplayer.audio.DefaultAudioSink; -import androidx.media3.exoplayer.audio.ForwardingAudioOutput; -import androidx.media3.exoplayer.audio.ForwardingAudioOutputProvider; -import androidx.media3.extractor.AacUtil; -import androidx.media3.extractor.MpegAudioUtil; - -import com.github.catvod.crawler.SpiderDebug; - -import java.nio.ByteBuffer; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -public final class ExoCompressedAudioDirectPolicy - implements DefaultAudioSink.AudioOffloadSupportProvider { - - private static final int VENDOR_DIRECT_BUFFER_SIZE = 256 * 1024; - private static final long OUTPUT_STALL_CONFIRMATION_MS = 2_000; - private static final long PCM_CONFIRMATION_MS = 2_000; - static final long STARTUP_STALL_MS = 800; - private static final long STARTUP_AUDIO_US = 200_000; - private static AudioDeviceCallback processDeviceCallback; - - interface DirectPlaybackSupport { - boolean isSupported(Format format, AudioAttributes audioAttributes); - } - - interface VendorDirectOutputFactory { - AudioOutput create(AudioOutputProvider.OutputConfig config) - throws AudioOutputProvider.InitializationException; - } - - interface OutputEnvironment { - ExoAudioDirectFailureMemory.Route expectedRoute(AudioAttributes attributes); - ExoAudioDirectFailureMemory.Route actualRoute(AudioOutput output); - - default StartupBuffer startupBuffer(AudioOutput output) { - return StartupBuffer.UNKNOWN; - } - - default StartupBuffer setStartThresholdBytes(AudioOutput output, int bytes) { - return StartupBuffer.UNKNOWN; - } - - default long rawPlaybackHead(AudioOutput output) { - return C.LENGTH_UNSET; - } - } - - record StartupBuffer(int capacityBytes, int sizeBytes, int thresholdBytes) { - static final StartupBuffer UNKNOWN = new StartupBuffer(0, 0, 0); - - int effectiveThresholdBytes() { - return capacityBytes > 0 && sizeBytes > 0 && sizeBytes <= capacityBytes - && thresholdBytes > 0 && thresholdBytes <= capacityBytes - ? Math.min(sizeBytes, thresholdBytes) : 0; - } - } - - private static final OutputEnvironment UNKNOWN_ENVIRONMENT = new OutputEnvironment() { - @Override public ExoAudioDirectFailureMemory.Route expectedRoute(AudioAttributes attributes) { - return null; - } - @Override public ExoAudioDirectFailureMemory.Route actualRoute(AudioOutput output) { - return null; - } - }; - - private final DefaultAudioSink.AudioOffloadSupportProvider standardProvider; - private final DirectPlaybackSupport directPlaybackSupport; - private final Clock clock; - private final VendorDirectOutputFactory vendorDirectOutputFactory; - private final Map vendorDirectConfigs; - private final Set failedVendorDirectConfigs; - private final ExoAudioDirectFailureMemory failureMemory; - private final OutputEnvironment environment; - private volatile boolean audioPassthroughEnabled = true; - private final AtomicReference pendingPcmFallback = new AtomicReference<>(); - private final AtomicBoolean initializationFailureNotified = new AtomicBoolean(); - private final AtomicReference initializationFailureListener = - new AtomicReference<>(); - private final ExoAudioOutputState audioOutputState = new ExoAudioOutputState(); - private final AtomicReference outputAttempt = - new AtomicReference<>(new OutputAttempt()); - - public ExoCompressedAudioDirectPolicy(Context context) { - this(new DefaultAudioOffloadSupportProvider(context.getApplicationContext()), - ExoCompressedAudioDirectPolicy::platformSupportsDirectPlayback, - Clock.DEFAULT, ExoCompressedAudioDirectPolicy::createVendorDirectAudioOutput, - ExoAudioDirectFailureMemory.process(), platformEnvironment(context)); - } - - ExoCompressedAudioDirectPolicy( - DefaultAudioSink.AudioOffloadSupportProvider standardProvider, - DirectPlaybackSupport directPlaybackSupport) { - this(standardProvider, directPlaybackSupport, Clock.DEFAULT, - ExoCompressedAudioDirectPolicy::createVendorDirectAudioOutput); - } - - ExoCompressedAudioDirectPolicy( - DefaultAudioSink.AudioOffloadSupportProvider standardProvider, - DirectPlaybackSupport directPlaybackSupport, - Clock clock, - VendorDirectOutputFactory vendorDirectOutputFactory) { - this(standardProvider, directPlaybackSupport, clock, vendorDirectOutputFactory, - new ExoAudioDirectFailureMemory(), UNKNOWN_ENVIRONMENT); - } - - ExoCompressedAudioDirectPolicy( - DefaultAudioSink.AudioOffloadSupportProvider standardProvider, - DirectPlaybackSupport directPlaybackSupport, - Clock clock, - VendorDirectOutputFactory vendorDirectOutputFactory, - ExoAudioDirectFailureMemory failureMemory, - OutputEnvironment environment) { - this.standardProvider = standardProvider; - this.directPlaybackSupport = directPlaybackSupport; - this.clock = clock; - this.vendorDirectOutputFactory = vendorDirectOutputFactory; - this.vendorDirectConfigs = new ConcurrentHashMap<>(); - this.failedVendorDirectConfigs = ConcurrentHashMap.newKeySet(); - this.failureMemory = failureMemory; - this.environment = environment; - } - - /** Configure before exposing the renderer's output provider to the playback thread. */ - void setAudioPassthroughEnabled(boolean enabled) { - audioPassthroughEnabled = enabled; - if (!enabled) vendorDirectConfigs.clear(); - } - - @Override - public AudioOffloadSupport getAudioOffloadSupport( - Format format, AudioAttributes audioAttributes) { - if (!audioPassthroughEnabled) return AudioOffloadSupport.DEFAULT_UNSUPPORTED; - AudioOffloadSupport standard = standardProvider.getAudioOffloadSupport( - format, audioAttributes); - if (standard.isFormatSupported) { - OutputKey key = OutputKey.from(format); - if (key != null) vendorDirectConfigs.remove(new DirectKey(key, - effectiveAttributes(audioAttributes))); - logDecision(format, key, "standard-offload"); - } - return standard; - } - - AudioOutputProvider wrapOutputProvider(AudioOutputProvider delegate) { - return wrapOutputProvider(delegate, null); - } - - AudioOutputProvider wrapOutputProvider(AudioOutputProvider delegate, ExoDiagnosticCollector diagnostics) { - return new ForwardingAudioOutputProvider(delegate) { - private boolean observingCapabilities; - private final Listener capabilityListener = () -> { - failureMemory.invalidate(); - vendorDirectConfigs.clear(); - OutputAttempt attempt = outputAttempt.get(); - attempt.cancelRecovery(); - }; - - @Override - public void addListener(Listener listener) { - if (!observingCapabilities) { - super.addListener(capabilityListener); - observingCapabilities = true; - } - super.addListener(listener); - } - - @Override - public void release() { - if (observingCapabilities) super.removeListener(capabilityListener); - super.release(); - } - - @Override - public AudioOutputProvider.FormatSupport getFormatSupport( - AudioOutputProvider.FormatConfig config) { - if (!audioPassthroughEnabled && !MimeTypes.AUDIO_RAW.equals(config.format.sampleMimeType)) { - return AudioOutputProvider.FormatSupport.UNSUPPORTED; - } - AudioOutputProvider.FormatSupport standard = - super.getFormatSupport(config); - OutputKey key = OutputKey.from(config.format); - AudioAttributes attributes = effectiveAttributes(config.audioAttributes); - DirectKey directKey = key == null ? null : new DirectKey(key, attributes); - // Tunneling is a shared audio/video contract. This vendor-only output cannot - // supply HW_AV_SYNC timestamps, so let Media3 choose a standard output/decoder. - if (config.enableTunneling || key == null || !supportsEncodedFrames(key.encoding())) { - if (directKey != null) vendorDirectConfigs.remove(directKey); - return standard; - } - - if (standard.isFormatSupportedForOffload) { - vendorDirectConfigs.remove(directKey); - return standard; - } - - // Do not let a failed vendor-direct configuration fall through to - // the delegate's generic passthrough/direct claim. Media3 must see - // it as unsupported on the next selection and choose decoder + PCM. - if (failedVendorDirectConfigs.contains(directKey) - || remembersFailure(config, attributes)) { - vendorDirectConfigs.remove(directKey); - logDecision(config.format, key, "vendor-direct-failed-force-pcm"); - return standard.isFormatSupportedForOffload - ? standard : AudioOutputProvider.FormatSupport.UNSUPPORTED; - } - - // A passthrough support level only means that the default provider can - // create an encoded AudioTrack. It does not prove that the device's - // non-standard direct path will initialize successfully. Preserve the - // standard path only for real Media3 offload support; otherwise probe - // and prefer the vendor-direct output when the platform advertises it. - OutputKey resolved = resolveVendorDirect(config.format, attributes); - if (resolved == null) return standard; - return standard.buildUpon() - .setFormatSupportLevel( - AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY) - .build(); - } - - @Override - public AudioOutputProvider.OutputConfig getOutputConfig( - AudioOutputProvider.FormatConfig config) - throws AudioOutputProvider.ConfigurationException { - if (!audioPassthroughEnabled && !MimeTypes.AUDIO_RAW.equals(config.format.sampleMimeType)) { - throw new AudioOutputProvider.ConfigurationException("Audio passthrough is disabled"); - } - OutputKey key = OutputKey.from(config.format); - AudioAttributes attributes = effectiveAttributes(config.audioAttributes); - if (config.enableTunneling || key == null - || !vendorDirectConfigs.containsKey(new DirectKey(key, attributes))) { - try { - return super.getOutputConfig(config); - } catch (RuntimeException error) { - // AudioTrackAudioOutputProvider currently lets an invalid - // channel mask escape as IllegalStateException from - // getMinBufferSize(). Convert it to the provider contract - // so Media3 reports a recoverable audio-track init error - // instead of ERROR_CODE_FAILED_RUNTIME_CHECK. - throw new AudioOutputProvider.ConfigurationException( - "AudioTrack output configuration failed: " - + error.getMessage()); - } - } - if (SpiderDebug.isEnabled()) { - SpiderDebug.log("exo-audio-direct", - "config encoding=%d sampleRate=%d channelMask=0x%X session=%d tunneling=%s virtualDevice=%d attrs=%d/%d/%d", - key.encoding(), key.sampleRate(), key.channelMask(), - config.audioSessionId, config.enableTunneling, - config.virtualDeviceId, attributes.contentType, - attributes.usage, attributes.flags); - } - return new AudioOutputProvider.OutputConfig.Builder() - .setEncoding(key.encoding()) - .setSampleRate(key.sampleRate()) - .setChannelMask(key.channelMask()) - .setBufferSize(VENDOR_DIRECT_BUFFER_SIZE) - // vivo's compressed output rejects non-zero effect sessions (status -38). - .setAudioSessionId(0) - .setAudioAttributes(attributes) - .setIsOffload(false) - // Compressed direct tracks cannot use the HW_AV_SYNC tunneling attributes - // on the target HAL; keep tunneling for the normal PCM/offload path. - .setIsTunneling(false) - .setUsePlaybackParameters( - config.enablePlaybackParameters) - .setUseOffloadGapless(false) - // The target HAL rejects compressed tracks when Media3 attaches a - // virtual-device Context, including the default device id 0. - .setVirtualDeviceId(C.INDEX_UNSET) - .build(); - } - - @Override - public AudioOutput getAudioOutput(AudioOutputProvider.OutputConfig config) - throws AudioOutputProvider.InitializationException { - // Recheck the final config as well: a prior encoded capability/config must not - // bypass a disabled setting. PCM still uses the normal output ownership wrapper. - if (!audioPassthroughEnabled && !Util.isEncodingLinearPcm(config.encoding)) { - throw new AudioOutputProvider.InitializationException(); - } - OutputAttempt attempt = outputAttempt.get(); - boolean vendorDirect = usesVendorDirect(config); - try { - AudioOutput raw = vendorDirect - ? vendorDirectOutputFactory.create(config) - : super.getAudioOutput(config); - AudioOutput output = ExoDiagnosticAudioOutput.wrap(raw, config, diagnostics); - VendorDirectAudioOutput directOutput = vendorDirect - ? new VendorDirectAudioOutput(output, raw, config, attempt) : null; - attempt.output.set(directOutput); - if (directOutput != null) { - output = directOutput; - } else if ((attempt.recovery != null || attempt.startupRecoveryUsed) - && Util.isEncodingLinearPcm(config.encoding) - && !config.isTunneling && !config.isOffload) { - output = new PcmRecoveryAudioOutput(output, raw, attempt); - } - attempt.currentOutput.set(output); - return audioOutputState.track(output, config); - } catch (AudioOutputProvider.InitializationException error) { - if (vendorDirect && outputAttempt.get() == attempt) { - synchronized (outputAttempt) { - if (outputAttempt.get() != attempt) throw error; - disableVendorDirect(config, "initialization"); - } - notifyInitializationFailure(); - } - throw error; - } - } - }; - } - - private static AudioOutput createVendorDirectAudioOutput( - AudioOutputProvider.OutputConfig config) - throws AudioOutputProvider.InitializationException { - AudioTrack audioTrack = null; - try { - AudioFormat format = new AudioFormat.Builder() - .setEncoding(config.encoding) - .setSampleRate(config.sampleRate) - .setChannelMask(config.channelMask) - .build(); - AudioTrack.Builder builder = new AudioTrack.Builder() - .setAudioAttributes(config.audioAttributes.getPlatformAudioAttributes()) - .setAudioFormat(format) - .setTransferMode(AudioTrack.MODE_STREAM) - .setBufferSizeInBytes(config.bufferSize) - .setSessionId(0); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - builder.setOffloadedPlayback(false); - } - audioTrack = builder.build(); - if (audioTrack.getState() != AudioTrack.STATE_INITIALIZED) { - audioTrack.release(); - throw new AudioOutputProvider.InitializationException(); - } - return new AudioTrackAudioOutput( - audioTrack, config, null, Clock.DEFAULT); - } catch (AudioOutputProvider.InitializationException error) { - throw error; - } catch (RuntimeException error) { - if (audioTrack != null) audioTrack.release(); - throw new AudioOutputProvider.InitializationException(error); - } - } - - void modifyAudioTrackBuilder( - AudioTrack.Builder builder, AudioOutputProvider.OutputConfig config) { - if (!usesVendorDirect(config)) return; - if (SpiderDebug.isEnabled()) { - SpiderDebug.log("exo-audio-direct", - "builder encoding=%d sampleRate=%d channelMask=0x%X directSession=0", - config.encoding, config.sampleRate, config.channelMask); - } - builder.setBufferSizeInBytes(VENDOR_DIRECT_BUFFER_SIZE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - builder.setOffloadedPlayback(false); - } - } - - boolean usesVendorDirect(int encoding, int sampleRate, int channelMask) { - if (!audioPassthroughEnabled) return false; - OutputKey output = new OutputKey(encoding, sampleRate, channelMask); - return vendorDirectConfigs.keySet().stream().anyMatch(key -> key.output.equals(output)); - } - - private boolean usesVendorDirect(AudioOutputProvider.OutputConfig config) { - // A capability query can cache this encoding while another standard output is being - // configured. The final output mode, not that cache, owns tunneling and offload. - return audioPassthroughEnabled && !config.isTunneling && !config.isOffload - && vendorDirectConfigs.containsKey(DirectKey.from(config)); - } - - public ExoAudioOutputState.Snapshot getAudioOutputSnapshot() { - return audioOutputState.snapshot(); - } - - public boolean consumePcmFallbackRequest() { - return pendingPcmFallback.getAndSet(null) != null; - } - - /** - * Registers a listener that can begin the PCM fallback without waiting for Media3's - * delayed audio initialization error. - */ - public void setInitializationFailureListener(Runnable listener) { - initializationFailureListener.set(listener); - } - - /** Forget retired output evidence without retrying a failed configuration. */ - public void resetOutputProgress() { - synchronized (outputAttempt) { - initializationFailureNotified.set(false); - outputAttempt.get().cancelRecovery(); - outputAttempt.set(new OutputAttempt()); - pendingPcmFallback.set(null); - } - } - - /** Only the explicit audio retry can carry failure evidence into the next preparation. */ - public void prepareForPlayback(String url, boolean pcmRetry) { - String media = ExoAudioDirectFailureMemory.mediaId(url); - synchronized (outputAttempt) { - initializationFailureNotified.set(false); - OutputAttempt previous = outputAttempt.get(); - Recovery recovery = pcmRetry && media != null && media.equals(previous.media) - ? previous.failure : null; - if (previous.recovery != null) previous.recovery.cancelled = true; - if (previous.failure != null && previous.failure != recovery) previous.failure.cancelled = true; - outputAttempt.set(new OutputAttempt(media, recovery)); - pendingPcmFallback.set(null); - } - } - - public void setSelectedAudioFormat(Format format) { - synchronized (outputAttempt) { - OutputAttempt attempt = outputAttempt.get(); - if (format == null) return; - attempt.selectedFormat = ExoAudioDirectFailureMemory.formatId(format); - if (attempt.recovery != null - && !attempt.recovery.key.format().equals(attempt.selectedFormat)) { - attempt.recovery.cancelled = true; - } - } - } - - /** Called only by the active audio renderer on its existing playback thread. */ - Format maybeRequestStartupPcmFallback(boolean rendererPlaying) { - OutputAttempt attempt = outputAttempt.get(); - VendorDirectAudioOutput output = attempt.output.get(); - if (output == null || attempt.startupRecoveryUsed) return null; - if (!rendererPlaying) { - output.startupWindowAtMs = C.TIME_UNSET; - return null; - } - if (!output.isStartupStalled()) return null; - synchronized (outputAttempt) { - if (outputAttempt.get() != attempt || attempt.currentOutput.get() != output - || attempt.startupRecoveryUsed || !output.playing || output.startupComplete - || !attempt.output.compareAndSet(output, null)) return null; - attempt.startupRecoveryUsed = true; - attempt.fallbackAtMs = clock.elapsedRealtime(); - rememberPendingFailure(output); - if (outputAttempt.get() != attempt || attempt.currentOutput.get() != output) return null; - attempt.recovery = attempt.failure; - disableVendorDirect(output.config, "startup-no-progress"); - output.logProgress("startup-fallback", attempt.fallbackAtMs); - return output.format; - } - } - - /** Keep dynamic scheduling awake only while a vendor output still needs startup validation. */ - long startupProgressIntervalUs() { - VendorDirectAudioOutput output = outputAttempt.get().output.get(); - return output != null && output.playing && !output.startupComplete - ? 50_000 : Long.MAX_VALUE; - } - - public boolean requestPcmFallbackForStuckPlayback(PlaybackException error) { - if (error == null || error.errorCode != PlaybackException.ERROR_CODE_TIMEOUT) { - return false; - } - boolean stuckPlaying = false; - Throwable cause = error.getCause(); - for (int depth = 0; cause != null && depth < 8; depth++, cause = cause.getCause()) { - if (cause instanceof StuckPlayerException stuck) { - stuckPlaying = stuck.stuckType == StuckPlayerException.STUCK_PLAYING_NO_PROGRESS; - break; - } - } - if (!stuckPlaying) return false; - synchronized (outputAttempt) { - OutputAttempt attempt = outputAttempt.get(); - VendorDirectAudioOutput output = attempt.output.get(); - if (output == null || !output.stalled - || !attempt.output.compareAndSet(output, null)) return false; - rememberPendingFailure(output); - if (outputAttempt.get() != attempt) return false; - disableVendorDirect(output.config, "playing-no-progress"); - return true; - } - } - - void disableVendorDirect(int encoding, int sampleRate, int channelMask) { - disableVendorDirect(new DirectKey(new OutputKey(encoding, sampleRate, channelMask), - effectiveAttributes(AudioAttributes.DEFAULT)), - "test"); - } - - static boolean supportsEncodedFrames(int encoding) { - return switch (encoding) { - case C.ENCODING_MP3, - C.ENCODING_AAC_LC, - C.ENCODING_AAC_HE_V1, - C.ENCODING_AAC_HE_V2, - C.ENCODING_AAC_XHE, - C.ENCODING_AAC_ELD -> true; - default -> false; - }; - } - - private OutputKey resolveVendorDirect( - Format format, AudioAttributes audioAttributes) { - OutputKey key = OutputKey.from(format); - if (key == null || !supportsEncodedFrames(key.encoding())) { - logDecision(format, key, "unsupported-encoding"); - return null; - } - DirectKey directKey = new DirectKey(key, audioAttributes); - if (failedVendorDirectConfigs.contains(directKey)) { - logDecision(format, key, "vendor-direct-failed"); - return null; - } - if (!directPlaybackSupport.isSupported(format, audioAttributes)) { - vendorDirectConfigs.remove(directKey); - logDecision(format, key, "no-direct-support"); - return null; - } - vendorDirectConfigs.put(directKey, format); - logDecision(format, key, "vendor-direct"); - return key; - } - - private static boolean platformSupportsDirectPlayback( - Format format, AudioAttributes audioAttributes) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return false; - OutputKey key = OutputKey.from(format); - if (key == null) return false; - try { - AudioFormat platformFormat = new AudioFormat.Builder() - .setEncoding(key.encoding()) - .setSampleRate(key.sampleRate()) - .setChannelMask(key.channelMask()) - .build(); - android.media.AudioAttributes platformAttributes = - audioAttributes.getPlatformAudioAttributes(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return supportsBitstream(AudioManager.getDirectPlaybackSupport( - platformFormat, platformAttributes)); - } - return AudioTrack.isDirectPlaybackSupported( - platformFormat, platformAttributes); - } catch (Throwable ignored) { - return false; - } - } - - static boolean supportsBitstream(int support) { - return (support & AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0; - } - - static AudioAttributes effectiveAttributes(AudioAttributes attributes) { - // Preserve the existing vivo compatibility adjustment, but apply it before - // querying capability and keying the decision as well as during creation. - return attributes.contentType == C.AUDIO_CONTENT_TYPE_UNKNOWN - ? attributes.buildUpon().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build() - : attributes; - } - - private boolean remembersFailure(AudioOutputProvider.FormatConfig config, - AudioAttributes attributes) { - OutputAttempt attempt = outputAttempt.get(); - if (config.preferredDevice != null || attempt.media == null) return false; - long nowMs = clock.elapsedRealtime(); - if (!failureMemory.hasMedia(attempt.media, nowMs)) return false; - ExoAudioDirectFailureMemory.Route route = environment.expectedRoute(attributes); - if (route == null) return false; - return failureMemory.contains(new ExoAudioDirectFailureMemory.Key(attempt.media, - ExoAudioDirectFailureMemory.formatId(config.format), attributes, route), nowMs); - } - - private void rememberPendingFailure(VendorDirectAudioOutput output) { - OutputAttempt attempt = output.attempt; - if (outputAttempt.get() != attempt || attempt.media == null || output.format == null - || output.routeAtStall == null) return; - ExoAudioDirectFailureMemory.Route expected = - environment.expectedRoute(output.config.audioAttributes); - if (!output.routeAtStall.equals(expected)) return; - attempt.failure = new Recovery(new ExoAudioDirectFailureMemory.Key(attempt.media, - ExoAudioDirectFailureMemory.formatId(output.format), - output.config.audioAttributes, expected), failureMemory.generation()); - } - - private static OutputEnvironment platformEnvironment(Context context) { - AudioManager manager = (AudioManager) context.getApplicationContext() - .getSystemService(Context.AUDIO_SERVICE); - return new OutputEnvironment() { - @Override - public ExoAudioDirectFailureMemory.Route expectedRoute(AudioAttributes attributes) { - if (manager == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null; - try { - if (manager.getMode() != AudioManager.MODE_NORMAL) return null; - if (!observeDeviceChanges(manager)) return null; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - List devices = manager.getAudioDevicesForAttributes( - attributes.getPlatformAudioAttributes()); - return devices.size() == 1 ? route(devices.get(0)) : null; - } - // Older APIs cannot predict the attribute-specific route. Only an - // unambiguous sole output is safe; never guess among HDMI/BT/speakers. - AudioDeviceInfo[] devices = manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS); - return devices.length == 1 ? route(devices[0]) : null; - } catch (RuntimeException unavailable) { - return null; - } - } - - @Override - public ExoAudioDirectFailureMemory.Route actualRoute(AudioOutput output) { - if (!(output instanceof AudioTrackAudioOutput track)) return null; - try { - return route(track.getAudioTrack().getRoutedDevice()); - } catch (RuntimeException unavailable) { - return null; - } - } - - @Override - public StartupBuffer startupBuffer(AudioOutput output) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S - || !(output instanceof AudioTrackAudioOutput track)) return StartupBuffer.UNKNOWN; - try { - AudioTrack audioTrack = track.getAudioTrack(); - return new StartupBuffer(audioTrack.getBufferCapacityInFrames(), - audioTrack.getBufferSizeInFrames(), audioTrack.getStartThresholdInFrames()); - } catch (RuntimeException unavailable) { - return StartupBuffer.UNKNOWN; - } - } - - @Override - public StartupBuffer setStartThresholdBytes(AudioOutput output, int bytes) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S - || !(output instanceof AudioTrackAudioOutput track)) return StartupBuffer.UNKNOWN; - try { - // Compressed AudioTrack's "frames" are bytes. Do not shrink the steady buffer: - // setBufferSizeInFrames is PCM-only, whereas this API controls startup fill. - track.getAudioTrack().setStartThresholdInFrames(bytes); - return startupBuffer(output); - } catch (RuntimeException unavailable) { - return StartupBuffer.UNKNOWN; - } - } - - @Override - public long rawPlaybackHead(AudioOutput output) { - if (!(output instanceof AudioTrackAudioOutput track)) return C.LENGTH_UNSET; - try { - return track.getAudioTrack().getPlaybackHeadPosition() & 0xffffffffL; - } catch (RuntimeException unavailable) { - return C.LENGTH_UNSET; - } - } - }; - } - - private static ExoAudioDirectFailureMemory.Route route(AudioDeviceInfo device) { - if (device == null || !device.isSink()) return null; - return new ExoAudioDirectFailureMemory.Route(device.getId(), device.getType(), - Arrays.hashCode(device.getEncodings()), Arrays.hashCode(device.getSampleRates()), - Arrays.hashCode(device.getChannelMasks())); - } - - private static synchronized boolean observeDeviceChanges(AudioManager manager) { - if (processDeviceCallback != null) return true; - // Register lazily on the first failure/lookup, on the existing main Looper. - // The callback retains only port IDs and the process memory, never an engine. - int[] initialIds = outputDeviceIds(manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)); - AudioDeviceCallback callback = new AudioDeviceCallback() { - private boolean initial = true; - - @Override public void onAudioDevicesAdded(AudioDeviceInfo[] devices) { - int[] ids = outputDeviceIds(devices); - if (initial) { - initial = false; - // Registration reports already connected devices, not a route change. - if (Arrays.equals(initialIds, ids)) return; - } - if (ids.length != 0) ExoAudioDirectFailureMemory.process().invalidate(); - } - - @Override public void onAudioDevicesRemoved(AudioDeviceInfo[] devices) { - initial = false; - if (outputDeviceIds(devices).length != 0) ExoAudioDirectFailureMemory.process().invalidate(); - } - }; - manager.registerAudioDeviceCallback(callback, null); - processDeviceCallback = callback; - return true; - } - - private static int[] outputDeviceIds(AudioDeviceInfo[] devices) { - return Arrays.stream(devices).filter(AudioDeviceInfo::isSink) - .mapToInt(AudioDeviceInfo::getId).sorted().toArray(); - } - - private static void logDecision(Format format, OutputKey key, String reason) { - if (!SpiderDebug.isEnabled()) return; - SpiderDebug.log("exo-audio-direct", - "mime=%s codecs=%s encoding=%d sampleRate=%d channels=%d reason=%s", - format == null ? "" : format.sampleMimeType, - format == null ? "" : format.codecs, - key == null ? C.ENCODING_INVALID : key.encoding(), - key == null ? 0 : key.sampleRate(), - format == null ? 0 : Math.max(0, format.channelCount), reason); - } - - private void disableVendorDirect( - AudioOutputProvider.OutputConfig config, String reason) { - disableVendorDirect(DirectKey.from(config), reason); - } - - private void disableVendorDirect(DirectKey key, String reason) { - vendorDirectConfigs.remove(key); - failedVendorDirectConfigs.add(key); - pendingPcmFallback.set(key.output); - if (SpiderDebug.isEnabled()) { - SpiderDebug.log("exo-audio-direct", - "disable encoding=%d sampleRate=%d channelMask=0x%X reason=%s", - key.output.encoding(), key.output.sampleRate(), key.output.channelMask(), reason); - } - } - - private void notifyInitializationFailure() { - if (!initializationFailureNotified.compareAndSet(false, true)) return; - Runnable listener = initializationFailureListener.get(); - if (listener != null) listener.run(); - } - - private static final class OutputAttempt { - final AtomicReference output = new AtomicReference<>(); - final AtomicReference currentOutput = new AtomicReference<>(); - final String media; - volatile Recovery recovery; - volatile Recovery failure; - volatile String selectedFormat; - boolean startupRecoveryUsed; - long fallbackAtMs = C.TIME_UNSET; - - OutputAttempt() { - this(null, null); - } - - OutputAttempt(String media, Recovery recovery) { - this.media = media; - this.recovery = recovery; - } - - void cancelRecovery() { - if (recovery != null) recovery.cancelled = true; - if (failure != null) failure.cancelled = true; - } - } - - private static final class Recovery { - final ExoAudioDirectFailureMemory.Key key; - final long generation; - volatile boolean cancelled; - - Recovery(ExoAudioDirectFailureMemory.Key key, long generation) { - this.key = key; - this.generation = generation; - } - } - - private record DirectKey(OutputKey output, AudioAttributes attributes) { - static DirectKey from(AudioOutputProvider.OutputConfig config) { - return new DirectKey(new OutputKey(config.encoding, config.sampleRate, config.channelMask), - config.audioAttributes); - } - } - - private final class VendorDirectAudioOutput extends ForwardingAudioOutput { - private final AudioOutputProvider.OutputConfig config; - private final AudioOutput raw; - private final OutputAttempt attempt; - private final Format format; - private StartupBuffer startupBuffer; - private boolean thresholdAdjusted; - private boolean startupComplete; - private int framesPerAccessUnit; - private long acceptedFrames; - private long startupWindowAtMs = C.TIME_UNSET; - private long startupGeneration; - private ExoAudioDirectFailureMemory.Route startupRoute; - private boolean playing; - private boolean acceptedData; - private long acceptedBytes; - private long playAtMs = C.TIME_UNSET; - private long firstWriteAtMs = C.TIME_UNSET; - private long firstProgressAtMs = C.TIME_UNSET; - private long lastPositionUs = C.TIME_UNSET; - private long unchangedSinceMs = C.TIME_UNSET; - private volatile boolean stalled; - private ExoAudioDirectFailureMemory.Route routeAtStall; - - VendorDirectAudioOutput(AudioOutput output, AudioOutput raw, - AudioOutputProvider.OutputConfig config, OutputAttempt attempt) { - super(output); - this.config = config; - this.raw = raw; - this.attempt = attempt; - this.format = vendorDirectConfigs.get(DirectKey.from(config)); - this.startupBuffer = environment.startupBuffer(raw); - if (SpiderDebug.isEnabled()) { - SpiderDebug.log("exo-audio-direct", - "event=created encoding=%d capacityBytes=%d sizeBytes=%d startThresholdBytes=%d effectiveThresholdBytes=%d", - config.encoding, startupBuffer.capacityBytes(), startupBuffer.sizeBytes(), - startupBuffer.thresholdBytes(), startupBuffer.effectiveThresholdBytes()); - } - } - - @Override - public boolean write(ByteBuffer buffer, int accessUnitCount, long presentationTimeUs) - throws AudioOutput.WriteException { - int position = buffer.position(); - if (!startupComplete && framesPerAccessUnit == 0 && buffer.hasRemaining()) { - framesPerAccessUnit = startupFramesPerAccessUnit(config.encoding, buffer); - } - try { - boolean handled = super.write(buffer, accessUnitCount, presentationTimeUs); - if (buffer.position() > position) { - acceptedData = true; - acceptedBytes += buffer.position() - position; - if (firstWriteAtMs == C.TIME_UNSET) { - firstWriteAtMs = clock.elapsedRealtime(); - logProgress("first-write", firstWriteAtMs); - } - if (!startupComplete && handled && accessUnitCount > 0) { - acceptedFrames += (long) framesPerAccessUnit * accessUnitCount; - maybeLowerStartThreshold(); - } - } - return handled; - } catch (AudioOutput.WriteException error) { - synchronized (outputAttempt) { - if (outputAttempt.get() == attempt && attempt.currentOutput.get() == this) { - routeAtStall = environment.actualRoute(raw); - rememberPendingFailure(this); - if (outputAttempt.get() == attempt) { - disableVendorDirect(config, "write-" + error.errorCode); - } - } - } - throw new AudioOutput.WriteException(error.errorCode, true); - } - } - - @Override - public long getPositionUs() { - long positionUs = super.getPositionUs(); - if (playing && acceptedData) { - if (positionUs < 0) { - resetObservation(); - } else { - long nowMs = clock.elapsedRealtime(); - if (positionUs > 0 && firstProgressAtMs == C.TIME_UNSET) { - firstProgressAtMs = nowMs; - logProgress("first-progress", nowMs); - } - if (positionUs > 0) startupComplete = true; - if (positionUs != lastPositionUs) { - lastPositionUs = positionUs; - unchangedSinceMs = nowMs; - stalled = false; - routeAtStall = null; - } else { - boolean observedStall = nowMs - unchangedSinceMs >= OUTPUT_STALL_CONFIRMATION_MS; - if (observedStall && !stalled && outputAttempt.get() == attempt) { - routeAtStall = environment.actualRoute(raw); - } - stalled = observedStall; - } - } - } - return positionUs; - } - - @Override - public void play() { - super.play(); - if (!playing) resetObservation(); - playing = true; - if (playAtMs == C.TIME_UNSET) playAtMs = clock.elapsedRealtime(); - } - - @Override - public void pause() { - playing = false; - startupWindowAtMs = C.TIME_UNSET; - super.pause(); - } - - @Override - public void flush() { - acceptedData = false; - acceptedBytes = 0; - acceptedFrames = 0; - // A seek/flush is not a fresh first-playback probe. Keep the established generic - // stall recovery for these epochs instead of replaying stale startup evidence. - startupComplete = true; - resetObservation(); - super.flush(); - } - - @Override - public void stop() { - playing = false; - acceptedData = false; - acceptedBytes = 0; - acceptedFrames = 0; - startupComplete = true; - resetObservation(); - super.stop(); - } - - @Override - public void release() { - playing = false; - startupWindowAtMs = C.TIME_UNSET; - // Media3 posts stop to the playback thread before notifying the App of a timeout. - // Keep already observed evidence for that error; the next output/attempt replaces it. - super.release(); - } - - private void resetObservation() { - lastPositionUs = C.TIME_UNSET; - unchangedSinceMs = C.TIME_UNSET; - stalled = false; - routeAtStall = null; - startupWindowAtMs = C.TIME_UNSET; - } - - private void maybeLowerStartThreshold() { - if (thresholdAdjusted || config.sampleRate <= 0 - || acceptedFrames * 1_000_000L < STARTUP_AUDIO_US * config.sampleRate) return; - thresholdAdjusted = true; - int threshold = startupBuffer.effectiveThresholdBytes(); - if (threshold == 0 || acceptedBytes <= 0 || acceptedBytes >= threshold) return; - StartupBuffer adjusted = environment.setStartThresholdBytes(raw, (int) acceptedBytes); - // Read-back is the only authority, including a vendor's clamping of the request. - if (adjusted.effectiveThresholdBytes() > 0) startupBuffer = adjusted; - } - - private boolean isStartupStalled() { - if (!playing || startupComplete || format == null || lastPositionUs != 0 - || !acceptedData || startupBuffer.effectiveThresholdBytes() == 0 - || acceptedBytes < startupBuffer.effectiveThresholdBytes()) { - startupWindowAtMs = C.TIME_UNSET; - return false; - } - long nowMs = clock.elapsedRealtime(); - if (startupWindowAtMs == C.TIME_UNSET) { - startupWindowAtMs = nowMs; - startupGeneration = failureMemory.generation(); - startupRoute = environment.actualRoute(raw); - return false; - } - if (nowMs - startupWindowAtMs < STARTUP_STALL_MS) return false; - // Query the raw head and route only at the decision boundary, not every render. - // A working head with stale timestamp feedback must not be replayed as silent audio. - long head = environment.rawPlaybackHead(raw); - if (head > 0) { - startupComplete = true; - return false; - } - ExoAudioDirectFailureMemory.Route route = environment.actualRoute(raw); - if (head < 0 || startupRoute == null || !startupRoute.equals(route) - || startupGeneration != failureMemory.generation()) { - startupComplete = true; - return false; - } - // Re-read the real threshold: routing/HAL changes can alter it after construction. - StartupBuffer current = environment.startupBuffer(raw); - if (current.effectiveThresholdBytes() == 0 - || acceptedBytes < current.effectiveThresholdBytes()) { - startupBuffer = current; - startupWindowAtMs = C.TIME_UNSET; - return false; - } - routeAtStall = route; - return true; - } - - private void logProgress(String event, long nowMs) { - if (!SpiderDebug.isEnabled()) return; - SpiderDebug.log("exo-audio-direct", - "event=%s encoding=%d sincePlayMs=%d sinceFirstWriteMs=%d acceptedBytes=%d", - event, config.encoding, playAtMs == C.TIME_UNSET ? -1 : nowMs - playAtMs, - firstWriteAtMs == C.TIME_UNSET ? -1 : nowMs - firstWriteAtMs, acceptedBytes); - } - } - - // Same frame counts as the shipped DefaultAudioSink; inspect only the first complete header. - private static int startupFramesPerAccessUnit(int encoding, ByteBuffer buffer) { - return switch (encoding) { - case C.ENCODING_AAC_LC -> AacUtil.AAC_LC_AUDIO_SAMPLE_COUNT; - case C.ENCODING_AAC_HE_V1, C.ENCODING_AAC_HE_V2 -> AacUtil.AAC_HE_AUDIO_SAMPLE_COUNT; - case C.ENCODING_AAC_XHE -> AacUtil.AAC_XHE_AUDIO_SAMPLE_COUNT; - case C.ENCODING_AAC_ELD -> AacUtil.AAC_LD_AUDIO_SAMPLE_COUNT; - case C.ENCODING_MP3 -> buffer.remaining() < 4 ? 0 - : Math.max(0, MpegAudioUtil.parseMpegAudioFrameSampleCount( - Util.getBigEndianInt(buffer, buffer.position()))); - default -> 0; - }; - } - - /** Installed only for PCM recovery, never on healthy PCM playback. */ - private final class PcmRecoveryAudioOutput extends ForwardingAudioOutput { - private final AudioOutput raw; - private final OutputAttempt attempt; - private final Recovery recovery; - private boolean playing; - private boolean acceptedData; - private boolean finished; - private boolean reportedProgress; - private long windowAtMs = C.TIME_UNSET; - private long windowPositionUs; - private long lastPositionUs; - private long lastProgressAtMs; - - PcmRecoveryAudioOutput(AudioOutput output, AudioOutput raw, OutputAttempt attempt) { - super(output); - this.raw = raw; - this.attempt = attempt; - this.recovery = attempt.recovery; - } - - @Override - public boolean write(ByteBuffer buffer, int accessUnitCount, long presentationTimeUs) - throws AudioOutput.WriteException { - int position = buffer.position(); - boolean result = super.write(buffer, accessUnitCount, presentationTimeUs); - acceptedData |= buffer.position() > position; - return result; - } - - @Override - public long getPositionUs() { - long positionUs = super.getPositionUs(); - if (finished || !playing || !acceptedData || (recovery != null && recovery.cancelled) - || outputAttempt.get() != attempt || attempt.currentOutput.get() != this) { - return positionUs; - } - long nowMs = clock.elapsedRealtime(); - if (!reportedProgress && positionUs > 0) { - reportedProgress = true; - synchronized (outputAttempt) { - if (outputAttempt.get() == attempt && attempt.currentOutput.get() == this) { - pendingPcmFallback.set(null); - } - } - if (attempt.fallbackAtMs != C.TIME_UNSET && SpiderDebug.isEnabled()) { - SpiderDebug.log("exo-audio-direct", "event=pcm-first-progress sinceFallbackMs=%d", - nowMs - attempt.fallbackAtMs); - } - } - if (recovery == null) { - finished = reportedProgress; - return positionUs; - } - if (positionUs < 0 || (windowAtMs != C.TIME_UNSET && positionUs < lastPositionUs)) { - recovery.cancelled = true; - return positionUs; - } - if (windowAtMs == C.TIME_UNSET || nowMs - lastProgressAtMs >= PCM_CONFIRMATION_MS) { - windowAtMs = nowMs; - windowPositionUs = positionUs; - lastProgressAtMs = nowMs; - } - if (positionUs > lastPositionUs) lastProgressAtMs = nowMs; - lastPositionUs = positionUs; - if (nowMs - windowAtMs >= PCM_CONFIRMATION_MS - && positionUs - windowPositionUs >= 1_000_000 - && nowMs == lastProgressAtMs - && recovery.key.format().equals(attempt.selectedFormat)) { - finished = true; - ExoAudioDirectFailureMemory.Route actual = environment.actualRoute(raw); - ExoAudioDirectFailureMemory.Route expected = - environment.expectedRoute(recovery.key.attributes()); - if (Objects.equals(recovery.key.route(), actual) - && Objects.equals(actual, expected) - && confirmRecovery(nowMs) - && SpiderDebug.isEnabled()) { - SpiderDebug.log("exo-audio-direct", - "event=pcm-recovery-confirmed stableMs=%d progressUs=%d ttlMs=%d", - nowMs - windowAtMs, positionUs - windowPositionUs, - ExoAudioDirectFailureMemory.TTL_MS); - } - } - return positionUs; - } - - private boolean confirmRecovery(long nowMs) { - synchronized (outputAttempt) { - return outputAttempt.get() == attempt && attempt.currentOutput.get() == this - && !recovery.cancelled && recovery.key.format().equals(attempt.selectedFormat) - && failureMemory.confirm(recovery.key, recovery.generation, nowMs); - } - } - - @Override public void play() { - super.play(); - if (!playing) windowAtMs = C.TIME_UNSET; - playing = true; - } - - @Override public void pause() { - playing = false; - windowAtMs = C.TIME_UNSET; - super.pause(); - } - - @Override public void flush() { - if (recovery != null) recovery.cancelled = true; - super.flush(); - } - - @Override public void stop() { - if (recovery != null) recovery.cancelled = true; - super.stop(); - } - - @Override public void release() { - if (recovery != null) recovery.cancelled = true; - super.release(); - } - } - - private record OutputKey(int encoding, int sampleRate, int channelMask) { - - static OutputKey from(Format format) { - if (format == null || format.sampleMimeType == null - || format.sampleRate <= 0 || format.channelCount <= 0) { - return null; - } - int encoding = MimeTypes.getEncoding(format.sampleMimeType, - format.codecs); - int channelMask = Util.getAudioTrackChannelConfig(format); - if (encoding == C.ENCODING_INVALID || channelMask == 0) return null; - return new OutputKey(encoding, format.sampleRate, channelMask); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticAudioOutput.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticAudioOutput.java deleted file mode 100644 index f164564d26..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticAudioOutput.java +++ /dev/null @@ -1,239 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import android.media.AudioDeviceInfo; -import android.media.AudioTimestamp; -import android.media.AudioTrack; -import android.os.Build; -import android.os.SystemClock; - -import androidx.media3.exoplayer.audio.AudioOutput; -import androidx.media3.exoplayer.audio.AudioOutputProvider; -import androidx.media3.exoplayer.audio.AudioSink; -import androidx.media3.exoplayer.audio.AudioTrackAudioOutput; -import androidx.media3.exoplayer.audio.ForwardingAudioOutput; -import androidx.media3.exoplayer.audio.ForwardingAudioOutputProvider; -import androidx.media3.exoplayer.audio.ForwardingAudioSink; - -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector; -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector.Context; -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; - -import java.nio.ByteBuffer; -import java.util.function.Consumer; - -import static com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.*; - -/** Observes the real provider result, including vendor-direct, without replacing its implementation. */ -final class ExoDiagnosticAudioOutput extends ForwardingAudioOutput { - private final PlaybackDiagnosticCollector log; - private final Context owner; - private final AudioTrack track; - private final String outputId = PlaybackDiagnosticCollector.id("audio-output"); - private long epoch, calls, requested, accepted, shorts, zeros, errors, maxWriteUs, lastSummaryMs, lastWriteMs; - private long rawHead = -1, extendedHead, lastTimestampMs; - private long firstPts = Long.MIN_VALUE, lastPts = Long.MIN_VALUE; - private final com.fongmi.android.tv.player.PcmDiagnosticProbe pcmProbe = new com.fongmi.android.tv.player.PcmDiagnosticProbe(); - private final int pcmEncoding, pcmChannels; - private final com.fongmi.android.tv.player.OutputProgressDiagnostic audioProgress = new com.fongmi.android.tv.player.OutputProgressDiagnostic(false); - - static AudioOutput wrap(AudioOutput output, AudioOutputProvider.OutputConfig config, ExoDiagnosticCollector collector) { - return collector == null ? output : new ExoDiagnosticAudioOutput(output, config, collector); - } - - static AudioOutputProvider provider(AudioOutputProvider delegate, ExoDiagnosticCollector collector) { - if (collector == null) return delegate; - return new ForwardingAudioOutputProvider(delegate) { - @Override public FormatSupport getFormatSupport(FormatConfig config) { - FormatSupport result = super.getFormatSupport(config); - collector.log.emit("audio.candidates", "audio-output-provider", e -> { - ExoDiagnosticCollector.format(e, config.format); - e.observed("support", result.supportLevel).observed("offload", result.isFormatSupportedForOffload); - }); - return result; - } - @Override public OutputConfig getOutputConfig(FormatConfig config) throws ConfigurationException { - try { return super.getOutputConfig(config); } - catch (ConfigurationException | RuntimeException error) { - collector.log.error(collector.log.context(), "audio-output", "configuration", error); throw error; - } - } - @Override public AudioOutput getAudioOutput(OutputConfig config) throws InitializationException { - long start = SystemClock.elapsedRealtime(); - collector.log.emit("audio.output.lifecycle", "audio-output-provider", e -> e.observed("operation", "initialize").observed("phase", "begin")); - try { - AudioOutput result = super.getAudioOutput(config); - collector.log.emit("audio.output.lifecycle", "audio-output-provider", e -> e.observed("operation", "initialize") - .observed("phase", "end").observed("result", "success").observed("durationMs", SystemClock.elapsedRealtime() - start)); - return result; - } catch (InitializationException | RuntimeException error) { - collector.log.error(collector.log.context(), "audio-output", "initialize", error); throw error; - } - } - }; - } - - static AudioSink sink(AudioSink delegate, ExoDiagnosticCollector collector) { - if (collector == null) return delegate; - Media3DiagnosticBridge.bind(delegate, collector.log, null, PlaybackDiagnosticCollector.id("audio-sink")); - return new ForwardingAudioSink(delegate) { - private long lastMs, calls, bytes; - private androidx.media3.common.Format inputFormat; - private final com.fongmi.android.tv.player.PcmDiagnosticProbe inputProbe = new com.fongmi.android.tv.player.PcmDiagnosticProbe(); - @Override public void configure(AudioSinkConfig config) throws ConfigurationException { - inputFormat = config.format; - collector.log.emit("audio.processing", "audio-sink-config", e -> { - ExoDiagnosticCollector.format(e, config.format); - e.observed("mapping", config.outputChannelMapping == null ? null : config.outputChannelMapping.toString()) - .observed("stage", "sink-input; before processors"); - }); - super.configure(config); - } - @Override public boolean handleBuffer(ByteBuffer buffer, long pts, int count) throws InitializationException, WriteException { - if (!DebugLogStore.acceptsEvent("audio.processing")) return super.handleBuffer(buffer, pts, count); - int position = buffer.position(); - boolean result = super.handleBuffer(buffer, pts, count); - if (inputFormat != null && "audio/raw".equals(inputFormat.sampleMimeType)) inputProbe.sample(collector.log, collector.log.context(), - buffer, position, buffer.position(), inputFormat.pcmEncoding, inputFormat.channelCount, "sink-before-processors", collector.log.protectedMedia()); - calls++; bytes += buffer.position() - position; - long now = SystemClock.elapsedRealtime(); - if (now - lastMs >= 5000) { - lastMs = now; - collector.log.emit("audio.processing", "audio-sink", e -> e.observed("writeCalls", calls).observed("acceptedBytes", bytes) - .observed("lastPtsUs", pts).observed("metricScope", "sink acceptance; not AudioTrack.write")); - } - return result; - } - @Override public void setSkipSilenceEnabled(boolean value) { - super.setSkipSilenceEnabled(value); - collector.log.emit("audio.processing", "audio-sink", e -> e.observed("skipSilence", value)); - } - @Override public void setPlaybackParameters(androidx.media3.common.PlaybackParameters value) { - super.setPlaybackParameters(value); - collector.log.emit("audio.processing", "audio-sink", e -> e.observed("speed", value.speed).observed("stage", "requested-parameters")); - } - }; - } - - private ExoDiagnosticAudioOutput(AudioOutput output, AudioOutputProvider.OutputConfig config, ExoDiagnosticCollector collector) { - super(output); log = collector.log; owner = log.context(); - track = output instanceof AudioTrackAudioOutput audioTrack ? audioTrack.getAudioTrack() : null; - Media3DiagnosticBridge.bind(track, log, owner, outputId); - pcmEncoding = config.encoding; pcmChannels = track == null ? Integer.bitCount(config.channelMask) : track.getChannelCount(); - event("audio.output.configure", e -> e.observed("encoding", config.encoding).observed("sampleRate", config.sampleRate) - .observed("channelMask", config.channelMask).observed("offload", config.isOffload).observed("tunneling", config.isTunneling) - .observed("sessionId", output.getAudioSessionId()).observed("bufferSize", config.bufferSize).observed("bufferUnit", "bytes") - .observed("usage", config.audioAttributes.usage).observed("contentType", config.audioAttributes.contentType) - .observed("flags", config.audioAttributes.flags).observed("source", output.getClass().getSimpleName()) - .unknown("audibility", NOT_OBSERVABLE).pin(outputId + "-config")); - } - - private void event(String name, Consumer facts) { - log.emit(owner, name, "audio-output", "output-lifetime; may span gapless media", e -> { - e.observed("audioOutputId", outputId).observed("epoch", epoch); facts.accept(e); - }); - } - - @Override public boolean write(ByteBuffer buffer, int units, long pts) throws WriteException { - if (!DebugLogStore.acceptsEvent("audio.output.write")) return super.write(buffer, units, pts); - long generation = DebugLogStore.captureGeneration(); - if (generation != diagnosticGeneration) { diagnosticGeneration = generation; resetEpoch(); lastSummaryMs = 0; } - int position = buffer.position(), remaining = buffer.remaining(); - long start = SystemClock.elapsedRealtimeNanos(); - try { - boolean result = super.write(buffer, units, pts); - int consumed = buffer.position() - position; - pcmProbe.sample(log, owner, buffer, position, buffer.position(), pcmEncoding, pcmChannels, "audio-output-after-processors", log.protectedMedia()); - calls++; requested += remaining; accepted += consumed; - if (consumed == 0) zeros++; else if (consumed < remaining) shorts++; - lastWriteMs = SystemClock.elapsedRealtime(); lastPts = pts; - maxWriteUs = Math.max(maxWriteUs, (SystemClock.elapsedRealtimeNanos() - start) / 1000); - if (consumed > 0 && firstPts == Long.MIN_VALUE) { - firstPts = pts; - event("audio.output.write", e -> e.observed("stage", "first-payload-accepted").observed("acceptedBytes", consumed).observed("firstPtsUs", pts)); - log.evidence(owner, false, 3); - } - sample(false); - return result; - } catch (WriteException error) { - errors++; - event("audio.error", e -> e.severity("error").observed("stage", "write").observed("errorCode", error.errorCode) - .observed("recoverable", error.isRecoverable).pin(outputId + "-error")); - log.error(owner, "audio-output", "write", error); sample(true); throw error; - } - } - - private void sample(boolean force) { - if (!DebugLogStore.acceptsEvent("audio.output.write")) return; - long now = SystemClock.elapsedRealtime(); - if (!force && now - lastSummaryMs < 5000) return; - lastSummaryMs = now; - event("audio.output.write", e -> e.observed("writeCalls", calls).observed("requestedBytes", requested) - .observed("acceptedBytes", accepted).observed("shortWrites", shorts).observed("zeroWrites", zeros) - .observed("writeErrors", errors).observed("maxWriteUs", maxWriteUs) - .observed("lastPtsUs", lastPts == Long.MIN_VALUE ? null : lastPts) - .observed("lastAgeMs", lastWriteMs == 0 ? null : now - lastWriteMs) - .observed("metricScope", "AudioOutput payload consumption; retried requests included, internal headers excluded")); - if (track == null) { - event("audio.output.playhead", e -> e.unknown("headRaw", NOT_OBSERVABLE).observed("nativeHook", "non-AudioTrack backend")); - return; - } - try { - long raw = Integer.toUnsignedLong(track.getPlaybackHeadPosition()); - long delta = rawHead < 0 ? 0 : (raw - rawHead) & 0xffffffffL; - // A large backward jump can be a backend reset, not a four-billion-frame advance. - if (delta > 0x7fffffffL) { epoch++; extendedHead = 0; delta = 0; } - else extendedHead += delta; - boolean first = rawHead < 0; rawHead = raw; - audioProgress.sample(log, owner, epoch, now, extendedHead, - track.getPlayState() != AudioTrack.PLAYSTATE_PLAYING ? "audio-paused-or-stopped" - : accepted == 0 ? "no-audio-payload-observed" : null, - "actual AudioTrack playhead during writes; not acoustic audibility"); - long change = delta; - event("audio.output.playhead", e -> { - e.observed("headRaw", raw).observed("headFrames", extendedHead) - .observed("headDeltaFrames", first ? null : change).observed("state", track.getState()) - .observed("playState", track.getPlayState()).observed("metricScope", "uint32 AudioTrack frames; reset/flush epoch") - .unknown("audibility", NOT_OBSERVABLE); - if (Build.VERSION.SDK_INT >= 24) e.observed("underruns", track.getUnderrunCount()); - else e.unknown("underruns", NOT_SUPPORTED); - }); - if (delta > 0) log.evidence(owner, false, 4); - if (Build.VERSION.SDK_INT >= 23) { - AudioDeviceInfo route = track.getRoutedDevice(); - event("audio.route", e -> e.observed("routeRole", "actual").observed("routeId", route == null ? null : "device-" + route.getId()) - .observed("routeType", route == null ? null : route.getType()).observed("source", "AudioTrack.getRoutedDevice")); - } else event("audio.route", e -> e.unknown("routeId", NOT_SUPPORTED)); - if (now - lastTimestampMs >= 10000) { - lastTimestampMs = now; - AudioTimestamp timestamp = new AudioTimestamp(); - boolean valid = track.getTimestamp(timestamp); - event("audio.output.playhead", e -> { - e.observed("timestampValid", valid); - if (valid) e.observed("timestampFrames", timestamp.framePosition).observed("timestampNs", timestamp.nanoTime) - .observed("timestampAgeMs", Math.max(0, (System.nanoTime() - timestamp.nanoTime) / 1_000_000)); - else e.unknown("timestampFrames", UNAVAILABLE).unknown("timestampNs", UNAVAILABLE); - }); - } - } catch (RuntimeException error) { - event("audio.output.playhead", e -> e.unknown("headRaw", READ_ERROR)); - } - } - - @Override public void play() { super.play(); lifecycle("play"); } - @Override public void pause() { sample(true); super.pause(); lifecycle("pause"); } - @Override public void flush() { sample(true); super.flush(); resetEpoch(); lifecycle("flush"); } - @Override public void stop() { sample(true); super.stop(); resetEpoch(); lifecycle("stop"); } - @Override public void release() { sample(true); super.release(); lifecycle("release"); } - @Override public void setVolume(float volume) { super.setVolume(volume); event("audio.volume", e -> e.observed("volume", volume).observed("stage", "AudioOutput.setVolume")); } - @Override public void setPreferredDevice(AudioDeviceInfo device) { - super.setPreferredDevice(device); - event("audio.route", e -> e.observed("routeRole", "preferred").observed("routeId", device == null ? null : "device-" + device.getId())); - } - private void lifecycle(String operation) { event("audio.output.lifecycle", e -> e.observed("operation", operation).observed("phase", "end").observed("result", "success")); } - private void resetEpoch() { - epoch++; rawHead = -1; extendedHead = 0; calls = requested = accepted = shorts = zeros = errors = maxWriteUs = 0; - firstPts = lastPts = Long.MIN_VALUE; lastWriteMs = 0; lastTimestampMs = 0; - } - private long diagnosticGeneration = -1; -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCodecAdapter.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCodecAdapter.java deleted file mode 100644 index d69ba92c71..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCodecAdapter.java +++ /dev/null @@ -1,326 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import android.os.Handler; -import android.os.SystemClock; -import android.view.Surface; - -import androidx.media3.decoder.CryptoInfo; -import androidx.media3.exoplayer.mediacodec.ForwardingMediaCodecAdapter; -import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter; -import androidx.media3.exoplayer.mediacodec.MediaCodecInfo; -import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; - -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector; -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector.Context; -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import static com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.*; - -/** Counts only existing calls. Does not dequeue, inspect samples, or install a new frame listener. */ -final class ExoDiagnosticCodecAdapter extends ForwardingMediaCodecAdapter { - private final PlaybackDiagnosticCollector log; - private final Context owner; - private final String decoderId, name; - private final boolean video; - private String surfaceId; - private long epoch, inputs, bytes, outputs, submitted, discarded, encrypted, eos, regressions; - private volatile long callbacks; - private boolean hasFrameListener; - private long firstPts = Long.MIN_VALUE, lastPts = Long.MIN_VALUE, lastInputMs, lastOutputMs, lastSummaryMs; - private long lastDecodedPts = Long.MIN_VALUE; - - static MediaCodecAdapter.Factory factory(MediaCodecAdapter.Factory delegate, ExoDiagnosticCollector collector) { - return config -> { - PlaybackDiagnosticCollector log = collector.log; - Context owner = log.context(); - String decoderId = PlaybackDiagnosticCollector.id("decoder"); - boolean video = config.format.sampleMimeType != null && config.format.sampleMimeType.startsWith("video/"); - String event = video ? "video.decoder.attempt" : "audio.decoder.attempt"; - long start = SystemClock.elapsedRealtime(); - log.emit(owner, event, "codec-adapter-factory", "engine-at-create; media-unconfirmed", e -> e - .observed("decoderId", decoderId).observed("decoderName", config.codecInfo.name) - .observed("stage", "create-configure-start aggregate").observed("phase", "begin") - .unknown("operation", NOT_OBSERVABLE).pin(decoderId + "-attempt")); - mediaFormat(log, owner, config.mediaFormat, decoderId, video ? "video.configure" : "audio.decoder.output"); - Object previousDiagnosticOwner = Media3DiagnosticBridge.enter(log, owner, decoderId, config.codecInfo.name, video); - try { - MediaCodecAdapter adapter = delegate.createAdapter(config); - log.emit(owner, event, "codec-adapter-factory", "engine-at-create; media-unconfirmed", e -> e - .observed("decoderId", decoderId).observed("decoderName", config.codecInfo.name) - .observed("stage", "create-configure-start aggregate").observed("phase", "end") - .observed("result", "success").observed("durationMs", SystemClock.elapsedRealtime() - start)); - return new ExoDiagnosticCodecAdapter(adapter, log, owner, decoderId, config, video); - } catch (IOException | RuntimeException error) { - log.emit(owner, event, "codec-adapter-factory", "engine-at-create; media-unconfirmed", e -> e.severity("error") - .observed("decoderId", decoderId).observed("decoderName", config.codecInfo.name) - .observed("stage", "create-configure-start aggregate").observed("phase", "end") - .observed("result", "failed").observed("durationMs", SystemClock.elapsedRealtime() - start) - .unknown("operation", NOT_OBSERVABLE).pin(decoderId + "-failure")); - log.error(owner, video ? "video-codec" : "audio-codec", "create-configure-start aggregate", error); - throw error; - } finally { - androidx.media3.common.util.PlaybackDiagnostics.leave(previousDiagnosticOwner); - } - }; - } - - static MediaCodecSelector selector(MediaCodecSelector delegate, ExoDiagnosticCollector collector, String policy) { - return (mime, secure, tunnel) -> { - List infos; - try { infos = delegate.getDecoderInfos(mime, secure, tunnel); } - catch (androidx.media3.exoplayer.mediacodec.MediaCodecUtil.DecoderQueryException error) { - collector.log.error(collector.log.context(), "codec-query", policy, error); throw error; - } - candidates(collector, infos, mime, secure, tunnel, policy); - return infos; - }; - } - - static void candidates(ExoDiagnosticCollector collector, List infos, String mime, boolean secure, boolean tunnel, String policy) { - if (collector == null) return; - boolean video = mime != null && mime.startsWith("video/"); - String event = video ? "video.candidates" : "audio.candidates"; - if (!DebugLogStore.acceptsEvent(event)) return; - Context owner = collector.log.context(); - List candidates = new ArrayList<>(infos.size()); - for (MediaCodecInfo info : infos) { - List levels = new ArrayList<>(); - int profileCount = 0; - boolean readable = true; - try { - android.media.MediaCodecInfo.CodecProfileLevel[] profiles = info.getProfileLevels(); - profileCount = profiles.length; - for (int p = 0; p < Math.min(64, profiles.length); p++) { - levels.add(new ExoDiagnosticCodecSnapshotCache.Profile(profiles[p].profile, profiles[p].level)); - } - } catch (RuntimeException ignored) { - readable = false; - DebugLogStore.collectorFailure(); - } - candidates.add(new ExoDiagnosticCodecSnapshotCache.Candidate(info.name, - info.hardwareAccelerated, info.softwareOnly, info.vendor, profileCount, levels, readable)); - } - ExoDiagnosticCodecSnapshotCache.Observation observation = collector.codecSnapshots.observe( - owner, DebugLogStore.captureGeneration(), - new ExoDiagnosticCodecSnapshotCache.Query(mime, secure, tunnel, policy), candidates); - String snapshotId = collector.log.instanceId() + "-codecs-" + observation.snapshotId(); - if (!observation.fullSnapshot()) { - collector.log.emit(owner, event, "codec-selector", "engine-context", e -> e - .observed("phase", "snapshot-reuse").observed("snapshotId", snapshotId) - .observed("queryCount", observation.queryCount()).observed("candidateCount", candidates.size()) - .observed("mime", mime).observed("secure", secure).observed("tunneling", tunnel) - .observed("reason", policy)); - return; - } - if (candidates.isEmpty()) collector.log.emit(owner, event, "codec-selector", "engine-context", e -> e - .observed("snapshotId", snapshotId).observed("mime", mime).observed("candidateCount", 0) - .observed("reason", policy).observed("secure", secure).observed("tunneling", tunnel)); - for (int i = 0; i < candidates.size(); i++) { - ExoDiagnosticCodecSnapshotCache.Candidate info = candidates.get(i); - int index = i; - collector.log.emit(owner, event, "codec-selector", "engine-context", e -> e - .observed("snapshotId", snapshotId).observed("mime", mime).observed("candidateIndex", index) - .observed("candidateCount", candidates.size()).observed("decoderName", info.name()) - .observed("hardwareAccelerated", info.hardwareAccelerated()).observed("softwareOnly", info.softwareOnly()) - .observed("vendor", info.vendor()).observed("secure", secure).observed("tunneling", tunnel) - .observed("reason", policy).observed("metricScope", "selector result; renderer may further filter/order")); - for (ExoDiagnosticCodecSnapshotCache.Profile profile : info.profiles()) { - collector.log.emit(owner, event, "codec-capabilities", "engine-context", e -> e - .observed("snapshotId", snapshotId).observed("candidateIndex", index).observed("decoderName", info.name()) - .observed("profile", profile.profile()).observed("level", profile.level()) - .observed("truncated", info.profileCount() > 64).observed("metricScope", "Android capability declaration")); - } - if (!info.readable()) collector.log.emit(owner, event, "codec-capabilities", "engine-context", e -> e - .observed("snapshotId", snapshotId).observed("decoderName", info.name()).unknown("profiles", READ_ERROR)); - } - } - - private ExoDiagnosticCodecAdapter(MediaCodecAdapter delegate, PlaybackDiagnosticCollector log, Context owner, - String decoderId, Configuration config, boolean video) { - super(delegate); - this.log = log; this.owner = owner; this.decoderId = decoderId; this.video = video; name = config.codecInfo.name; - surfaceId = PlaybackDiagnosticCollector.objectId(config.surface, "surface"); - emit(video ? "video.configure" : "audio.decoder.output", e -> { - ExoDiagnosticCollector.format(e, config.format); - e.observed("surfaceId", surfaceId).unknown("renderCallbacks", NOT_COLLECTED); - }); - } - - private void emit(String event, java.util.function.Consumer facts) { - log.emit(owner, event, "codec-adapter", "adapter-lifetime; media-at-create-unconfirmed", e -> { - e.observed("decoderId", decoderId).observed("decoderName", name).observed("epoch", epoch); - facts.accept(e); - }); - } - - static void mediaFormat(PlaybackDiagnosticCollector log, Context owner, MediaFormat format, String id, String event) { - if (!DebugLogStore.acceptsEvent(event)) return; - String[] keys = {"mime", "width", "height", "max-width", "max-height", "profile", "level", "color-standard", "color-range", - "color-transfer", "rotation-degrees", "max-input-size", "operating-rate", "priority", "low-latency", "crop-left", - "crop-right", "crop-top", "crop-bottom", "stride", "slice-height", "sample-rate", "channel-count", "pcm-encoding"}; - for (String key : keys) { - if (!format.containsKey(key)) continue; - try { - Object value = "mime".equals(key) ? format.getString(key) - : "operating-rate".equals(key) ? format.getFloat(key) : format.getInteger(key); - log.emit(owner, event, "codec-mediaformat", "adapter-configuration", e -> e.observed("decoderId", id) - .observed("property", key).observed("value", value)); - } catch (RuntimeException ignored) { - log.emit(owner, event, "codec-mediaformat", "adapter-configuration", e -> e.observed("decoderId", id) - .observed("property", key).unknown("value", READ_ERROR)); - } - } - int count = 0, bytes = 0; - boolean protectedCsd = log.protectedMedia(); - boolean digestComplete = !protectedCsd && owner == log.context(); - java.security.MessageDigest digest = null; - if (digestComplete) try { digest = java.security.MessageDigest.getInstance("SHA-256"); } - catch (java.security.NoSuchAlgorithmException ignored) { digestComplete = false; } - for (int i = 0; i < 8; i++) { - try { - java.nio.ByteBuffer csd = format.getByteBuffer("csd-" + i); - if (csd != null) { - count++; int length = csd.remaining(); - bytes = (int) Math.min(Integer.MAX_VALUE, (long) bytes + length); - if (bytes > 65536) digestComplete = false; - if (digestComplete) { - digest.update((byte) i); - digest.update(new byte[]{(byte)(length >>> 24), (byte)(length >>> 16), (byte)(length >>> 8), (byte)length}); - digest.update(csd.duplicate()); // Never consume the codec's original buffer. - } - } - } catch (RuntimeException ignored) { digestComplete = false; DebugLogStore.collectorFailure(); } - } - String digestValue = null; - if (digestComplete && count > 0) { - StringBuilder text = new StringBuilder(64); - for (byte value : digest.digest()) text.append(Character.forDigit((value >>> 4) & 15, 16)).append(Character.forDigit(value & 15, 16)); - digestValue = text.toString(); - } - int csdCount = count, csdBytes = bytes; - String csdDigest = digestValue; - log.emit(owner, event, "codec-mediaformat", "adapter-configuration", e -> { - e.observed("decoderId", id).observed("csdCount", csdCount).observed("csdBytes", csdBytes); - if (csdDigest != null) e.observed("csdDigest", csdDigest); - else e.unknown("csdDigest", protectedCsd ? PERMISSION_DENIED : csdCount == 0 ? NOT_APPLICABLE : NOT_COLLECTED); - }); - } - - @Override public void queueInputBuffer(int index, int offset, int size, long pts, int flags) { - super.queueInputBuffer(index, offset, size, pts, flags); - input(size, pts, flags, false); - } - @Override public void queueSecureInputBuffer(int index, int offset, CryptoInfo crypto, long pts, int flags) { - super.queueSecureInputBuffer(index, offset, crypto, pts, flags); - input(0, pts, flags, true); - } - private void input(int size, long pts, int flags, boolean secure) { - if (!DebugLogStore.acceptsEvent(video ? "video.sample.summary" : "audio.decoder.output")) return; - captureWindow(); - inputs++; bytes += size; if (secure) encrypted++; - if ((flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) eos++; - if (lastPts != Long.MIN_VALUE && pts < lastPts) regressions++; - lastPts = pts; lastInputMs = SystemClock.elapsedRealtime(); - if (firstPts == Long.MIN_VALUE) { - firstPts = pts; - emit(video ? "video.first-output" : "audio.decoder.output", e -> e.observed("stage", "first-input-queued").observed("firstPtsUs", pts)); - } - summary(false); - } - @Override public int dequeueOutputBufferIndex(MediaCodec.BufferInfo info) { - int result = super.dequeueOutputBufferIndex(info); - if (result >= 0 && DebugLogStore.acceptsEvent(video ? "video.output.summary" : "audio.decoder.output")) { - captureWindow(); - outputs++; lastOutputMs = SystemClock.elapsedRealtime(); - if ((info.flags & (MediaCodec.BUFFER_FLAG_CODEC_CONFIG | MediaCodec.BUFFER_FLAG_END_OF_STREAM)) == 0) lastDecodedPts = info.presentationTimeUs; - if (outputs == 1) emit(video ? "video.first-output" : "audio.decoder.output", e -> e - .observed("stage", "first-output-buffer").observed("firstPtsUs", info.presentationTimeUs)); - summary(false); - } - return result; - } - @Override public MediaFormat getOutputFormat() { - MediaFormat result = super.getOutputFormat(); - mediaFormat(log, owner, result, decoderId, video ? "video.output.format" : "audio.decoder.output"); - return result; - } - @Override public void releaseOutputBuffer(int index, boolean render) { super.releaseOutputBuffer(index, render); released(render); } - @Override public void releaseOutputBuffer(int index, long time) { super.releaseOutputBuffer(index, time); released(true); } - private void released(boolean render) { - if (!DebugLogStore.acceptsEvent(video ? "video.output.summary" : "audio.decoder.output")) return; - captureWindow(); - if (render) { - submitted++; - if (submitted == 1) emit("video.first-output", e -> e.observed("stage", "first-release-to-surface").observed("surfaceId", surfaceId)); - } else discarded++; - } - @Override public void setOnFrameRenderedListener(OnFrameRenderedListener listener, Handler handler) { - // Only observe the listener already requested by Media3 (notably tunneling). - hasFrameListener = true; - super.setOnFrameRenderedListener((codec, pts, time) -> { - if (PlaybackDiagnosticCollector.enabled()) callbacks++; - listener.onFrameRendered(this, pts, time); - }, handler); - } - @Override public void setOutputSurface(Surface surface) { - super.setOutputSurface(surface); - String old = surfaceId; surfaceId = PlaybackDiagnosticCollector.objectId(surface, "surface"); - emit("surface.bind", e -> e.observed("oldSurfaceId", old).observed("surfaceId", surfaceId).observed("result", "success")); - } - @Override public void detachOutputSurface() { - super.detachOutputSurface(); String old = surfaceId; surfaceId = "none"; - emit("surface.bind", e -> e.observed("oldSurfaceId", old).observed("surfaceId", surfaceId).observed("phase", "detached")); - } - @Override public void flush() { - summary(true); - try { super.flush(); lifecycle("flush", "success"); } - catch (RuntimeException error) { log.error(owner, "codec", "flush", error); throw error; } - epoch++; inputs = bytes = outputs = submitted = discarded = encrypted = eos = regressions = callbacks = 0; - firstPts = lastPts = lastDecodedPts = Long.MIN_VALUE; lastInputMs = lastOutputMs = 0; - } - @Override public void release() { - summary(true); - try { super.release(); lifecycle("release", "success"); } - catch (RuntimeException error) { log.error(owner, "codec", "release", error); throw error; } - } - private void lifecycle(String operation, String result) { - emit(video ? "video.flush-reuse-release" : "audio.decoder.attempt", e -> e.observed("operation", operation).observed("result", result)); - } - private void summary(boolean force) { - if (!DebugLogStore.acceptsEvent(video ? "video.output.summary" : "audio.decoder.output")) return; - long now = SystemClock.elapsedRealtime(); - if (!force && now - lastSummaryMs < 5000) return; - lastSummaryMs = now; - emit("audio.sync", e -> e.observed("video", video).observed("lastPtsUs", lastDecodedPts == Long.MIN_VALUE ? null : lastDecodedPts) - .observed("lastAgeMs", lastOutputMs == 0 ? null : now - lastOutputMs) - .observed("metricScope", "decoder output PTS; correlate by owner/epoch; not physical presentation time")); - emit(video ? "video.sample.summary" : "audio.decoder.output", e -> e.observed("inputBuffers", inputs) - .observed("bytes", bytes).observed("encryptedBuffers", encrypted).observed("eosBuffers", eos) - .observed("ptsRegressions", regressions).observed("firstPtsUs", firstPts == Long.MIN_VALUE ? null : firstPts) - .observed("lastPtsUs", lastPts == Long.MIN_VALUE ? null : lastPts) - .observed("lastAgeMs", lastInputMs == 0 ? null : now - lastInputMs)); - emit(video ? "video.output.summary" : "audio.decoder.output", e -> e.observed("outputBuffers", outputs) - .observed("submittedBuffers", submitted).observed("discardedBuffers", discarded) - .observed("surfaceId", surfaceId).observed("lastAgeMs", lastOutputMs == 0 ? null : now - lastOutputMs) - .observed("metricScope", "adapter calls since capture/flush; secure/tunneling may bypass dequeue") - .unknown("physicalDisplay", NOT_OBSERVABLE)); - if (hasFrameListener) emit("video.surface-render-callback", e -> e.observed("renderCallbacks", callbacks) - .observed("surfaceId", surfaceId).observed("metricScope", "existing Media3 listener; may batch/delay")); - } - - private long diagnosticGeneration = -1; - private void captureWindow() { - long generation = DebugLogStore.captureGeneration(); - if (generation == diagnosticGeneration) return; - diagnosticGeneration = generation; epoch++; - inputs = bytes = outputs = submitted = discarded = encrypted = eos = regressions = callbacks = 0; - firstPts = lastPts = lastDecodedPts = Long.MIN_VALUE; lastInputMs = lastOutputMs = lastSummaryMs = 0; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCodecSnapshotCache.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCodecSnapshotCache.java deleted file mode 100644 index ee186347ee..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCodecSnapshotCache.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector.Context; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Objects; - -/** Per-collector diagnostic snapshots only; never caches or changes decoder selection results. */ -final class ExoDiagnosticCodecSnapshotCache { - static final int MAX_QUERIES = 32; - private static final int MAX_CANDIDATES = 128; - - record Query(String mime, boolean secure, boolean tunneling, String policy) {} - record Profile(int profile, int level) {} - record Candidate(String name, boolean hardwareAccelerated, boolean softwareOnly, - boolean vendor, int profileCount, List profiles, boolean readable) { - Candidate { profiles = List.copyOf(profiles); } - } - record Observation(long snapshotId, long queryCount, boolean fullSnapshot) {} - - private static final class Entry { - final long id; - final List candidates; - long queries = 1; - - Entry(long id, List candidates) { - this.id = id; - this.candidates = List.copyOf(candidates); - } - } - - private final LinkedHashMap entries = new LinkedHashMap<>(16, 0.75f, true); - private Context owner; - private long captureGeneration = -1; - private long nextId; - - synchronized Observation observe(Context context, long generation, Query query, - List candidates) { - if (!Objects.equals(owner, context) || captureGeneration != generation) { - entries.clear(); - owner = context; - captureGeneration = generation; - } - boolean cacheable = candidates.size() <= MAX_CANDIDATES; - for (Candidate candidate : candidates) cacheable &= candidate.readable(); - Entry previous = entries.get(query); - if (cacheable && previous != null && previous.candidates.equals(candidates)) { - if (previous.queries < Long.MAX_VALUE) previous.queries++; - return new Observation(previous.id, previous.queries, false); - } - long id = ++nextId; - if (cacheable) { - entries.put(query, new Entry(id, candidates)); - if (entries.size() > MAX_QUERIES) entries.remove(entries.keySet().iterator().next()); - } else { - entries.remove(query); // A failed capabilities read must never look like a cache hit. - } - return new Observation(id, 1, true); - } - - synchronized void clear() { - entries.clear(); - owner = null; - captureGeneration = -1; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCollector.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCollector.java deleted file mode 100644 index e5d9d7638d..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCollector.java +++ /dev/null @@ -1,290 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import android.os.Handler; -import android.os.SystemClock; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MediaItem; -import androidx.media3.common.PlaybackException; -import androidx.media3.common.Player; -import androidx.media3.common.Timeline; -import androidx.media3.common.Tracks; -import androidx.media3.exoplayer.DecoderCounters; -import androidx.media3.exoplayer.DecoderReuseEvaluation; -import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.exoplayer.analytics.AnalyticsListener; -import androidx.media3.exoplayer.source.LoadEventInfo; -import androidx.media3.exoplayer.source.MediaLoadData; - -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector; -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector.Context; -import com.fongmi.android.tv.player.SystemAudioDiagnosticCollector; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; - -import java.io.IOException; -import java.lang.ref.WeakReference; -import java.util.List; -import java.util.Map; -import java.util.WeakHashMap; -import java.util.function.Consumer; - -import static com.github.catvod.crawler.diagnostics.DiagnosticEvent.Status.*; - -/** Public Media3 hooks, separate from PlaybackAnalyticsListener's compatibility/UI state. */ -public final class ExoDiagnosticCollector implements AnalyticsListener { - private static final Map> PLAYERS = new WeakHashMap<>(); - private static final Map> MEDIA = new WeakHashMap<>(); - final PlaybackDiagnosticCollector log = new PlaybackDiagnosticCollector("exo", "1.11.0-alpha01-fongmi"); - final ExoDiagnosticCodecSnapshotCache codecSnapshots = new ExoDiagnosticCodecSnapshotCache(); - private WeakReference player = new WeakReference<>(null); - private Handler handler; - private final Runnable tick = this::sample; - private DecoderCounters videoCounters, audioCounters; - private Context videoOwner, audioOwner; - private long seekEpoch; - private final com.fongmi.android.tv.player.OutputProgressDiagnostic videoProgress = new com.fongmi.android.tv.player.OutputProgressDiagnostic(true); - - void attach(ExoPlayer value) { - player = new WeakReference<>(value); - synchronized (PLAYERS) { PLAYERS.put(value, new WeakReference<>(this)); } - Media3DiagnosticBridge.bind(value, log, null, log.instanceId()); - value.addAnalyticsListener(this); - handler = new Handler(value.getApplicationLooper()); - handler.postDelayed(tick, 5000); - } - - public static void effects(ExoPlayer player, List effects) { - if (!PlaybackDiagnosticCollector.enabled()) return; - ExoDiagnosticCollector collector; - synchronized (PLAYERS) { - WeakReference reference = PLAYERS.get(player); - collector = reference == null ? null : reference.get(); - } - if (collector == null) return; - collector.log.emit("video.effects", "ExoPlayer.setVideoEffects", e -> e.observed("count", effects.size()).observed("phase", "requested")); - for (int i = 0; i < effects.size(); i++) { - int index = i; - collector.log.emit("video.effects", "ExoPlayer.setVideoEffects", e -> e.observed("candidateIndex", index) - .observed("value", effects.get(index).getClass().getSimpleName()).observed("phase", "requested")); - } - } - - public static MediaItem prepare(ExoPlayer player, MediaItem item, String trace) { - ExoDiagnosticCollector collector; - synchronized (PLAYERS) { - WeakReference reference = PLAYERS.get(player); - collector = reference == null ? null : reference.get(); - } - if (collector == null) return item; - Context owner = collector.log.begin(trace, "foreground"); - collector.codecSnapshots.clear(); - synchronized (MEDIA) { MEDIA.put(owner, new WeakReference<>(collector.log)); } - collector.log.protectedMedia(item.localConfiguration != null && item.localConfiguration.drmConfiguration != null); - collector.seekEpoch = 0; - // The private tag follows EventTime's media item through replace/seek/reprepare. - // Keep an existing application tag intact; its events then explicitly lack association. - if (item.localConfiguration == null || item.localConfiguration.tag != null) return item; - return item.buildUpon().setTag(owner).build(); - } - - static PlaybackDiagnosticCollector mediaLog(Context owner) { - synchronized (MEDIA) { - WeakReference value = MEDIA.get(owner); - return value == null ? null : value.get(); - } - } - - private static Context owner(Timeline timeline, int windowIndex) { - if (timeline.isEmpty() || windowIndex < 0 || windowIndex >= timeline.getWindowCount()) return null; - MediaItem item = timeline.getWindow(windowIndex, new Timeline.Window()).mediaItem; - return item.localConfiguration != null && item.localConfiguration.tag instanceof Context context ? context : null; - } - - private Context owner(EventTime time) { return owner(time.timeline, time.windowIndex); } - - private void event(EventTime time, String name, Consumer facts) { - if (!PlaybackDiagnosticCollector.enabled()) return; - Context owner = owner(time); - log.emit(owner, name, "media3-analytics", owner == null ? "unresolved" : "event-time-media", e -> { - e.observed("positionMs", time.eventPlaybackPositionMs).observed("bufferedMs", time.totalBufferedDurationMs) - .observed("ageMs", Math.max(0, SystemClock.elapsedRealtime() - time.realtimeMs)); - facts.accept(e); - }); - } - - static void format(DiagnosticEvent e, Format f) { - if (f == null) { e.unknown("format", UNAVAILABLE); return; } - e.observed("mime", f.sampleMimeType).observed("codecs", f.codecs) - .observed("width", f.width > 0 ? f.width : null).observed("height", f.height > 0 ? f.height : null) - .observed("frameRate", f.frameRate > 0 ? f.frameRate : null) - .observed("sampleRate", f.sampleRate > 0 ? f.sampleRate : null) - .observed("channels", f.channelCount > 0 ? f.channelCount : null) - .observed("encoding", f.pcmEncoding != Format.NO_VALUE ? f.pcmEncoding : null) - .observed("bitrate", f.bitrate > 0 ? f.bitrate : null) - .observed("language", f.language).observed("flags", f.selectionFlags) - .observed("secure", f.cryptoType != C.CRYPTO_TYPE_NONE); - } - - private void sample() { - ExoPlayer current = player.get(); - if (current == null) return; - try { - if (PlaybackDiagnosticCollector.enabled()) { - log.baseline(); - Context owner = owner(current.getCurrentTimeline(), current.getCurrentMediaItemIndex()); - log.emit(owner, "play.clock", "media3-player", "current-timeline", e -> e - .observed("positionMs", current.getCurrentPosition()).observed("bufferedMs", current.getTotalBufferedDuration()) - .observed("state", current.getPlaybackState()).observed("playWhenReady", current.getPlayWhenReady()) - .observed("isPlaying", current.isPlaying()).observed("suppressionReason", current.getPlaybackSuppressionReason()) - .observed("speed", current.getPlaybackParameters().speed).observed("seekEpoch", seekEpoch) - .unknown("physicalDisplay", NOT_OBSERVABLE)); - counters(videoOwner, videoCounters, true); - counters(audioOwner, audioCounters, false); - log.emit(owner, "audio.sync", "media3-player-clock", "current-timeline", e -> e - .observed("positionMs", current.getCurrentPosition()).observed("speed", current.getPlaybackParameters().speed) - .observed("seekEpoch", seekEpoch).observed("property", "avsync").unknown("value", NOT_OBSERVABLE) - .observed("metricScope", "player media clock; decoder PTS recorded separately, not a physical A/V sync measurement")); - Format selected = current.getVideoFormat(); - String gate = !current.isPlaying() ? "paused-buffering-or-suppressed" - : owner != videoOwner || videoCounters == null ? "video-owner-or-counter-unavailable" - : selected == null || selected.frameRate < 1 ? "no-motion-video-or-unknown-cadence" - : videoCounters.queuedInputBufferCount == 0 ? "no-video-input-observed" : null; - videoProgress.sample(log, owner, seekEpoch, SystemClock.elapsedRealtime(), - videoCounters == null ? Double.NaN : videoCounters.renderedOutputBufferCount, gate, - "renderer submitted buffers; known cadence >= 1 fps; three 5-second samples"); - SystemAudioDiagnosticCollector.snapshot(log, owner); - } - } catch (RuntimeException ignored) { com.github.catvod.crawler.DebugLogStore.collectorFailure(); } - handler.postDelayed(tick, 5000); - } - - private void counters(Context owner, DecoderCounters c, boolean video) { - if (c == null || !PlaybackDiagnosticCollector.enabled()) return; - c.ensureUpdated(); - log.emit(owner, video ? "video.output.summary" : "audio.decoder.output", "media3-decoder-counters", "renderer-enable-lifetime", e -> e - .observed("inputBuffers", c.queuedInputBufferCount).observed("submittedBuffers", c.renderedOutputBufferCount) - .observed("skippedBuffers", c.skippedOutputBufferCount).observed("droppedBuffers", c.droppedBufferCount) - .observed("metricScope", "renderer counters since enable; submitted does not mean physical presentation") - .unknown("physicalDisplay", NOT_OBSERVABLE)); - if (c.queuedInputBufferCount > 0) log.evidence(owner, video, 1); - if (c.renderedOutputBufferCount > 0) log.evidence(owner, video, video ? 3 : 2); - } - - @Override public void onVideoEnabled(EventTime t, DecoderCounters c) { videoCounters = c; videoOwner = owner(t); } - @Override public void onAudioEnabled(EventTime t, DecoderCounters c) { audioCounters = c; audioOwner = owner(t); } - @Override public void onVideoDisabled(EventTime t, DecoderCounters c) { counters(owner(t), c, true); videoCounters = null; } - @Override public void onAudioDisabled(EventTime t, DecoderCounters c) { counters(owner(t), c, false); audioCounters = null; } - - @Override public void onVideoDecoderInitialized(EventTime t, String name, long time, long duration) { - event(t, "video.decoder.attempt", e -> e.observed("decoderName", name).observed("durationMs", duration) - .observed("phase", "initialized").observed("stage", "create-configure-start aggregate")); - } - @Override public void onAudioDecoderInitialized(EventTime t, String name, long time, long duration) { - event(t, "audio.decoder.attempt", e -> e.observed("decoderName", name).observed("durationMs", duration).observed("phase", "initialized")); - } - @Override public void onVideoInputFormatChanged(EventTime t, Format f, DecoderReuseEvaluation reuse) { inputFormat(t, f, reuse, true); } - @Override public void onAudioInputFormatChanged(EventTime t, Format f, DecoderReuseEvaluation reuse) { inputFormat(t, f, reuse, false); } - private void inputFormat(EventTime t, Format f, DecoderReuseEvaluation reuse, boolean video) { - event(t, video ? "video.configure" : "audio.track", e -> { - format(e, f); - e.observed("stage", "renderer-input"); - if (reuse != null) e.observed("reuseResult", reuse.result).observed("discardReasons", reuse.discardReasons) - .observed("decoderName", reuse.decoderName); - }); - } - - @Override public void onTracksChanged(EventTime t, Tracks tracks) { - if (!PlaybackDiagnosticCollector.enabled()) return; - int groupId = 0; - for (Tracks.Group group : tracks.getGroups()) { - if (group.getType() == C.TRACK_TYPE_VIDEO) log.evidence(owner(t), true, 0); - if (group.getType() == C.TRACK_TYPE_AUDIO) log.evidence(owner(t), false, 0); - int id = groupId++; - for (int i = 0; i < group.length; i++) { - int index = i; - event(t, "media.tracks", e -> { - format(e, group.getTrackFormat(index)); - e.observed("trackId", id + ":" + index).observed("trackType", group.getType()) - .observed("selected", group.isTrackSelected(index)).observed("support", group.getTrackSupport(index)); - }); - } - } - } - - @Override public void onTimelineChanged(EventTime time, int reason) { - if (time.timeline.isEmpty()) return; - Timeline.Window window = time.timeline.getWindow(time.windowIndex, new Timeline.Window()); - event(time, "media.container", e -> e.observed("durationMs", window.getDurationMs() == C.TIME_UNSET ? null : window.getDurationMs()) - .observed("seekable", window.isSeekable).observed("live", window.isLive()).observed("reason", reason) - .observed("source", "Media3 timeline").unknown("backend", NOT_COLLECTED)); - } - - @Override public void onRenderedFirstFrame(EventTime t, Object output, long time) { - event(t, "video.first-output", e -> e.observed("stage", "media3-first-frame-callback") - .observed("surfaceId", PlaybackDiagnosticCollector.objectId(output, "surface")) - .unknown("physicalDisplay", NOT_OBSERVABLE)); - log.evidence(owner(t), true, 3); - } - @Override public void onAudioPositionAdvancing(EventTime t, long time) { - event(t, "audio.output.playhead", e -> e.observed("stage", "media3-position-advancing").unknown("audibility", NOT_OBSERVABLE)); - log.evidence(owner(t), false, 4); - } - @Override public void onVideoCodecError(EventTime t, Exception error) { log.error(owner(t), "video-codec", "runtime", error); } - @Override public void onAudioCodecError(EventTime t, Exception error) { log.error(owner(t), "audio-codec", "runtime", error); } - @Override public void onAudioSinkError(EventTime t, Exception error) { log.error(owner(t), "audio-sink", "sink-callback", error); } - @Override public void onPlayerError(EventTime t, PlaybackException error) { log.error(owner(t), "media3-player", "player-error", error); } - @Override public void onDrmSessionManagerError(EventTime t, Exception error) { log.error(owner(t), "drm", "session", error); } - @Override public void onDrmSessionAcquired(EventTime t, int state) { event(t, "drm.state", e -> e.observed("state", state).observed("phase", "acquired")); } - @Override public void onDrmKeysLoaded(EventTime t) { event(t, "drm.state", e -> e.observed("phase", "keys-loaded")); } - @Override public void onDrmSessionReleased(EventTime t) { event(t, "drm.state", e -> e.observed("phase", "released")); } - @Override public void onPositionDiscontinuity(EventTime t, Player.PositionInfo oldPosition, Player.PositionInfo newPosition, int reason) { - seekEpoch++; - event(t, "play.clock", e -> e.observed("reason", reason).observed("seekEpoch", seekEpoch).observed("phase", "discontinuity")); - } - @Override public void onPlaybackStateChanged(EventTime t, int state) { event(t, "play.lifecycle", e -> e.observed("state", state)); } - @Override public void onPlayWhenReadyChanged(EventTime t, boolean value, int reason) { - event(t, "play.lifecycle", e -> e.observed("playWhenReady", value).observed("reason", reason)); - } - @Override public void onPlaybackSuppressionReasonChanged(EventTime t, int reason) { - event(t, "audio.focus", e -> e.observed("focusOwner", "Media3").observed("suppressionReason", reason) - .unknown("focusRequest", NOT_COLLECTED).unknown("muted", NOT_OBSERVABLE)); - } - @Override public void onVolumeChanged(EventTime t, float value) { event(t, "audio.volume", e -> e.observed("volume", value).observed("stage", "player-volume")); } - @Override public void onAudioUnderrun(EventTime t, int bytes, long bufferMs, long elapsed) { - event(t, "audio.error", e -> e.severity("warn").observed("stage", "underrun").observed("bufferSize", bytes) - .observed("bufferUnit", "bytes").observed("durationMs", bufferMs).observed("lastAgeMs", elapsed)); - } - @Override public void onLoadStarted(EventTime t, LoadEventInfo info, MediaLoadData data) { load(t, info, "input.open"); } - @Override public void onLoadCompleted(EventTime t, LoadEventInfo info, MediaLoadData data) { load(t, info, "input.read.summary"); } - @Override public void onLoadError(EventTime t, LoadEventInfo info, MediaLoadData data, IOException error, boolean canceled) { - load(t, info, "input.read.summary"); log.error(owner(t), "input", canceled ? "canceled" : "load", error); - } - private void load(EventTime t, LoadEventInfo info, String name) { - event(t, name, e -> { - e.observed("loadTaskId", info.loadTaskId).observed("rangeStart", info.dataSpec.position) - .observed("rangeLength", info.dataSpec.length < 0 ? null : info.dataSpec.length) - .observed("bytes", info.bytesLoaded).observed("durationMs", info.loadDurationMs); - for (Map.Entry> header : info.responseHeaders.entrySet()) { - if (header.getKey() == null || header.getValue().isEmpty()) continue; - String field = switch (header.getKey().toLowerCase(java.util.Locale.ROOT)) { - case "content-type" -> "contentTypeHeader"; - case "content-length" -> "contentLength"; - case "content-range" -> "contentRange"; - default -> null; - }; - if (field != null) e.observed(field, header.getValue().get(0)); - } - e.unknown("httpCode", NOT_COLLECTED); - }); - } - - @Override public void onPlayerReleased(EventTime t) { - if (handler != null) handler.removeCallbacks(tick); - codecSnapshots.clear(); - counters(videoOwner, videoCounters, true); counters(audioOwner, audioCounters, false); - log.end("player-released"); - synchronized (PLAYERS) { ExoPlayer p = player.get(); if (p != null) PLAYERS.remove(p); } - player.clear(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDv5GpuRendererFactory.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDv5GpuRendererFactory.java index a0a4ac1a06..75828b3964 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDv5GpuRendererFactory.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoDv5GpuRendererFactory.java @@ -8,18 +8,20 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter; import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; import androidx.media3.exoplayer.video.VideoRendererEventListener; -/** Creates the DV5 fallback renderer when the native GPU backend is available. */ +/** Creates the E9-3 experimental renderer only after an explicit opt-in. */ final class ExoDv5GpuRendererFactory { private ExoDv5GpuRendererFactory() { } - static boolean shouldCreate(ExoDv5Native.Probe probe) { - return probe != null && probe.available(); + static boolean shouldCreate( + boolean experimentEnabled, ExoDv5Native.Probe probe) { + return experimentEnabled && probe != null && probe.available(); } @Nullable static ExoDv5GpuRenderer create( + boolean experimentEnabled, Context context, MediaCodecAdapter.Factory codecAdapterFactory, MediaCodecSelector mediaCodecSelector, @@ -29,7 +31,7 @@ final class ExoDv5GpuRendererFactory { @Nullable VideoRendererEventListener eventListener, ExoFrameSchedulingExperimentPolicy.Decision frameSchedulingDecision) { ExoDv5Native.Probe probe = ExoDv5Native.probe(); - if (!shouldCreate(probe)) return null; + if (!shouldCreate(experimentEnabled, probe)) return null; return new ExoDv5GpuRenderer( context, codecAdapterFactory, diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSource.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSource.java deleted file mode 100644 index 874496ed83..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSource.java +++ /dev/null @@ -1,183 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import android.net.Uri; - -import androidx.annotation.Nullable; -import androidx.media3.common.C; -import androidx.media3.datasource.DataSource; -import androidx.media3.datasource.DataSpec; -import androidx.media3.datasource.TransferListener; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.api.config.AdBlockStatsStore; -import com.fongmi.android.tv.utils.HlsAdblockNotice; -import com.fongmi.android.tv.utils.HlsAdblockPipeline; -import com.fongmi.android.tv.utils.HlsManifestCleaner; -import com.fongmi.android.tv.api.config.HlsRuleConfig; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.utils.Notify; -import com.github.catvod.crawler.SpiderDebug; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -final class ExoHlsAdblockDataSource implements DataSource { - - private static final String TAG = "exo-hls-adblock"; - private final DataSource upstream; - private byte[] manifest; - private int position; - - ExoHlsAdblockDataSource(DataSource upstream) { - this.upstream = upstream; - } - - @Override - public void addTransferListener(TransferListener transferListener) { - upstream.addTransferListener(transferListener); - } - - @Override - public long open(DataSpec dataSpec) throws IOException { - long upstreamLength = upstream.open(dataSpec); - if (!isManifestRequest(dataSpec.uri.toString(), upstream.getResponseHeaders())) return upstreamLength; - try { - byte[] original = readAll(); - String text = new String(original, StandardCharsets.UTF_8); - List rules = List.of(); - boolean legacyFallback = false; - if (Setting.isAdblock()) { - rules = HlsRuleConfig.getRules(); - legacyFallback = HlsRuleConfig.isLegacyFallbackEnabled(); - } - HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( - dataSpec.uri.toString(), text, rules, legacyFallback); - manifest = outcome.manifest().getBytes(StandardCharsets.UTF_8); - position = 0; - recordAndNotify(dataSpec.uri, outcome); - if (SpiderDebug.isEnabled()) { - SpiderDebug.log(TAG, "manifest bytes=%d rewritten=%d removed=%d structured=%s legacy=%s url=%s", - original.length, manifest.length, outcome.removedSegments(), outcome.structured(), - outcome.legacy(), dataSpec.uri); - } - return manifest.length; - } catch (RuntimeException e) { - close(); - throw e; - } - } - - @Override - public int read(byte[] buffer, int offset, int length) throws IOException { - if (manifest == null) return upstream.read(buffer, offset, length); - if (position >= manifest.length) return C.RESULT_END_OF_INPUT; - int count = Math.min(length, manifest.length - position); - System.arraycopy(manifest, position, buffer, offset, count); - position += count; - return count; - } - - @Nullable - @Override - public Uri getUri() { - return upstream.getUri(); - } - - @Override - public Map> getResponseHeaders() { - return upstream.getResponseHeaders(); - } - - @Override - public void close() throws IOException { - manifest = null; - position = 0; - upstream.close(); - } - - static boolean isManifestUrl(String url) { - if (url == null) return false; - int query = url.indexOf('?'); - int fragment = url.indexOf('#'); - int end = url.length(); - if (query >= 0) end = Math.min(end, query); - if (fragment >= 0) end = Math.min(end, fragment); - return url.substring(0, end).toLowerCase(Locale.ROOT).endsWith(".m3u8"); - } - - static boolean isManifestRequest(String url, Map> responseHeaders) { - if (isManifestUrl(url) || responseHeaders == null) return isManifestUrl(url); - for (Map.Entry> header : responseHeaders.entrySet()) { - if (header.getKey() == null || !"content-type".equalsIgnoreCase(header.getKey())) continue; - for (String value : header.getValue()) { - if (isHlsContentType(value)) return true; - } - } - return false; - } - - private static boolean isHlsContentType(String value) { - if (value == null) return false; - String type = value.toLowerCase(Locale.ROOT); - return type.contains("application/vnd.apple.mpegurl") - || type.contains("application/x-mpegurl") - || type.contains("audio/mpegurl") - || type.contains("audio/x-mpegurl"); - } - - static Result cleanForTest(String url, String manifest) { - HlsManifestCleaner.Rule rule = HlsManifestCleaner.Rule.builder() - .id("test-ad-path") - .segmentUrlPatterns(List.of("(^|/)ad/")) - .build(); - HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( - url, manifest, List.of(rule), true); - return new Result(outcome.manifest(), outcome.structured() || outcome.legacy(), notice(outcome)); - } - - private byte[] readAll() throws IOException { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - byte[] buffer = new byte[8192]; - int count; - while ((count = upstream.read(buffer, 0, buffer.length)) != C.RESULT_END_OF_INPUT) { - output.write(buffer, 0, count); - } - return output.toByteArray(); - } - - private static void recordAndNotify(Uri uri, HlsAdblockPipeline.Outcome outcome) { - if (!outcome.structured() && !outcome.legacy()) return; - long fallbackCount = outcome.legacy() ? Math.max(1, outcome.removedSegments()) : 0; - AdBlockStatsStore.recordBlocks(uri.getHost(), "EXO", outcome.ruleCounts(), fallbackCount, - uri.getHost(), outcome.removedDurationSec(), outcome.removedSegmentDetails()); - if (!HlsAdblockNotice.shouldNotify(uri.toString(), System.currentTimeMillis())) return; - String message = notice(outcome); - App.post(() -> Notify.show(message)); - } - - private static String notice(HlsAdblockPipeline.Outcome outcome) { - int removed = outcome.removedSegments() > 0 ? outcome.removedSegments() : (outcome.legacy() ? 1 : 0); - return outcome.structured() && outcome.removedDurationSec() > 0 - ? String.format(Locale.US, "已跳过 %d 个广告片段(%.1f 秒)", removed, outcome.removedDurationSec()) - : "已跳过 " + removed + " 个广告片段"; - } - - record Result(String manifest, boolean changed, String notice) {} - - static final class Factory implements DataSource.Factory { - private final DataSource.Factory upstreamFactory; - - Factory(DataSource.Factory upstreamFactory) { - this.upstreamFactory = upstreamFactory; - } - - @Override - public DataSource createDataSource() { - return new ExoHlsAdblockDataSource(upstreamFactory.createDataSource()); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoLoadControlPolicy.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoLoadControlPolicy.java index cdd3ac1fe2..6c8cc98b03 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoLoadControlPolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoLoadControlPolicy.java @@ -45,8 +45,8 @@ final class ExoLoadControlPolicy { } private static BufferDurations custom(int bufferLevel) { - int level = Math.clamp(bufferLevel, 1, 10); - int minBufferMs = MIN_BUFFER_MS + (level - 1) * (MAX_BUFFER_MS - MIN_BUFFER_MS) / 9; + int level = Math.clamp(bufferLevel, 1, 15); + int minBufferMs = MIN_BUFFER_MS + (level - 1) * (MAX_BUFFER_MS - MIN_BUFFER_MS) / 14; return new BufferDurations(minBufferMs, minBufferMs * 2); } diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoNetworkGuardEligibility.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoNetworkGuardEligibility.java index 005834c155..7838a425ca 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoNetworkGuardEligibility.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoNetworkGuardEligibility.java @@ -1,8 +1,6 @@ package com.fongmi.android.tv.player.exo; -import com.fongmi.android.tv.player.AudioPlaybackDiagnostics; - -/** Ensures network protection never changes an output feature active in the current session. */ +/** Ensures network protection never changes an output feature selected by the user. */ public final class ExoNetworkGuardEligibility { private ExoNetworkGuardEligibility() { @@ -15,23 +13,11 @@ public final class ExoNetworkGuardEligibility { if (!request.userUnitSpeed()) return Decision.blocked("user-speed"); if (!request.speedCommandAvailable()) return Decision.blocked("speed-unsupported"); if (request.tunnelingRequested()) return Decision.blocked("preserve-tunneling"); - if (request.audioOutputMode() == AudioPlaybackDiagnostics.OutputMode.UNKNOWN) { - return Decision.blocked("audio-output-pending"); - } - if (request.audioOutputMode() == AudioPlaybackDiagnostics.OutputMode.PASSTHROUGH) { - return Decision.blocked("preserve-passthrough"); - } + if (request.audioPassthroughRequested()) return Decision.blocked("preserve-passthrough"); return new Decision(true, "eligible"); } - public record Request(boolean enabled, boolean exo, boolean vod, boolean userUnitSpeed, - boolean speedCommandAvailable, boolean tunnelingRequested, - AudioPlaybackDiagnostics.OutputMode audioOutputMode) { - - public Request { - audioOutputMode = audioOutputMode == null - ? AudioPlaybackDiagnostics.OutputMode.UNKNOWN : audioOutputMode; - } + public record Request(boolean enabled, boolean exo, boolean vod, boolean userUnitSpeed, boolean speedCommandAvailable, boolean tunnelingRequested, boolean audioPassthroughRequested) { } public record Decision(boolean eligible, String reason) { diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoRuntimeAwareVideoRenderer.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoRuntimeAwareVideoRenderer.java index f8bbc87f78..18c353d8a1 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoRuntimeAwareVideoRenderer.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoRuntimeAwareVideoRenderer.java @@ -22,7 +22,6 @@ final class ExoRuntimeAwareVideoRenderer extends MediaCodecVideoRenderer { private final ExoDecoderRuntimeSession runtimeSession; private final ExoDecoderRuntimeSession.OutputConfig output; - private final ExoDiagnosticCollector diagnostics; ExoRuntimeAwareVideoRenderer( Context context, @@ -35,7 +34,7 @@ final class ExoRuntimeAwareVideoRenderer extends MediaCodecVideoRenderer { ExoDecoderRuntimeSession runtimeSession, ExoDecoderRuntimeSession.OutputConfig output, ExoFrameSchedulingExperimentPolicy.Decision - frameSchedulingDecision, ExoDiagnosticCollector diagnostics) { + frameSchedulingDecision) { super(builder( context, codecAdapterFactory, @@ -47,7 +46,6 @@ final class ExoRuntimeAwareVideoRenderer extends MediaCodecVideoRenderer { frameSchedulingDecision)); this.runtimeSession = runtimeSession; this.output = output; - this.diagnostics = diagnostics; } @Override @@ -71,17 +69,12 @@ final class ExoRuntimeAwareVideoRenderer extends MediaCodecVideoRenderer { List allowed = new ArrayList<>(infos.size()); long nowEpochMs = System.currentTimeMillis(); for (MediaCodecInfo info : infos) { - boolean excluded = info == null || runtimeSession.shouldExclude( - info.name, format, secure, output, nowEpochMs); - if (info != null && diagnostics != null) diagnostics.log.emit("video.candidates", "runtime-profile-selector", e -> e - .observed("decoderName", info.name).observed("accepted", !excluded) - .observed("reason", excluded ? "existing-runtime-profile-exclusion" : "runtime-profile-allowed")); - if (excluded) { + if (info == null || runtimeSession.shouldExclude( + info.name, format, secure, output, nowEpochMs)) { continue; } allowed.add(info); } - ExoDiagnosticCodecAdapter.candidates(diagnostics, allowed, format.sampleMimeType, secure, output.tunneling(), "runtime-renderer-final-order"); return allowed; } diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoStartupAudioRenderer.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoStartupAudioRenderer.java deleted file mode 100644 index 9bd9d7db46..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoStartupAudioRenderer.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.PlaybackException; -import androidx.media3.common.util.Clock; -import androidx.media3.exoplayer.ExoPlaybackException; -import androidx.media3.exoplayer.ForwardingRenderer; -import androidx.media3.exoplayer.Renderer; -import androidx.media3.exoplayer.RendererConfiguration; -import androidx.media3.exoplayer.analytics.PlayerId; -import androidx.media3.exoplayer.source.MediaSource; -import androidx.media3.exoplayer.source.SampleStream; - -/** Retains Media3's renderer/clock and uses its recovery contract without preparing a new source. */ -final class ExoStartupAudioRenderer extends ForwardingRenderer { - private final ExoCompressedAudioDirectPolicy policy; - private int index; - private MediaSource.MediaPeriodId mediaPeriodId; - - ExoStartupAudioRenderer(Renderer renderer, ExoCompressedAudioDirectPolicy policy) { - super(renderer); - this.policy = policy; - } - - @Override - public void init(int index, PlayerId playerId, Clock clock) { - this.index = index; - super.init(index, playerId, clock); - } - - @Override - public void enable(RendererConfiguration configuration, Format[] formats, SampleStream stream, - long positionUs, boolean joining, boolean mayRenderStartOfStream, - long startPositionUs, long offsetUs, MediaSource.MediaPeriodId mediaPeriodId) - throws ExoPlaybackException { - this.mediaPeriodId = mediaPeriodId; - super.enable(configuration, formats, stream, positionUs, joining, mayRenderStartOfStream, - startPositionUs, offsetUs, mediaPeriodId); - } - - @Override - public void replaceStream(Format[] formats, SampleStream stream, long startPositionUs, - long offsetUs, MediaSource.MediaPeriodId mediaPeriodId) - throws ExoPlaybackException { - this.mediaPeriodId = mediaPeriodId; - super.replaceStream(formats, stream, startPositionUs, offsetUs, mediaPeriodId); - } - - @Override - public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { - super.render(positionUs, elapsedRealtimeUs); - Format stalledFormat = policy.maybeRequestStartupPcmFallback( - getState() == STATE_STARTED && isReady() && !isEnded()); - if (stalledFormat != null) { - // A typed application observation, not a fabricated AudioTrack platform error. - // Media3 reselects and seeks in its existing period/sample queues; the App's costly - // startInternal/prepare path remains only the fallback if internal recovery fails. - throw ExoPlaybackException.createForRenderer(new StartupStallException(), getName(), - index, stalledFormat, C.FORMAT_HANDLED, mediaPeriodId, true, - PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED); - } - } - - @Override - public long getDurationToProgressUs(long positionUs, long elapsedRealtimeUs) { - return Math.min(super.getDurationToProgressUs(positionUs, elapsedRealtimeUs), - policy.startupProgressIntervalUs()); - } - - static final class StartupStallException extends Exception { - StartupStallException() { - super("Vendor audio output accepted startup data but its playback head did not advance"); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java index 89e1b89b4f..ff330d80ed 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java @@ -16,6 +16,7 @@ import android.view.accessibility.CaptioningManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.media3.common.AudioAttributes; +import androidx.media3.common.audio.AudioProcessor; import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.ColorInfo; @@ -23,9 +24,7 @@ import androidx.media3.common.MediaItem; import androidx.media3.common.MimeTypes; import androidx.media3.common.PlaybackException; import androidx.media3.common.Player; -import androidx.media3.common.TrackSelectionParameters; import androidx.media3.common.Tracks; -import androidx.media3.common.audio.AudioProcessor; import androidx.media3.exoplayer.DefaultLoadControl; import androidx.media3.exoplayer.DefaultRenderersFactory; import androidx.media3.exoplayer.DecoderReuseEvaluation; @@ -35,16 +34,14 @@ import androidx.media3.exoplayer.Renderer; import androidx.media3.exoplayer.RenderersFactory; import androidx.media3.exoplayer.audio.AudioCapabilities; import androidx.media3.exoplayer.audio.AudioSink; -import androidx.media3.exoplayer.audio.AudioRendererEventListener; import androidx.media3.exoplayer.audio.AudioOutputProvider; +import androidx.media3.exoplayer.audio.AudioRendererEventListener; import androidx.media3.exoplayer.audio.AudioTrackAudioOutputProvider; import androidx.media3.exoplayer.audio.DefaultAudioSink; -import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer; import androidx.media3.exoplayer.mediacodec.MediaCodecInfo; import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter; import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; import androidx.media3.exoplayer.source.MediaSource; -import androidx.media3.exoplayer.text.TextRenderer; import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; import androidx.media3.exoplayer.analytics.AnalyticsListener; import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime; @@ -60,22 +57,18 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.BuildConfig; import com.fongmi.android.tv.bean.Drm; import com.fongmi.android.tv.bean.Sub; -import com.fongmi.android.tv.api.config.HlsRuleConfig; import com.fongmi.android.tv.player.PlayerHelper; import com.fongmi.android.tv.player.PlaybackAutoContext; import com.fongmi.android.tv.player.PlaybackAutoContextStore; import com.fongmi.android.tv.player.PlaybackExperimentCoordinator; import com.fongmi.android.tv.player.PlaybackExperimentPolicy; import com.fongmi.android.tv.player.PlaybackSystemConditionCoordinator; -import com.fongmi.android.tv.player.engine.PlaySpec; -import com.fongmi.android.tv.player.engine.PlayerEngine; import com.fongmi.android.tv.player.audio.PlaybackMediaAudioOutputProvider; import com.fongmi.android.tv.player.audio.PlaybackMediaAudioPipeline; import com.fongmi.android.tv.player.audio.PlaybackMediaClock; import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; -import com.fongmi.android.tv.player.exo.ass.AssInput; -import com.fongmi.android.tv.player.exo.ass.ExoAssSession; -import com.fongmi.android.tv.player.exo.subtitle.ExoSubtitleSession; +import com.fongmi.android.tv.player.engine.PlaySpec; +import com.fongmi.android.tv.player.engine.PlayerEngine; import com.fongmi.android.tv.player.lut.LutSetting; import com.fongmi.android.tv.player.track.LangUtil; import com.fongmi.android.tv.setting.ExoPerformanceSetting; @@ -84,7 +77,7 @@ import com.fongmi.android.tv.setting.PlaybackExperimentSetting; import com.fongmi.android.tv.setting.PlaybackPerformanceCatalog; import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; import com.fongmi.android.tv.setting.PlayerSetting; -import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.subtitle.RealtimeSubtitleBufferSizeProvider; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.UrlUtil; import com.github.catvod.crawler.SpiderDebug; @@ -96,7 +89,7 @@ import java.util.Map; import java.util.stream.Collectors; import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.CompatFfmpegAudioRenderer; -import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.FfmpegVideoRenderer; +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.CompatFfmpegVideoRenderer; public class ExoUtil { @@ -104,9 +97,12 @@ public class ExoUtil { private static final long ENHANCED_ADAPT_COOLDOWN_MS = 15_000L; private static final int ENHANCED_DROPPED_FRAMES_THRESHOLD = 24; private static final int ENHANCED_DROPPED_FRAMES_PER_SECOND_THRESHOLD = 4; - private static final int FFMPEG_SKIP_FRAME_NONREF = 8; + // FFmpeg AVDiscard values. + private static final int FFMPEG_SKIP_FRAME_DEFAULT = 0; private static final int FFMPEG_SKIP_LOOP_FILTER_ALL = 48; private static final int FFMPEG_LOWRES_HALF = 1; + private static final int FFMPEG_MIN_DECODE_BUFFERS = 4; + private static final int FFMPEG_MAX_DECODE_BUFFERS = 12; private static volatile EnhancedVideoProfile enhancedVideoProfile; private static volatile ExoPlaybackCapability.Report playbackCapabilityReport; @@ -169,27 +165,6 @@ public class ExoUtil { decoderRuntimeSession, frameSchedulingSettings, dolbyVisionPlaybackState, - null); - } - - public static ExoPlayer buildPlayer( - int decode, - Player.Listener listener, - boolean tunnelingFallbackAttempted, - @Nullable ExoDecoderRuntimeSession decoderRuntimeSession, - ExoFrameSchedulingPlayerSettings frameSchedulingSettings, - @Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState, - @Nullable ExoCompressedAudioDirectPolicy compressedAudioDirectPolicy) { - return buildPlayer( - decode, - listener, - tunnelingFallbackAttempted, - decoderRuntimeSession, - frameSchedulingSettings, - dolbyVisionPlaybackState, - compressedAudioDirectPolicy, - null, - null, null, null); } @@ -201,70 +176,6 @@ public class ExoUtil { @Nullable ExoDecoderRuntimeSession decoderRuntimeSession, ExoFrameSchedulingPlayerSettings frameSchedulingSettings, @Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState, - @Nullable ExoCompressedAudioDirectPolicy compressedAudioDirectPolicy, - @Nullable ExoAssSession assSession) { - return buildPlayer(decode, listener, tunnelingFallbackAttempted, decoderRuntimeSession, - frameSchedulingSettings, dolbyVisionPlaybackState, compressedAudioDirectPolicy, assSession, null); - } - - public static ExoPlayer buildPlayer( - int decode, - Player.Listener listener, - boolean tunnelingFallbackAttempted, - @Nullable ExoDecoderRuntimeSession decoderRuntimeSession, - ExoFrameSchedulingPlayerSettings frameSchedulingSettings, - @Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState, - @Nullable ExoCompressedAudioDirectPolicy compressedAudioDirectPolicy, - @Nullable ExoAssSession assSession, - @Nullable ExoSubtitleSession subtitleSession) { - return buildPlayer( - decode, - listener, - tunnelingFallbackAttempted, - decoderRuntimeSession, - frameSchedulingSettings, - dolbyVisionPlaybackState, - compressedAudioDirectPolicy, - assSession, - subtitleSession, - null, - null); - } - - public static ExoPlayer buildPlayer( - int decode, - Player.Listener listener, - boolean tunnelingFallbackAttempted, - @Nullable ExoDecoderRuntimeSession decoderRuntimeSession, - ExoFrameSchedulingPlayerSettings frameSchedulingSettings, - @Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState, - @Nullable ExoCompressedAudioDirectPolicy compressedAudioDirectPolicy, - @Nullable PlaybackMediaSignalHub mediaSignals, - @Nullable PlaybackMediaClock mediaClock) { - return buildPlayer( - decode, - listener, - tunnelingFallbackAttempted, - decoderRuntimeSession, - frameSchedulingSettings, - dolbyVisionPlaybackState, - compressedAudioDirectPolicy, - null, - null, - mediaSignals, - mediaClock); - } - - public static ExoPlayer buildPlayer( - int decode, - Player.Listener listener, - boolean tunnelingFallbackAttempted, - @Nullable ExoDecoderRuntimeSession decoderRuntimeSession, - ExoFrameSchedulingPlayerSettings frameSchedulingSettings, - @Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState, - @Nullable ExoCompressedAudioDirectPolicy compressedAudioDirectPolicy, - @Nullable ExoAssSession assSession, - @Nullable ExoSubtitleSession subtitleSession, @Nullable PlaybackMediaSignalHub mediaSignals, @Nullable PlaybackMediaClock mediaClock) { ExoFrameSchedulingPlayerSettings schedulingSettings = @@ -282,18 +193,18 @@ public class ExoUtil { ExoDecoderRuntimeSession.OutputConfig decoderOutput = ExoDecoderRuntimeProfiles.currentOutput( isTunnelingEnabled(decode, tunnelingFallbackAttempted)); - ExoDiagnosticCollector diagnostics = new ExoDiagnosticCollector(); - RenderersFactory renderersFactory = withAssObserver(buildPlaybackRenderersFactory( - decode, automaticProfile ? decoderRuntimeSession : null, decoderOutput, - schedulingSettings, dolbyVisionPlaybackState, compressedAudioDirectPolicy, - diagnostics, mediaSignals, mediaClock), assSession); - // Install the primary ASS observer before appending the independent secondary renderer. - if (subtitleSession != null) renderersFactory = subtitleSession.wrapRenderersFactory(renderersFactory); ExoPlayer.Builder builder = new ExoPlayer.Builder(App.get()) - .setTrackSelector(subtitleSession == null ? trackSelector : subtitleSession.wrapTrackSelector(trackSelector)) - .setRenderersFactory(renderersFactory) + .setTrackSelector(trackSelector) + .setRenderersFactory(buildPlaybackRenderersFactory( + decode, + automaticProfile ? decoderRuntimeSession : null, + decoderOutput, + schedulingSettings, + dolbyVisionPlaybackState, + mediaSignals, + mediaClock)) .setMediaSourceFactory(buildMediaSourceFactory( - dolbyVisionPlaybackState, assSession)) + dolbyVisionPlaybackState)) .setVideoChangeFrameRateStrategy(ExoPerformanceSetting.getFrameRateStrategy()); if (PlaybackPerformanceSetting.isHighBufferEnabled()) builder.setLoadControl(buildEnhancedLoadControl()); else ExoPlaybackDiagnostics.logDefaultLoadControl(PlaybackPerformanceSetting.getProfile(PlayerSetting.EXO)); @@ -306,15 +217,10 @@ public class ExoUtil { builder.experimentalSetDynamicSchedulingEnabled(true); } ExoPlayer player = builder.build(); - diagnostics.attach(player); PlaybackAnalyticsListener.reset(); PlaybackAnalyticsListener analyticsListener = new PlaybackAnalyticsListener(); player.addAnalyticsListener(analyticsListener); - if (assSession == null) player.setVideoFrameMetadataListener(analyticsListener); - else player.setVideoFrameMetadataListener((presentationTimeUs, releaseTimeNs, format, mediaFormat) -> { - analyticsListener.onVideoFrameAboutToBeRendered(presentationTimeUs, releaseTimeNs, format, mediaFormat); - assSession.onVideoFrame(format); - }); + player.setVideoFrameMetadataListener(analyticsListener); if (PlaybackPerformanceSetting.isAdaptiveDowngradeEnabled()) { if (PlaybackPerformanceSetting.isAuto( PlayerSetting.EXO, @@ -333,31 +239,26 @@ public class ExoUtil { return player; } - private static RenderersFactory withAssObserver(RenderersFactory factory, @Nullable ExoAssSession session) { - if (session == null) return factory; - return (handler, video, audio, text, metadata) -> { - Renderer[] renderers = factory.createRenderers(handler, video, audio, text, metadata); - for (Renderer renderer : renderers) { - if (renderer instanceof TextRenderer textRenderer) textRenderer.setObserver(session); - } - return renderers; - }; - } - public static MediaItem getMediaItem(PlaySpec spec, int decode) { - return getMediaItem(spec, decode, spec.getKey()); - } - - public static MediaItem getMediaItem(PlaySpec spec, int decode, String mediaId) { - MediaItem.Builder builder = new MediaItem.Builder().setUri(spec.getUri()); + String url = spec.getUrl(); + if (url == null) url = ""; + Map headers = spec.getHeaders() == null ? new HashMap<>() : new HashMap<>(spec.getHeaders()); + MediaItem.Builder builder = new MediaItem.Builder().setUri(UrlUtil.uri(url)); builder.setSubtitleConfigurations(buildSubtitleConfigs(spec.getSubs())); builder.setDrmConfiguration(buildDrmConfig(spec.getDrm())); - builder.setRequestMetadata(buildRequestMetadata(spec)); + builder.setRequestMetadata(buildRequestMetadata(url, headers)); builder.setMediaMetadata(spec.getMetadata()); - builder.setAdblock(Setting.isAdblock() && HlsRuleConfig.isLegacyFallbackEnabled()); + // Deliberately false: ad removal for Exo lives in HlsPlaylistCleaningDataSource, + // which runs both engines through HlsAdblockPipeline. This flag would make the + // fork's HlsPlaylistParser run HlsAdsParser again on the already-cleaned text, + // and that heuristic is not independent of what precedes it — its "largest group + // exceeds 50%" gate is measured against the segment count, so removing ads can + // open a gate that was closed and make it delete main content without bound. + // See HlsPlaylistCleaningDataSource's class javadoc for the measured case. + builder.setAdblock(false); builder.setMimeType(spec.getFormat()); builder.setImageDurationMs(15000); - builder.setMediaId(mediaId == null || mediaId.isEmpty() ? spec.getKey() : mediaId); + builder.setMediaId(spec.getKey()); builder.setDecode(decode); return builder.build(); } @@ -374,10 +275,73 @@ public class ExoUtil { return extras.keySet().stream().filter(key -> extras.getString(key) != null).collect(Collectors.toMap(key -> key, extras::getString)); } - private static int getVideoRenderMode(int decode) { + static int getRenderMode(int decode) { return decode == PlayerEngine.HARD ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER; } + static int getFfmpegVideoRenderMode(int videoRenderMode) { + return videoRenderMode == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON : videoRenderMode; + } + + // 硬解档位(videoRenderMode=OFF)且用户未选“视频软解优先”时,FFmpeg 视频渲染器只兜底平台缺失的编码。 + // 它保留 VC-1 等系统无解码器格式的软解能力,同时不让 FFmpeg 抢走 MediaCodec 只能报 + // FORMAT_EXCEEDS_CAPABILITIES 的高规格轨道(4K HEVC 等),后者软解必然掉帧。 + static boolean isFfmpegVideoFallbackOnly(int videoRenderMode, boolean videoPrefer) { + return videoRenderMode == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF && !videoPrefer; + } + + /** + * Load shedding must also cover the hard-decode fallback, not just an explicit soft + * decode selection. In the hard-decode profile the FFmpeg video renderer is still + * installed as a fallback for codecs MediaCodec refuses ({@link + * #isFfmpegVideoFallbackOnly}); that content is by definition the heaviest, so leaving + * it untuned means single-threaded full-filter software decode, observed as continuous + * stutter with a zero dropped-frame count because frames arrive late rather than being + * dropped. + * + *

The decode profile is deliberately not a gate here. Kept separate from the flag + * handed to the FFmpeg audio renderer so audio behavior is unchanged. + */ + static boolean shouldTuneFfmpegVideo(boolean tuneEnabled, boolean ffmpegVideoReachable) { + return tuneEnabled && ffmpegVideoReachable; + } + + /** Whether the FFmpeg video renderer can decode at all for this profile. */ + static boolean isFfmpegVideoReachable(int videoRenderMode) { + return getFfmpegVideoRenderMode(videoRenderMode) + != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF; + } + + /** + * Frame-threaded FFmpeg decode keeps roughly {@code threads + 1} frames in flight, and + * {@code numOutputBuffers} sizes that pool directly ({@code FfmpegVideoDecoder} passes it + * to {@code new VideoDecoderOutputBuffer[numOutputBuffers]}). A fixed pool smaller than the + * thread count throttles the decoder into periodic stalls even while CPU headroom remains, + * so the pool scales with the thread count instead. + */ + static int ffmpegDecodeBuffers(int threads) { + // Clamp before adding: availableProcessors() is trusted here, but an overflowing + // addition would wrap negative and silently collapse back to the minimum. + int safeThreads = Math.max(1, Math.min(FFMPEG_MAX_DECODE_BUFFERS, threads)); + return Math.max(FFMPEG_MIN_DECODE_BUFFERS, + Math.min(FFMPEG_MAX_DECODE_BUFFERS, safeThreads + 2)); + } + + /** + * Load shedding deliberately skips the loop filter but keeps every frame. Discarding + * non-reference frames ({@code AVDISCARD_NONREF}) removes frames the renderer never sees, + * which breaks motion continuity while reporting a zero dropped-frame count, and it is not + * warranted when the decode is buffer-throttled rather than CPU-bound. Loop-filter skipping + * costs image quality only. + */ + static int ffmpegSkipFrame() { + return FFMPEG_SKIP_FRAME_DEFAULT; + } + + private static int getVideoRenderMode(int decode) { + return getRenderMode(decode); + } + private static int getAudioRenderMode() { return DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON; } @@ -386,7 +350,7 @@ public class ExoUtil { return decode != PlayerEngine.SOFT && PlayerSetting.isAudioPrefer(PlayerSetting.EXO); } - public static CaptionStyleCompat getCaptionStyle() { + private static CaptionStyleCompat getCaptionStyle() { return PlayerSetting.isCaption() ? CaptionStyleCompat.createFromCaptionStyle(((CaptioningManager) App.get().getSystemService(Context.CAPTIONING_SERVICE)).getUserStyle()) : new CaptionStyleCompat(Color.WHITE, Color.TRANSPARENT, Color.TRANSPARENT, CaptionStyleCompat.EDGE_TYPE_OUTLINE, Color.BLACK, null); } @@ -394,12 +358,6 @@ public class ExoUtil { DefaultTrackSelector trackSelector = new DefaultTrackSelector(App.get()); DefaultTrackSelector.Parameters.Builder builder = trackSelector.buildUponParameters(); if (PlayerSetting.isPreferAAC(PlayerSetting.EXO)) builder.setPreferredAudioMimeType(MimeTypes.AUDIO_AAC); - builder.setAudioOffloadPreferences( - new TrackSelectionParameters.AudioOffloadPreferences.Builder() - .setAudioOffloadMode(PlayerSetting.isAudioPassThrough(PlayerSetting.EXO) - ? TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED - : TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED) - .build()); builder.setPreferredTextLanguages(LangUtil.getPreferredTextLanguages()); ExoTunnelingPolicy.Decision tunneling = getTunnelingDecision(decode, tunnelingFallbackAttempted); builder.setTunnelingEnabled(tunneling.enabled()); @@ -457,7 +415,13 @@ public class ExoUtil { builder.setExceedVideoConstraintsIfNecessary(true); builder.setAllowVideoNonSeamlessAdaptiveness(true); builder.setAllowVideoMixedMimeTypeAdaptiveness(true); - builder.setForceHighestSupportedBitrate(false); + // 起播就选约束内画质最高的轨道,而不是把选择交给 media3 原生 ABR。原生 ABR 起播只有 + // DefaultBandwidthMeter 的初始猜测(Wi-Fi 4.3Mbps 起、未知网络 1Mbps)可用,再乘 + // bandwidthFraction,HLS 多码率直链必然落到最低几档;直播缓冲为 0 时还不允许向上切。 + // 代价是同组只会选中一条轨道(eligibility 变成 FIXED),原生 ABR 不再兜底吞吐, + // 因此降级完全依赖 AutomaticVideoConstraintController / LegacyAdaptiveVideoProfileController + // 收紧上面这些 max 约束:温度、解码、网络计费之外,两者都必须自己按带宽和重缓冲降档。 + builder.setForceHighestSupportedBitrate(true); } public static EnhancedVideoProfile getEnhancedVideoProfile() { @@ -768,23 +732,24 @@ public class ExoUtil { ExoDecoderRuntimeSession.OutputConfig decoderOutput, ExoFrameSchedulingPlayerSettings frameSchedulingSettings, @Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState, - @Nullable ExoCompressedAudioDirectPolicy compressedAudioDirectPolicy, - @Nullable ExoDiagnosticCollector diagnostics, @Nullable PlaybackMediaSignalHub mediaSignals, @Nullable PlaybackMediaClock mediaClock) { + int videoRenderMode = getVideoRenderMode(decode); return buildRenderersFactory( getAudioRenderMode(), - getVideoRenderMode(decode), + videoRenderMode, isAudioPrefer(decode), PlayerSetting.isVideoPrefer(PlayerSetting.EXO), decode == PlayerEngine.SOFT && PlaybackPerformanceSetting.isSoftVideoTuneEnabled(), + shouldTuneFfmpegVideo( + PlaybackPerformanceSetting.isSoftVideoTuneEnabled(), + isFfmpegVideoReachable(videoRenderMode)), + true, decoderRuntimeSession, decoderOutput, frameSchedulingSettings, dolbyVisionPlaybackState, - compressedAudioDirectPolicy, - diagnostics, mediaSignals, mediaClock); } @@ -799,6 +764,8 @@ public class ExoUtil { PlayerSetting.isAudioPrefer(PlayerSetting.EXO), PlayerSetting.isVideoPrefer(PlayerSetting.EXO), false, + false, + false, null, ExoDecoderRuntimeSession.OutputConfig.unknown(), new ExoFrameSchedulingPlayerSettings( @@ -813,8 +780,6 @@ public class ExoUtil { codecQueueMode), null, null, - null, - null, null); } @@ -824,55 +789,59 @@ public class ExoUtil { boolean audioPrefer, boolean videoPrefer, boolean softVideoTune, + boolean ffmpegVideoTune, + boolean realtimePipeline, @Nullable ExoDecoderRuntimeSession decoderRuntimeSession, ExoDecoderRuntimeSession.OutputConfig decoderOutput, ExoFrameSchedulingPlayerSettings frameSchedulingSettings, @Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState, - @Nullable ExoCompressedAudioDirectPolicy compressedAudioDirectPolicy, - @Nullable ExoDiagnosticCollector diagnostics, @Nullable PlaybackMediaSignalHub mediaSignals, @Nullable PlaybackMediaClock mediaClock) { ExoFrameSchedulingExperimentPolicy.Decision frameSchedulingDecision = frameSchedulingSettings.decision(); - ExoCompressedAudioDirectPolicy directPolicy = compressedAudioDirectPolicy == null - ? new ExoCompressedAudioDirectPolicy(App.get()) : compressedAudioDirectPolicy; - DefaultRenderersFactory factory = new FfmpegRenderersFactory( - App.get(), - audioRenderMode, - videoRenderMode, - audioPrefer, - videoPrefer, - softVideoTune, - decoderRuntimeSession, - decoderOutput, - frameSchedulingDecision, - dolbyVisionPlaybackState, diagnostics) { - @Override - protected AudioSink buildAudioSink(@NonNull Context context, boolean enableFloatOutput, boolean enableAudioOutputPlaybackParams) { - return ExoUtil.buildAudioSink( - context, enableFloatOutput, - enableAudioOutputPlaybackParams, - directPolicy, - diagnostics, - mediaSignals, - mediaClock); - } - - @Override - protected void buildAudioRenderers(Context context, int extensionRendererMode, - MediaCodecSelector selector, boolean enableDecoderFallback, AudioSink audioSink, - Handler handler, AudioRendererEventListener listener, ArrayList out) { - int firstAudioRenderer = out.size(); - super.buildAudioRenderers(context, extensionRendererMode, selector, - enableDecoderFallback, audioSink, handler, listener, out); - for (int i = firstAudioRenderer; i < out.size(); i++) { - Renderer renderer = out.get(i); - if (renderer instanceof MediaCodecAudioRenderer) { - out.set(i, new ExoStartupAudioRenderer(renderer, directPolicy)); - } + int mode = PlayerSetting.getEffectiveFFmpegMode(); + DefaultRenderersFactory factory; + if (mode == PlayerSetting.FFMPEG_MODE_NEXTLIB) { + factory = new FfmpegRenderersFactory( + App.get(), + audioRenderMode, + videoRenderMode, + audioPrefer, + videoPrefer, + softVideoTune, + ffmpegVideoTune, + decoderRuntimeSession, + decoderOutput, + frameSchedulingDecision, + dolbyVisionPlaybackState) { + @Override + protected AudioSink buildAudioSink(@NonNull Context context, boolean enableFloatOutput, boolean enableAudioOutputPlaybackParams) { + return ExoUtil.buildAudioSink(context, enableFloatOutput, enableAudioOutputPlaybackParams, + realtimePipeline, mediaSignals, mediaClock); } - } - }; + }; + } else if (useFfmpegAudioFallback(mode) || useFfmpegVideoRenderer(mode)) { + factory = new FfmpegFallbackRenderersFactory( + App.get(), + audioRenderMode, + videoRenderMode, + audioPrefer, + videoPrefer) { + @Override + protected AudioSink buildAudioSink(@NonNull Context context, boolean enableFloatOutput, boolean enableAudioOutputPlaybackParams) { + return ExoUtil.buildAudioSink(context, enableFloatOutput, enableAudioOutputPlaybackParams, + realtimePipeline, mediaSignals, mediaClock); + } + }; + } else { + factory = new DefaultRenderersFactory(App.get()) { + @Override + protected AudioSink buildAudioSink(@NonNull Context context, boolean enableFloatOutput, boolean enableAudioOutputPlaybackParams) { + return ExoUtil.buildAudioSink(context, enableFloatOutput, enableAudioOutputPlaybackParams, + realtimePipeline, mediaSignals, mediaClock); + } + }; + } if (frameSchedulingSettings.codecQueueMode() == ExoPerformanceSetting.CODEC_QUEUE_ASYNC) { factory.forceEnableMediaCodecAsynchronousQueueing(); @@ -883,7 +852,18 @@ public class ExoUtil { ExoFrameSchedulingRendererSettings.from(frameSchedulingDecision) .apply(factory); if (PlaybackPerformanceSetting.isLateDropInputEnabled()) factory.experimentalSetLateThresholdToDropDecoderInputUs(ENHANCED_LATE_THRESHOLD_TO_DROP_INPUT_US); - return factory.setEnableDecoderFallback(PlaybackPerformanceSetting.isDecoderFallbackEnabled()).setExtensionRendererMode(Math.max(audioRenderMode, videoRenderMode)); + boolean decoderFallback = mode == PlayerSetting.FFMPEG_MODE_NEXTLIB + ? PlaybackPerformanceSetting.isDecoderFallbackEnabled() + : true; + return factory.setEnableDecoderFallback(decoderFallback).setExtensionRendererMode(Math.max(audioRenderMode, videoRenderMode)); + } + + static boolean useFfmpegAudioFallback(int mode) { + return mode == PlayerSetting.FFMPEG_MODE_NEXTLIB || mode == PlayerSetting.FFMPEG_MODE_SIMPLE; + } + + static boolean useFfmpegVideoRenderer(int mode) { + return mode == PlayerSetting.FFMPEG_MODE_NEXTLIB || mode == PlayerSetting.FFMPEG_MODE_SIMPLE; } public static ExoFrameSchedulingExperimentPolicy.Decision @@ -915,14 +895,11 @@ public class ExoUtil { ExoFrameSchedulingExperimentSetting.getResolution())); } - private static AudioSink buildAudioSink( - Context context, - boolean enableFloatOutput, - boolean enableAudioOutputPlaybackParams, - @Nullable ExoCompressedAudioDirectPolicy compressedAudioDirectPolicy, - @Nullable ExoDiagnosticCollector diagnostics, - @Nullable PlaybackMediaSignalHub mediaSignals, - @Nullable PlaybackMediaClock mediaClock) { + private static AudioSink buildAudioSink(Context context, boolean enableFloatOutput, + boolean enableAudioOutputPlaybackParams, + boolean realtimePipeline, + @Nullable PlaybackMediaSignalHub mediaSignals, + @Nullable PlaybackMediaClock mediaClock) { boolean passthrough = PlayerSetting.isAudioPassThrough(PlayerSetting.EXO); if (SpiderDebug.isEnabled()) { AudioCapabilities capabilities = AudioCapabilities.getCapabilities( @@ -940,75 +917,56 @@ public class ExoUtil { capabilities.getSpeakerLayoutChannelMasks(), capabilities.supportsEncoding(C.ENCODING_AC3)); } - ExoCompressedAudioDirectPolicy directPolicy = - compressedAudioDirectPolicy == null - ? new ExoCompressedAudioDirectPolicy(context) - : compressedAudioDirectPolicy; - directPolicy.setAudioPassthroughEnabled(passthrough); - AudioTrackAudioOutputProvider outputProvider = - new AudioTrackAudioOutputProvider.Builder( - passthrough ? context.getApplicationContext() : null) - .setAudioOffloadSupportProvider(directPolicy) - .setAudioTrackBuilderModifier( - directPolicy::modifyAudioTrackBuilder) - .build(); - PlaybackMediaAudioPipeline mediaPipeline = mediaSignals != null && mediaClock != null - ? PlaybackMediaAudioPipeline.create(mediaSignals, mediaClock) : null; - AudioOutputProvider finalOutputProvider = - directPolicy.wrapOutputProvider(outputProvider, diagnostics); - if (mediaPipeline != null) { - finalOutputProvider = new PlaybackMediaAudioOutputProvider( - finalOutputProvider, mediaPipeline.clockSink()); + if (mediaSignals != null && mediaClock != null && mediaSignals.isCaptureRequested()) { + PlaybackMediaAudioPipeline pipeline = PlaybackMediaAudioPipeline.create(mediaSignals, mediaClock); + DefaultAudioSink.Builder builder = new DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams) + .setAudioProcessors(new AudioProcessor[]{pipeline.audioProcessor()}); + if (!passthrough) { + AudioTrackAudioOutputProvider.Builder outputBuilder = new AudioTrackAudioOutputProvider.Builder(null); + outputBuilder.setAudioTrackBufferSizeProvider(new RealtimeSubtitleBufferSizeProvider()); + AudioOutputProvider output = outputBuilder.build(); + builder.setAudioOutputProvider(new PlaybackMediaAudioOutputProvider(output, pipeline.clockSink())); + } + return builder.build(); } - finalOutputProvider = ExoDiagnosticAudioOutput.provider(finalOutputProvider, diagnostics); + if (mediaSignals != null) mediaSignals.detachPipeline(); DefaultAudioSink.Builder builder = new DefaultAudioSink.Builder(context) .setEnableFloatOutput(enableFloatOutput) - .setEnableAudioOutputPlaybackParameters( - enableAudioOutputPlaybackParams) - .setAudioOutputProvider(finalOutputProvider); - if (mediaPipeline != null) { - builder.setAudioProcessors(new AudioProcessor[]{mediaPipeline.audioProcessor()}); + .setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams); + if (!passthrough) { + builder.setAudioOutputProvider(new AudioTrackAudioOutputProvider.Builder(null).build()); } - return ExoDiagnosticAudioOutput.sink(builder.build(), diagnostics); - } - - public static boolean supportsPlaylistPreload(Player player) { - return player instanceof ExoPlayer; - } - - public static MediaSource.Factory buildMediaSourceFactory( - @Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState) { - return buildMediaSourceFactory(dolbyVisionPlaybackState, null); + return builder.build(); } private static MediaSource.Factory buildMediaSourceFactory( - @Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState, @Nullable ExoAssSession assSession) { - return new MediaSourceFactory(dolbyVisionPlaybackState, assSession); - } - - /** Builds an item-scoped source for playlist append without touching the active player. */ - public static MediaSource createMediaSource( - MediaItem mediaItem, @Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState) { - return buildMediaSourceFactory(dolbyVisionPlaybackState).createMediaSource(mediaItem); + return new MediaSourceFactory(dolbyVisionPlaybackState); } - private static MediaItem.RequestMetadata buildRequestMetadata(PlaySpec spec) { - return new MediaItem.RequestMetadata.Builder().setMediaUri(spec.getUri()).setExtras(PlayerHelper.toBundle(spec.getHeaders())).build(); + private static MediaItem.RequestMetadata buildRequestMetadata(String url, Map headers) { + return new MediaItem.RequestMetadata.Builder().setMediaUri(UrlUtil.uri(url)).setExtras(PlayerHelper.toBundle(headers == null ? Map.of() : headers)).build(); + } + + private static String hostOf(String url) { + try { + String host = Uri.parse(url).getHost(); + return host == null ? "" : host; + } catch (RuntimeException e) { + return ""; + } } private static List buildSubtitleConfigs(List subs) { List configs = new ArrayList<>(); - if (subs != null) for (Sub sub : subs) configs.add(buildSubConfig(sub, configs.size())); + if (subs != null) for (Sub sub : subs) configs.add(buildSubConfig(sub)); return configs; } - private static MediaItem.SubtitleConfiguration buildSubConfig(Sub sub, int index) { - MediaItem.SubtitleConfiguration.Builder builder = new MediaItem.SubtitleConfiguration.Builder(Uri.parse(UrlUtil.convert(sub.getUrl()))) - .setLabel(sub.getName()).setMimeType(sub.getFormat()).setSelectionFlags(sub.getFlag()).setLanguage(sub.getLang()); - if (MimeTypes.TEXT_SSA.equals(sub.getFormat())) - builder.setId(AssInput.EXTERNAL_ID_PREFIX + index); - return builder.build(); + private static MediaItem.SubtitleConfiguration buildSubConfig(Sub sub) { + return new MediaItem.SubtitleConfiguration.Builder(Uri.parse(UrlUtil.convert(sub.getUrl()))).setLabel(sub.getName()).setMimeType(sub.getFormat()).setSelectionFlags(sub.getFlag()).setLanguage(sub.getLang()).build(); } private static MediaItem.DrmConfiguration buildDrmConfig(Drm drm) { @@ -1017,12 +975,12 @@ public class ExoUtil { private static class FfmpegRenderersFactory extends DefaultRenderersFactory { - @Nullable private final ExoDiagnosticCollector diagnostics; private final int audioRenderMode; private final int videoRenderMode; private final boolean audioPrefer; private final boolean videoPrefer; private final boolean softVideoTune; + private final boolean ffmpegVideoTune; @Nullable private final ExoDecoderRuntimeSession decoderRuntimeSession; private final ExoDecoderRuntimeSession.OutputConfig decoderOutput; private final ExoFrameSchedulingExperimentPolicy.Decision @@ -1037,56 +995,44 @@ public class ExoUtil { boolean audioPrefer, boolean videoPrefer, boolean softVideoTune, + boolean ffmpegVideoTune, @Nullable ExoDecoderRuntimeSession decoderRuntimeSession, ExoDecoderRuntimeSession.OutputConfig decoderOutput, ExoFrameSchedulingExperimentPolicy.Decision frameSchedulingDecision, @Nullable ExoDolbyVisionPlaybackState - dolbyVisionPlaybackState, - @Nullable ExoDiagnosticCollector diagnostics) { + dolbyVisionPlaybackState) { super(context); - this.diagnostics = diagnostics; this.audioRenderMode = audioRenderMode; this.videoRenderMode = videoRenderMode; this.audioPrefer = audioPrefer; this.videoPrefer = videoPrefer; this.softVideoTune = softVideoTune; + this.ffmpegVideoTune = ffmpegVideoTune; this.decoderRuntimeSession = decoderRuntimeSession; this.decoderOutput = decoderOutput; this.frameSchedulingDecision = frameSchedulingDecision; this.dolbyVisionPlaybackState = dolbyVisionPlaybackState; } - @Override - protected MediaCodecAdapter.Factory getCodecAdapterFactory() { - MediaCodecAdapter.Factory factory = super.getCodecAdapterFactory(); - return diagnostics == null ? factory : ExoDiagnosticCodecAdapter.factory(factory, diagnostics); - } - @Override protected void buildAudioRenderers(Context context, int extensionRendererMode, MediaCodecSelector mediaCodecSelector, boolean enableDecoderFallback, AudioSink audioSink, Handler eventHandler, AudioRendererEventListener eventListener, ArrayList out) { - MediaCodecSelector audioCodecSelector = - ExoAudioCodecSelector.hardwareFirst(mediaCodecSelector); - if (diagnostics != null) audioCodecSelector = ExoDiagnosticCodecAdapter.selector(audioCodecSelector, diagnostics, "audio-hardware-first / ALAC-FFmpeg-policy"); - // Audio fallback is part of the playback contract. It only runs - // after decoder initialization fails and does not affect buffers. - super.buildAudioRenderers(context, audioRenderMode, - audioCodecSelector, true, audioSink, eventHandler, - eventListener, out); + super.buildAudioRenderers(context, audioRenderMode, mediaCodecSelector, enableDecoderFallback, audioSink, eventHandler, eventListener, out); if (audioRenderMode == EXTENSION_RENDERER_MODE_OFF) return; try { out.add(getExtensionRendererIndex(audioRenderMode, audioPrefer, out), new CompatFfmpegAudioRenderer(context, eventHandler, eventListener, audioSink, softVideoTune)); } catch (Throwable ignored) { - if (diagnostics != null) diagnostics.log.error(diagnostics.log.context(), "audio-extension", "load-renderer", ignored); } } @Override protected void buildVideoRenderers(Context context, int extensionRendererMode, MediaCodecSelector mediaCodecSelector, boolean enableDecoderFallback, Handler eventHandler, VideoRendererEventListener eventListener, long allowedVideoJoiningTimeMs, ArrayList out) { MediaCodecSelector videoCodecSelector = getVideoCodecSelector(mediaCodecSelector); - if (diagnostics != null) videoCodecSelector = ExoDiagnosticCodecAdapter.selector(videoCodecSelector, diagnostics, "video-selector-result"); + int ffmpegVideoRenderMode = getFfmpegVideoRenderMode(videoRenderMode); try { ExoDv5GpuRenderer dv5Renderer = ExoDv5GpuRendererFactory.create( + PlaybackExperimentSetting.isDomainEnabled( + PlaybackExperimentPolicy.Domain.EXO), context, getCodecAdapterFactory(), videoCodecSelector, @@ -1110,7 +1056,7 @@ public class ExoUtil { eventListener, decoderRuntimeSession, decoderOutput, - frameSchedulingDecision, diagnostics)); + frameSchedulingDecision)); } else { super.buildVideoRenderers(context, videoRenderMode, videoCodecSelector, enableDecoderFallback, eventHandler, eventListener, allowedVideoJoiningTimeMs, out); } @@ -1131,18 +1077,21 @@ public class ExoUtil { dolbyVisionPlaybackState)); } catch (Throwable ignored) { } - // Video decode mode is explicit: hardware mode must never register - // a software fallback. Audio has its own policy. - if (videoRenderMode == EXTENSION_RENDERER_MODE_OFF) return; try { - out.add(getExtensionRendererIndex(videoRenderMode, videoPrefer, out), buildFfmpegVideoRenderer(allowedVideoJoiningTimeMs, eventHandler, eventListener)); - } catch (Throwable ignored) { + int index = getExtensionRendererIndex(ffmpegVideoRenderMode, videoPrefer, out); + out.add(index, buildFfmpegVideoRenderer(allowedVideoJoiningTimeMs, eventHandler, eventListener, videoCodecSelector)); + if (SpiderDebug.isEnabled()) SpiderDebug.log("exo-ffmpeg", "loaded ffmpeg video renderer mode=%d index=%d", ffmpegVideoRenderMode, index); + } catch (Throwable e) { + if (SpiderDebug.isEnabled()) SpiderDebug.log("exo-ffmpeg", "ffmpeg video renderer unavailable mode=%d error=%s", ffmpegVideoRenderMode, e.toString()); } } - private FfmpegVideoRenderer buildFfmpegVideoRenderer(long allowedVideoJoiningTimeMs, Handler eventHandler, VideoRendererEventListener eventListener) { - if (!softVideoTune) return new FfmpegVideoRenderer(allowedVideoJoiningTimeMs, eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); - return new FfmpegVideoRenderer(allowedVideoJoiningTimeMs, eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY, Runtime.getRuntime().availableProcessors(), 4, 4, FFMPEG_SKIP_FRAME_NONREF, FFMPEG_SKIP_LOOP_FILTER_ALL, FFMPEG_LOWRES_HALF); + private CompatFfmpegVideoRenderer buildFfmpegVideoRenderer(long allowedVideoJoiningTimeMs, Handler eventHandler, VideoRendererEventListener eventListener, MediaCodecSelector platformDecoderSelector) { + boolean fallbackOnly = isFfmpegVideoFallbackOnly(videoRenderMode, videoPrefer); + if (!ffmpegVideoTune) return new CompatFfmpegVideoRenderer(allowedVideoJoiningTimeMs, eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY, fallbackOnly, platformDecoderSelector); + int threads = Runtime.getRuntime().availableProcessors(); + int buffers = ffmpegDecodeBuffers(threads); + return new CompatFfmpegVideoRenderer(allowedVideoJoiningTimeMs, eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY, threads, buffers, buffers, ffmpegSkipFrame(), FFMPEG_SKIP_LOOP_FILTER_ALL, FFMPEG_LOWRES_HALF, fallbackOnly, platformDecoderSelector); } private MediaCodecSelector getVideoCodecSelector(MediaCodecSelector mediaCodecSelector) { @@ -1163,6 +1112,51 @@ public class ExoUtil { } } + private static class FfmpegFallbackRenderersFactory extends DefaultRenderersFactory { + + private final int audioRenderMode; + private final int videoRenderMode; + private final boolean audioPrefer; + private final boolean videoPrefer; + + FfmpegFallbackRenderersFactory(Context context, int audioRenderMode, int videoRenderMode, boolean audioPrefer, boolean videoPrefer) { + super(context); + this.audioRenderMode = audioRenderMode; + this.videoRenderMode = videoRenderMode; + this.audioPrefer = audioPrefer; + this.videoPrefer = videoPrefer; + } + + @Override + protected void buildAudioRenderers(Context context, int extensionRendererMode, MediaCodecSelector mediaCodecSelector, boolean enableDecoderFallback, AudioSink audioSink, Handler eventHandler, AudioRendererEventListener eventListener, ArrayList out) { + super.buildAudioRenderers(context, audioRenderMode, mediaCodecSelector, enableDecoderFallback, audioSink, eventHandler, eventListener, out); + if (audioRenderMode == EXTENSION_RENDERER_MODE_OFF) return; + try { + out.add(getExtensionRendererIndex(audioRenderMode, audioPrefer, out), new CompatFfmpegAudioRenderer(context, eventHandler, eventListener, audioSink, true)); + } catch (Throwable ignored) { + } + } + + @Override + protected void buildVideoRenderers(Context context, int extensionRendererMode, MediaCodecSelector mediaCodecSelector, boolean enableDecoderFallback, Handler eventHandler, VideoRendererEventListener eventListener, long allowedVideoJoiningTimeMs, ArrayList out) { + super.buildVideoRenderers(context, videoRenderMode, mediaCodecSelector, enableDecoderFallback, eventHandler, eventListener, allowedVideoJoiningTimeMs, out); + int ffmpegVideoRenderMode = getFfmpegVideoRenderMode(videoRenderMode); + try { + int index = getExtensionRendererIndex(ffmpegVideoRenderMode, videoPrefer, out); + out.add(index, new CompatFfmpegVideoRenderer(allowedVideoJoiningTimeMs, eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY, isFfmpegVideoFallbackOnly(videoRenderMode, videoPrefer), mediaCodecSelector)); + if (SpiderDebug.isEnabled()) SpiderDebug.log("exo-ffmpeg", "loaded ffmpeg video renderer mode=%d index=%d", ffmpegVideoRenderMode, index); + } catch (Throwable e) { + if (SpiderDebug.isEnabled()) SpiderDebug.log("exo-ffmpeg", "ffmpeg video renderer unavailable mode=%d error=%s", ffmpegVideoRenderMode, e.toString()); + } + } + + private int getExtensionRendererIndex(int extensionRendererMode, boolean prefer, ArrayList out) { + int index = out.size(); + if (index > 0 && (extensionRendererMode == EXTENSION_RENDERER_MODE_PREFER || prefer)) index--; + return index; + } + } + private static final class DolbyVisionHdr10FallbackRenderer extends MediaCodecVideoRenderer { private final boolean dv7FallbackEnabled; @@ -1293,11 +1287,10 @@ public class ExoUtil { private Format selectedFormat; private int playbackState; private boolean playing; + private boolean everReady; private boolean adaptiveVideo; private int selectedVideoCandidates; private int availableVideoFormats; - private Tracks constraintTracks = Tracks.EMPTY; - private Boolean constraintEligibility; private volatile boolean released; AutomaticVideoConstraintController( @@ -1322,7 +1315,24 @@ public class ExoUtil { @Override public void onPlaybackStateChanged(EventTime eventTime, @Player.State int state) { + // 先对齐会话再判断:everReady 由 bindSession 重置,而 bindSession 原本只在 + // buildInput 里才发生。换片时若先读 everReady,会把上一片的起播状态带过来, + // 让新片的首次缓冲被误判成重缓冲而白降一档。 + bindEventSession(); + boolean rebuffered = state == Player.STATE_BUFFERING && everReady; + if (state == Player.STATE_READY) everReady = true; playbackState = state; + // 已经起播过又退回缓冲,说明当前轨道的码率超出实际可用带宽。带宽估算可能仍然偏乐观, + // 因此把重缓冲本身也当作吞吐不足的证据,避免弱网锁在最高档反复重缓冲。 + if (rebuffered) { + applyFault( + ExoAutomaticVideoConstraintPolicy.Fault.THROUGHPUT, + eventTime.currentPlaybackPositionMs, + 0, + 0, + "rebuffer"); + return; + } refresh("playback-state", eventTime.currentPlaybackPositionMs); } @@ -1335,7 +1345,6 @@ public class ExoUtil { @Override public void onTracksChanged(EventTime eventTime, Tracks tracks) { bindEventSession(); - constraintTracks = tracks; TrackShape shape = inspectTracks(tracks); adaptiveVideo = shape.adaptiveVideo(); selectedVideoCandidates = shape.selectedVideoCandidates(); @@ -1380,6 +1389,25 @@ public class ExoUtil { videoCodecError == null ? "unknown" : videoCodecError.getClass().getSimpleName()); } + // 起播固定选约束内最高画质后原生 ABR 不再兜底吞吐,带宽撑不住当前轨道时解码器并无压力, + // 只会表现为持续重缓冲,掉帧与编解码错误都不会触发。这里按实测带宽独立降档, + // 否则弱网锁在最高档会一直重缓冲且无法恢复。 + @Override + public void onBandwidthEstimate(EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { + // 只比对当前轨道自己申报的码率。轨道码率未知时不能退回 appliedLimit 的上限, + // 那是设备能力上限(4K 档可达 20Mbps),拿它比会让码率不高的正常片源也持续误降档; + // 这种情况交给重缓冲证据兜底。 + int selectedBitrate = ExoPlaybackDiagnostics.trackConstraintBitrate(selectedFormat); + if (selectedBitrate <= 0) return; + if (!ExoAdaptiveVideoBitratePolicy.shouldDowngrade(selectedBitrate, bitrateEstimate)) return; + applyFault( + ExoAutomaticVideoConstraintPolicy.Fault.THROUGHPUT, + eventTime.currentPlaybackPositionMs, + 0, + 0, + "bandwidth=" + bitrateEstimate); + } + @Override public void onEvents(Player player, AnalyticsListener.Events events) { playbackState = player.getPlaybackState(); @@ -1433,18 +1461,6 @@ public class ExoUtil { PlaybackAutoContext context = currentExoContext(now); if (context == null) return null; if (!context.session().equals(boundSession)) bindSession(context.session()); - boolean applicable = ExoVideoConstraintApplicability.canAdjust( - constraintTracks, trackSelector.getParameters()); - if (constraintEligibility == null || constraintEligibility != applicable) { - constraintEligibility = applicable; - SpiderDebug.log("exo-enhance", "automatic constraint applicable=%s adaptiveVideo=%s selectedVideoCandidates=%d availableVideoFormats=%d reason=%s", - applicable, adaptiveVideo, selectedVideoCandidates, availableVideoFormats, - applicable ? "selectable-video-alternatives" : "preserve-current-video-selection"); - } - if (!applicable) { - App.removeCallbacks(refreshRunnable); - return null; - } ExoAutomaticVideoConstraintPolicy.Environment environment = ExoAutomaticVideoConstraintPolicy.environment(context, now); return new ExoAutomaticVideoConstraintPolicy.Input( @@ -1463,11 +1479,10 @@ public class ExoUtil { lastDecision = null; lastLoggedAction = null; selectedFormat = null; + everReady = false; adaptiveVideo = false; selectedVideoCandidates = 0; availableVideoFormats = 0; - constraintTracks = Tracks.EMPTY; - constraintEligibility = null; apply(baselineLimit); } diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoVideoConstraintApplicability.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoVideoConstraintApplicability.java deleted file mode 100644 index b80e021348..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoVideoConstraintApplicability.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.TrackSelectionParameters; -import androidx.media3.common.Tracks; - -import java.util.ArrayList; -import java.util.List; - -/** Runtime limits only help when the current content offers selectable alternative formats. */ -final class ExoVideoConstraintApplicability { - private ExoVideoConstraintApplicability() {} - - static boolean canAdjust(Tracks tracks, TrackSelectionParameters parameters) { - if (tracks == null || parameters.disabledTrackTypes.contains(C.TRACK_TYPE_VIDEO)) return false; - for (Tracks.Group group : tracks.getGroups()) { - if (group.getType() != C.TRACK_TYPE_VIDEO) continue; - boolean manual = parameters.overrides.containsKey(group.getMediaTrackGroup()); - if (!group.isSelected() || !group.isAdaptiveSupported() || manual) continue; - List supported = new ArrayList<>(group.length); - for (int i = 0; i < group.length; i++) { - if (group.isTrackSupported(i)) supported.add(group.getTrackFormat(i)); - } - if (canAdjust(group.isSelected(), group.isAdaptiveSupported(), manual, false, supported)) return true; - } - return false; - } - - static boolean canAdjust(boolean selected, boolean adaptive, boolean manual, - boolean disabled, List supportedFormats) { - if (!selected || !adaptive || manual || disabled) return false; - int width = 0, height = 0, bitrate = 0; - float frameRate = 0; - for (Format candidate : supportedFormats) { - if (candidate.width > 0 && candidate.height > 0) { - if (width > 0 && (width != candidate.width || height != candidate.height)) return true; - width = candidate.width; - height = candidate.height; - } - if (candidate.frameRate > 0 && Float.isFinite(candidate.frameRate)) { - if (frameRate > 0 && frameRate != candidate.frameRate) return true; - frameRate = candidate.frameRate; - } - int candidateBitrate = ExoPlaybackDiagnostics.trackConstraintBitrate(candidate); - if (candidateBitrate > 0) { - if (bitrate > 0 && bitrate != candidateBitrate) return true; - bitrate = candidateBitrate; - } - } - return false; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSource.java b/app/src/main/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSource.java new file mode 100644 index 0000000000..74208191ed --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSource.java @@ -0,0 +1,324 @@ +package com.fongmi.android.tv.player.exo; + +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; + +import com.fongmi.android.tv.api.config.HlsRuleConfig; +import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.utils.HlsAdblockPipeline; +import com.fongmi.android.tv.utils.HlsManifestCleaner; +import com.github.catvod.crawler.SpiderDebug; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; + +/** + * Applies the HLS ad removal pipeline to playlist responses on the Exo path. + * + *

Exo has no local HLS proxy, so {@link HlsManifestCleaner} never ran there — + * the only ad removal was the legacy {@code HlsAdsParser} heuristic invoked inside + * the forked {@code HlsPlaylistParser}. User and interface rules were therefore + * silently inert on the default kernel. Cleaning here gives Exo the same + * {@link HlsAdblockPipeline} policy that IJK already gets through its proxy. + * + *

This class owns the whole pipeline: {@code ExoUtil} deliberately stops + * passing {@code adblock=true} into the {@code MediaItem}, so the fork's heuristic no + * longer runs behind our back. That is not tidiness — chaining them is actively + * destructive. {@code HlsAdsParser.findMinorityGroup} gates on "largest group must + * exceed 50% of segments" with no cap on how much it may delete, and removing ad + * segments shrinks the total it measures against. Measured: main content split across + * two CDN hosts (260 + 240 segments) plus 25 ads is left untouched by the heuristic on + * the raw playlist (520 <= 525), but after structured cleaning 520 > 500 opens the + * gate and it deletes the 240-segment group — 48% of the film, silently. Dropping the + * ad host can also collapse two structural groups into one, which pushes input into + * {@code findAdsByPrefixAnalysis}, a path it could never have reached. Running the two + * engines through {@link HlsAdblockPipeline} instead keeps them mutually exclusive: + * the heuristic only ever sees a manifest the structured engine did not change. + * + *

VOD only, gated on {@code #EXT-X-ENDLIST}. Live playlists must not be cleaned + * here: {@link HlsManifestCleaner} bumps {@code #EXT-X-MEDIA-SEQUENCE} by the number + * of removed head segments, but its fallback gates (removal ratio, removed duration, + * and the requirement that removals be contiguous at the head) flip between + * consecutive reloads of a sliding window — a new ad break always appears at the + * window tail first, which is non-contiguous and therefore falls back to the raw + * sequence. The emitted sequence then oscillates, media3's + * {@code HlsMediaPlaylist.isNewerThan} silently discards the "older" reload, and after + * {@code targetDuration * 3.5} playback dies with {@code PlaylistStuckException}. + * The same gate is why {@code MpvHlsProxy.applyAdblock} checks {@code isVodPlaylist}. + * + *

Sits above the cache so that editing a rule takes effect on the next load + * instead of waiting for eviction. + * + *

Playlists are recognised by sniffing the {@code #EXTM3U} header rather than by + * URL shape: one factory serves both manifest and segment loads, and media3 gives + * no reliable data-type hint at this layer. Non-playlist responses only pay for a + * {@value #SNIFF_BYTES}-byte lookahead. + */ +final class HlsPlaylistCleaningDataSource implements DataSource { + + private static final byte[] PLAYLIST_HEADER = "#EXTM3U".getBytes(StandardCharsets.US_ASCII); + /** {@code String.trim} does not strip U+FEFF, so it has to go before cleaning. */ + private static final String BOM = ""; + /** Room for a byte order mark, stray leading whitespace and the header itself. */ + private static final int SNIFF_BYTES = 64; + /** + * Mirrors {@code HlsManifestCleaner}'s own 2 MiB ceiling: past that the cleaner + * bails out anyway, so buffering the response would cost memory for nothing. + */ + private static final int MAX_PLAYLIST_BYTES = 2 * 1024 * 1024; + private static final int CHUNK_BYTES = 8 * 1024; + + private final DataSource upstream; + private final Supplier> rules; + private final BooleanSupplier adblockEnabled; + + @Nullable private byte[] buffer; + private int bufferLength; + private int bufferPos; + + HlsPlaylistCleaningDataSource(DataSource upstream) { + this(upstream, HlsPlaylistCleaningDataSource::configuredRules, Setting::isAdblock); + } + + HlsPlaylistCleaningDataSource(DataSource upstream, + Supplier> rules, + BooleanSupplier adblockEnabled) { + this.upstream = upstream; + this.rules = rules; + this.adblockEnabled = adblockEnabled; + } + + /** + * Reading the rule config must never break playback. Degrading to an empty rule + * set still lets the legacy heuristic run, which is what happened before this + * class existed. + */ + private static List configuredRules() { + try { + return HlsRuleConfig.getRules(); + } catch (RuntimeException e) { + return List.of(); + } + } + + @Override + public void addTransferListener(@NonNull TransferListener transferListener) { + upstream.addTransferListener(transferListener); + } + + @Override + public long open(@NonNull DataSpec dataSpec) throws IOException { + return open(dataSpec, dataSpec.uri == null ? "" : dataSpec.uri.toString(), + dataSpec.position, dataSpec.length); + } + + /** + * The request fields are passed in rather than read off {@code dataSpec} so that + * unit tests can drive this without constructing a {@link DataSpec} — + * {@code Uri.parse} is an unmocked android.jar stub on the JVM. + */ + long open(@Nullable DataSpec dataSpec, String url, long position, long length) + throws IOException { + buffer = null; + bufferLength = 0; + bufferPos = 0; + long upstreamLength = upstream.open(dataSpec); + if (!canInspect(url, position, length)) return upstreamLength; + + byte[] head = new byte[SNIFF_BYTES]; + int headLength = readUpTo(head); + if (!startsWithPlaylistHeader(head, headLength)) { + // Not a playlist: hand back the bytes already consumed, then stream on. + buffer = head; + bufferLength = headLength; + return upstreamLength; + } + + byte[] raw = readAll(head, headLength); + // Oversized: readAll already parked what it read in buffer; pass the rest through. + if (raw == null) return upstreamLength; + + String text = new String(raw, StandardCharsets.UTF_8); + String cleaned = cleanedOrNull(url, text); + buffer = cleaned == null ? raw : cleaned.getBytes(StandardCharsets.UTF_8); + bufferLength = buffer.length; + // The response was drained, so the served length is exactly what we hold. + // Reporting the upstream length would look like a truncated response to Exo, + // because cleaning only ever makes the playlist shorter. + return bufferLength; + } + + @Override + public int read(@NonNull byte[] target, int offset, int length) throws IOException { + if (length == 0) return 0; + if (buffer != null && bufferPos < bufferLength) { + int count = Math.min(length, bufferLength - bufferPos); + System.arraycopy(buffer, bufferPos, target, offset, count); + bufferPos += count; + return count; + } + return upstream.read(target, offset, length); + } + + @Nullable + @Override + public Uri getUri() { + return upstream.getUri(); + } + + @NonNull + @Override + public Map> getResponseHeaders() { + return upstream.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + buffer = null; + bufferLength = 0; + bufferPos = 0; + upstream.close(); + } + + /** + * Only whole-response reads can be rewritten: a ranged request addresses byte + * offsets in the original body, and cleaning changes the length. + */ + private boolean canInspect(String url, long position, long length) { + if (position != 0 || length != C.LENGTH_UNSET) return false; + // The nano /m3u8 proxy already cleaned its output; doing it twice would apply + // the rules to the same playlist again, doubling the removal count and + // possibly crossing the cleaner's fallback gates. + if (HlsAdblockPipeline.isCoreM3u8Proxy(url)) return false; + try { + return adblockEnabled.getAsBoolean(); + } catch (RuntimeException e) { + return false; + } + } + + /** Fills {@code target}, stopping early only at end of input. */ + private int readUpTo(byte[] target) throws IOException { + int total = 0; + while (total < target.length) { + int read = upstream.read(target, total, target.length - total); + if (read == C.RESULT_END_OF_INPUT) break; + total += read; + } + return total; + } + + /** + * Reads the rest of the response, or returns null when it outgrows + * {@link #MAX_PLAYLIST_BYTES} — in that case the bytes consumed so far are left + * in {@link #buffer} so the caller can pass the response through untouched. + */ + @Nullable + private byte[] readAll(byte[] head, int headLength) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(headLength, 1024)); + out.write(head, 0, headLength); + byte[] chunk = new byte[CHUNK_BYTES]; + while (true) { + int read = upstream.read(chunk, 0, chunk.length); + if (read == C.RESULT_END_OF_INPUT) return out.toByteArray(); + out.write(chunk, 0, read); + if (out.size() > MAX_PLAYLIST_BYTES) { + buffer = out.toByteArray(); + bufferLength = buffer.length; + return null; + } + } + } + + /** + * @return the cleaned playlist, or null when nothing changed. + * + *

Runs the full pipeline ({@code legacyFallback=true}) because the fork's own + * heuristic pass is switched off for Exo — see the class javadoc. The pipeline + * keeps the two engines mutually exclusive: the heuristic runs only when the + * structured rules did not change the manifest. + * + *

A leading byte order mark is dropped before cleaning: + * {@code HlsManifestCleaner} requires {@code trim()} to reveal {@code #EXTM3U}, + * and {@code String.trim} does not strip U+FEFF, so a BOM would make it bail out + * on a playlist this class already recognised. Serving the cleaned text without + * the BOM is safe — the playlist parser discards it either way — and when nothing + * is cleaned the original bytes are served untouched. + */ + @Nullable + private String cleanedOrNull(String url, String text) { + String payload = text.startsWith(BOM) ? text.substring(BOM.length()) : text; + // Live windows must keep their upstream media sequence; see class javadoc. + if (!isVodPlaylist(payload)) return null; + try { + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(url, payload, rules.get(), true); + if (outcome.manifest() == null || TextUtils.equals(outcome.manifest(), payload)) return null; + SpiderDebug.log("exo-adblock", "filtered bytes=%d->%d structured=%s legacy=%s removed=%d", + text.length(), outcome.manifest().length(), outcome.structured(), + outcome.legacy(), outcome.removedSegments()); + return outcome.manifest(); + } catch (RuntimeException e) { + return null; + } + } + + /** + * Same criterion as {@code MpvHlsProxy.isVodPlaylist}: a media playlist without + * {@code #EXT-X-ENDLIST} is a live window and must not be rewritten. + * + *

Case-sensitive on purpose, matching {@code HlsManifestCleaner} and media3's + * own parser ({@code line.equals(TAG_ENDLIST)}). Accepting a lowercase spelling + * here would be worse than rejecting it: this gate would classify the playlist as + * VOD and hand it over, while the cleaner would still see it as live, take the + * media-sequence bump branch, and reintroduce exactly the cross-reload sequence + * regression the gate exists to prevent. + * + *

A multivariant playlist has no {@code #EXT-X-ENDLIST} either and is therefore + * skipped too, which is correct — it carries no segments to remove, and its + * variants are fetched as separate media playlists that do reach this code. + */ + private static boolean isVodPlaylist(String text) { + return text.contains("#EXT-X-ENDLIST"); + } + + private static boolean startsWithPlaylistHeader(byte[] data, int length) { + int index = 0; + // Skip the UTF-8 byte order mark, which the playlist parser also tolerates. + if (length >= 3 && (data[0] & 0xFF) == 0xEF && (data[1] & 0xFF) == 0xBB + && (data[2] & 0xFF) == 0xBF) index = 3; + while (index < length && Character.isWhitespace(data[index])) index++; + if (length - index < PLAYLIST_HEADER.length) return false; + for (int i = 0; i < PLAYLIST_HEADER.length; i++) { + if (data[index + i] != PLAYLIST_HEADER[i]) return false; + } + return true; + } + + static final class Factory implements DataSource.Factory { + + private final DataSource.Factory upstream; + + Factory(DataSource.Factory upstream) { + this.upstream = upstream; + } + + @NonNull + @Override + public DataSource createDataSource() { + return new HlsPlaylistCleaningDataSource(upstream.createDataSource()); + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/Media3DiagnosticBridge.java b/app/src/main/java/com/fongmi/android/tv/player/exo/Media3DiagnosticBridge.java deleted file mode 100644 index d2aa25d9a3..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/Media3DiagnosticBridge.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import androidx.media3.common.util.PlaybackDiagnostics; -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector; -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector.Context; -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; -import java.lang.ref.WeakReference; -import java.util.WeakHashMap; - -/** Maps library-owned events to their original player/output. Never invokes playback APIs. */ -final class Media3DiagnosticBridge implements PlaybackDiagnostics.Listener { - private static final WeakHashMap OWNERS = new WeakHashMap<>(); - private static final Media3DiagnosticBridge INSTANCE = new Media3DiagnosticBridge(); - private static final ThreadLocal WRITES = ThreadLocal.withInitial(WriteCache::new); - private static final class WriteCache { WeakReference key = new WeakReference<>(null); Binding binding; } - static final class Binding { - final PlaybackDiagnosticCollector log; - final Context owner; - final String id, decoderName; - final boolean video; - long capture = -1, calls, bytes, requested, shortWrites, zeros, errors, headerBytes, maxNs, lastMs; - Binding(PlaybackDiagnosticCollector log, Context owner, String id, String decoderName, boolean video) { - this.log = log; this.owner = owner; this.id = id; this.decoderName = decoderName; this.video = video; - } - Context context() { return owner == null ? log.context() : owner; } - } - static void bind(Object key, PlaybackDiagnosticCollector log, Context owner, String id) { - if (key == null) return; - PlaybackDiagnostics.setListener(INSTANCE); - synchronized (OWNERS) { OWNERS.put(key, new Binding(log, owner, id, null, false)); } - } - static Object enter(PlaybackDiagnosticCollector log, Context owner, String decoderId, String name, boolean video) { - PlaybackDiagnostics.setListener(INSTANCE); - return PlaybackDiagnostics.enter(new Binding(log, owner, decoderId, name, video)); - } - private static Binding binding(Object key) { - if (key instanceof Binding binding) return binding; - synchronized (OWNERS) { return OWNERS.get(key); } - } - - static androidx.media3.extractor.ExtractorsFactory extractors(androidx.media3.extractor.ExtractorsFactory delegate, - androidx.media3.common.MediaItem item) { - if (item.localConfiguration == null || !(item.localConfiguration.tag instanceof Context owner)) return delegate; - PlaybackDiagnosticCollector log = ExoDiagnosticCollector.mediaLog(owner); - if (log == null) return delegate; - return new androidx.media3.extractor.ForwardingExtractorsFactory(delegate) { - private androidx.media3.extractor.Extractor[] bindOutputs(androidx.media3.extractor.Extractor[] extractors) { - for (int i = 0; i < extractors.length; i++) { - extractors[i] = new androidx.media3.extractor.ForwardingExtractor(extractors[i]) { - @Override public void init(androidx.media3.extractor.ExtractorOutput output) { - // Bind on the original loader's init, never to the globally current media. - bind(output, log, owner, owner.mediaId()); - super.init(output); - } - }; - } - return extractors; - } - @Override public androidx.media3.extractor.Extractor[] createExtractors() { return bindOutputs(delegate.createExtractors()); } - @Override public androidx.media3.extractor.Extractor[] createExtractors(android.net.Uri uri, java.util.Map> headers) { - return bindOutputs(delegate.createExtractors(uri, headers)); - } - }; - } - @Override public boolean enabled(String event) { - return "codec".equals(event) ? DebugLogStore.acceptsEvent("video.decoder.attempt") || DebugLogStore.acceptsEvent("audio.decoder.attempt") : DebugLogStore.acceptsEvent(event); - } - @Override public void event(Object key, String event, String operation, String phase, long durationNs, long value, String detail, Throwable error) { - Binding binding = binding(key); - if (binding == null) { - if ("codec".equals(event)) return; - DebugLogStore.event(new DiagnosticEvent(event, "none", PlaybackDiagnosticCollector.objectId(key, "media3-owner"), 0, 0) - .observed("operation", operation).observed("phase", phase).observed("value", value).message(detail) - .observed("association", "library-owner; media-unresolved")); - return; - } - String name = "codec".equals(event) ? binding.video ? "video.decoder.attempt" : "audio.decoder.attempt" : event; - binding.log.emit(binding.context(), name, "media3-owner-hook", binding.owner == null ? "engine-owner-current-context" : "fixed-operation-owner", e -> { - e.observed("operation", operation).observed("phase", phase).observed("durationMs", durationNs < 0 ? null : durationNs / 1_000_000.0) - .observed("result", error == null ? phase : "failed"); - if ("codec".equals(event)) e.observed("decoderId", binding.id).observed("decoderName", binding.decoderName).observed("stage", operation); - else if ("audio.focus".equals(event)) { - e.observed("focusOwner", "Media3"); - if ("callback".equals(operation)) e.observed("focusChange", value); - else if ("duck-gain".equals(operation)) e.observed("effectiveGain", Float.parseFloat(detail)); - else if ("player-command".equals(operation)) e.observed("focusAction", value); - else e.observed("focusResult", value).observed("focusRequest", detail); - } else if ("audio.processing".equals(event)) { - e.observed("processorIndex", value).observed("active", "active".equals(operation) ? true : null).message(detail); - int input = detail.indexOf(" in="), output = detail.indexOf(" out="); - e.observed("processorName", input < 0 ? detail : detail.substring(0, input)); - if (input >= 0 && output > input) e.observed("inputFormat", detail.substring(input + 4, output)).observed("outputFormat", detail.substring(output + 5)); - } else e.message(detail).observed("value", value); - if (error != null) e.severity("error").observed("javaClass", error.getClass().getName()); - }); - if (error != null) binding.log.error(binding.context(), binding.video ? "video-codec" : "audio-codec", operation, error); - } - @Override public void write(Object key, int requested, int result, long pts, boolean header, long durationNs) { - WriteCache cache = WRITES.get(); - if (cache.key.get() != key) { cache.key = new WeakReference<>(key); cache.binding = binding(key); } - Binding binding = cache.binding; - if (binding == null) return; - long capture = DebugLogStore.captureGeneration(); - if (binding.capture != capture) { - binding.capture = capture; binding.calls = binding.bytes = binding.requested = binding.shortWrites = binding.zeros = binding.errors = binding.headerBytes = binding.maxNs = binding.lastMs = 0; - } - binding.calls++; binding.requested += requested; - if (result < 0) binding.errors++; else if (result == 0) binding.zeros++; else { - if (header) binding.headerBytes += result; else binding.bytes += result; - if (result < requested) binding.shortWrites++; - } - binding.maxNs = Math.max(binding.maxNs, durationNs); - long now = android.os.SystemClock.elapsedRealtime(); - if (result >= 0 && binding.calls != 1 && now - binding.lastMs < 5000) return; - binding.lastMs = now; - binding.log.emit(binding.context(), "audio.output.write", "AudioTrack.write-owner", "fixed-output-lifetime", e -> { - e.observed("audioOutputId", binding.id).observed("writeCalls", binding.calls).observed("requestedBytes", binding.requested) - .observed("acceptedBytes", binding.bytes).observed("shortWrites", binding.shortWrites).observed("zeroWrites", binding.zeros) - .observed("writeErrors", binding.errors).observed("maxWriteUs", binding.maxNs / 1000).observed("lastPtsUs", pts) - .observed("errorCode", result).observed("bytes", binding.headerBytes) - .observed("metricScope", "raw AudioTrack API; acceptedBytes=payload; bytes=AV-sync headers; request includes retries"); - if (result < 0) e.severity("error").observed("stage", header ? "av-sync-header-write" : "payload-write"); - }); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/MediaSourceFactory.java b/app/src/main/java/com/fongmi/android/tv/player/exo/MediaSourceFactory.java index b43bd9899e..b941339893 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/MediaSourceFactory.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/MediaSourceFactory.java @@ -2,12 +2,10 @@ package com.fongmi.android.tv.player.exo; import static androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS; -import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.MediaItem; -import androidx.media3.common.MimeTypes; import androidx.media3.common.PriorityTaskManager; import androidx.media3.database.StandaloneDatabaseProvider; import androidx.media3.datasource.DataSource; @@ -15,10 +13,6 @@ import androidx.media3.datasource.DefaultDataSource; import androidx.media3.datasource.HttpDataSource; import androidx.media3.datasource.cache.Cache; import androidx.media3.datasource.cache.CacheDataSource; -import androidx.media3.datasource.cache.CacheKeyFactory; -import androidx.media3.datasource.cache.CacheSpan; -import androidx.media3.datasource.cache.ContentMetadata; -import androidx.media3.datasource.cache.ContentMetadataMutations; import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor; import androidx.media3.datasource.cache.SimpleCache; import androidx.media3.datasource.okhttp.OkHttpDataSource; @@ -32,8 +26,6 @@ import androidx.media3.extractor.ExtractorsFactory; import androidx.media3.extractor.ts.TsExtractor; import com.fongmi.android.tv.App; -import com.fongmi.android.tv.player.exo.ass.AssFontSet; -import com.fongmi.android.tv.player.exo.ass.ExoAssSession; import com.fongmi.android.tv.player.cache.DiskCacheCapacityPolicy; import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; import com.fongmi.android.tv.setting.PlayerSetting; @@ -43,22 +35,13 @@ import com.fongmi.android.tv.utils.UrlUtil; import com.github.catvod.crawler.SpiderDebug; import com.github.catvod.net.OkHttp; import com.github.catvod.utils.Path; -import com.google.common.base.Ascii; import java.io.File; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; +import java.net.URI; import java.util.Iterator; import java.util.LinkedHashMap; -import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.NavigableSet; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; - -import okhttp3.Call; public class MediaSourceFactory implements MediaSource.Factory { @@ -77,7 +60,6 @@ public class MediaSourceFactory implements MediaSource.Factory { private DataSource.Factory dataSourceFactory; private ExtractorsFactory extractorsFactory; @Nullable private final ExoDolbyVisionPlaybackState dolbyVisionPlaybackState; - @Nullable private final ExoAssSession assSession; public MediaSourceFactory() { this(null); @@ -85,18 +67,13 @@ public class MediaSourceFactory implements MediaSource.Factory { MediaSourceFactory( @Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState) { - this(dolbyVisionPlaybackState, null); - } - - MediaSourceFactory(@Nullable ExoDolbyVisionPlaybackState dolbyVisionPlaybackState, - @Nullable ExoAssSession assSession) { this.dolbyVisionPlaybackState = dolbyVisionPlaybackState; - this.assSession = assSession; defaultMediaSourceFactory = new DefaultMediaSourceFactory(getDataSourceFactory(), getExtractorsFactory()).setLoadOnlySelectedTracks(PlaybackPerformanceSetting.isLoadOnlySelectedTracksEnabled()); } static DataSource.Factory createUpstreamDataSourceFactory(Map headers) { - OkHttpDataSource.Factory factory = createHttpDataSourceFactory(OkHttp.player(), headers); + OkHttpDataSource.Factory factory = new OkHttpDataSource.Factory(OkHttp.player()); + applyHeaders(factory, headers); DataSource.Factory upstream = new DefaultDataSource.Factory(App.get(), factory); DataSource.Factory recovered = new HttpEofRecoveryDataSource.Factory(upstream); return new PriorityTaskDataSource.Factory(recovered, PLAYBACK_PRIORITY_MANAGER, C.PRIORITY_PLAYBACK_PRELOAD, true); @@ -195,7 +172,7 @@ public class MediaSourceFactory implements MediaSource.Factory { } } - public static boolean isConcatenatingUrl(String url) { + static boolean isConcatenatingUrl(String url) { return url != null && url.contains(CONCAT_SOURCE_SEPARATOR) && url.contains(CONCAT_DURATION_SEPARATOR); } @@ -220,117 +197,62 @@ public class MediaSourceFactory implements MediaSource.Factory { @NonNull @Override public MediaSource createMediaSource(@NonNull MediaItem mediaItem) { - Map headers = ExoUtil.extractHeaders(mediaItem); - DefaultMediaSourceFactory itemFactory = createItemMediaSourceFactory(headers); + applyHeaders(getHttpDataSourceFactory(), ExoUtil.extractHeaders(mediaItem)); String url = mediaItem.requestMetadata.mediaUri != null ? mediaItem.requestMetadata.mediaUri.toString() : ""; - AssFontSet fonts = assSession == null ? null : assSession.beginMediaFonts(); - if (fonts != null || mediaItem.localConfiguration != null - && mediaItem.localConfiguration.tag instanceof com.fongmi.android.tv.player.PlaybackDiagnosticCollector.Context) { - itemFactory = createItemMediaSourceFactory(headers, fonts, mediaItem); - } - if (isConcatenatingUrl(url)) return createConcatenatingMediaSource(mediaItem, url, itemFactory); - return itemFactory.createMediaSource(mediaItem); + if (isConcatenatingUrl(url)) return createConcatenatingMediaSource(mediaItem, url); + else return defaultMediaSourceFactory.createMediaSource(mediaItem); } - private MediaSource createConcatenatingMediaSource( - MediaItem mediaItem, String url, DefaultMediaSourceFactory itemFactory) { + private MediaSource createConcatenatingMediaSource(MediaItem mediaItem, String url) { ConcatenatingMediaSource2.Builder builder = new ConcatenatingMediaSource2.Builder(); - DefaultMediaSourceFactory sourceFactory = new DefaultMediaSourceFactory(getDataSourceFactory(), - Media3DiagnosticBridge.extractors(getExtractorsFactory(), mediaItem)) - .setLoadOnlySelectedTracks(PlaybackPerformanceSetting.isLoadOnlySelectedTracksEnabled()); for (String split : url.split(CONCAT_SOURCE_SEPARATOR_REGEX)) { String[] info = split.split(CONCAT_DURATION_SEPARATOR_REGEX); - if (info.length >= 2) { - builder.add(itemFactory.createMediaSource( - mediaItem.buildUpon().setUri(UrlUtil.uri(info[0])).build()), - Long.parseLong(info[1])); - } + if (info.length >= 2) builder.add(defaultMediaSourceFactory.createMediaSource(mediaItem.buildUpon().setUri(UrlUtil.uri(info[0])).build()), Long.parseLong(info[1])); } return builder.build(); } private ExtractorsFactory getExtractorsFactory() { - if (extractorsFactory == null) extractorsFactory = buildExtractorsFactory(null); - return extractorsFactory; - } - - private ExtractorsFactory buildExtractorsFactory(@Nullable AssFontSet fonts) { + if (extractorsFactory == null) { ExtractorsFactory defaults = new DefaultExtractorsFactory() .setTsExtractorFlags(FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS) .setTsExtractorTimestampSearchBytes( TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES * 10); - ExtractorsFactory withApe = new ExtractorsFactory() { - @Override - public androidx.media3.extractor.Extractor[] createExtractors() { - return prependApe(defaults.createExtractors()); - } - - @Override - public androidx.media3.extractor.Extractor[] createExtractors( - Uri uri, Map> responseHeaders) { - return prependApe(defaults.createExtractors(uri, responseHeaders)); - } - }; - return new DolbyVisionP81ExtractorsFactory(withApe, dolbyVisionPlaybackState, fonts); - } - - private static androidx.media3.extractor.Extractor[] prependApe( - androidx.media3.extractor.Extractor[] defaults) { - androidx.media3.extractor.Extractor[] extractors = - new androidx.media3.extractor.Extractor[defaults.length + 1]; - extractors[0] = new ApeExtractor(); - System.arraycopy(defaults, 0, extractors, 1, defaults.length); - return extractors; + extractorsFactory = new DolbyVisionP81ExtractorsFactory( + defaults, dolbyVisionPlaybackState); + } + return extractorsFactory; } private DataSource.Factory getDataSourceFactory() { if (dataSourceFactory == null) { - DataSource.Factory cacheDataSource = getCacheDataSource( - new DefaultDataSource.Factory(App.get(), getHttpDataSourceFactory()), - Map.of()); - DataSource.Factory adblockDataSource = new ExoHlsAdblockDataSource.Factory(cacheDataSource); - DataSource.Factory trackedDataSource = new PlaybackBytePositionDataSource.Factory(adblockDataSource); + DataSource.Factory cacheDataSource = getCacheDataSource(new DefaultDataSource.Factory(App.get(), getHttpDataSourceFactory())); + DataSource.Factory trackedDataSource = new PlaybackBytePositionDataSource.Factory(withPlaylistCleaning(cacheDataSource)); dataSourceFactory = new PriorityTaskDataSource.Factory(trackedDataSource, PLAYBACK_PRIORITY_MANAGER, C.PRIORITY_PLAYBACK, false); } return dataSourceFactory; } - private DataSource.Factory getDataSourceFactory(Map headers) { - OkHttpDataSource.Factory httpFactory = createHttpDataSourceFactory(OkHttp.player(), headers); - DataSource.Factory upstreamFactory = new DefaultDataSource.Factory(App.get(), httpFactory); - DataSource.Factory cacheDataSource = getCacheDataSource(upstreamFactory, headers); - DataSource.Factory adblockDataSource = new ExoHlsAdblockDataSource.Factory(cacheDataSource); - DataSource.Factory trackedDataSource = new PlaybackBytePositionDataSource.Factory(adblockDataSource); - return new PriorityTaskDataSource.Factory( - trackedDataSource, PLAYBACK_PRIORITY_MANAGER, C.PRIORITY_PLAYBACK, false); - } - - private DefaultMediaSourceFactory createItemMediaSourceFactory(Map headers) { - return createItemMediaSourceFactory(headers, null, null); - } - - private DefaultMediaSourceFactory createItemMediaSourceFactory( - Map headers, @Nullable AssFontSet fonts, @Nullable MediaItem mediaItem) { - ExtractorsFactory itemExtractors = fonts != null || mediaItem != null - && mediaItem.localConfiguration != null - && mediaItem.localConfiguration.tag instanceof com.fongmi.android.tv.player.PlaybackDiagnosticCollector.Context - ? Media3DiagnosticBridge.extractors(buildExtractorsFactory(fonts), mediaItem) - : getExtractorsFactory(); - return new DefaultMediaSourceFactory(getDataSourceFactory(headers), itemExtractors) - .setLoadOnlySelectedTracks(PlaybackPerformanceSetting.isLoadOnlySelectedTracksEnabled()); + /** + * Applies the structured HLS ad rules to playlist responses. + * + *

Exo has no local HLS proxy, so {@code HlsManifestCleaner} never ran on this + * kernel and user/interface rules were silently inert — only the legacy heuristic + * inside the forked playlist parser did anything. Wrapping here gives Exo the same + * rule engine IJK already gets through {@code MpvHlsProxy}. + * + *

Deliberately above the cache: rules must apply to cached playlists too, and + * editing a rule should take effect on the next load rather than after eviction. + */ + static DataSource.Factory withPlaylistCleaning(DataSource.Factory upstream) { + return new HlsPlaylistCleaningDataSource.Factory(upstream); } private CacheDataSource.Factory getCacheDataSource(DataSource.Factory upstreamFactory) { - return getCacheDataSource(upstreamFactory, Map.of()); - } - - private CacheDataSource.Factory getCacheDataSource( - DataSource.Factory upstreamFactory, Map headers) { return new CacheDataSource.Factory() .setCache(getCache()) .setUpstreamDataSourceFactory(new HttpEofRecoveryDataSource.Factory(upstreamFactory)) .setCacheWriteDataSinkFactory(null) - .setCacheKeyFactory(cacheKeyFactory(headers)) .setEventListener(PlaybackCacheMetrics.listener()) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR); } @@ -346,206 +268,6 @@ public class MediaSourceFactory implements MediaSource.Factory { factory.setUserAgent(userAgent).setDefaultRequestProperties(sanitized); } - static OkHttpDataSource.Factory createHttpDataSourceFactory( - Call.Factory client, Map headers) { - // Keep the player's CookieJar, redirect policy, proxy and connection pool unchanged. - OkHttpDataSource.Factory factory = new OkHttpDataSource.Factory(client); - applyHeaders(factory, headers); - return factory; - } - - /** - * Builds a cache namespace from request headers without putting credentials into the cache - * key. The namespace is shared by the foreground MediaSource and any future item-scoped - * preload for the same immutable request snapshot. - */ - static String cacheNamespace(Map headers) { - Map sanitized = sanitizeHeaders(headers); - String userAgent = removeUserAgentHeader(sanitized); - if (userAgent != null) sanitized.put("User-Agent", userAgent); - // No request identity was added: retain the existing URL/custom-key cache hits. - if (sanitized.isEmpty()) return ""; - TreeMap normalized = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - normalized.putAll(sanitized); - boolean hasCaseVariants = normalized.size() != sanitized.size(); - // Media3 retains case variants until OkHttp builds the request. Do not guess which - // credential wins or silently discard one when computing its cache identity. - if (hasCaseVariants) normalized = new TreeMap<>(sanitized); - StringBuilder value = new StringBuilder(); - value.append(hasCaseVariants ? 'D' : 'S'); - for (Map.Entry entry : normalized.entrySet()) { - String name = hasCaseVariants ? entry.getKey() : Ascii.toLowerCase(entry.getKey()); - // Length-prefix fields: cache lookup happens before HTTP validation, so newline - // delimiters would allow even a rejected header to alias valid cached credentials. - value.append(name.length()).append(':').append(name) - .append(entry.getValue().length()).append(':').append(entry.getValue()); - } - return sha256(value.toString()); - } - - static CacheKeyFactory cacheKeyFactory(Map headers) { - String namespace = cacheNamespace(headers); - return dataSpec -> cacheKey(namespace, dataSpec.key == null ? dataSpec.uri.toString() : dataSpec.key); - } - - static String cacheKey(Map headers, String resourceKey) { - return cacheKey(cacheNamespace(headers), resourceKey); - } - - private static String cacheKey(String namespace, String resourceKey) { - return namespace.isEmpty() ? resourceKey : namespace + "|" + resourceKey; - } - - /** - * Gives Media3's PreCacheHelper an item-scoped view of the shared cache. PreCacheHelper does - * not expose CacheKeyFactory, so the scope has to be applied at the Cache boundary. The - * foreground CacheDataSource uses the same {@link #cacheKey(Map, String)} contract. - */ - static Cache scopedCache(Map headers) { - return scopedCache(getCache(), headers); - } - - static Cache scopedCache(Cache delegate, Map headers) { - return new ScopedCache(delegate, cacheNamespace(headers)); - } - - private static final class ScopedCache implements Cache { - - private final Cache delegate; - private final String namespace; - private final String prefix; - - private ScopedCache(Cache delegate, String namespace) { - this.delegate = delegate; - this.namespace = namespace; - this.prefix = namespace.isEmpty() ? "" : namespace + "|"; - } - - private String key(String rawKey) { - return cacheKey(namespace, rawKey); - } - - @Override - public long getUid() { - return delegate.getUid(); - } - - @Override - public void release() { - // The process cache is shared by foreground playback and other sessions. - } - - @Override - public NavigableSet addListener(String rawKey, Listener listener) { - return delegate.addListener(key(rawKey), listener); - } - - @Override - public void removeListener(String rawKey, Listener listener) { - delegate.removeListener(key(rawKey), listener); - } - - @Override - public NavigableSet getCachedSpans(String rawKey) { - return delegate.getCachedSpans(key(rawKey)); - } - - @Override - public Set getKeys() { - Set keys = new TreeSet<>(); - for (String key : delegate.getKeys()) { - if (key.startsWith(prefix)) keys.add(key.substring(prefix.length())); - } - return keys; - } - - @Override - public long getCacheSpace() { - return delegate.getCacheSpace(); - } - - @Override - public CacheSpan startReadWrite(String rawKey, long position, long length) - throws InterruptedException, CacheException { - return delegate.startReadWrite(key(rawKey), position, length); - } - - @Override - public CacheSpan startReadWriteNonBlocking(String rawKey, long position, long length) - throws CacheException { - return delegate.startReadWriteNonBlocking(key(rawKey), position, length); - } - - @Override - public File startFile(String rawKey, long position, long length) throws CacheException { - return delegate.startFile(key(rawKey), position, length); - } - - @Override - public void commitFile(File file, long length) throws CacheException { - delegate.commitFile(file, length); - } - - @Override - public void releaseHoleSpan(CacheSpan holeSpan) { - delegate.releaseHoleSpan(holeSpan); - } - - @Override - public void removeResource(String rawKey) { - delegate.removeResource(key(rawKey)); - } - - @Override - public void removeSpan(CacheSpan span) { - delegate.removeSpan(span); - } - - @Override - public boolean isCached(String rawKey, long position, long length) { - return delegate.isCached(key(rawKey), position, length); - } - - @Override - public long getCachedLength(String rawKey, long position, long length) { - return delegate.getCachedLength(key(rawKey), position, length); - } - - @Override - public long getCachedBytes(String rawKey, long position, long length) { - return delegate.getCachedBytes(key(rawKey), position, length); - } - - @Override - public void applyContentMetadataMutations(String rawKey, ContentMetadataMutations mutations) - throws CacheException { - delegate.applyContentMetadataMutations(key(rawKey), mutations); - } - - @Override - public ContentMetadata getContentMetadata(String rawKey) { - return delegate.getContentMetadata(key(rawKey)); - } - } - - private static String sha256(String value) { - try { - // Preserve every code unit, including malformed headers that HTTP will reject. - // UTF-8's replacement of unpaired surrogates would alias a literal '?' credential. - MessageDigest hash = MessageDigest.getInstance("SHA-256"); - for (int i = 0; i < value.length(); i++) { - hash.update((byte) (value.charAt(i) >>> 8)); - hash.update((byte) value.charAt(i)); - } - byte[] digest = hash.digest(); - StringBuilder result = new StringBuilder(digest.length * 2); - for (byte item : digest) result.append(String.format(Locale.ROOT, "%02x", item)); - return result.toString(); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError("SHA-256 is required by the Android runtime", e); - } - } - static Map sanitizeHeaders(Map headers) { Map sanitized = new LinkedHashMap<>(); if (headers == null || headers.isEmpty()) return sanitized; @@ -570,4 +292,26 @@ public class MediaSourceFactory implements MediaSource.Factory { } return userAgent; } + + public static boolean isHlsUrl(String url) { + String lower = url == null ? "" : url.toLowerCase(Locale.ROOT); + if (lower.contains("m3u8") || lower.contains("type=hls") || lower.contains("format=hls")) return true; + String path = getUrlPath(lower); + return path.endsWith("/live.php") || path.contains("/live/"); + } + + private static String getUrlPath(String url) { + try { + String path = URI.create(url).getPath(); + if (path != null) return path; + } catch (IllegalArgumentException ignored) { + } + int end = url.length(); + int query = url.indexOf('?'); + int fragment = url.indexOf('#'); + if (query >= 0) end = Math.min(end, query); + if (fragment >= 0) end = Math.min(end, fragment); + return url.substring(0, end); + } + } diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/PreCache.java b/app/src/main/java/com/fongmi/android/tv/player/exo/PreCache.java index 507852cbeb..5a1ef850f4 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/PreCache.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/PreCache.java @@ -12,7 +12,6 @@ import androidx.media3.common.Format; import androidx.media3.common.MediaItem; import androidx.media3.common.Player; import androidx.media3.datasource.DataSource; -import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.source.preload.PreCacheHelper; import com.fongmi.android.tv.BuildConfig; @@ -43,19 +42,6 @@ import java.util.concurrent.TimeUnit; public class PreCache implements Player.Listener { - public static boolean isPlaylistPreloadEnabled() { - return PreloadSetting.isPreload(PlayerSetting.EXO) - && PlaybackExperimentSetting.isAllowed(PlaybackExperimentPolicy.Action.EXO_AUTO_PRELOAD); - } - - public boolean setPlaylistPreloadDurationMs(Player player, long durationMs) { - if (!(player instanceof ExoPlayer exoPlayer) || durationMs < 0) return false; - if (durationMs > 0 && !isPlaylistPreloadEnabled()) return false; - exoPlayer.setPreloadConfiguration(new ExoPlayer.PreloadConfiguration( - durationMs > Long.MAX_VALUE / 1000L ? Long.MAX_VALUE : durationMs * 1000L)); - return true; - } - private static final String TAG = "TV-exo-preload"; private static final long TICK_MS = 5000; private static final long BUFFER_GAP_MS = 1250; @@ -92,6 +78,8 @@ public class PreCache implements Player.Listener { private PreCacheHelper helper; private Handler handler; private HandlerThread worker; + private volatile WorkerResources workerResources; + private volatile Thread failedWorker; private Player player; private PlaybackRoute route; private PlaybackRoute.Resolution routeResolution = PlaybackRoute.resolve(null); @@ -113,8 +101,6 @@ public class PreCache implements Player.Listener { private boolean diskPreloadCircuitOpen; private boolean memoryPreloadPaused; private BufferGate bufferGate; - private long preloadNotBeforeMs = C.TIME_UNSET; - private long nextRangeNotBeforeMs = C.TIME_UNSET; private AutoPreloadPolicy autoPolicy; private PlaybackAutoContext.SessionToken autoSession = PlaybackAutoContext.SessionToken.none(); private AutoPreloadPolicy.Inputs lastAutoInputs; @@ -177,6 +163,11 @@ public class PreCache implements Player.Listener { // media segment/stream, which makes Exo hand them to HlsMediaSource // and repeatedly fail with "Input does not start with #EXTM3U". this.helper = createHelper(mediaItem); + if (this.helper == null) { + if (BuildConfig.DEBUG) Log.i(TAG, "start skipped reason=worker-unavailable"); + stop("worker-unavailable"); + return; + } if (BuildConfig.DEBUG) { Log.i(TAG, "session active automatic=" + automatic + " experimentAllowed=" + experimentAllowed @@ -195,8 +186,6 @@ public class PreCache implements Player.Listener { externalPreloadCircuitOpen = false; diskPreloadCircuitOpen = false; bufferGate = BufferGate.FIRST_FRAME; - preloadNotBeforeMs = C.TIME_UNSET; - nextRangeNotBeforeMs = C.TIME_UNSET; this.player.addListener(this); PlaybackCacheMetrics.Snapshot cacheMetrics = PlaybackCacheMetrics.snapshot(); logSession(lifecycle.beginSession(), "generation=%d %s configuredThreads=%d effectiveThreads=%d durationTargetMs=%d aheadTargetMs=%d pausePolicy=%d cacheCapacityBytes=%d cachedBytesRead=%d cacheSizeBytes=%d", generation, this.routeResolution.logSummary(), PreloadSetting.getPreloadThreads(PlayerSetting.EXO), threads, PreloadSetting.getPreloadDurationMs(PlayerSetting.EXO), PreloadSetting.getPreloadAheadDurationMs(PlayerSetting.EXO), PreloadSetting.getPausePreloadPolicy(PlayerSetting.EXO), MediaSourceFactory.getCacheCapacityBytes(), cacheMetrics.cachedBytesRead(), cacheMetrics.cacheSizeBytes()); @@ -204,15 +193,6 @@ public class PreCache implements Player.Listener { check(); } - /** Rebinds preload state to the item Media3 made current without rebuilding the player. */ - public void onMediaItemTransition( - Player player, - MediaItem mediaItem, - String playbackTraceId, - PlaybackRoute.Resolution routeResolution) { - start(player, mediaItem, playbackTraceId, routeResolution); - } - public void stop() { stop("player-stop"); } @@ -257,8 +237,6 @@ public class PreCache implements Player.Listener { diskPreloadCircuitOpen = false; memoryPreloadPaused = false; bufferGate = BufferGate.FIRST_FRAME; - preloadNotBeforeMs = C.TIME_UNSET; - nextRangeNotBeforeMs = C.TIME_UNSET; } public void release() { @@ -271,6 +249,7 @@ public class PreCache implements Player.Listener { HandlerThread retiringWorker = worker; executor = null; worker = null; + workerResources = null; threads = 0; if (retiringWorker == null) { shutdownExecutor(retiringExecutor); @@ -280,11 +259,16 @@ public class PreCache implements Player.Listener { // PreCacheHelper.release() posts cancellation to this same looper. // Queue resource teardown behind it so SegmentDownloader cannot submit // work to an executor which has already entered SHUTTING_DOWN. - new Handler(retiringWorker.getLooper()).post(() -> { + boolean posted = postToWorker(retiringWorker, () -> { shutdownExecutor(retiringExecutor); retiringWorker.quitSafely(); completeRelease(completion); }); + if (!posted) { + shutdownExecutor(retiringExecutor); + retiringWorker.quitSafely(); + } + if (failedWorker == retiringWorker) failedWorker = null; } private void completeRelease(Runnable completion) { @@ -295,7 +279,6 @@ public class PreCache implements Player.Listener { @Override public void onPlaybackStateChanged(int state) { if (state == Player.STATE_BUFFERING) { - deferPreload("buffering"); if (autoPolicy != null) autoPolicy.disrupt(SystemClock.elapsedRealtime()); if (playable) bufferGate = BufferGate.RECOVERY; transition(PreloadLifecycleTracker.State.CANCELLED_BUFFERING, "buffering", "generation=%d position=%d buffered=%d loading=%s", generation, player.getCurrentPosition(), player.getTotalBufferedDuration(), player.isLoading()); @@ -337,7 +320,6 @@ public class PreCache implements Player.Listener { @Override public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, int reason) { if (!isSeek(reason) || helper == null) return; - deferPreload("seek"); transition(PreloadLifecycleTracker.State.CANCELLED_SEEK, "seek", "generation=%d oldPosition=%d newPosition=%d", generation, oldPosition.positionMs, newPosition.positionMs); if (autoPolicy != null) autoPolicy.disrupt(SystemClock.elapsedRealtime()); seekPreloadSuppressed = true; @@ -359,11 +341,6 @@ public class PreCache implements Player.Listener { return; } cancel(); - long nowMs = SystemClock.elapsedRealtime(); - if (nextRangeNotBeforeMs != C.TIME_UNSET && nowMs < nextRangeNotBeforeMs) { - scheduleAt(expectedGeneration, nextRangeNotBeforeMs - nowMs); - return; - } if (update()) schedule(generation); } @@ -395,20 +372,6 @@ public class PreCache implements Player.Listener { transition(PreloadLifecycleTracker.State.WAIT_FIRST_FRAME, "first-frame", "generation=%d position=%d buffered=%d loading=%s", generation, player.getCurrentPosition(), player.getTotalBufferedDuration(), player.isLoading()); return true; } - if (!PreCachePolicy.isPlaybackStableForPreload( - SystemClock.elapsedRealtime(), - preloadNotBeforeMs, - player.isPlaying(), - player.isLoading())) { - PreloadLifecycleTracker.State waitState = bufferGate == BufferGate.RECOVERY - ? PreloadLifecycleTracker.State.WAIT_RECOVERY_BUFFER - : PreloadLifecycleTracker.State.WAIT_INITIAL_BUFFER; - transition(waitState, "playback-stability-grace", - "generation=%d notBefore=%d position=%d buffered=%d playing=%s loading=%s", - generation, preloadNotBeforeMs, player.getCurrentPosition(), - player.getTotalBufferedDuration(), player.isPlaying(), player.isLoading()); - return true; - } PreloadPausePolicy.Decision pauseDecision = getPauseDecision(); if (!pauseDecision.allowed()) { if (lifecycle.hasActiveTask()) stopCurrentTask("pause-" + pauseDecision.reason().label()); @@ -555,7 +518,15 @@ public class PreCache implements Player.Listener { } try { helper.preCache(startMs, lengthMs); - } catch (RuntimeException | Error e) { + } catch (RuntimeException e) { + PreloadLifecycleTracker.TaskEvent event = finishTask(PreloadLifecycleTracker.TaskEvent.Outcome.INTERNAL_ERROR, "start-error", e); + if (event != null && ExoCacheWriteErrorClassifier.isDiskWriteFailure(e)) { + openDiskCircuit("start-error", e); + return false; + } + stop("start-error"); + return false; + } catch (Error e) { PreloadLifecycleTracker.TaskEvent event = finishTask(PreloadLifecycleTracker.TaskEvent.Outcome.START_ERROR, "start-error", e); if (event != null && ExoCacheWriteErrorClassifier.isDiskWriteFailure(e)) { openDiskCircuit("start-error", e); @@ -568,14 +539,9 @@ public class PreCache implements Player.Listener { } private void schedule(long expectedGeneration) { - scheduleAt(expectedGeneration, TICK_MS); - } - - private void scheduleAt(long expectedGeneration, long delayMs) { if (handler == null || expectedGeneration != generation) return; - cancel(); scheduledTask = () -> check(expectedGeneration); - handler.postDelayed(scheduledTask, Math.max(0, delayMs)); + handler.postDelayed(scheduledTask, TICK_MS); } private void cancel() { @@ -617,20 +583,10 @@ public class PreCache implements Player.Listener { if (!playable) { playable = true; bufferGate = BufferGate.INITIAL; - deferPreload("first-frame"); } check(); } - private void deferPreload(String reason) { - long now = SystemClock.elapsedRealtime(); - if (preloadNotBeforeMs > now) return; - preloadNotBeforeMs = now > Long.MAX_VALUE - PreCachePolicy.PLAYBACK_STABILITY_GRACE_MS - ? Long.MAX_VALUE : now + PreCachePolicy.PLAYBACK_STABILITY_GRACE_MS; - PlaybackTrace.log("exo-preload", playbackTraceId, - "event=stability-grace reason=%s notBefore=%d", reason, preloadNotBeforeMs); - } - private void bindMemoryPressure() { memoryPreloadPaused = false; if (autoPolicy == null || !autoSession.active()) return; @@ -763,10 +719,18 @@ public class PreCache implements Player.Listener { private PreCacheHelper createHelper(MediaItem mediaItem) { DataSource.Factory upstreamFactory = MediaSourceFactory.createUpstreamDataSourceFactory(ExoUtil.extractHeaders(mediaItem)); - return new PreCacheHelper.Factory(MediaSourceFactory.scopedCache(ExoUtil.extractHeaders(mediaItem)), upstreamFactory, ExoUtil.buildRenderersFactory(), getWorker().getLooper()) + HandlerThread activeWorker = getWorker(); + WorkerResources resources = workerResources; + if (resources == null || resources.worker != activeWorker || resources.isFailed()) return null; + PreCacheHelper created = new PreCacheHelper.Factory(MediaSourceFactory.getCache(), upstreamFactory, ExoUtil.buildRenderersFactory(), activeWorker.getLooper()) .setDownloadExecutor(getExecutor()) .setListener(preCacheListener) .create(mediaItem); + if (!resources.bindHelper(created)) { + created.release(false); + return null; + } + return created; } private String errorDetails(Throwable error) { @@ -803,13 +767,17 @@ public class PreCache implements Player.Listener { String reason = !http ? "unsupported-scheme" : concatenating ? "concatenating-url" : "eligible"; return new PreCacheEligibility( - http && !concatenating, + canPreCache(scheme, url), reason, scheme == null ? "-" : scheme, concatenating, local.mimeType == null ? "-" : local.mimeType); } + static boolean canPreCache(String scheme, String url) { + return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) && !MediaSourceFactory.isConcatenatingUrl(url); + } + private long getStart(long effectiveBufferedEndMs) { long startMs = Math.max(0, effectiveBufferedEndMs); return startMs > Long.MAX_VALUE - BUFFER_GAP_MS ? startMs : startMs + BUFFER_GAP_MS; @@ -883,7 +851,14 @@ public class PreCache implements Player.Listener { } retireExecutor(); threads = count; - return executor = new ThreadPoolExecutor(count, count, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); + ThreadPoolExecutor created = new ThreadPoolExecutor(count, count, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); + executor = created; + WorkerResources resources = workerResources; + if (resources == null || resources.worker != worker || !resources.bindExecutor(created)) { + if (executor == created) executor = null; + shutdownExecutor(created); + } + return created; } private AutoPreloadPolicy.Decision getAutoDecision() { @@ -1105,8 +1080,7 @@ public class PreCache implements Player.Listener { if (executor == null) return; ThreadPoolExecutor retiringExecutor = executor; executor = null; - if (worker == null) shutdownExecutor(retiringExecutor); - else new Handler(worker.getLooper()).post(() -> shutdownExecutor(retiringExecutor)); + if (!postToWorker(worker, () -> shutdownExecutor(retiringExecutor))) shutdownExecutor(retiringExecutor); } private void shutdownExecutor(ThreadPoolExecutor target) { @@ -1114,11 +1088,144 @@ public class PreCache implements Player.Listener { target.shutdownNow(); } + static PreCacheWorkerRecovery.Result recoverWorkerResources(PreCacheWorkerRecovery.Queue queue, ThreadPoolExecutor executor, PreCacheWorkerRecovery.FailureListener failureListener) { + return PreCacheWorkerRecovery.recover(queue, executor, failureListener); + } + private HandlerThread getWorker() { - if (worker != null) return worker; - worker = new HandlerThread("CurrentMediaPreCache"); - worker.start(); - return worker; + WorkerResources currentResources = workerResources; + if (isWorkerUsable(worker) && currentResources != null && currentResources.worker == worker && !currentResources.isFailed()) return worker; + discardFailedWorker(); + HandlerThread created = new HandlerThread("CurrentMediaPreCache"); + WorkerResources resources = new WorkerResources(created); + created.setUncaughtExceptionHandler(new PreCacheThreadExceptionHandler((thread, error) -> onWorkerRuntimeFailure(resources, thread, error), Thread.getDefaultUncaughtExceptionHandler())); + worker = created; + workerResources = resources; + created.start(); + failedWorker = null; + return created; + } + + private void onWorkerRuntimeFailure(WorkerResources failedResources, Thread failedThread, RuntimeException error) { + failedResources.markFailed(); + failedWorker = failedThread; + PreCacheHelper failedHelper = failedResources.helper; + ThreadPoolExecutor failedExecutor = failedResources.executor; + PreCacheWorkerRecovery.Result recovery = releaseFailedWorkerResources(failedThread, failedHelper, failedExecutor, error); + logWorkerFailure("worker-failure", error, recovery); + Handler target = handler; + if (target == null) return; + try { + boolean posted = target.post(() -> disablePreCacheAfterWorkerFailure(failedThread, failedHelper, failedExecutor, recovery, error)); + if (!posted) logWorkerFailure("state-cleanup-rejected", error, recovery); + } catch (RuntimeException ignored) { + // A stopped application looper cannot recover this session. The next + // start/release still sees failedWorker and performs local cleanup. + logWorkerFailure("state-cleanup-error", error, recovery); + } + } + + private PreCacheWorkerRecovery.Result releaseFailedWorkerResources(Thread failedThread, PreCacheHelper failedHelper, ThreadPoolExecutor failedExecutor, RuntimeException error) { + if (!(failedThread instanceof HandlerThread target) || Thread.currentThread() != target) { + shutdownExecutor(failedExecutor); + return PreCacheWorkerRecovery.Result.QUEUE_REJECTED; + } + Looper failedLooper = target.getLooper(); + if (failedLooper == null || Looper.myLooper() != failedLooper) { + shutdownExecutor(failedExecutor); + return PreCacheWorkerRecovery.Result.QUEUE_REJECTED; + } + Handler recoveryHandler = new Handler(failedLooper); + PreCacheWorkerRecovery.Queue queue = new PreCacheWorkerRecovery.Queue() { + @Override + public boolean post(Runnable action) { + return recoveryHandler.post(action); + } + + @Override + public void enqueueRelease() { + if (failedHelper != null) failedHelper.release(false); + } + + @Override + public void quitSafely() { + target.quitSafely(); + } + + @Override + public void drain() { + // HandlerThread.run() has unwound, but this thread still owns the Looper. + Looper.loop(); + } + }; + return recoverWorkerResources(queue, failedExecutor, (cleanupError, releasePhase) -> logWorkerDrainFailure(error, cleanupError, releasePhase)); + } + + private void disablePreCacheAfterWorkerFailure(Thread failedThread, PreCacheHelper failedHelper, ThreadPoolExecutor failedExecutor, PreCacheWorkerRecovery.Result recovery, RuntimeException error) { + if (worker != failedThread) { + if (failedWorker == failedThread) failedWorker = null; + shutdownExecutor(failedExecutor); + return; + } + traceWorkerFailure("worker-failure", error, recovery); + finishTask(PreloadLifecycleTracker.TaskEvent.Outcome.INTERNAL_ERROR, "worker-failure", error); + ThreadPoolExecutor activeExecutor = executor; + worker = null; + executor = null; + WorkerResources activeResources = workerResources; + if (activeResources != null && activeResources.worker == failedThread) workerResources = null; + threads = 0; + if (helper == failedHelper) helper = null; + stop("worker-failure"); + if (failedWorker == failedThread) failedWorker = null; + shutdownExecutor(failedExecutor); + if (activeExecutor != failedExecutor) shutdownExecutor(activeExecutor); + } + + private void logWorkerDrainFailure(RuntimeException original, RuntimeException cleanupError, boolean releasePhase) { + String phase = releasePhase ? "release" : "queued-message"; + Log.e(TAG, String.format(Locale.US, "Worker recovery error phase=%s original=%s cleanup=%s origin=%s", phase, original.getClass().getSimpleName(), cleanupError.getClass().getSimpleName(), origin(cleanupError))); + } + + private void logWorkerFailure(String event, RuntimeException error, PreCacheWorkerRecovery.Result recovery) { + String origin = origin(error); + Log.e(TAG, String.format(Locale.US, "Worker failure event=%s recovery=%s error=%s origin=%s", event, recovery, error.getClass().getSimpleName(), origin)); + } + + private void traceWorkerFailure(String event, RuntimeException error, PreCacheWorkerRecovery.Result recovery) { + String origin = origin(error); + PlaybackTrace.log("exo-preload", playbackTraceId, "event=%s session=%d generation=%d recovery=%s error=%s origin=%s", event, lifecycle.sessionId(), generation, recovery, error.getClass().getSimpleName(), origin); + } + + private static String origin(Throwable error) { + StackTraceElement[] stack = error.getStackTrace(); + return stack.length == 0 ? "unknown" : stack[0].toString(); + } + + private void discardFailedWorker() { + if (worker == null) return; + HandlerThread discardedWorker = worker; + ThreadPoolExecutor discardedExecutor = executor; + worker = null; + executor = null; + WorkerResources discardedResources = workerResources; + if (discardedResources != null && discardedResources.worker == discardedWorker) workerResources = null; + threads = 0; + if (failedWorker == discardedWorker) failedWorker = null; + discardedWorker.quitSafely(); + shutdownExecutor(discardedExecutor); + } + + private boolean postToWorker(HandlerThread target, Runnable action) { + return isWorkerUsable(target) && new Handler(target.getLooper()).post(action); + } + + private boolean isWorkerUsable(HandlerThread target) { + return isWorkerUsable(target, failedWorker); + } + + static boolean isWorkerUsable(Thread target, Thread failedWorker) { + return target != null && target != failedWorker && target.isAlive(); } private boolean isSeek(int reason) { @@ -1165,20 +1272,15 @@ public class PreCache implements Player.Listener { if (outcome == PreloadLifecycleTracker.TaskEvent.Outcome.COMPLETED) { preloadFailureStreak = 0; diskBufferStore.recordCompleted(mediaKey, event.startMs(), saturatedAdd(event.startMs(), event.lengthMs())); - nextRangeNotBeforeMs = saturatedAdd( - SystemClock.elapsedRealtime(), - PreCachePolicy.nextRangeDelayMs(true)); requestImmediateCheck(event.generation()); } return event; } private void requestImmediateCheck(long expectedGeneration) { - if (handler == null) return; - long delayMs = nextRangeNotBeforeMs == C.TIME_UNSET - ? 0 - : Math.max(0, nextRangeNotBeforeMs - SystemClock.elapsedRealtime()); - scheduleAt(expectedGeneration, delayMs); + Handler currentHandler = handler; + if (currentHandler == null) return; + currentHandler.post(() -> check(expectedGeneration)); } private static long saturatedAdd(long value, long increment) { @@ -1245,6 +1347,38 @@ public class PreCache implements Player.Listener { private record SafeBufferStatus(boolean safe, boolean recovery, long requiredMs, long bufferedMs, boolean loading, long bitrate, int effectiveCapacityBytes, long capacityDurationMs) { } + private static final class WorkerResources { + + private final HandlerThread worker; + private volatile ThreadPoolExecutor executor; + private volatile PreCacheHelper helper; + private volatile boolean failed; + + private WorkerResources(HandlerThread worker) { + this.worker = worker; + } + + private synchronized boolean bindExecutor(ThreadPoolExecutor executor) { + if (failed) return false; + this.executor = executor; + return true; + } + + private synchronized boolean bindHelper(PreCacheHelper helper) { + if (failed) return false; + this.helper = helper; + return true; + } + + private synchronized void markFailed() { + failed = true; + } + + private boolean isFailed() { + return failed; + } + } + private record PreCacheEligibility(boolean eligible, String reason, String scheme, boolean concatenating, String mimeType) { diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/PreCachePolicy.java b/app/src/main/java/com/fongmi/android/tv/player/exo/PreCachePolicy.java index fee857ebee..4f4b8e3d08 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/PreCachePolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/PreCachePolicy.java @@ -4,8 +4,6 @@ final class PreCachePolicy { static final long INITIAL_SAFE_BUFFER_MS = 5_000; static final long RECOVERY_SAFE_BUFFER_MS = 8_000; - static final long PLAYBACK_STABILITY_GRACE_MS = 5_000; - static final long NEXT_RANGE_DELAY_MS = 5_000; private static final long INITIAL_IDLE_FLOOR_MS = 2_000; private static final long RECOVERY_IDLE_FLOOR_MS = 3_000; private static final int CAPACITY_HEADROOM_PERCENT = 80; @@ -35,19 +33,6 @@ final class PreCachePolicy { return !loading && bufferedDurationMs >= Math.min(targetMs, idleFloorMs); } - static boolean isPlaybackStableForPreload( - long nowMs, - long preloadNotBeforeMs, - boolean playing, - boolean loading) { - return playing && !loading && nowMs >= 0 && preloadNotBeforeMs >= 0 - && nowMs >= preloadNotBeforeMs; - } - - static long nextRangeDelayMs(boolean completed) { - return completed ? NEXT_RANGE_DELAY_MS : 0; - } - static long preloadLengthMs(long configuredLengthMs, long remainingMs, long bitrateBitsPerSecond, long cacheCapacityBytes) { long lengthMs = Math.max(0, configuredLengthMs); if (remainingMs >= 0) lengthMs = Math.min(lengthMs, remainingMs); diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/PreCacheThreadExceptionHandler.java b/app/src/main/java/com/fongmi/android/tv/player/exo/PreCacheThreadExceptionHandler.java new file mode 100644 index 0000000000..6687be3684 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/PreCacheThreadExceptionHandler.java @@ -0,0 +1,36 @@ +package com.fongmi.android.tv.player.exo; + +final class PreCacheThreadExceptionHandler implements Thread.UncaughtExceptionHandler { + + interface Listener { + + void onRuntimeFailure(Thread thread, RuntimeException error); + } + + private final Thread.UncaughtExceptionHandler fallback; + private final Listener listener; + + PreCacheThreadExceptionHandler(Listener listener, Thread.UncaughtExceptionHandler fallback) { + this.listener = listener; + this.fallback = fallback; + } + + @Override + public void uncaughtException(Thread thread, Throwable error) { + if (error instanceof RuntimeException runtimeException) { + try { + listener.onRuntimeFailure(thread, runtimeException); + } catch (Throwable recoveryFailure) { + if (recoveryFailure != error) recoveryFailure.addSuppressed(error); + delegate(thread, recoveryFailure); + } + } else { + delegate(thread, error); + } + } + + private void delegate(Thread thread, Throwable error) { + if (fallback != null) fallback.uncaughtException(thread, error); + else if (thread.getThreadGroup() != null) thread.getThreadGroup().uncaughtException(thread, error); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/PreCacheWorkerRecovery.java b/app/src/main/java/com/fongmi/android/tv/player/exo/PreCacheWorkerRecovery.java new file mode 100644 index 0000000000..8a8d8a2085 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/PreCacheWorkerRecovery.java @@ -0,0 +1,102 @@ +package com.fongmi.android.tv.player.exo; + +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicBoolean; + +final class PreCacheWorkerRecovery { + + interface Queue { + + boolean post(Runnable action); + + void enqueueRelease(); + + void quitSafely(); + + void drain(); + } + + interface FailureListener { + + void onFailure(RuntimeException error, boolean releasePhase); + } + + enum Result { + RELEASED, + RELEASE_FAILED, + QUEUE_REJECTED + } + + static Result recover(Queue queue, ThreadPoolExecutor executor, FailureListener failureListener) { + AtomicBoolean releaseStarted = new AtomicBoolean(); + AtomicBoolean releaseFinished = new AtomicBoolean(); + try { + if (!queue.post(() -> releaseStarted.set(true))) { + drainQuittingQueue(queue, failureListener); + return Result.QUEUE_REJECTED; + } + try { + queue.enqueueRelease(); + } catch (RuntimeException error) { + reportFailure(failureListener, error, true); + quitAfterEnqueueFailure(queue, failureListener); + return Result.RELEASE_FAILED; + } + boolean markerAccepted = queue.post(() -> releaseFinished.set(true)); + queue.quitSafely(); + boolean releaseFailed = drain(queue, releaseStarted, releaseFinished, failureListener); + if (!markerAccepted) return Result.QUEUE_REJECTED; + if (!releaseFinished.get()) return Result.RELEASE_FAILED; + return releaseFailed ? Result.RELEASE_FAILED : Result.RELEASED; + } finally { + if (executor != null) executor.shutdownNow(); + } + } + + private static void drainQuittingQueue(Queue queue, FailureListener failureListener) { + // Handler.post() rejects work once its MessageQueue is quitting, but due messages remain drainable. + while (true) { + try { + queue.drain(); + return; + } catch (RuntimeException error) { + reportFailure(failureListener, error, false); + } + } + } + + private static boolean drain(Queue queue, AtomicBoolean releaseStarted, AtomicBoolean releaseFinished, FailureListener failureListener) { + boolean releaseFailed = false; + while (true) { + try { + queue.drain(); + return releaseFailed; + } catch (RuntimeException error) { + boolean releasePhase = releaseStarted.get() && !releaseFinished.get(); + releaseFailed |= releasePhase; + reportFailure(failureListener, error, releasePhase); + if (releaseFinished.get()) return releaseFailed; + } + } + } + + private static void quitAfterEnqueueFailure(Queue queue, FailureListener failureListener) { + try { + queue.quitSafely(); + } catch (RuntimeException error) { + reportFailure(failureListener, error, false); + } + } + + private static void reportFailure(FailureListener listener, RuntimeException error, boolean releasePhase) { + if (listener == null) return; + try { + listener.onFailure(error, releasePhase); + } catch (RuntimeException ignored) { + // Diagnostics must not interrupt resource cleanup. + } + } + + private PreCacheWorkerRecovery() { + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/PreloadLifecycleTracker.java b/app/src/main/java/com/fongmi/android/tv/player/exo/PreloadLifecycleTracker.java index e61b209316..863bae922b 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/PreloadLifecycleTracker.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/PreloadLifecycleTracker.java @@ -138,6 +138,7 @@ final class PreloadLifecycleTracker { REPLACED("replaced"), PREPARE_ERROR("prepare-error"), DOWNLOAD_ERROR("download-error"), + INTERNAL_ERROR("internal-error"), START_ERROR("start-error"); private final String label; diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/TrackUtil.java b/app/src/main/java/com/fongmi/android/tv/player/exo/TrackUtil.java index 8b62e62d95..7c66c0d1b1 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/TrackUtil.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/TrackUtil.java @@ -1,12 +1,13 @@ package com.fongmi.android.tv.player.exo; +import androidx.media3.common.C; import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; import androidx.media3.common.Player; import androidx.media3.common.TrackGroup; import androidx.media3.common.TrackSelectionOverride; import androidx.media3.common.TrackSelectionParameters; import androidx.media3.common.Tracks; -import androidx.media3.common.C; import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; import com.fongmi.android.tv.bean.Track; @@ -53,6 +54,59 @@ public class TrackUtil { return onlySelectedFormat(selected); } + /** + * 自适应选轨会把同一组里的多条轨道同时标记为已选中,轨道列表因此会高亮出多项,用户看不出 + * 当前到底在播哪一条。这里用播放器正在解码的格式把范围收敛到一条;匹配不上时返回 null, + * 让调用方退回原始的选中标记,而不是让列表一项都不高亮。 + */ + public static Format uniqueActiveFormat(Tracks tracks, int type, Format active) { + if (tracks == null || tracks.isEmpty() || active == null) return null; + List selected = selectedFormats(tracks, type); + if (selected.size() < 2) return null; + for (Format format : selected) if (sameRendition(format, active)) return format; + return null; + } + + static List selectedFormats(Tracks tracks, int type) { + List selected = new ArrayList<>(); + if (tracks == null) return selected; + for (Tracks.Group group : tracks.getGroups()) { + if (group.getType() != type) continue; + for (int i = 0; i < group.length; i++) { + if (group.isTrackSelected(i)) selected.add(group.getTrackFormat(i)); + } + } + return selected; + } + + static boolean sameRendition(Format candidate, Format active) { + if (candidate == null || active == null) return false; + if (candidate.equals(active)) return true; + if (candidate.id != null && candidate.id.equals(active.id)) return true; + if (candidate.width != active.width || candidate.height != active.height) return false; + int candidateBitrate = getBitrate(candidate); + int activeBitrate = getBitrate(active); + boolean bitrateComparable = candidateBitrate > 0 && activeBitrate > 0; + if (bitrateComparable && candidateBitrate != activeBitrate) return false; + // 同一分辨率可能同时提供 H.264 和 HEVC 两条轨道,编码能比就必须一致。 + Boolean codecMatch = codecMatch(candidate, active); + if (codecMatch != null) return codecMatch; + // 编码无从比较时只能靠码率区分;两边码率都未知就判定不匹配, + // 让调用方退回播放器的选中标记,而不是高亮到错的一条。 + return bitrateComparable; + } + + /** 返回编码是否一致,两边信息不足以比较时返回 null。 */ + private static Boolean codecMatch(Format candidate, Format active) { + if (candidate.sampleMimeType != null && active.sampleMimeType != null) { + return candidate.sampleMimeType.equals(active.sampleMimeType); + } + if (candidate.codecs != null && active.codecs != null) { + return candidate.codecs.equals(active.codecs); + } + return null; + } + static Format onlySelectedFormat(List selected) { return selected != null && selected.size() == 1 ? selected.get(0) : null; } @@ -61,21 +115,32 @@ public class TrackUtil { player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon().clearOverrides().setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false).setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, false).setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false).build()); } +public static void reset(Player player, int type) { + player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon().clearOverridesOfType(type).setTrackTypeDisabled(type, false).build()); + } + + public static boolean preferAAC(Player player) { + TrackInfo info = findAAC(player); + if (info == null) return false; + if (info.trackGroup.isTrackSelected(info.trackIndex)) return false; + player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon().setOverrideForType(new TrackSelectionOverride(info.trackGroup.getMediaTrackGroup(), List.of(info.trackIndex))).build()); + return true; + } + + public static boolean hasTrack(Player player, List tracks, int type) { + for (Track track : tracks) { + if (track.getType() == type && find(player, track) != null) return true; + } + return false; + } + public static void enable(Player player, int type) { player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon().setTrackTypeDisabled(type, false).build()); } private static TrackInfo find(Player player, Track track) { - Tracks currentTracks = player.getCurrentTracks(); - if (track.getPlayerId() != null) { - for (Tracks.Group group : currentTracks.getGroups()) { - if (group.getType() != track.getType()) continue; - for (int i = 0; i < group.length; i++) { - if (track.getPlayerId().equals(group.getTrackFormat(i).id)) return new TrackInfo(group, i); - } - } - } if (track.getFormat() == null) return null; + Tracks currentTracks = player.getCurrentTracks(); for (Tracks.Group trackGroup : currentTracks.getGroups()) { if (trackGroup.getType() != track.getType()) continue; for (int i = 0; i < trackGroup.length; i++) { @@ -88,10 +153,27 @@ public class TrackUtil { return null; } - /** Resolves a current runtime id first, falling back to a persisted format preference. */ - public static TrackSelectionOverride findOverride(Player player, Track track) { - TrackInfo info = find(player, track); - return info == null ? null : new TrackSelectionOverride(info.trackGroup.getMediaTrackGroup(), info.trackIndex); + private static TrackInfo findAAC(Player player) { + TrackInfo best = null; + for (Tracks.Group trackGroup : player.getCurrentTracks().getGroups()) { + if (trackGroup.getType() != C.TRACK_TYPE_AUDIO) continue; + for (int i = 0; i < trackGroup.length; i++) { + Format format = trackGroup.getTrackFormat(i); + if (!trackGroup.isTrackSupported(i) || !isAAC(format)) continue; + TrackInfo info = new TrackInfo(trackGroup, i); + if (best == null || getBitrate(format) > getBitrate(best.trackGroup.getTrackFormat(best.trackIndex))) best = info; + } + } + return best; + } + + private static boolean isAAC(Format format) { + String codecs = format.codecs == null ? "" : format.codecs.toLowerCase(); + return MimeTypes.AUDIO_AAC.equals(format.sampleMimeType) || codecs.contains("mp4a") || codecs.contains("aac"); + } + + private static int getBitrate(Format format) { + return Math.max(format.averageBitrate, format.peakBitrate); } public static void setTrackSelection(Player player, List tracks) { diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssFontMatroskaExtractor.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssFontMatroskaExtractor.java deleted file mode 100644 index 44da922e21..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssFontMatroskaExtractor.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import androidx.media3.common.ParserException; -import androidx.media3.extractor.ExtractorInput; -import androidx.media3.extractor.mkv.EbmlProcessor; -import androidx.media3.extractor.mkv.MatroskaExtractor; -import androidx.media3.extractor.text.DefaultSubtitleParserFactory; -import java.io.IOException; -import java.util.Locale; - -/** The libass-android attachment hook, retaining WebHTV's Matroska flags and video handling. */ -public final class AssFontMatroskaExtractor extends MatroskaExtractor { - private static final int ATTACHMENTS = 0x1941A469, ATTACHED_FILE = 0x61A7; - private static final int FILE_NAME = 0x466E, FILE_MIME = 0x4660, FILE_DATA = 0x465C; - private final AssFontSet fonts; - private String name, mime; - private byte[] data; - private boolean rejected; - - public AssFontMatroskaExtractor(int flags, boolean dolbyVisionRpu, AssFontSet fonts) { - super(new DefaultSubtitleParserFactory(), flags, dolbyVisionRpu); - this.fonts = fonts; - } - - @Override protected int getElementType(int id) { - return switch (id) { - case ATTACHMENTS, ATTACHED_FILE -> EbmlProcessor.ELEMENT_TYPE_MASTER; - case FILE_NAME, FILE_MIME -> EbmlProcessor.ELEMENT_TYPE_STRING; - case FILE_DATA -> EbmlProcessor.ELEMENT_TYPE_BINARY; - default -> super.getElementType(id); - }; - } - - @Override protected boolean isLevel1Element(int id) { - return super.isLevel1Element(id) || id == ATTACHMENTS; - } - - @Override protected void startMasterElement(int id, long position, long size) throws ParserException { - super.startMasterElement(id, position, size); - if (id == ATTACHED_FILE) { name = mime = null; data = null; rejected = false; } - } - - @Override protected void endMasterElement(int id) throws ParserException { - if (id == ATTACHED_FILE) { - if (isFont(name, mime)) { - if (rejected) fonts.reject(); - else if (data != null) fonts.add(name, data); - } - name = mime = null; data = null; - } - super.endMasterElement(id); - } - - @Override protected void stringElement(int id, String value) throws ParserException { - if (id == FILE_NAME) name = value.length() <= 512 ? value : "attachment"; - else if (id == FILE_MIME) mime = value; - else super.stringElement(id, value); - } - - @Override protected void binaryElement(int id, int size, ExtractorInput input) throws IOException { - if (id != FILE_DATA) { super.binaryElement(id, size, input); return; } - // Matroska fields may arrive in any order; decide on the completed AttachedFile. - if ((name != null && mime != null && !isFont(name, mime)) || !fonts.canRead(size)) { - rejected = true; - input.skipFully(size); - } else { - data = new byte[size]; - input.readFully(data, 0, size); - } - } - - static boolean isFont(String name, String mime) { - String type = mime == null ? "" : mime.toLowerCase(Locale.ROOT).split(";", 2)[0].trim(); - if (switch (type) { - case "font/ttf", "font/otf", "font/sfnt", "font/collection", "font/woff", "font/woff2", - "application/font-sfnt", "application/font-woff", "application/x-truetype-font", - "application/vnd.ms-opentype", "application/x-font-ttf", "application/x-font-otf" -> true; - default -> false; - }) return true; - String file = name == null ? "" : name.toLowerCase(Locale.ROOT); - return file.endsWith(".ttf") || file.endsWith(".otf") || file.endsWith(".ttc") - || file.endsWith(".otc") || file.endsWith(".woff") || file.endsWith(".woff2"); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssFontSet.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssFontSet.java deleted file mode 100644 index 11541a3321..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssFontSet.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.function.Consumer; - -/** Fonts belong to one foreground media source, never a process-wide font directory. */ -public final class AssFontSet { - static final int MAX_FONT_BYTES = 16 * 1024 * 1024; - static final int MAX_TOTAL_BYTES = 32 * 1024 * 1024; - static final int MAX_FONTS = 64; - record Snapshot(String[] names, byte[][] data, int bytes) { } - static final Snapshot EMPTY = new Snapshot(new String[0], new byte[0][], 0); - private record Font(String name, byte[] data) { } - private final Map fonts = new LinkedHashMap<>(); - private final Consumer changed; - private Snapshot snapshot = EMPTY; - private String failure = ""; - private boolean closed; - - AssFontSet(Consumer changed) { this.changed = changed; } - - synchronized boolean canRead(int size) { - return !closed && failure.isEmpty() && size > 0 && size <= MAX_FONT_BYTES; - } - - void add(String name, byte[] data) { - String digest; - try { - byte[] hash = MessageDigest.getInstance("SHA-256").digest(data); - StringBuilder key = new StringBuilder(64); - for (byte b : hash) key.append(Character.forDigit((b >>> 4) & 15, 16)) - .append(Character.forDigit(b & 15, 16)); - digest = key.toString(); - } catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } - synchronized (this) { - if (closed || !failure.isEmpty() || fonts.containsKey(digest)) return; - if (data.length == 0 || data.length > MAX_FONT_BYTES || fonts.size() >= MAX_FONTS - || data.length > MAX_TOTAL_BYTES - snapshot.bytes()) { - failure = "font-budget"; - } else { - // Ownership transfers from the extractor. Neither side mutates font bytes. - fonts.put(digest, new Font(name == null ? "attachment" : name, data)); - String[] names = new String[fonts.size()]; - byte[][] bytes = new byte[fonts.size()][]; - int index = 0, total = 0; - for (Font font : fonts.values()) { - names[index] = font.name(); bytes[index++] = font.data(); total += font.data().length; - } - snapshot = new Snapshot(names, bytes, total); - } - } - changed.accept(this); - } - - void reject() { - synchronized (this) { - if (closed || !failure.isEmpty()) return; - failure = "font-budget"; - } - changed.accept(this); - } - - synchronized Snapshot snapshot() { return snapshot; } - synchronized String failure() { return failure; } - synchronized void close() { closed = true; fonts.clear(); snapshot = EMPTY; } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssFonts.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssFonts.java deleted file mode 100644 index 9c4f3e4591..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssFonts.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.content.Context; -import android.graphics.fonts.Font; -import android.graphics.fonts.SystemFonts; -import android.os.Build; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashSet; -import java.util.Set; - -/** Independent fontconfig state; no font downloads or MPV configuration ownership. */ -final class AssFonts { - static String prepare(Context context) throws IOException { - Set directories = new LinkedHashSet<>(); - if (Build.VERSION.SDK_INT >= 29) { - for (Font font : SystemFonts.getAvailableFonts()) { - File file = font.getFile(); - if (file != null && file.canRead()) directories.add(file.getParentFile()); - } - } - for (String path : new String[]{"/system/fonts", "/product/fonts", "/system_ext/fonts", "/vendor/fonts", "/odm/fonts"}) { - File dir = new File(path); - if (dir.isDirectory() && dir.canRead()) directories.add(dir); - } - if (directories.isEmpty()) throw new IOException("no-readable-system-fonts"); - File cache = new File(context.getCacheDir(), "exo-ass/fonts"); - if (!cache.isDirectory() && !cache.mkdirs()) throw new IOException("font-cache-directory"); - File config = new File(cache, "fonts.conf"); - StringBuilder xml = new StringBuilder(""); - for (File dir : directories) xml.append("

").append(escape(dir.getCanonicalPath())).append(""); - xml.append("").append(escape(cache.getCanonicalPath())).append(""); - xml.append("sans-serifRobotoNoto Sans"); - xml.append("Noto Sans CJK SCDroid Sans Fallback"); - xml.append("ArialLiberation SansRoboto"); - xml.append(""); - // Each worker has its own file; concurrent player replacement cannot observe a partial file. - File unique = File.createTempFile("fonts-", ".conf", cache); - try (FileOutputStream output = new FileOutputStream(unique)) { - output.write(xml.toString().getBytes(StandardCharsets.UTF_8)); - } - return unique.getAbsolutePath(); - } - - private static String escape(String value) { - return value.replace("&", "&").replace("<", "<").replace(">", ">") - .replace("\"", """).replace("'", "'"); - } - - private AssFonts() { } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssInput.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssInput.java deleted file mode 100644 index c89bf2174d..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssInput.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import org.mozilla.universalchardet.UniversalDetector; - -import java.nio.ByteBuffer; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.Locale; - -/** Complete ASS scripts and bounded CodecPrivate normalization; packets use AssPacketInput. */ -public final class AssInput { - public static final String EXTERNAL_ID_PREFIX = "webhtv-ass-external:"; - public static final int MAX_INPUT_BYTES = 4 * 1024 * 1024; - public static final int MAX_UTF8_BYTES = 8 * 1024 * 1024; - public static final int MAX_EVENTS = 20000; - public static final int MAX_STYLES = 512; - - static boolean isExternalId(String id) { - if (id == null || id.length() > 256) return false; - int offset = 0; - // MergingMediaPeriod prefixes Format IDs with the child index. This is - // only admission; the complete Stream/period/generation remains the identity. - while (!id.startsWith(EXTERNAL_ID_PREFIX, offset)) { - int start = offset; - while (offset < id.length() && id.charAt(offset) >= '0' && id.charAt(offset) <= '9') offset++; - if (offset == start || offset == id.length() || id.charAt(offset++) != ':') return false; - } - offset += EXTERNAL_ID_PREFIX.length(); - if (offset == id.length()) return false; - for (; offset < id.length(); offset++) { - if (id.charAt(offset) < '0' || id.charAt(offset) > '9') return false; - } - return true; - } - - static byte[] copy(ByteBuffer input) { - int length = input.remaining(); - if (length <= 0 || length > MAX_INPUT_BYTES) throw new IllegalArgumentException("script-bytes"); - byte[] copy = new byte[length]; - input.duplicate().get(copy); - return copy; - } - - static byte[] normalize(byte[] bytes) { - return normalize(bytes, false); - } - - static byte[] normalizeHeader(byte[] bytes) { - return normalize(bytes, true); - } - - private static byte[] normalize(byte[] bytes, boolean header) { - if (bytes.length == 0 || bytes.length > MAX_INPUT_BYTES) throw new IllegalArgumentException("script-bytes"); - Charset charset; - int offset = 0; - if (bytes.length >= 4 && bytes[0] == 0 && bytes[1] == 0 - && bytes[2] == (byte) 0xfe && bytes[3] == (byte) 0xff) { - charset = Charset.forName("UTF-32BE"); offset = 4; - } else if (bytes.length >= 4 && bytes[0] == (byte) 0xff && bytes[1] == (byte) 0xfe - && bytes[2] == 0 && bytes[3] == 0) { - charset = Charset.forName("UTF-32LE"); offset = 4; - } else if (bytes.length >= 2 && bytes[0] == (byte) 0xff && bytes[1] == (byte) 0xfe) { - charset = StandardCharsets.UTF_16LE; offset = 2; - } else if (bytes.length >= 2 && bytes[0] == (byte) 0xfe && bytes[1] == (byte) 0xff) { - charset = StandardCharsets.UTF_16BE; offset = 2; - } else if (bytes.length >= 3 && bytes[0] == (byte) 0xef - && bytes[1] == (byte) 0xbb && bytes[2] == (byte) 0xbf) { - charset = StandardCharsets.UTF_8; offset = 3; - } else { - UniversalDetector detector = new UniversalDetector(null); - detector.handleData(bytes, 0, bytes.length); - detector.dataEnd(); - String name = detector.getDetectedCharset(); - charset = name == null ? StandardCharsets.UTF_8 : Charset.forName(name); - } - String text = new String(bytes, offset, bytes.length - offset, charset); - // Match terminal-NUL tolerance after decoding, never on raw UTF-16 bytes. - int end = text.length(); - while (end > 0 && text.charAt(end - 1) == 0) end--; - text = text.substring(0, end); - if (text.indexOf('\0') >= 0) throw new IllegalArgumentException("embedded-nul"); - String lower = text.toLowerCase(Locale.ROOT); - if (!lower.contains("[script info]") || !header && !lower.contains("[events]")) - throw new IllegalArgumentException("not-full-script"); - if (lower.contains("[fonts]") || lower.contains("[graphics]")) - throw new IllegalArgumentException("inline-attachments-not-admitted"); - int events = 0, styles = 0; - for (String line : text.split("\n")) { - String trimmed = line.trim(); - if (header && trimmed.regionMatches(true, 0, "Dialogue:", 0, 9)) - throw new IllegalArgumentException("codec-private-events"); - if (trimmed.regionMatches(true, 0, "Dialogue:", 0, 9) && ++events > MAX_EVENTS) - throw new IllegalArgumentException("event-count"); - if (trimmed.regionMatches(true, 0, "Style:", 0, 6) && ++styles > MAX_STYLES) - throw new IllegalArgumentException("style-count"); - } - byte[] result = text.getBytes(StandardCharsets.UTF_8); - if (result.length > MAX_UTF8_BYTES) throw new IllegalArgumentException("utf8-bytes"); - return result; - } - - static long timeMs(long positionUs, long streamOffsetUs, long textOffsetUs) { - return Math.floorDiv(Math.subtractExact(Math.subtractExact(positionUs, streamOffsetUs), textOffsetUs), 1000); - } - - private AssInput() { } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssNative.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssNative.java deleted file mode 100644 index b19c3cc7f0..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssNative.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.view.Surface; - -/** Loaded only by the ASS worker after eligible, selected subtitle data arrives. */ -final class AssNative { - // HDR/wide-gamut video keeps ASS colors in the independent SDR RGB layer. - // Zero is outside Media3's BT.601/BT.709 values; exo_ass.cpp must match it. - static final int COLOR_SPACE_SDR_RGB = 0; - private static boolean loaded; - - static synchronized void ensureLoaded() { - if (loaded) return; - System.loadLibrary("exo_ass"); - loaded = true; - } - - static native long create(String fontConfig, String[] fontNames, byte[][] fonts); - static native boolean load(long handle, byte[] utf8); - static native boolean loadHeader(long handle, byte[] utf8); - static native boolean chunk(long handle, byte[] utf8, long startMs, long durationMs); - static native boolean setSurface(long handle, Surface surface); - static native int render(long handle, long timeMs, int width, int height, - int storageWidth, int storageHeight, double pixelAspect, - int colorSpace, int colorRange, boolean force, long[] stats); - static native void destroy(long handle); - static native boolean testSurface(long handle, int width, int height); - static native long createTestFonts(String[] names, byte[][] fonts); - static native byte[] readPixels(long handle); - - private AssNative() { } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssPacketInput.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssPacketInput.java deleted file mode 100644 index f1163af7e1..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssPacketInput.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** Bounded, selected-stream bridge for MatroskaExtractor's existing SSA sample format. */ -final class AssPacketInput { - static final byte[] FORMAT = ("Format: Start, End, ReadOrder, Layer, Style, Name, " - + "MarginL, MarginR, MarginV, Effect, Text").getBytes(StandardCharsets.UTF_8); - private static final byte[] PREFIX = "Dialogue: 0:00:00:00,".getBytes(StandardCharsets.US_ASCII); - private static final Pattern TIME = Pattern.compile("(\\d{1,10}):(\\d{2}):(\\d{2})[:.](\\d{2})"); - private final List packets = new ArrayList<>(); - private final Set readOrders = new HashSet<>(); - private int bytes; - - record Packet(byte[] data, long startMs, long durationMs) { } - - static boolean matches(List initializationData) { - return initializationData.size() == 2 && Arrays.equals(FORMAT, initializationData.get(0)) - && initializationData.get(1).length > 0; - } - - // Called under the session lock. A seek retains packets already received; libass ReadOrder - // semantics remove duplicate preroll. A new stream gets a new input, never the previous cache. - boolean add(ByteBuffer input, long sampleTimeUs, long streamOffsetUs) { - byte[] sample = AssInput.copy(input); - if (sample.length <= PREFIX.length) throw new IllegalArgumentException("ssa-packet-prefix"); - for (int i = 0; i < PREFIX.length; i++) - if (sample[i] != PREFIX[i]) throw new IllegalArgumentException("ssa-packet-prefix"); - int end = tokenEnd(sample, PREFIX.length, PREFIX.length + 24); - long durationMs = durationMs(new String(sample, PREFIX.length, end - PREFIX.length, StandardCharsets.US_ASCII)); - int start = end + 1; - int orderEnd = tokenEnd(sample, start, start + 11); - if (orderEnd == start) throw new IllegalArgumentException("ssa-read-order"); - for (int i = start; i < orderEnd; i++) - if (sample[i] < '0' || sample[i] > '9') throw new IllegalArgumentException("ssa-read-order"); - int readOrder = Integer.parseInt(new String(sample, start, orderEnd - start, StandardCharsets.US_ASCII)); - if (readOrders.contains(readOrder)) return false; - int separators = 0; - for (int i = start; i < sample.length && separators < 8; i++) if (sample[i] == ',') separators++; - if (separators < 8) throw new IllegalArgumentException("ssa-packet-fields"); - int length = sample.length - start; - if (packets.size() >= AssInput.MAX_EVENTS || length > AssInput.MAX_UTF8_BYTES - bytes) - throw new IllegalArgumentException("ssa-packet-budget"); - long timeMs = AssInput.timeMs(sampleTimeUs, streamOffsetUs, 0); - Math.addExact(timeMs, durationMs); - packets.add(new Packet(Arrays.copyOfRange(sample, start, sample.length), timeMs, durationMs)); - readOrders.add(readOrder); - bytes += length; - return true; - } - - private static int tokenEnd(byte[] data, int start, int limit) { - for (int i = start; i < Math.min(data.length, limit); i++) if (data[i] == ',') return i; - throw new IllegalArgumentException("ssa-packet-fields"); - } - - private static long durationMs(String value) { - Matcher time = TIME.matcher(value); - if (!time.matches()) throw new IllegalArgumentException("ssa-packet-duration"); - long hours = Long.parseLong(time.group(1)); - int minutes = Integer.parseInt(time.group(2)), seconds = Integer.parseInt(time.group(3)); - if (minutes >= 60 || seconds >= 60) throw new IllegalArgumentException("ssa-packet-duration"); - // This is the current Media3 duration, already quantized to centiseconds. Do not claim - // that stripping its prefix recovers the original Matroska blockDurationUs precision. - return hours * 3_600_000L + minutes * 60_000L + seconds * 1000L + Integer.parseInt(time.group(4)) * 10L; - } - - int size() { return packets.size(); } - int bytes() { return bytes; } - - List after(int cursor) { - return cursor == packets.size() ? List.of() : List.copyOf(packets.subList(cursor, packets.size())); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssSurfaceHost.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssSurfaceHost.java deleted file mode 100644 index 85f378a7c6..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssSurfaceHost.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.graphics.PixelFormat; -import android.view.SurfaceHolder; -import android.view.SurfaceView; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; - -import androidx.annotation.NonNull; -import androidx.media3.ui.PlayerView; -import androidx.media3.ui.SubtitleView; - -/** Main-thread display arbiter. Only the duplicate ASS SubtitleView is hidden. */ -final class AssSurfaceHost implements SurfaceHolder.Callback, View.OnLayoutChangeListener { - private final ExoAssSession session; - private final PlayerView view; - private final SubtitleView compatible; - private SurfaceView video; - private SurfaceView overlay; - private boolean hidingCompatible; - private int compatibleVisibility; - - AssSurfaceHost(ExoAssSession session, PlayerView view) { - this.session = session; - this.view = view; - this.compatible = view.getSubtitleView(); - } - - PlayerView view() { return view; } - - void update(boolean eligible, boolean active) { - View current = view.getVideoSurfaceView(); - if (!eligible || !(current instanceof SurfaceView) || !(current.getParent() instanceof FrameLayout)) { - removeSurface(); - return; - } - if (video != current) { - removeSurface(); - video = (SurfaceView) current; - FrameLayout parent = (FrameLayout) video.getParent(); - overlay = new SurfaceView(view.getContext()); - overlay.setZOrderMediaOverlay(true); - overlay.getHolder().setFormat(PixelFormat.TRANSLUCENT); - overlay.getHolder().addCallback(this); - overlay.setFocusable(false); - overlay.setClickable(false); - overlay.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); - overlay.setAlpha(0f); - FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(video.getLayoutParams()); - if (video.getLayoutParams() instanceof FrameLayout.LayoutParams original) params.gravity = original.gravity; - parent.addView(overlay, parent.indexOfChild(video) + 1, params); - video.addOnLayoutChangeListener(this); - } - if (active && overlay != null && overlay.getHolder().getSurface().isValid()) { - if (!hidingCompatible && compatible != null) { - compatibleVisibility = compatible.getVisibility(); - hidingCompatible = true; - compatible.setVisibility(View.INVISIBLE); - } - overlay.setAlpha(1f); - } else { - showCompatible(); - } - } - - private void showCompatible() { - if (overlay != null) overlay.setAlpha(0f); - if (hidingCompatible && compatible != null) compatible.setVisibility(compatibleVisibility); - hidingCompatible = false; - } - - private void removeSurface() { - showCompatible(); - if (video != null) video.removeOnLayoutChangeListener(this); - video = null; - if (overlay == null) return; - session.setSurface(this, null, 0, 0, true); - overlay.getHolder().removeCallback(this); - if (overlay.getParent() instanceof ViewGroup parent) parent.removeView(overlay); - overlay = null; - } - - void release() { removeSurface(); } - - @Override - public void surfaceCreated(@NonNull SurfaceHolder holder) { - if (overlay != null && holder == overlay.getHolder()) - session.setSurface(this, holder.getSurface(), overlay.getWidth(), overlay.getHeight(), true); - } - - @Override - public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, int height) { - if (overlay != null && holder == overlay.getHolder()) - session.setSurface(this, holder.getSurface(), width, height, false); - } - - @Override - public void surfaceDestroyed(@NonNull SurfaceHolder holder) { - if (overlay == null || holder != overlay.getHolder()) return; - showCompatible(); - session.setSurface(this, null, 0, 0, true); - } - - @Override - public void onLayoutChange(View v, int left, int top, int right, int bottom, - int oldLeft, int oldTop, int oldRight, int oldBottom) { - if (overlay != null && overlay.getHolder().getSurface().isValid()) { - // The overlay follows View layout. The video's fixed buffer dimensions are unrelated. - session.setSurface(this, overlay.getHolder().getSurface(), overlay.getWidth(), overlay.getHeight(), false); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssVideoPolicy.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssVideoPolicy.java deleted file mode 100644 index f70af1c9c8..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/AssVideoPolicy.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import androidx.media3.common.C; -import androidx.media3.common.ColorInfo; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; - -/** Video eligibility and the color contract for the independent SDR subtitle surface. */ -final class AssVideoPolicy { - static boolean supports(Format video) { - ColorInfo color = video.colorInfo; - return video.width > 0 && video.height > 0 && video.rotationDegrees == 0 - && video.drmInitData == null && video.cryptoType == C.CRYPTO_TYPE_NONE - && (color == null || color.colorTransfer == Format.NO_VALUE - || color.colorTransfer == C.COLOR_TRANSFER_SDR - || color.colorTransfer == C.COLOR_TRANSFER_ST2084 - || color.colorTransfer == C.COLOR_TRANSFER_HLG); - } - - static boolean usesSdrRgb(Format video) { - ColorInfo color = video.colorInfo; - return MimeTypes.VIDEO_DOLBY_VISION.equals(video.sampleMimeType) - || color != null && (ColorInfo.isTransferHdr(color) - || color.colorSpace == C.COLOR_SPACE_BT2020); - } - - static int colorSpace(Format video) { - if (usesSdrRgb(video)) return AssNative.COLOR_SPACE_SDR_RGB; - ColorInfo color = video.colorInfo; - return color == null || color.colorSpace == Format.NO_VALUE - ? (video.height <= 576 ? C.COLOR_SPACE_BT601 : C.COLOR_SPACE_BT709) - : color.colorSpace; - } - - static int colorRange(Format video) { - if (usesSdrRgb(video)) return C.COLOR_RANGE_FULL; - ColorInfo color = video.colorInfo; - return color == null || color.colorRange == Format.NO_VALUE - ? C.COLOR_RANGE_LIMITED : color.colorRange; - } - - private AssVideoPolicy() { } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/ExoAssSession.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ass/ExoAssSession.java deleted file mode 100644 index 523e1b40c9..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ass/ExoAssSession.java +++ /dev/null @@ -1,526 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.content.Context; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.Looper; -import android.os.Process; -import android.util.Log; -import android.view.Surface; - -import androidx.annotation.Nullable; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.text.CueGroup; -import androidx.media3.exoplayer.text.TextRenderer; -import androidx.media3.ui.PlayerView; - -import java.io.File; -import java.nio.ByteBuffer; -import java.util.Arrays; -import java.util.List; - -/** - * One engine-owned, optional ASS session. Playback callbacks only publish bounded input or time. - * A single lazy worker owns libass, EGL and all native teardown. UI detach retains the full script. - */ -public final class ExoAssSession implements TextRenderer.Observer { - public enum State { COMPAT, PREPARING, ACTIVE, FALLBACK, DISABLED } - - private static final String TAG = "ExoAss"; - private static final long FRAME_INTERVAL_NS = 16_666_667L; - private final Context context; - private final boolean tunneling; - private final Handler main = new Handler(Looper.getMainLooper()); - private final Object lock = new Object(); - private final Runnable workerTask = this::drain; - private final Runnable uiTask = this::refreshHost; - private final long[] renderTimes = new long[4096]; - private final long[] uploadTimes = new long[4096]; - private final long[] swapTimes = new long[4096]; - @Nullable private TextRenderer.Stream stream; - @Nullable private TextRenderer.Stream displayingStream; - @Nullable private Format videoFormat; - @Nullable private byte[] script; - @Nullable private AssPacketInput packets; - @Nullable private AssFontSet fonts; - @Nullable private AssSurfaceHost host; - @Nullable private Surface surface; - @Nullable private HandlerThread thread; - @Nullable private Handler worker; - private int width, height; - private long generation, surfaceEpoch, layoutEpoch, policyEpoch, revision, clockVersion; - private long positionUs = C.TIME_UNSET, textOffsetUs; - private long handledRevision = -1, handledClock = -1; - private boolean enabled = true, ended, released, releaseComplete, posted, running, uiPosted; - private boolean nativeAlive; - private State state = State.COMPAT; - private String failure = ""; - private long frames, scripts, staleFrames, maxRenderUs, renderedTimeMs; - - // Worker-only state. None of these fields is accessed by the UI/playback threads. - private long handle, loadedStream = -1, connectedSurfaceEpoch = -1, renderedRevision = -1; - private long submittedRevision = -1; - private volatile long lastStartNs; - private String fontConfig; - private AssFontSet.Snapshot loadedFonts; - private int loadedPacketCount; - private int slowFrames; - private final long[] nativeStats = new long[6]; - - @Nullable - public static ExoAssSession createIfEnabled(Context context, boolean tunneling) { - return Process.is64Bit() ? new ExoAssSession(context, tunneling) : null; - } - - ExoAssSession(Context context, boolean tunneling) { - this.context = context.getApplicationContext(); - this.tunneling = tunneling; - } - - /** Called for each foreground source; preload factories do not have this session. */ - @Nullable public AssFontSet beginMediaFonts() { - synchronized (lock) { - if (fonts != null) fonts.close(); - fonts = enabled && !released ? new AssFontSet(this::onFontsChanged) : null; - stream = displayingStream = null; - script = null; - packets = null; - videoFormat = null; - failure = ""; - invalidateLocked(); - return fonts; - } - } - - private void onFontsChanged(AssFontSet changed) { - synchronized (lock) { - if (released || fonts != changed) return; - if (!changed.failure().isEmpty()) failLocked(changed.failure()); - else invalidateLocked(); - } - } - - public void attach(PlayerView view) { - checkMain(); - synchronized (lock) { - if (released || host != null && host.view() == view) return; - } - detach(); - synchronized (lock) { - host = new AssSurfaceHost(this, view); - postUiLocked(); - } - } - - public void detach() { - checkMain(); - AssSurfaceHost previous; - synchronized (lock) { previous = host; } - if (previous != null) previous.release(); - synchronized (lock) { - host = null; - surface = null; - surfaceEpoch++; - invalidateLocked(); - } - } - - /** Session kill switch; immediately restores compatible Cue output without recreating Exo. */ - public void setEnabled(boolean enabled) { - synchronized (lock) { - if (released || this.enabled == enabled) return; - this.enabled = enabled; - policyEpoch++; - if (enabled) failure = fonts == null ? "" : fonts.failure(); - invalidateLocked(); - } - } - - public void release() { - checkMain(); - detach(); - synchronized (lock) { - if (released) return; - released = true; - if (fonts != null) fonts.close(); - fonts = null; - script = null; - packets = null; - stream = null; - invalidateLocked(); - if (worker == null) releaseComplete = true; - } - } - - /** Called by a multicast of the existing video metadata listener, without replacing analytics. */ - public void onVideoFrame(Format format) { - synchronized (lock) { - if (released || format.equals(videoFormat)) return; - videoFormat = format; - layoutEpoch++; - invalidateLocked(); - } - } - - @Override - public void onStream(TextRenderer.Stream stream, long generation) { - synchronized (lock) { - if (released) return; - this.stream = stream; - this.generation = generation; - displayingStream = null; - script = null; - packets = isPacketized(stream.format) ? new AssPacketInput() : null; - ended = false; - positionUs = C.TIME_UNSET; - failure = fonts == null ? "" : fonts.failure(); - Log.i(TAG, "input stream=" + stream.sequence + " kind=" - + (packets != null ? "media3-ssa" : isExternal(stream.format) ? "full-ass" : "compat")); - invalidateLocked(); - } - } - - @Override - public void onReset(TextRenderer.Stream stream, long generation, long positionUs) { - synchronized (lock) { - if (released || this.stream == null || this.stream.sequence != stream.sequence) return; - this.generation = generation; - this.positionUs = positionUs; - displayingStream = stream; - ended = false; - // Complete scripts and already received packets remain available across seeks. - // Repeated Matroska preroll is deduplicated by its original ReadOrder. - invalidateLocked(); - } - } - - @Override - public void onSample(TextRenderer.Stream stream, long generation, Format format, ByteBuffer data, long timeUs) { - synchronized (lock) { - if (released || this.stream == null || this.stream.sequence != stream.sequence - || generation != this.generation || !failure.isEmpty()) return; - try { - if (packets != null && isPacketized(format)) { - if (data.hasRemaining() && packets.add(data, timeUs, stream.offsetUs)) { - // Events are retained in order, not coalesced like clock notifications. - // Keep the last presented frame visible while appending the next event. - clockVersion++; - scheduleLocked(true); - } - return; - } - if (!isExternal(format) || script != null) return; - // At most one complete file is retained per selected stream. Seeks may redeliver it. - // No event/header/font queue uses a latest-wins policy. - script = AssInput.copy(data); - scripts++; - invalidateLocked(); - } catch (IllegalArgumentException | ArithmeticException error) { - failLocked(error.getMessage()); - } - } - } - - @Override - public void onClock(TextRenderer.Stream readingStream, @Nullable TextRenderer.Stream displayingStream, - long generation, long positionUs, long textOffsetUs, boolean started, boolean ended) { - synchronized (lock) { - if (released || stream == null || stream.sequence != readingStream.sequence || generation != this.generation) return; - boolean control = this.ended != ended || this.displayingStream != displayingStream - || this.textOffsetUs != textOffsetUs; - boolean changed = this.positionUs != positionUs || control; - this.displayingStream = displayingStream; - this.positionUs = positionUs; - this.textOffsetUs = textOffsetUs; - this.ended = ended; - if (control) invalidateLocked(); - if (changed) { - clockVersion++; - // Actual renderer media time freezes on pause/buffering and already includes speed. - // There is no UI position polling or independent extrapolating timer. - scheduleLocked(false); - } - } - } - - @Override - public void onCues(@Nullable TextRenderer.Stream displayingStream, long generation, CueGroup cues) { - // The original TextRenderer still delivers every CueGroup to PlayerView. Its SubtitleView - // stays current while hidden, so restoration needs no seek, network reload or reparse. - } - - @Override - public void onDisabled(long generation) { - synchronized (lock) { - if (released) return; - this.generation = generation; - stream = displayingStream = null; - script = null; - packets = null; - ended = false; - invalidateLocked(); - } - } - - void setSurface(AssSurfaceHost owner, @Nullable Surface surface, int width, int height, boolean recreated) { - checkMain(); - synchronized (lock) { - if (host != owner || released) return; - boolean replaced = this.surface != surface || recreated; - boolean resized = this.width != width || this.height != height; - if (!replaced && !resized) return; - this.surface = surface; - this.width = width; - this.height = height; - if (replaced) surfaceEpoch++; - if (resized) layoutEpoch++; - invalidateLocked(); - } - } - - private static boolean isExternal(Format format) { - return MimeTypes.TEXT_SSA.equals(format.sampleMimeType) && format.cryptoType == C.CRYPTO_TYPE_NONE - && format.initializationData.isEmpty() && AssInput.isExternalId(format.id); - } - - private static boolean isPacketized(Format format) { - return MimeTypes.TEXT_SSA.equals(format.sampleMimeType) && format.cryptoType == C.CRYPTO_TYPE_NONE - && AssPacketInput.matches(format.initializationData); - } - - private boolean admittedLocked() { - if (!enabled || released || ended || stream == null - || !(isExternal(stream.format) || isPacketized(stream.format)) - || !failure.isEmpty() || tunneling || videoFormat == null) return false; - return AssVideoPolicy.supports(videoFormat); - } - - private boolean renderableLocked() { - return admittedLocked() && (script != null || packets != null && packets.size() > 0) - && surface != null && width > 0 && height > 0 - && positionUs != C.TIME_UNSET && displayingStream != null - && displayingStream.sequence == stream.sequence; - } - - private void invalidateLocked() { - revision++; - state = released || !enabled || stream == null ? State.DISABLED - : !failure.isEmpty() ? State.FALLBACK : admittedLocked() ? State.PREPARING : State.COMPAT; - postUiLocked(); - scheduleLocked(true); - } - - private void failLocked(String reason) { - failure = reason == null ? "unknown" : reason; - Log.w(TAG, "fallback reason=" + failure + " generation=" + generation); - invalidateLocked(); - } - - private void scheduleLocked(boolean control) { - if (!control && !renderableLocked()) return; - if (worker == null) { - if (!renderableLocked()) return; - // Rendering is foreground playback work. BACKGROUND also applies Android's - // background scheduling policy; it is not just a label for a non-UI thread. - thread = new HandlerThread("ExoAss", Process.THREAD_PRIORITY_DEFAULT); - thread.start(); - worker = new Handler(thread.getLooper()); - } - if (running || posted) return; - posted = true; - long delayMs = control ? 0 : Math.max(0, (lastStartNs + FRAME_INTERVAL_NS - System.nanoTime() + 999_999) / 1_000_000); - worker.postDelayed(workerTask, delayMs); - } - - private void postUiLocked() { - if (uiPosted) return; - uiPosted = true; - main.post(uiTask); - } - - private void refreshHost() { - checkMain(); - AssSurfaceHost current; - boolean admitted; - boolean active; - synchronized (lock) { - uiPosted = false; - current = host; - admitted = admittedLocked(); - active = state == State.ACTIVE; - } - if (current != null) current.update(admitted, active); - } - - private record Request(long revision, long clockVersion, long stream, long generation, - long surfaceEpoch, long layoutEpoch, long policyEpoch, - boolean admitted, boolean renderable, boolean released, - byte[] script, boolean packetized, List chunks, int packetCount, - Surface surface, int width, int height, - Format video, long positionUs, long streamOffsetUs, long textOffsetUs, - AssFontSet.Snapshot fonts) { } - - private Request requestLocked() { - AssFontSet.Snapshot currentFonts = fonts == null ? AssFontSet.EMPTY : fonts.snapshot(); - boolean replay = handle == 0 || stream == null || loadedStream != stream.sequence || loadedFonts != currentFonts; - List chunks = packets == null ? List.of() : packets.after(replay ? 0 : loadedPacketCount); - return new Request(revision, clockVersion, stream == null ? -1 : stream.sequence, generation, - surfaceEpoch, layoutEpoch, policyEpoch, admittedLocked(), renderableLocked(), released, - packets == null ? script : stream.format.initializationData.get(1), packets != null, - chunks, packets == null ? 0 : packets.size(), - surface, width, height, videoFormat, positionUs, stream == null ? 0 : stream.offsetUs, textOffsetUs, - currentFonts); - } - - private void drain() { - Request request; - synchronized (lock) { - posted = false; - running = true; - request = requestLocked(); - } - try { - if (request.released || !request.admitted - || handle != 0 && (loadedStream != request.stream || loadedFonts != request.fonts)) { - destroyNative(); - } - if (request.released) return; - if (!request.renderable) { - if (handle != 0 && connectedSurfaceEpoch != request.surfaceEpoch) { - AssNative.setSurface(handle, null); - connectedSurfaceEpoch = request.surfaceEpoch; - } - return; - } - if (handle == 0) { - AssNative.ensureLoaded(); - fontConfig = AssFonts.prepare(context); - handle = AssNative.create(fontConfig, request.fonts.names(), request.fonts.data()); - if (handle == 0) throw new IllegalStateException("native-init"); - loadedFonts = request.fonts; - synchronized (lock) { nativeAlive = true; } - } - if (loadedStream != request.stream) { - boolean loaded = request.packetized - ? AssNative.loadHeader(handle, AssInput.normalizeHeader(request.script)) - : AssNative.load(handle, AssInput.normalize(request.script)); - if (!loaded) throw new IllegalStateException("script-rejected"); - loadedStream = request.stream; - loadedPacketCount = 0; - } - for (AssPacketInput.Packet packet : request.chunks) - if (!AssNative.chunk(handle, packet.data(), packet.startMs(), packet.durationMs())) - throw new IllegalStateException("packet-rejected"); - loadedPacketCount = request.packetCount; - if (connectedSurfaceEpoch != request.surfaceEpoch) { - if (!AssNative.setSurface(handle, request.surface)) throw new IllegalStateException("surface-init"); - connectedSurfaceEpoch = request.surfaceEpoch; - } - long timeMs = AssInput.timeMs(request.positionUs, request.streamOffsetUs, request.textOffsetUs); - int space = AssVideoPolicy.colorSpace(request.video); - int range = AssVideoPolicy.colorRange(request.video); - double aspect = request.video.pixelWidthHeightRatio > 0 ? request.video.pixelWidthHeightRatio : 1.0; - lastStartNs = System.nanoTime(); - int result = AssNative.render(handle, timeMs, request.width, request.height, - request.video.width, request.video.height, aspect, space, range, - renderedRevision != request.revision, nativeStats); - if (result < 0) throw new IllegalStateException(result == -2 ? "render-budget" : "render-surface"); - renderedRevision = request.revision; - synchronized (lock) { - int slot = (int) (frames % renderTimes.length); - renderTimes[slot] = nativeStats[0]; uploadTimes[slot] = nativeStats[1]; swapTimes[slot] = nativeStats[2]; - frames++; - maxRenderUs = Math.max(maxRenderUs, nativeStats[0]); - renderedTimeMs = timeMs; - if (revision != request.revision || released) { staleFrames++; return; } - slowFrames = nativeStats[0] + nativeStats[1] > 33_334 ? slowFrames + 1 : 0; - if (nativeStats[0] > 100_000 || slowFrames >= 3) { - failLocked("render-time-budget"); - return; - } - } - if (result > 0 && submittedRevision != request.revision) { - submittedRevision = request.revision; - main.post(() -> { - synchronized (lock) { - if (released || revision != request.revision || !renderableLocked()) return; - state = State.ACTIVE; - Log.i(TAG, "active stream=" + request.stream + " generation=" + request.generation - + " surface=" + request.surfaceEpoch + " layout=" + request.layoutEpoch - + " input=" + (request.packetized ? "media3-ssa" : "full-ass") - + " packets=" + request.packetCount + " fonts=" + request.fonts.names().length); - postUiLocked(); - } - }); - } - } catch (Exception | LinkageError error) { - synchronized (lock) { - if (!released && revision == request.revision) failLocked(error.getMessage()); - } - } finally { - synchronized (lock) { - running = false; - handledRevision = request.revision; - handledClock = request.clockVersion; - if (request.released) { - releaseComplete = true; - if (thread != null) thread.quitSafely(); - Log.i(TAG, "released frames=" + frames + " stale=" + staleFrames); - } else if (revision != handledRevision || clockVersion != handledClock) { - scheduleLocked(revision != handledRevision); - } - } - } - } - - private void destroyNative() { - if (handle != 0) AssNative.destroy(handle); - handle = 0; - loadedStream = connectedSurfaceEpoch = renderedRevision = submittedRevision = -1; - loadedFonts = null; - loadedPacketCount = 0; - slowFrames = 0; - if (fontConfig != null) new File(fontConfig).delete(); - fontConfig = null; - synchronized (lock) { nativeAlive = false; } - } - - /** Deterministic recoverable failure hook for the prototype test harness. */ - void injectFailure(String reason) { - synchronized (lock) { if (!released) failLocked("injected-" + reason); } - } - - public record Diagnostics(State state, String failure, long frames, long renderP95Us, - long uploadP95Us, long swapP95Us, long maxRenderUs, long staleFrames, - long scripts, long timeMs, long generation, long surfaceEpoch, - long layoutEpoch, long stream, boolean nativeAlive, boolean releaseComplete, - boolean workerAlive, int workerTid, - int fontCount, int fontBytes, int packetCount, int packetBytes) { } - - public Diagnostics diagnostics() { - synchronized (lock) { - int count = (int) Math.min(frames, renderTimes.length); - return new Diagnostics(state, failure, frames, percentile(renderTimes, count), - percentile(uploadTimes, count), percentile(swapTimes, count), maxRenderUs, staleFrames, - scripts, renderedTimeMs, generation, surfaceEpoch, layoutEpoch, - stream == null ? -1 : stream.sequence, nativeAlive, releaseComplete, - thread != null && thread.isAlive(), thread == null ? -1 : thread.getThreadId(), - fonts == null ? 0 : fonts.snapshot().names().length, - fonts == null ? 0 : fonts.snapshot().bytes(), - packets == null ? 0 : packets.size(), packets == null ? 0 : packets.bytes()); - } - } - - private static long percentile(long[] values, int count) { - if (count == 0) return 0; - long[] copy = Arrays.copyOf(values, count); - Arrays.sort(copy); - return copy[Math.min(count - 1, (int) Math.ceil(count * .95) - 1)]; - } - - private static void checkMain() { - if (Looper.myLooper() != Looper.getMainLooper()) throw new IllegalStateException("ASS host requires main thread"); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleTrackSelector.java b/app/src/main/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleTrackSelector.java deleted file mode 100644 index 2dc0db5850..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleTrackSelector.java +++ /dev/null @@ -1,235 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import androidx.annotation.Nullable; -import androidx.media3.common.AudioAttributes; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.Timeline; -import androidx.media3.common.TrackGroup; -import androidx.media3.common.TrackSelectionOverride; -import androidx.media3.common.TrackSelectionParameters; -import androidx.media3.common.Tracks; -import androidx.media3.exoplayer.ExoPlaybackException; -import androidx.media3.exoplayer.RendererCapabilities; -import androidx.media3.exoplayer.RendererConfiguration; -import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; -import androidx.media3.exoplayer.source.TrackGroupArray; -import androidx.media3.exoplayer.trackselection.ExoTrackSelection; -import androidx.media3.exoplayer.trackselection.FixedTrackSelection; -import androidx.media3.exoplayer.trackselection.TrackSelector; -import androidx.media3.exoplayer.trackselection.TrackSelectorResult; -import androidx.media3.exoplayer.upstream.BandwidthMeter; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Objects; - -/** Adds one explicit subtitle selection without changing the original renderer mapping or policy. */ -public final class DualSubtitleTrackSelector extends TrackSelector { - private final TrackSelector delegate; - private int secondaryRendererIndex = C.INDEX_UNSET; - private volatile Request request = new Request(null, 0); - private volatile SelectionInfo active = SelectionInfo.EMPTY; - - public DualSubtitleTrackSelector(TrackSelector delegate) { - this.delegate = delegate; - } - - void setSecondaryRendererIndex(int index) { - secondaryRendererIndex = index; - } - - /** A request is also an identity token, so queued output cannot survive a track/source change. */ - static final class Request { - @Nullable final TrackSelectionOverride override; - final long mediaGeneration; - - Request(@Nullable TrackSelectionOverride override, long mediaGeneration) { - this.override = override; - this.mediaGeneration = mediaGeneration; - } - - boolean accepts(@Nullable Format format) { - return override != null && format != null - && override.mediaTrackGroup.getFormat(override.trackIndices.get(0)).equals(format); - } - } - - Request request() { - return request; - } - - public synchronized boolean setSecondaryOverride(@Nullable TrackSelectionOverride override) { - if (override != null) { - if (override.getType() != C.TRACK_TYPE_TEXT || override.trackIndices.size() != 1) { - throw new IllegalArgumentException("A secondary subtitle must select exactly one text track"); - } - if (active.mediaGeneration == request.mediaGeneration - && active.primaryGroups.contains(override.mediaTrackGroup)) { - return false; - } - } - if (Objects.equals(request.override, override)) return true; - request = new Request(override, request.mediaGeneration); - invalidate(); - return true; - } - - public synchronized void reset() { - request = new Request(null, request.mediaGeneration + 1); - active = SelectionInfo.EMPTY; - invalidate(); - } - - public boolean isPrimarySelected(@Nullable Format format) { - SelectionInfo selection = active; - return format != null && selection.mediaGeneration == request.mediaGeneration - && selection.primaryFormats.contains(format); - } - - public boolean isSecondarySelected(@Nullable Format format) { - SelectionInfo selection = active; - Request current = request; - return format != null && selection.mediaGeneration == current.mediaGeneration - && format.equals(selection.secondaryFormat) && current.accepts(format); - } - - @Override - public void init(InvalidationListener listener, BandwidthMeter bandwidthMeter) { - super.init(listener, bandwidthMeter); - delegate.init(listener, bandwidthMeter); - } - - @Override - public void release() { - delegate.release(); - active = SelectionInfo.EMPTY; - super.release(); - } - - @Override - public TrackSelectionParameters getParameters() { - return delegate.getParameters(); - } - - @Override - public void setParameters(TrackSelectionParameters parameters) { - delegate.setParameters(parameters); - } - - @Override - public boolean isSetParametersSupported() { - return delegate.isSetParametersSupported(); - } - - @Override - public void setAudioAttributes(AudioAttributes audioAttributes) { - delegate.setAudioAttributes(audioAttributes); - } - - @Nullable - @Override - public RendererCapabilities.Listener getRendererCapabilitiesListener() { - return delegate.getRendererCapabilitiesListener(); - } - - @Override - public TrackSelectorResult selectTracks(RendererCapabilities[] capabilities, TrackGroupArray groups, - MediaPeriodId periodId, Timeline timeline) throws ExoPlaybackException { - int secondaryIndex = secondaryRendererIndex; - if (secondaryIndex < 0 || secondaryIndex != capabilities.length - 1 - || capabilities[secondaryIndex].getTrackType() != C.TRACK_TYPE_TEXT) { - throw new IllegalStateException("The secondary TextRenderer must be appended to the original renderers"); - } - Request requested = request; - // The delegate sees exactly the renderer list it saw before dual subtitles were enabled. - // In particular, no extra audio/video capability queries or new automatic policies are added. - TrackSelectorResult primary = delegate.selectTracks( - Arrays.copyOf(capabilities, secondaryIndex), groups, periodId, timeline); - RendererConfiguration[] configurations = Arrays.copyOf(primary.rendererConfigurations, capabilities.length); - ExoTrackSelection[] selections = Arrays.copyOf(primary.selections, capabilities.length); - List primaryFormats = selectedTextFormats(primary.selections); - List primaryGroups = selectedTextGroups(primary.selections); - ExoTrackSelection secondary = selectSecondary(requested, groups, capabilities[secondaryIndex], primaryGroups); - if (secondary != null) { - configurations[secondaryIndex] = RendererConfiguration.DEFAULT; - selections[secondaryIndex] = secondary; - } - return new TrackSelectorResult(configurations, selections, withSecondary(primary.tracks, secondary), - new SelectionInfo(primary.info, primaryFormats, primaryGroups, - secondary == null ? null : secondary.getSelectedFormat(), requested.mediaGeneration)); - } - - @Nullable - private static ExoTrackSelection selectSecondary(Request request, TrackGroupArray groups, - RendererCapabilities capabilities, List primaryGroups) - throws ExoPlaybackException { - TrackSelectionOverride override = request.override; - if (override == null || groups.indexOf(override.mediaTrackGroup) == C.INDEX_UNSET) return null; - int index = override.trackIndices.get(0); - Format format = override.mediaTrackGroup.getFormat(index); - // HLS/Progressive streams may have only one queue per group. Two variants of one - // group are adaptive alternatives, not two independently consumable subtitles. - if (primaryGroups.contains(override.mediaTrackGroup) - || RendererCapabilities.getFormatSupport(capabilities.supportsFormat(format)) != C.FORMAT_HANDLED) { - return null; - } - return new FixedTrackSelection(override.mediaTrackGroup, index); - } - - private static List selectedTextFormats(ExoTrackSelection[] selections) { - List formats = new ArrayList<>(); - for (ExoTrackSelection selection : selections) { - if (selection == null || selection.getTrackGroup().type != C.TRACK_TYPE_TEXT) continue; - for (int i = 0; i < selection.length(); i++) formats.add(selection.getFormat(i)); - } - return Collections.unmodifiableList(formats); - } - - private static List selectedTextGroups(ExoTrackSelection[] selections) { - List groups = new ArrayList<>(); - for (ExoTrackSelection selection : selections) { - if (selection != null && selection.getTrackGroup().type == C.TRACK_TYPE_TEXT) groups.add(selection.getTrackGroup()); - } - return Collections.unmodifiableList(groups); - } - - private static Tracks withSecondary(Tracks primary, @Nullable ExoTrackSelection secondary) { - if (secondary == null) return primary; - List groups = new ArrayList<>(primary.getGroups().size()); - TrackGroup selectedGroup = secondary.getTrackGroup(); - for (Tracks.Group group : primary.getGroups()) { - if (!group.getMediaTrackGroup().equals(selectedGroup)) { - groups.add(group); - continue; - } - int[] support = new int[group.length]; - boolean[] selected = new boolean[group.length]; - for (int i = 0; i < group.length; i++) { - support[i] = group.getTrackSupport(i); - selected[i] = group.isTrackSelected(i) || i == secondary.getIndexInTrackGroup(0); - } - groups.add(new Tracks.Group(selectedGroup, group.isAdaptiveSupported(), support, selected)); - } - return new Tracks(groups); - } - - @Override - public void onSelectionActivated(@Nullable Object info) { - if (!(info instanceof SelectionInfo selection)) { - delegate.onSelectionActivated(info); - active = SelectionInfo.EMPTY; - return; - } - delegate.onSelectionActivated(selection.delegateInfo); - // Selection may happen while buffering a future period. Only activation publishes roles. - if (selection.mediaGeneration == request.mediaGeneration) active = selection; - } - - private record SelectionInfo(@Nullable Object delegateInfo, List primaryFormats, List primaryGroups, - @Nullable Format secondaryFormat, long mediaGeneration) { - static final SelectionInfo EMPTY = new SelectionInfo(null, Collections.emptyList(), Collections.emptyList(), null, -1); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/subtitle/ExoSubtitleSession.java b/app/src/main/java/com/fongmi/android/tv/player/exo/subtitle/ExoSubtitleSession.java deleted file mode 100644 index 6ed7036258..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/subtitle/ExoSubtitleSession.java +++ /dev/null @@ -1,191 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.os.Handler; -import android.os.Looper; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; - -import androidx.annotation.Nullable; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.Player; -import androidx.media3.common.TrackSelectionOverride; -import androidx.media3.common.text.Cue; -import androidx.media3.common.text.CueGroup; -import androidx.media3.exoplayer.Renderer; -import androidx.media3.exoplayer.RenderersFactory; -import androidx.media3.exoplayer.text.TextRenderer; -import androidx.media3.exoplayer.trackselection.TrackSelector; -import androidx.media3.ui.PlayerView; -import androidx.media3.ui.SubtitleView; - -import com.fongmi.android.tv.bean.Track; -import com.fongmi.android.tv.player.exo.ExoUtil; -import com.fongmi.android.tv.player.exo.TrackUtil; - -import java.nio.ByteBuffer; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -/** One player's secondary subtitle state; the Activity owns only the currently attached View. */ -public final class ExoSubtitleSession { - private final Handler handler = new Handler(Looper.getMainLooper()); - @Nullable private DualSubtitleTrackSelector selector; - @Nullable private PlayerView host; - @Nullable private SubtitleView view; - private List cues = Collections.emptyList(); - private volatile boolean released; - - public TrackSelector wrapTrackSelector(TrackSelector delegate) { - if (selector != null) throw new IllegalStateException("Subtitle session already has a selector"); - return selector = new DualSubtitleTrackSelector(delegate); - } - - public RenderersFactory wrapRenderersFactory(RenderersFactory factory) { - return (eventHandler, video, audio, primaryText, metadata) -> { - Renderer[] original = factory.createRenderers(eventHandler, video, audio, primaryText, metadata); - if (selector == null) throw new IllegalStateException("Create the subtitle selector first"); - selector.setSecondaryRendererIndex(original.length); - // Keep a real TextRenderer so Media3 still handles offset, final-stream and scheduling. - // The existing observer supplies stream identity, protecting against queued old Cues. - TextRenderer secondary = new TextRenderer(cueGroup -> { }, null); - secondary.setObserver(new SecondaryObserver(selector)); - Renderer[] result = Arrays.copyOf(original, original.length + 1); - result[original.length] = secondary; - return result; - }; - } - - public boolean isPrimarySelected(Format format) { - return selector != null && selector.isPrimarySelected(format); - } - - public boolean isSecondarySelected(Format format) { - return selector != null && selector.isSecondarySelected(format); - } - - public boolean selectSecondary(Player player, Track track) { - if (released || selector == null || track == null) return false; - TrackSelectionOverride override = track.isDisabled() ? null : TrackUtil.findOverride(player, track); - if (!track.isDisabled() && override == null) return false; - DualSubtitleTrackSelector.Request previous = selector.request(); - if (!selector.setSecondaryOverride(override)) return false; - if (previous != selector.request()) clearCues(); - return true; - } - - public boolean canSelectPrimary(Player player, Track track) { - if (selector == null || track.getType() != C.TRACK_TYPE_TEXT || track.isDisabled() || !track.isSelected()) { - return true; - } - TrackSelectionOverride override = TrackUtil.findOverride(player, track); - TrackSelectionOverride secondary = selector.request().override; - return override == null || secondary == null || !secondary.mediaTrackGroup.equals(override.mediaTrackGroup); - } - - /** Called before replacing media, stopping or resetting track preferences. */ - public void reset() { - if (selector != null) selector.reset(); - clearCues(); - } - - public void attach(PlayerView host) { - if (released) return; - if (this.host != host) { - detach(); - this.host = host; - } - updateView(); - } - - public void detach() { - if (view != null && view.getParent() instanceof ViewGroup parent) parent.removeView(view); - view = null; - host = null; - } - - public void release() { - released = true; - handler.removeCallbacksAndMessages(null); - cues = Collections.emptyList(); - detach(); - } - - private void clearCues() { - cues = Collections.emptyList(); - updateView(); - } - - private void updateView() { - if (view == null && !cues.isEmpty() && host != null) { - SubtitleView primary = host.getSubtitleView(); - if (primary == null || !(primary.getParent() instanceof ViewGroup parent)) return; - view = new SubtitleView(host.getContext()); - view.setStyle(ExoUtil.getCaptionStyle()); - view.setApplyEmbeddedStyles(false); - view.setApplyEmbeddedFontSizes(false); - view.setFocusable(false); - view.setClickable(false); - view.setSaveEnabled(false); - view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); - ViewGroup.LayoutParams params = primary.getLayoutParams() instanceof FrameLayout.LayoutParams original - ? new FrameLayout.LayoutParams(original) : new ViewGroup.LayoutParams(primary.getLayoutParams()); - parent.addView(view, parent.indexOfChild(primary) + 1, params); - } - if (view != null) view.setCues(cues); - } - - private void publish(DualSubtitleTrackSelector source, DualSubtitleTrackSelector.Request request, CueGroup group) { - handler.post(() -> { - if (released || selector != source || source.request() != request) return; - cues = SecondarySubtitleCues.atTop(group.cues); - updateView(); - }); - } - - private final class SecondaryObserver implements TextRenderer.Observer { - private final DualSubtitleTrackSelector source; - @Nullable private TextRenderer.Stream reading; - @Nullable private TextRenderer.Stream previous; - private long readingMediaGeneration = -1; - private long previousMediaGeneration = -1; - - SecondaryObserver(DualSubtitleTrackSelector source) { - this.source = source; - } - - @Override - public void onStream(TextRenderer.Stream stream, long generation) { - // Read-ahead can replace the input while the previous stream is still on screen. - // Keep both identities, but never relabel an old source with a new request's epoch. - previous = reading; - previousMediaGeneration = readingMediaGeneration; - reading = stream; - readingMediaGeneration = source.request().mediaGeneration; - } - @Override public void onReset(TextRenderer.Stream stream, long generation, long positionUs) { } - @Override public void onSample(TextRenderer.Stream stream, long generation, Format format, ByteBuffer data, long timeUs) { } - @Override public void onClock(TextRenderer.Stream reading, @Nullable TextRenderer.Stream displaying, - long generation, long positionUs, long textOffsetUs, boolean started, boolean ended) { } - - @Override - public void onCues(@Nullable TextRenderer.Stream stream, long generation, CueGroup group) { - if (released) return; - DualSubtitleTrackSelector.Request current = source.request(); - if (current.override == null || stream == null || !current.accepts(stream.format)) return; - long mediaGeneration = stream == reading ? readingMediaGeneration - : stream == previous ? previousMediaGeneration : -1; - if (mediaGeneration != current.mediaGeneration) return; - publish(source, current, group); - } - - @Override - public void onDisabled(long generation) { - reading = previous = null; - readingMediaGeneration = previousMediaGeneration = -1; - if (!released) publish(source, source.request(), CueGroup.EMPTY_TIME_ZERO); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/subtitle/SecondarySubtitleCues.java b/app/src/main/java/com/fongmi/android/tv/player/exo/subtitle/SecondarySubtitleCues.java deleted file mode 100644 index 0147010dcf..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/subtitle/SecondarySubtitleCues.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.text.Layout; - -import androidx.media3.common.text.Cue; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** MPV's default secondary-sub-ass-override=strip policy, applied to already decoded Cues. */ -final class SecondarySubtitleCues { - static final float TOP_PADDING = 0.08f; - - private SecondarySubtitleCues() { } - - static List atTop(List input) { - if (input.isEmpty()) return Collections.emptyList(); - StringBuilder text = new StringBuilder(); - float firstBitmapTop = Float.POSITIVE_INFINITY; - for (Cue cue : input) { - if (cue.text != null && cue.text.length() > 0) { - if (text.length() > 0) text.append('\n'); - // Converting to String strips spans, including ASS font/size/colour overrides. - text.append(cue.text.toString()); - } else if (cue.bitmap != null) { - firstBitmapTop = Math.min(firstBitmapTop, bitmapTop(cue)); - } - } - List output = new ArrayList<>(); - if (text.length() > 0) { - output.add(new Cue.Builder().setText(text.toString()) - .setTextAlignment(Layout.Alignment.ALIGN_CENTER) - .setPosition(0.5f).setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) - .setLine(TOP_PADDING, Cue.LINE_TYPE_FRACTION).setLineAnchor(Cue.ANCHOR_TYPE_START) - .setSize(0.9f).build()); - } - for (Cue cue : input) { - if (cue.bitmap == null) continue; - // Move the whole bitmap composition, retaining its width, height and relative regions. - output.add(cue.buildUpon() - .setLine(TOP_PADDING + bitmapTop(cue) - firstBitmapTop, Cue.LINE_TYPE_FRACTION) - .setLineAnchor(Cue.ANCHOR_TYPE_START).build()); - } - return Collections.unmodifiableList(output); - } - - private static float bitmapTop(Cue cue) { - float line = cue.lineType == Cue.LINE_TYPE_FRACTION && cue.line != Cue.DIMEN_UNSET - && Float.isFinite(cue.line) ? cue.line : 0f; - float height = cue.bitmapHeight != Cue.DIMEN_UNSET && Float.isFinite(cue.bitmapHeight) - ? cue.bitmapHeight : 0f; - if (cue.lineAnchor == Cue.ANCHOR_TYPE_END) line -= height; - else if (cue.lineAnchor == Cue.ANCHOR_TYPE_MIDDLE) line -= height / 2f; - return line; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/iso/HttpRangeIsoSource.java b/app/src/main/java/com/fongmi/android/tv/player/iso/HttpRangeIsoSource.java index 9d1747ba8e..149edb80fc 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/iso/HttpRangeIsoSource.java +++ b/app/src/main/java/com/fongmi/android/tv/player/iso/HttpRangeIsoSource.java @@ -1,5 +1,7 @@ package com.fongmi.android.tv.player.iso; +import android.text.TextUtils; + import androidx.annotation.Nullable; import com.github.catvod.crawler.SpiderDebug; @@ -8,10 +10,7 @@ import com.github.catvod.net.OkHttp; import java.io.EOFException; import java.io.IOException; import java.util.LinkedHashMap; -import java.util.ArrayList; -import java.util.HashSet; import java.util.Map; -import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -30,56 +29,46 @@ public final class HttpRangeIsoSource implements RemoteIsoSource { private final Map headers; private final OkHttpClient client; private final String url; - private final Object callLock = new Object(); - private final Set activeCalls = new HashSet<>(); + private volatile Call activeCall; private volatile boolean closed; private volatile String validator; private volatile long sourceLength = -1; public HttpRangeIsoSource(String url, Map headers) { - this(url, headers, OkHttp.player().newBuilder() - .connectTimeout(20, TimeUnit.SECONDS) - .readTimeout(45, TimeUnit.SECONDS) - .build()); - } - - HttpRangeIsoSource(String url, Map headers, OkHttpClient client) { this.url = url; this.headers = sanitize(headers); - this.client = client; + this.client = OkHttp.player().newBuilder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(45, TimeUnit.SECONDS) + .build(); } @Override public synchronized long length() throws IOException { ensureOpen(); if (sourceLength >= 0) return sourceLength; - Call call = newCall(0, 1); - try (Response response = execute(call)) { + try (Response response = execute(0, 1)) { validateResponse(response, 0, 1); return sourceLength; - } finally { - release(call); } } @Override public int readAt(long offset, byte[] buffer, int bufferOffset, int length) throws IOException { ensureOpen(); - if (offset < 0 || bufferOffset < 0 || length < 0 || bufferOffset > buffer.length - length) throw new IndexOutOfBoundsException(); + if (offset < 0 || bufferOffset < 0 || length < 0 || bufferOffset + length > buffer.length) throw new IndexOutOfBoundsException(); long total = length(); if (offset >= total || length == 0) return 0; int requested = (int) Math.min(length, total - offset); IOException failure = null; for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { ensureOpen(); - Call call = newCall(offset, requested); - try (Response response = execute(call)) { + try (Response response = execute(offset, requested)) { validateResponse(response, offset, requested); ResponseBody body = response.body(); if (body == null) throw new EOFException("Empty ISO range body"); int read = 0; while (read < requested) { - ensureOpen(); int count = body.byteStream().read(buffer, bufferOffset + read, requested - read); if (count < 0) break; read += count; @@ -91,63 +80,12 @@ public final class HttpRangeIsoSource implements RemoteIsoSource { } catch (IOException e) { failure = e; if (attempt == MAX_RETRIES || closed) break; - } finally { - release(call); } } - ensureOpen(); throw new IsoSourceException(IsoSourceException.Reason.NETWORK, "ISO range request failed", failure); } - @Override - public int readAt(long offset, byte[] buffer, int bufferOffset, int length, - ReadRequest request) throws IOException { - ensureOpen(); - request.checkCancelled(); - if (offset < 0 || bufferOffset < 0 || length < 0 || bufferOffset > buffer.length - length) throw new IndexOutOfBoundsException(); - long total = length(); - if (offset >= total || length == 0) return 0; - int requested = (int) Math.min(length, total - offset); - int read = 0; - IOException failure = null; - for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { - ensureOpen(); - request.checkCancelled(); - // A consumer may already be using this prefix. A retry must resume, - // not write the same array region again, even with identical bytes. - Call call = newCall(offset + read, requested - read); - request.attach(call::cancel); - try (Response response = execute(call)) { - int responseLength = validateResponse(response, offset + read, requested - read); - ResponseBody body = response.body(); - if (body == null) throw new EOFException("Empty ISO range body"); - int end = read + responseLength; - while (read < end) { - ensureOpen(); - request.checkCancelled(); - int count = body.byteStream().read(buffer, bufferOffset + read, Math.min(64 * 1024, end - read)); - if (count < 0) throw new EOFException("Incomplete ISO range body"); - if (count == 0) continue; - read += count; - request.publish(read); - } - return read; - } catch (IsoSourceException e) { - throw e; - } catch (IOException e) { - request.checkCancelled(); - failure = e; - if (attempt == MAX_RETRIES || closed) break; - } finally { - request.detach(); - release(call); - } - } - ensureOpen(); - throw new IsoSourceException(IsoSourceException.Reason.NETWORK, "ISO range request failed", failure); - } - - private Call newCall(long offset, int length) { + private Response execute(long offset, int length) throws IOException { Request.Builder builder = new Request.Builder().url(url) .header("Range", "bytes=" + offset + "-" + (offset + length - 1)) .header("Accept-Encoding", "identity"); @@ -155,24 +93,16 @@ public final class HttpRangeIsoSource implements RemoteIsoSource { if ("Range".equalsIgnoreCase(entry.getKey()) || "Accept-Encoding".equalsIgnoreCase(entry.getKey())) continue; builder.header(entry.getKey(), entry.getValue()); } - return client.newCall(builder.build()); - } - - private Response execute(Call call) throws IOException { - synchronized (callLock) { - ensureOpen(); - activeCalls.add(call); - } - return call.execute(); - } - - private void release(Call call) { - synchronized (callLock) { - activeCalls.remove(call); + Call call = client.newCall(builder.build()); + activeCall = call; + try { + return call.execute(); + } finally { + if (activeCall == call) activeCall = null; } } - private synchronized int validateResponse(Response response, long offset, int requested) throws IOException { + private synchronized void validateResponse(Response response, long offset, int requested) throws IOException { int code = response.code(); if (code == 401 || code == 403) throw new IsoSourceException(IsoSourceException.Reason.UNAUTHORIZED, code, "ISO link unauthorized or expired"); if (code == 404) throw new IsoSourceException(IsoSourceException.Reason.NOT_FOUND, code, "ISO file not found"); @@ -185,18 +115,17 @@ public final class HttpRangeIsoSource implements RemoteIsoSource { long start = Long.parseLong(matcher.group(1)); long end = Long.parseLong(matcher.group(2)); long total = Long.parseLong(matcher.group(3)); - if (start != offset || end < start || end >= total || end - start + 1 > requested || total <= 0) { + if (start != offset || end < start || end - start + 1 > requested || total <= 0) { throw new IsoSourceException(IsoSourceException.Reason.RANGE_INVALID, code, "Invalid ISO Content-Range"); } if (sourceLength >= 0 && sourceLength != total) throw new IsoSourceException(IsoSourceException.Reason.SOURCE_CHANGED, "ISO length changed"); sourceLength = total; String nextValidator = first(response.header("ETag"), response.header("Last-Modified")); - if (!isEmpty(validator) && !isEmpty(nextValidator) && !validator.equals(nextValidator)) { + if (!TextUtils.isEmpty(validator) && !TextUtils.isEmpty(nextValidator) && !validator.equals(nextValidator)) { throw new IsoSourceException(IsoSourceException.Reason.SOURCE_CHANGED, "ISO validator changed"); } - if (isEmpty(validator)) validator = nextValidator; - if (SpiderDebug.isEnabled()) SpiderDebug.log("iso-source", "range code=%d offset=%d length=%d total=%d validator=%s", code, offset, end - start + 1, total, isEmpty(validator) ? "none" : "present"); - return (int) (end - start + 1); + if (TextUtils.isEmpty(validator)) validator = nextValidator; + SpiderDebug.log("iso-source", "range code=%d offset=%d length=%d total=%d validator=%s", code, offset, end - start + 1, total, TextUtils.isEmpty(validator) ? "none" : "present"); } private void ensureOpen() throws IsoSourceException { @@ -210,30 +139,22 @@ public final class HttpRangeIsoSource implements RemoteIsoSource { @Override public void close() { - ArrayList calls; - synchronized (callLock) { - closed = true; - calls = new ArrayList<>(activeCalls); - activeCalls.clear(); - } - for (Call call : calls) call.cancel(); + closed = true; + Call call = activeCall; + if (call != null) call.cancel(); } private static Map sanitize(@Nullable Map input) { Map result = new LinkedHashMap<>(); if (input == null) return result; for (Map.Entry entry : input.entrySet()) { - if (isEmpty(entry.getKey()) || entry.getValue() == null) continue; + if (TextUtils.isEmpty(entry.getKey()) || entry.getValue() == null) continue; result.put(entry.getKey(), entry.getValue()); } return result; } private static String first(String first, String second) { - return !isEmpty(first) ? first : second == null ? "" : second; - } - - private static boolean isEmpty(String value) { - return value == null || value.isEmpty(); + return !TextUtils.isEmpty(first) ? first : second == null ? "" : second; } } diff --git a/app/src/main/java/com/fongmi/android/tv/player/iso/IsoDiskPageStore.java b/app/src/main/java/com/fongmi/android/tv/player/iso/IsoDiskPageStore.java deleted file mode 100644 index c2460ed6ba..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/iso/IsoDiskPageStore.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.fongmi.android.tv.player.iso; - -import androidx.media3.mpvplayer.MpvHlsCacheCoordinator; - -import com.github.catvod.crawler.SpiderDebug; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.UUID; - -/** Session-private raw ISO pages, sharing the existing MPV disk-cache budget. */ -final class IsoDiskPageStore implements AutoCloseable { - - private final String prefix = "iso-" + UUID.randomUUID() + "-"; - private final File directory; - private final long capacity; - private final MpvHlsCacheCoordinator coordinator; - private final MpvHlsCacheCoordinator.ClientLease lease; - private boolean closed; - - IsoDiskPageStore(File directory, long capacity) { - this(directory, capacity, MpvHlsCacheCoordinator.shared(directory)); - } - - IsoDiskPageStore(File directory, long capacity, MpvHlsCacheCoordinator coordinator) { - this.directory = directory; - this.capacity = capacity; - this.coordinator = coordinator; - this.lease = coordinator.registerClient(capacity, null); - } - - synchronized byte[] read(long index, int expected) { - if (closed) return null; - File file = file(index); - try { - if (file.length() != expected) { - discard(file); - return null; - } - try (MpvHlsCacheCoordinator.ReadLease input = coordinator.openRead(file)) { - if (input == null) return null; - byte[] data = new byte[expected]; - int read = 0; - while (read < expected) { - int count = input.read(data, read, expected - read); - if (count <= 0) throw new IOException("Incomplete cached ISO page"); - read += count; - } - return data; - } - } catch (IOException | RuntimeException error) { - discard(file); - logFailure("read", error); - return null; - } - } - - synchronized void write(long index, byte[] data, boolean speculative) { - if (closed || data.length == 0) return; - File file = file(index); - MpvHlsCacheCoordinator.WriteReservation reservation = null; - try { - MpvHlsCacheCoordinator.ReservationDecision decision = coordinator.tryReserve( - file.getName(), file, data.length, capacity, - speculative ? MpvHlsCacheCoordinator.WriterType.PREFETCH : MpvHlsCacheCoordinator.WriterType.FOREGROUND); - reservation = decision.reservation(); - if (reservation == null) return; - try (FileOutputStream output = new FileOutputStream(reservation.tempFile())) { - for (int offset = 0; offset < data.length; ) { - int count = Math.min(64 * 1024, data.length - offset); - if (!reservation.canWrite(count)) return; - output.write(data, offset, count); - if (!reservation.recordWritten(count)) return; - offset += count; - } - } - reservation.commit("application/octet-stream"); - } catch (IOException | RuntimeException error) { - if (reservation != null) reservation.fail(error); - logFailure("write", error); - } finally { - if (reservation != null) reservation.close(); - } - } - - private File file(long index) { - return new File(directory, prefix + index + ".bin"); - } - - private static void discard(File file) { - try { - // Only a session-owned cache object and its MIME sidecar, never source data. - //noinspection ResultOfMethodCallIgnored - file.delete(); - //noinspection ResultOfMethodCallIgnored - new File(file.getParentFile(), file.getName() + ".meta").delete(); - } catch (SecurityException ignored) { - } - } - - private static void logFailure(String operation, Exception error) { - if (SpiderDebug.isEnabled()) SpiderDebug.log("iso-cache", "disk-%s fallback=%s", operation, error.getClass().getSimpleName()); - } - - @Override - public synchronized void close() { - if (closed) return; - closed = true; - lease.close(); - try { - File[] files = directory.listFiles(file -> file.isFile() && file.getName().startsWith(prefix)); - if (files != null) for (File file : files) discard(file); - } catch (SecurityException ignored) { - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/iso/IsoPageCache.java b/app/src/main/java/com/fongmi/android/tv/player/iso/IsoPageCache.java index f68249fbcc..b87003608a 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/iso/IsoPageCache.java +++ b/app/src/main/java/com/fongmi/android/tv/player/iso/IsoPageCache.java @@ -2,16 +2,13 @@ package com.fongmi.android.tv.player.iso; import com.github.catvod.crawler.SpiderDebug; -import java.io.EOFException; import java.io.IOException; -import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.RejectedExecutionException; public final class IsoPageCache implements RemoteIsoSource { @@ -22,47 +19,21 @@ public final class IsoPageCache implements RemoteIsoSource { private final LinkedHashMap pages; private final ExecutorService prefetch; private final RemoteIsoSource source; - private final IsoDiskPageStore disk; private final int maxPages; private final int pageSize; - private final int prefetchParallelism; - private final int prefetchDistance; private volatile boolean closed; - private volatile IOException sourceFailure; - private long lastReadEnd = -1; - private long nextPrefetch = -1; - private long lastPrefetch = -1; - private int prefetchWorkers; private long hits; private long misses; - private long diskHits; - private long networkPages; - private long prefetchedPages; public IsoPageCache(RemoteIsoSource source) { this(source, DEFAULT_PAGE_SIZE, DEFAULT_MAX_PAGES); } - IsoPageCache(RemoteIsoSource source, IsoDiskPageStore disk) { - this(source, DEFAULT_PAGE_SIZE, DEFAULT_MAX_PAGES, disk); - } - public IsoPageCache(RemoteIsoSource source, int pageSize, int maxPages) { - this(source, pageSize, maxPages, null); - } - - IsoPageCache(RemoteIsoSource source, int pageSize, int maxPages, IsoDiskPageStore disk) { - if (pageSize <= 0 || maxPages <= 0) throw new IllegalArgumentException("Invalid ISO cache size"); this.source = source; - this.disk = disk; this.pageSize = pageSize; this.maxPages = maxPages; - // Interactive ISO playback cannot use demuxer read-ahead. Keep a small - // raw-byte horizon instead, without advancing libbluray's VM. - this.prefetchParallelism = disk == null ? 1 : 2; - this.prefetchDistance = disk == null ? 1 : Math.min(4, Math.max(1, maxPages - 1)); - this.prefetch = Executors.newFixedThreadPool(prefetchParallelism, - r -> new Thread(r, "iso-prefetch")); + this.prefetch = Executors.newSingleThreadExecutor(r -> new Thread(r, "iso-prefetch")); this.pages = new LinkedHashMap<>(maxPages, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry eldest) { @@ -73,55 +44,35 @@ public final class IsoPageCache implements RemoteIsoSource { @Override public long length() throws IOException { - ensureOpen(); return source.length(); } @Override public int readAt(long offset, byte[] buffer, int bufferOffset, int length) throws IOException { - ensureOpen(); - if (offset < 0 || bufferOffset < 0 || length < 0 || bufferOffset > buffer.length - length) { - throw new IndexOutOfBoundsException(); - } + if (closed) throw new IsoSourceException(IsoSourceException.Reason.CLOSED, "ISO cache closed"); long total = length(); if (offset >= total || length == 0) return 0; - boolean sequential; - synchronized (this) { - sequential = offset == lastReadEnd; - // A seek replaces the queued hint. An already running raw-page read may finish, - // but never queues more work or advances the disc VM. - if (!sequential) nextPrefetch = lastPrefetch = -1; - } int remaining = (int) Math.min(length, total - offset); int written = 0; while (remaining > 0) { long pageIndex = offset / pageSize; int pageOffset = (int) (offset % pageSize); - byte[] page = page(pageIndex, false); - ensureOpen(); + byte[] page = page(pageIndex); if (pageOffset >= page.length) break; int count = Math.min(remaining, page.length - pageOffset); System.arraycopy(page, pageOffset, buffer, bufferOffset + written, count); offset += count; written += count; remaining -= count; - if ((sequential && pageOffset + count >= Math.min(64 * 1024, Math.max(1, pageSize / 4))) - || pageOffset + count == page.length) { - prefetch(pageIndex + 1, total); - } - sequential = true; - } - synchronized (this) { - lastReadEnd = offset; + if (pageOffset + count == page.length) prefetch(pageIndex + 1); } return written; } - private byte[] page(long index, boolean speculative) throws IOException { + private byte[] page(long index) throws IOException { CompletableFuture future; boolean owner = false; synchronized (this) { - ensureOpen(); byte[] cached = pages.get(index); if (cached != null) { hits++; @@ -129,7 +80,6 @@ public final class IsoPageCache implements RemoteIsoSource { } misses++; future = pending.get(index); - if (speculative && future != null) return null; if (future == null) { future = new CompletableFuture<>(); pending.put(index, future); @@ -138,22 +88,17 @@ public final class IsoPageCache implements RemoteIsoSource { } if (owner) { try { - byte[] loaded = load(index, speculative); + byte[] loaded = load(index); synchronized (this) { - ensureOpen(); pages.put(index, loaded); - pending.remove(index, future); + pending.remove(index); } future.complete(loaded); logStats(); return loaded; } catch (Throwable e) { synchronized (this) { - pending.remove(index, future); - } - if (e instanceof IsoSourceException iso && iso.reason() == IsoSourceException.Reason.SOURCE_CHANGED) { - sourceFailure = iso; - close(); + pending.remove(index); } future.completeExceptionally(e); if (e instanceof IOException io) throw io; @@ -161,9 +106,7 @@ public final class IsoPageCache implements RemoteIsoSource { } } try { - byte[] loaded = future.get(); - ensureOpen(); - return loaded; + return future.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("ISO page wait interrupted", e); @@ -174,86 +117,39 @@ public final class IsoPageCache implements RemoteIsoSource { } } - private byte[] load(long index, boolean speculative) throws IOException { + private byte[] load(long index) throws IOException { long offset = index * pageSize; long total = source.length(); if (offset >= total) return new byte[0]; int expected = (int) Math.min(pageSize, total - offset); - if (disk != null) { - byte[] cached = disk.read(index, expected); - if (cached != null) { - synchronized (this) { - diskHits++; - } - return cached; - } - } byte[] data = new byte[expected]; int read = 0; while (read < expected) { - ensureOpen(); int count = source.readAt(offset + read, data, read, expected - read); - if (count <= 0) throw new EOFException("Incomplete ISO page"); + if (count <= 0) break; read += count; } - ensureOpen(); - if (disk != null) disk.write(index, data, speculative); - synchronized (this) { - networkPages++; - if (speculative) prefetchedPages++; - } - return data; + if (read == expected) return data; + byte[] partial = new byte[read]; + System.arraycopy(data, 0, partial, 0, read); + return partial; } - private synchronized void prefetch(long index, long total) { - if (closed || index > (total - 1) / pageSize) return; - nextPrefetch = index; - lastPrefetch = Math.min((total - 1) / pageSize, index + prefetchDistance - 1); - while (nextPrefetch <= lastPrefetch - && (pages.containsKey(nextPrefetch) || pending.containsKey(nextPrefetch))) nextPrefetch++; - if (nextPrefetch > lastPrefetch) return; - while (prefetchWorkers < prefetchParallelism) { - prefetchWorkers++; - try { - prefetch.execute(this::runPrefetch); - } catch (RejectedExecutionException ignored) { - prefetchWorkers--; - break; - } - } - } - - private void runPrefetch() { - while (true) { - long index; + private void prefetch(long index) { + prefetch.execute(() -> { + if (closed) return; synchronized (IsoPageCache.this) { - while (nextPrefetch >= 0 && nextPrefetch <= lastPrefetch - && (pages.containsKey(nextPrefetch) || pending.containsKey(nextPrefetch))) { - nextPrefetch++; - } - if (closed || nextPrefetch < 0 || nextPrefetch > lastPrefetch) { - prefetchWorkers--; - return; - } - index = nextPrefetch++; + if (pages.containsKey(index) || pending.containsKey(index)) return; } try { - page(index, true); + page(index); } catch (IOException ignored) { - // Failed speculation is not cached; a later demand can retry the page. } - } + }); } private synchronized void logStats() { - if (SpiderDebug.isEnabled()) SpiderDebug.log("iso-cache", - "pages=%d pending=%d hits=%d misses=%d diskHits=%d networkPages=%d prefetched=%d disk=%s", - pages.size(), pending.size(), hits, misses, diskHits, networkPages, prefetchedPages, disk != null); - } - - private void ensureOpen() throws IOException { - if (sourceFailure != null) throw sourceFailure; - if (closed) throw new IsoSourceException(IsoSourceException.Reason.CLOSED, "ISO cache closed"); + SpiderDebug.log("iso-cache", "pages=%d pending=%d hits=%d misses=%d", pages.size(), pending.size(), hits, misses); } @Override @@ -263,20 +159,12 @@ public final class IsoPageCache implements RemoteIsoSource { @Override public void close() { - ArrayList> waiters; + closed = true; + prefetch.shutdownNow(); synchronized (this) { - if (closed) return; - closed = true; - nextPrefetch = lastPrefetch = -1; - waiters = new ArrayList<>(pending.values()); pages.clear(); pending.clear(); } - IOException error = sourceFailure != null ? sourceFailure - : new IsoSourceException(IsoSourceException.Reason.CLOSED, "ISO cache closed"); - for (CompletableFuture waiter : waiters) waiter.completeExceptionally(error); - prefetch.shutdownNow(); source.close(); - if (disk != null) disk.close(); } } diff --git a/app/src/main/java/com/fongmi/android/tv/player/iso/IsoPlaybackSession.java b/app/src/main/java/com/fongmi/android/tv/player/iso/IsoPlaybackSession.java index fee0415b94..68b163d4ed 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/iso/IsoPlaybackSession.java +++ b/app/src/main/java/com/fongmi/android/tv/player/iso/IsoPlaybackSession.java @@ -1,8 +1,6 @@ package com.fongmi.android.tv.player.iso; import com.github.catvod.crawler.SpiderDebug; -import com.github.catvod.utils.Path; -import com.fongmi.android.tv.setting.PlayerSetting; import java.io.IOException; import java.nio.ByteBuffer; @@ -18,29 +16,15 @@ final class IsoPlaybackSession { private final AtomicBoolean closed = new AtomicBoolean(); private final CopyOnWriteArrayList metadataListeners = new CopyOnWriteArrayList<>(); - private final RemoteIsoSource source; + private final IsoPageCache source; private final long id; private volatile IsoTrackMetadataResolver.Snapshot trackMetadata = IsoTrackMetadataResolver.Snapshot.EMPTY; private volatile boolean trackMetadataReady; private boolean metadataPreparing; - private int demandReaders; - private long demandStartedNs; IsoPlaybackSession(long id, String url, Map headers) { this.id = id; - HttpRangeIsoSource remote = new HttpRangeIsoSource(url, headers); - IsoDiskPageStore disk = createDiskStore(); - this.source = disk == null ? new IsoPageCache(remote) : new ProgressiveIsoPageCache(remote, disk); - } - - private static IsoDiskPageStore createDiskStore() { - if (!PlayerSetting.isBlurayMenu()) return null; - try { - return new IsoDiskPageStore(Path.cache("mpv_hls"), PlayerSetting.getPlayCacheSize(PlayerSetting.MPV)); - } catch (RuntimeException error) { - if (SpiderDebug.isEnabled()) SpiderDebug.log("iso-cache", "disk-init fallback=%s", error.getClass().getSimpleName()); - return null; - } + this.source = new IsoPageCache(new HttpRangeIsoSource(url, headers)); } long id() { @@ -56,22 +40,9 @@ final class IsoPlaybackSession { ensureOpen(); int wanted = Math.min(length, target.remaining()); byte[] data = new byte[wanted]; - synchronized (this) { - if (demandReaders++ == 0) demandStartedNs = System.nanoTime(); - } - try { - int read = source.readAt(offset, data, 0, wanted); - if (read > 0) target.put(data, 0, read); - return read; - } finally { - synchronized (this) { - if (--demandReaders == 0) demandStartedNs = 0; - } - } - } - - synchronized long demandWaitMs() { - return demandReaders == 0 ? 0 : Math.max(0, (System.nanoTime() - demandStartedNs) / 1_000_000); + int read = source.readAt(offset, data, 0, wanted); + if (read > 0) target.put(data, 0, read); + return read; } boolean hasDiscImageSignature() throws IOException { diff --git a/app/src/main/java/com/fongmi/android/tv/player/iso/IsoSessionManager.java b/app/src/main/java/com/fongmi/android/tv/player/iso/IsoSessionManager.java index 2507c51e1d..4aedaf40fb 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/iso/IsoSessionManager.java +++ b/app/src/main/java/com/fongmi/android/tv/player/iso/IsoSessionManager.java @@ -87,12 +87,6 @@ public final class IsoSessionManager { } } - /** Only a playback-demand read, never speculative prefetch or signature probing. */ - public static long demandWaitMs(String uri) { - IsoPlaybackSession session = SESSIONS.get(parseId(uri)); - return session == null ? 0 : session.demandWaitMs(); - } - public static int readAt(long id, long offset, ByteBuffer target, int length) { IsoPlaybackSession session = SESSIONS.get(id); if (session == null || target == null) return -1; diff --git a/app/src/main/java/com/fongmi/android/tv/player/iso/ProgressiveIsoPageCache.java b/app/src/main/java/com/fongmi/android/tv/player/iso/ProgressiveIsoPageCache.java deleted file mode 100644 index 009995d9dd..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/iso/ProgressiveIsoPageCache.java +++ /dev/null @@ -1,377 +0,0 @@ -package com.fongmi.android.tv.player.iso; - -import com.github.catvod.crawler.SpiderDebug; - -import java.io.EOFException; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.TreeMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -/** Raw-byte read-ahead for interactive discs. Never reads or advances the disc VM. */ -final class ProgressiveIsoPageCache implements RemoteIsoSource { - - private static final int MAX_READERS = 3; // Two read-ahead requests plus foreground capacity. - private final RemoteIsoSource source; - private final IsoDiskPageStore disk; - private final int pageSize; - private final int maxPages; - private final int readAlignment; - private final int prefetchDistance; - private final LinkedHashMap pages = new LinkedHashMap<>(8, 0.75f, true); - private final LinkedHashMap loads = new LinkedHashMap<>(); - private final ExecutorService workers = Executors.newFixedThreadPool(MAX_READERS, - runnable -> new Thread(runnable, "iso-range")); - private boolean closed; - private IOException sourceFailure; - private int running; - private long lastReadEnd = -1; - private long hits; - private long misses; - private long partialHits; - private long diskHits; - private long networkPages; - private long cancelledReads; - - ProgressiveIsoPageCache(RemoteIsoSource source, IsoDiskPageStore disk) { - this(source, IsoPageCache.DEFAULT_PAGE_SIZE, IsoPageCache.DEFAULT_MAX_PAGES, disk); - } - - ProgressiveIsoPageCache(RemoteIsoSource source, int pageSize, int maxPages, IsoDiskPageStore disk) { - if (pageSize <= 0 || maxPages <= 0) throw new IllegalArgumentException("Invalid ISO cache size"); - this.source = source; - this.disk = disk; - this.pageSize = pageSize; - this.maxPages = maxPages; - readAlignment = Math.min(64 * 1024, Math.max(1, pageSize / 16)); - prefetchDistance = Math.min(4, Math.max(0, maxPages - 1)); - } - - @Override - public long length() throws IOException { - synchronized (this) { - ensureOpen(); - } - return source.length(); - } - - @Override - public int readAt(long offset, byte[] buffer, int bufferOffset, int length) throws IOException { - if (offset < 0 || bufferOffset < 0 || length < 0 || bufferOffset > buffer.length - length) throw new IndexOutOfBoundsException(); - long total = length(); - if (offset >= total || length == 0) return 0; - int wanted = (int) Math.min(length, total - offset); - int written = 0; - boolean sequential; - synchronized (this) { - ensureOpen(); - sequential = offset == lastReadEnd; - if (!sequential) cancelOutside(offset / pageSize, offset / pageSize); - } - while (written < wanted) { - long index = offset / pageSize; - int position = (int) (offset % pageSize); - int count = Math.min(wanted - written, (int) Math.min(pageSize - position, total - offset)); - long started = System.nanoTime(); - synchronized (this) { - ensureOpen(); - Page page = page(index, total); - page.waiters++; - page.failure = null; // A new demand may retry only the still-missing bytes. - try { - if (page.available(position) >= position + count) { - hits++; - if (!page.complete()) partialHits++; - } else { - misses++; - } - while (page.available(position) < position + count) { - ensureOpen(); - if (page.failure != null) throw page.failure; - schedule(page, position, false); - try { - wait(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("ISO range wait interrupted", e); - } - } - ensureOpen(); - System.arraycopy(page.data, position, buffer, bufferOffset + written, count); - long waitMs = (System.nanoTime() - started) / 1_000_000; - if (waitMs >= 100 && SpiderDebug.isEnabled()) { - SpiderDebug.log("iso-cache", "demand page=%d offset=%d length=%d waitMs=%d partial=%s progressive=true", - index, position, count, waitMs, !page.complete()); - } - } finally { - page.waiters--; - trim(); - } - if ((sequential && position + count >= readAlignment) || position + count == page.size) { - prefetch(index, total); - } - offset += count; - written += count; - lastReadEnd = offset; - } - sequential = true; - } - return written; - } - - private Page page(long index, long total) { - Page page = pages.get(index); - if (page == null) { - Load existing = loads.get(index); - page = existing == null ? new Page(index, (int) Math.min(pageSize, total - index * pageSize)) : existing.page; - pages.put(index, page); - } - return page; - } - - private void schedule(Page page, int preferred, boolean speculative) { - if (page.complete() || closed) return; - Load existing = loads.get(page.index); - if (existing != null) { - if (!speculative) { - existing.speculative = false; - if (!existing.started) existing.preferred = preferred; - } - } else { - loads.put(page.index, new Load(page, preferred, speculative)); - } - dispatch(); - } - - /** The executor only contains running jobs; the bounded page/horizon map owns queued work. */ - private void dispatch() { - while (!closed && running < MAX_READERS) { - Load next = null; - for (Load load : loads.values()) { - if (load.started || load.cancelled) continue; - if (next == null || (next.speculative && !load.speculative)) next = load; - if (!load.speculative) break; - } - if (next == null) return; - next.started = true; - running++; - workers.execute(next); - } - } - - private void prefetch(long current, long total) { - long last = Math.min((total - 1) / pageSize, current + prefetchDistance); - cancelOutside(current, last); - for (long index = current + 1; index <= last; index++) { - Page page = page(index, total); - if (page.failure == null) schedule(page, 0, true); - } - trim(); - } - - private void cancelOutside(long first, long last) { - Iterator iterator = loads.values().iterator(); - while (iterator.hasNext()) { - Load load = iterator.next(); - if (load.page.waiters != 0 || load.page.complete() - || (load.page.index >= first && load.page.index <= last)) continue; - cancel(load); - if (!load.started) iterator.remove(); - } - } - - private void cancel(Load load) { - if (load.cancelled) return; - load.cancelled = true; - cancelledReads++; - if (load.request != null) load.request.cancel(); - } - - private void trim() { - Iterator iterator = pages.values().iterator(); - while (pages.size() > maxPages && iterator.hasNext()) { - Page evicted = iterator.next(); - iterator.remove(); - Load load = loads.get(evicted.index); - if (load != null && evicted.waiters == 0 && !evicted.complete()) { - cancel(load); - if (!load.started) loads.remove(evicted.index); - } - } - } - - private final class Load implements Runnable { - final Page page; - int preferred; - boolean speculative; - boolean started; - boolean cancelled; - ReadRequest request; - - Load(Page page, int preferred, boolean speculative) { - this.page = page; - this.preferred = preferred; - this.speculative = speculative; - } - - @Override - public void run() { - IOException failure = null; - try { - load(); - } catch (IOException e) { - failure = e; - if (e instanceof IsoSourceException iso && iso.reason() == IsoSourceException.Reason.SOURCE_CHANGED) { - synchronized (ProgressiveIsoPageCache.this) { - sourceFailure = iso; - } - close(); - } - } catch (RuntimeException e) { - failure = new IOException("ISO page load failed", e); - } finally { - synchronized (ProgressiveIsoPageCache.this) { - if (!cancelled && failure != null) page.failure = failure; - loads.remove(page.index, this); - running--; - ProgressiveIsoPageCache.this.notifyAll(); - dispatch(); - } - } - } - - private void load() throws IOException { - boolean checkDisk; - synchronized (ProgressiveIsoPageCache.this) { - ensureActive(); - checkDisk = !page.diskChecked; - page.diskChecked = true; - } - if (checkDisk && disk != null) { - byte[] cached = disk.read(page.index, page.size); - if (cached != null) { - synchronized (ProgressiveIsoPageCache.this) { - ensureActive(); - page.data = cached; - page.publish(0, cached.length); - diskHits++; - ProgressiveIsoPageCache.this.notifyAll(); - logStats(); - } - return; - } - } - while (true) { - final int begin; - final int end; - synchronized (ProgressiveIsoPageCache.this) { - ensureActive(); - if (page.complete()) break; - if (page.data == null) page.data = new byte[page.size]; - int aligned = preferred / readAlignment * readAlignment; - int available = page.available(aligned); - begin = available == page.size ? page.available(0) : available; - Integer next = page.ready.ceilingKey(begin); - end = next == null ? page.size : next; - request = new ReadRequest(count -> { - synchronized (ProgressiveIsoPageCache.this) { - ensureActive(); - if (count <= 0 || count > end - begin) throw new IOException("Invalid ISO read progress"); - page.publish(begin, begin + count); - ProgressiveIsoPageCache.this.notifyAll(); - } - }); - } - int count = source.readAt(page.index * pageSize + begin, page.data, begin, end - begin, request); - if (count <= 0) throw new EOFException("Incomplete ISO page"); - } - // Readers can already consume the completed bytes while the existing - // coordinator reserves, writes and atomically commits the disk page. - if (disk != null) disk.write(page.index, page.data, speculative); - synchronized (ProgressiveIsoPageCache.this) { - ensureOpen(); - networkPages++; - logStats(); - } - } - - private void ensureActive() throws IOException { - ensureOpen(); - if (cancelled) throw new IOException("ISO page superseded"); - } - } - - private static final class Page { - final long index; - final int size; - final TreeMap ready = new TreeMap<>(); - byte[] data; - int waiters; - boolean diskChecked; - IOException failure; - - Page(long index, int size) { - this.index = index; - this.size = size; - } - - int available(int position) { - Map.Entry entry = ready.floorEntry(position); - return entry == null ? position : Math.max(position, entry.getValue()); - } - - boolean complete() { - return available(0) == size; - } - - void publish(int start, int end) { - Map.Entry previous = ready.floorEntry(start); - if (previous != null && previous.getValue() >= start) { - start = previous.getKey(); - end = Math.max(end, previous.getValue()); - ready.remove(previous.getKey()); - } - Map.Entry next; - while ((next = ready.ceilingEntry(start)) != null && next.getKey() <= end) { - end = Math.max(end, next.getValue()); - ready.remove(next.getKey()); - } - ready.put(start, end); - } - } - - private void ensureOpen() throws IOException { - if (sourceFailure != null) throw sourceFailure; - if (closed) throw new IsoSourceException(IsoSourceException.Reason.CLOSED, "ISO cache closed"); - } - - private void logStats() { - if (SpiderDebug.isEnabled()) SpiderDebug.log("iso-cache", - "pages=%d pending=%d hits=%d misses=%d partialHits=%d diskHits=%d networkPages=%d cancelled=%d progressive=true", - pages.size(), loads.size(), hits, misses, partialHits, diskHits, networkPages, cancelledReads); - } - - @Override - public String validator() { - return source.validator(); - } - - @Override - public void close() { - synchronized (this) { - if (closed) return; - closed = true; - for (Load load : new ArrayList<>(loads.values())) cancel(load); - pages.clear(); - loads.clear(); - notifyAll(); - } - workers.shutdownNow(); - source.close(); - if (disk != null) disk.close(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/iso/RemoteIsoSource.java b/app/src/main/java/com/fongmi/android/tv/player/iso/RemoteIsoSource.java index e8818a7dc7..acfbe01428 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/iso/RemoteIsoSource.java +++ b/app/src/main/java/com/fongmi/android/tv/player/iso/RemoteIsoSource.java @@ -2,7 +2,6 @@ package com.fongmi.android.tv.player.iso; import java.io.Closeable; import java.io.IOException; -import java.io.InterruptedIOException; public interface RemoteIsoSource extends Closeable { @@ -10,68 +9,8 @@ public interface RemoteIsoSource extends Closeable { int readAt(long offset, byte[] buffer, int bufferOffset, int length) throws IOException; - /** Publish only bytes already written to the target; never overwrite a published prefix. */ - default int readAt(long offset, byte[] buffer, int bufferOffset, int length, - ReadRequest request) throws IOException { - request.checkCancelled(); - int count = readAt(offset, buffer, bufferOffset, length); - if (count > 0) request.publish(count); - return count; - } - String validator(); @Override void close(); - - final class ReadRequest { - public interface Progress { - void available(int count) throws IOException; - } - - private final Progress progress; - private volatile boolean cancelled; - private Runnable cancellation; - - public ReadRequest(Progress progress) { - this.progress = progress; - } - - public void publish(int count) throws IOException { - checkCancelled(); - progress.available(count); - } - - public void checkCancelled() throws InterruptedIOException { - if (cancelled) throw new InterruptedIOException("ISO range superseded"); - } - - public boolean isCancelled() { - return cancelled; - } - - public void attach(Runnable action) { - synchronized (this) { - if (!cancelled) { - cancellation = action; - return; - } - } - action.run(); - } - - public synchronized void detach() { - cancellation = null; - } - - public void cancel() { - Runnable action; - synchronized (this) { - cancelled = true; - action = cancellation; - cancellation = null; - } - if (action != null) action.run(); - } - } } diff --git a/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvAutoOutputPolicy.java b/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvAutoOutputPolicy.java index a1575099f9..5eace2e5f2 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvAutoOutputPolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvAutoOutputPolicy.java @@ -27,18 +27,6 @@ public final class MpvAutoOutputPolicy { DolbyVisionSupport dolbyVisionSupport, int dolbyVisionProfile, boolean dv7Hdr10FallbackEnabled) { - return evaluate(width, height, hardDecode, leanback, lutOrFilterActive, - customGpuProcessing, dolbyVisionSupport, dolbyVisionProfile, - dv7Hdr10FallbackEnabled, DolbyVisionSupport.UNKNOWN); - } - - public static Decision evaluate(int width, int height, boolean hardDecode, - boolean leanback, boolean lutOrFilterActive, - boolean customGpuProcessing, - DolbyVisionSupport dolbyVisionSupport, - int dolbyVisionProfile, - boolean dv7Hdr10FallbackEnabled, - DolbyVisionSupport hevcHdr10Support) { if (!leanback) return new Decision(false, "not-tv"); if (!hardDecode) return new Decision(false, "software-decode"); if (lutOrFilterActive) return new Decision(false, "lut-or-filter-active"); @@ -52,27 +40,12 @@ public final class MpvAutoOutputPolicy { && dolbyVisionSupport == DolbyVisionSupport.UNSUPPORTED) { return new Decision(true, "dv7-hdr10-base-layer"); } - if (dolbyVisionProfile == 8 - && dolbyVisionSupport == DolbyVisionSupport.UNSUPPORTED - && hevcHdr10Support == DolbyVisionSupport.SUPPORTED) { - return new Decision(true, "dv8-hdr10-base-layer"); - } return new Decision(false, dolbyVisionSupport == DolbyVisionSupport.UNKNOWN ? "dolby-vision-hw-unknown" : "dolby-vision-hw-unsupported"); } return new Decision(true, "tv-hardware-decode"); } - /** A same-item rebuild must not retry an output that already failed. */ - public static Decision afterSurfaceFailure(Decision candidate, boolean failedForItem) { - return failedForItem ? new Decision(false, "surface-direct-failed-for-item") : candidate; - } - - /** A manually selected FEL reconstruction needs both layers in gpu-next. */ - public static Decision forFelReconstruction(Decision candidate, boolean enabled) { - return enabled ? new Decision(false, "dv7-fel-reconstruction") : candidate; - } - /** Select the initial TV output before MPV has reported a video size. */ public static boolean canStartSurfaceDirect(boolean hardDecode, boolean leanback, boolean lutOrFilterActive, diff --git a/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvAutoRenderPolicy.java b/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvAutoRenderPolicy.java index 867dcff7a1..4d9018d025 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvAutoRenderPolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvAutoRenderPolicy.java @@ -13,29 +13,10 @@ public final class MpvAutoRenderPolicy { boolean deviceVulkan, boolean currentlyVulkan, boolean disabledForItem) { - return evaluate(renderAutomatic, hardDecode, dolbyVisionProfile, - dolbyVisionSupport, nativeVulkan, deviceVulkan, - currentlyVulkan, disabledForItem, false); - } - - public static Decision evaluate(boolean renderAutomatic, - boolean hardDecode, - int dolbyVisionProfile, - MpvAutoOutputPolicy.DolbyVisionSupport dolbyVisionSupport, - boolean nativeVulkan, - boolean deviceVulkan, - boolean currentlyVulkan, - boolean disabledForItem, - boolean felReconstruction) { if (disabledForItem) return keep("item-fallback"); if (currentlyVulkan) return keep("already-vulkan"); if (!renderAutomatic) return keep("render-overridden"); if (!hardDecode) return keep("software-decode"); - if (dolbyVisionProfile == 7 && felReconstruction) { - return nativeVulkan && deviceVulkan - ? new Decision(Action.ENABLE_VULKAN, "dv7-fel-reconstruction-vulkan") - : keep("vulkan-unavailable"); - } if (dolbyVisionProfile != 5) return keep("not-dv5"); if (dolbyVisionSupport != MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED) { return keep(dolbyVisionSupport == MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED diff --git a/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvConfigStore.java b/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvConfigStore.java index 6e9c9e02c7..791320e843 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvConfigStore.java +++ b/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvConfigStore.java @@ -21,10 +21,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; -import java.util.HashMap; import java.util.Iterator; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.Locale; @@ -45,17 +43,10 @@ public final class MpvConfigStore { private static final String TYPE_FILE = "file"; private static final String TYPE_URL = "url"; private static final String TYPE_TEXT = "text"; - private static final String TYPE_CUSTOM_BUTTON = "custom_button"; private static final String CONFIG_DIR = "mpv"; private static final String CONFIG_FILE = "mpv.conf"; private static final String PROFILE_DIR = "profiles"; - /** mpv ignores hidden entries in scripts/, so managed source files are not auto-loaded. */ - private static final String MANAGED_SCRIPTS_DIR = ".webhtv"; private static final int MAX_PROFILE_BYTES = 1024 * 1024; - public static final String CUSTOM_BUTTON_MESSAGE = "webhtv-custom-button"; - public static final String CUSTOM_BUTTON_STATE_PROPERTY = "user-data/webhtv-custom-buttons"; - private static final String CUSTOM_BUTTONS_FILE = "custombuttons.json"; - private static final String CUSTOM_BUTTON_SCRIPT = "webhtv-custom-buttons.lua"; private MpvConfigStore() { } @@ -83,61 +74,8 @@ public final class MpvConfigStore { return new File(configDir(), TARGET_SCRIPTS); } - private static File scriptLibraryDir() { - File dir = new File(scriptsDir(), MANAGED_SCRIPTS_DIR); - if (!dir.exists()) dir.mkdirs(); - return dir; - } - - /** - * Older builds stored managed scripts directly under scripts/, which mpv - * loads independently of the WebHTV button bridge. Move those files into - * the hidden library and keep any button metadata pointing at the new file. - */ - private static synchronized void migrateManagedScripts() { - File root = scriptsDir(); - File[] files = root.listFiles(file -> file.isFile() && isUserScriptName(file.getName())); - if (files == null || files.length == 0) { - scriptLibraryDir(); - return; - } - List buttons = null; - boolean metadataChanged = false; - for (File source : files) { - File target = new File(scriptLibraryDir(), source.getName()); - if (target.exists()) target = uniqueScriptFile(source.getName()); - if (!moveScript(source, target)) continue; - if (TextUtils.equals(source.getName(), target.getName())) continue; - if (buttons == null) buttons = customButtons(); - for (CustomButton button : buttons) { - if (!TextUtils.equals(button.script, source.getName())) continue; - button.script = target.getName(); - metadataChanged = true; - } - } - if (metadataChanged) { - try { - writeCustomButtons(buttons); - } catch (IOException ignored) { - } - } - } - - private static boolean moveScript(File source, File target) { - if (source.renameTo(target)) return true; - try { - writeTextChecked(target, readText(source)); - if (source.delete()) return true; - target.delete(); - } catch (IOException ignored) { - target.delete(); - } - return false; - } - public static boolean hasGpuVideoProcessing() { - migrateManagedScripts(); - File[] scripts = scriptLibraryDir().listFiles(file -> file.isFile() && isUserScriptName(file.getName())); + File[] scripts = scriptsDir().listFiles(file -> file.isFile() && isScriptName(file.getName())); if (scripts != null && scripts.length > 0) return true; try { return containsGpuVideoProcessing(readText(configFile())); @@ -198,7 +136,6 @@ public final class MpvConfigStore { } public static void ensureReady() { - migrateManagedScripts(); File file = configFile(); if (!file.isFile() || file.length() == 0) { writeText(defaultConfig()); @@ -441,8 +378,8 @@ public final class MpvConfigStore { public static synchronized boolean deleteProfile(String target, String id) throws IOException { if (TARGET_SCRIPTS.equals(target)) { File file = safeScriptFile(id); - if (file.exists() && !file.delete()) throw writeFailed(); - removeScriptButton(id); + if (!file.exists()) return true; + if (!file.delete()) throw writeFailed(); return true; } if (TextUtils.isEmpty(id) || "default".equals(id)) return false; @@ -462,11 +399,10 @@ public final class MpvConfigStore { String displayName = name == null ? "" : name.trim(); if (TextUtils.isEmpty(displayName)) throw new IOException(App.get().getString(R.string.mpv_config_name_required)); if (TARGET_SCRIPTS.equals(target)) { - migrateManagedScripts(); File source = safeScriptFile(id); if (!source.isFile()) throw missingProfile(); String fileName = safeScriptName(displayName); - File output = new File(scriptLibraryDir(), fileName); + File output = new File(scriptsDir(), fileName); if (source.equals(output)) return source.getName(); if (output.exists()) throw new IOException(App.get().getString(R.string.mpv_config_script_exists)); writeTextChecked(output, readText(source)); @@ -474,7 +410,6 @@ public final class MpvConfigStore { output.delete(); throw writeFailed(); } - renameScriptButton(id, output.getName(), displayName); return output.getName(); } if (TextUtils.isEmpty(id) || "default".equals(id)) throw missingProfile(); @@ -605,7 +540,7 @@ public final class MpvConfigStore { private static String saveScript(String id, String name, String content) throws IOException { String fileName = safeScriptName(name); File previous = TextUtils.isEmpty(id) ? null : safeScriptFile(id); - File output = previous == null || !safeScriptName(id).equals(fileName) ? new File(scriptLibraryDir(), fileName) : previous; + File output = previous == null || !safeScriptName(id).equals(fileName) ? new File(scriptsDir(), fileName) : previous; if (previous != null && !previous.equals(output) && output.exists()) throw new IOException(App.get().getString(R.string.mpv_config_script_exists)); writeTextChecked(output, content); if (previous != null && !previous.equals(output) && previous.exists() && !previous.delete()) throw writeFailed(); @@ -613,65 +548,22 @@ public final class MpvConfigStore { } private static List scriptProfiles() { - migrateManagedScripts(); - migrateLegacyButtons(); - Map enabledScripts = new HashMap<>(); - for (CustomButton button : customButtons()) enabledScripts.putIfAbsent(button.script, button.scriptEnabled); List result = new ArrayList<>(); - File[] files = scriptLibraryDir().listFiles(file -> file.isFile() && isUserScriptName(file.getName())); - if (files != null) { - for (File file : files) { - ConfigProfile profile = new ConfigProfile(); - profile.id = file.getName(); - profile.name = file.getName(); - profile.type = TYPE_TEXT; - profile.source = file.getAbsolutePath(); - profile.time = file.lastModified(); - profile.scriptEnabled = enabledScripts.getOrDefault(profile.id, true); - result.add(profile); - } + File[] files = scriptsDir().listFiles(file -> file.isFile() && isScriptName(file.getName())); + if (files == null) return result; + for (File file : files) { + ConfigProfile profile = new ConfigProfile(); + profile.id = file.getName(); + profile.name = file.getName(); + profile.type = TYPE_TEXT; + profile.source = file.getAbsolutePath(); + profile.time = file.lastModified(); + result.add(profile); } Collections.sort(result, Comparator.comparing(item -> item.name.toLowerCase())); return result; } - private static void migrateLegacyButtons() { - List buttons = customButtons(); - boolean changed = false; - for (CustomButton button : buttons) { - if (button == null || !TextUtils.isEmpty(button.script)) continue; - String fileName = uniqueScriptFile(button.title).getName(); - String content; - String trigger; - if (!TextUtils.isEmpty(button.content)) { - content = button.content; - trigger = "click"; - } else if (!TextUtils.isEmpty(button.longPressContent)) { - content = button.longPressContent; - trigger = "long"; - } else { - content = value(button.onStartup); - trigger = "startup"; - } - try { - writeTextChecked(new File(scriptLibraryDir(), fileName), content); - button.script = fileName; - button.trigger = trigger; - button.content = ""; - button.longPressContent = ""; - button.onStartup = ""; - changed = true; - } catch (IOException ignored) { - } - } - if (changed) { - try { - writeCustomButtons(buttons); - } catch (IOException ignored) { - } - } - } - private static File profileSnapshot(String target, String id) { File dir = new File(configDir(), PROFILE_DIR + File.separator + safeTarget(target)); if (!dir.exists()) dir.mkdirs(); @@ -680,27 +572,26 @@ public final class MpvConfigStore { private static File safeScriptFile(String id) throws IOException { if (TextUtils.isEmpty(id) || !TextUtils.equals(id, fileName(id)) || !isScriptName(id)) throw missingProfile(); - return new File(scriptLibraryDir(), id); + return new File(scriptsDir(), id); } private static File uniqueScriptFile(String name) { String safe = safeScriptName(name); - File file = new File(scriptLibraryDir(), safe); + File file = new File(scriptsDir(), safe); if (!file.exists()) return file; int dot = safe.lastIndexOf('.'); String base = dot > 0 ? safe.substring(0, dot) : safe; String extension = dot > 0 ? safe.substring(dot) : ".lua"; for (int i = 2; i < 1000; i++) { - file = new File(scriptLibraryDir(), base + " (" + i + ")" + extension); + file = new File(scriptsDir(), base + " (" + i + ")" + extension); if (!file.exists()) return file; } - return new File(scriptLibraryDir(), base + "-" + System.currentTimeMillis() + extension); + return new File(scriptsDir(), base + "-" + System.currentTimeMillis() + extension); } private static String safeScriptName(String name) { String file = fileName(name).replaceAll("[^\\p{L}\\p{N}._ -]", "_").trim(); if (TextUtils.isEmpty(file) || ".".equals(file) || "..".equals(file)) file = "script-" + System.currentTimeMillis(); - if (CUSTOM_BUTTON_SCRIPT.equalsIgnoreCase(file)) file = "script-" + System.currentTimeMillis() + ".lua"; if (!isScriptName(file)) file += ".lua"; return file; } @@ -709,10 +600,6 @@ public final class MpvConfigStore { return name != null && (name.toLowerCase().endsWith(".lua") || name.toLowerCase().endsWith(".js")); } - private static boolean isUserScriptName(String name) { - return isScriptName(name) && !CUSTOM_BUTTON_SCRIPT.equalsIgnoreCase(name); - } - private static String readSource(String type, String source) throws IOException { if (TYPE_URL.equals(type)) return readUrl(source); if (TYPE_FILE.equals(type)) return readLocal(source); @@ -817,7 +704,7 @@ public final class MpvConfigStore { } private static File scriptFile(String source, String name) { - return new File(scriptLibraryDir(), safeScriptName(TextUtils.isEmpty(name) ? fileName(source) : name)); + return new File(scriptsDir(), safeScriptName(TextUtils.isEmpty(name) ? fileName(source) : name)); } private static List readProfiles(String target) { @@ -1031,340 +918,16 @@ public final class MpvConfigStore { } private static String scriptsSummary() { - migrateManagedScripts(); - File[] files = scriptLibraryDir().listFiles(file -> file.isFile() && isUserScriptName(file.getName())); + File[] files = scriptsDir().listFiles(file -> file.isFile() && isScriptName(file.getName())); int count = files == null ? 0 : files.length; if (count <= 0) return ResUtil.getString(R.string.mpv_config_default); return ResUtil.getString(R.string.mpv_config_scripts_count, count); } - public static synchronized List customButtons() { - File file = customButtonsFile(); - if (!file.isFile()) return new ArrayList<>(); - try { - return parseCustomButtonsJson(readText(file)); - } catch (IOException ignored) { - return new ArrayList<>(); - } - } - - static List parseCustomButtonsJson(String json) { - List result = new ArrayList<>(); - try { - for (JsonElement element : parseArray(json)) { - if (element == null || !element.isJsonObject()) continue; - JsonObject object = element.getAsJsonObject(); - String id = stringValue(object, "id").trim(); - String title = stringValue(object, "title").trim(); - if (!isSafeCustomButtonId(id) || title.isEmpty()) continue; - CustomButton button = new CustomButton(); - button.id = id; - button.title = title; - button.script = stringValue(object, "script").trim(); - button.trigger = stringValue(object, "trigger").trim(); - button.content = stringValue(object, "content"); - button.longPressContent = stringValue(object, "longPressContent"); - button.onStartup = stringValue(object, "onStartup"); - button.enabled = !object.has("enabled") || object.get("enabled").isJsonNull() || object.get("enabled").getAsBoolean(); - button.scriptEnabled = !object.has("scriptEnabled") || object.get("scriptEnabled").isJsonNull() || object.get("scriptEnabled").getAsBoolean(); - result.add(button); - } - } catch (Throwable ignored) { - } - return result; - } - - /** Returns the button metadata attached to a script file, if configured. */ - public static synchronized CustomButton scriptButton(String script) { - if (TextUtils.isEmpty(script)) return null; - for (CustomButton button : customButtons()) { - if (TextUtils.equals(button.script, script)) return button; - } - return null; - } - - public static synchronized String saveScriptSettings(String id, String title, String content, - boolean enabled, String trigger) throws IOException { - CustomButton previous = scriptButton(id); - return saveScriptSettings(id, title, content, enabled, trigger, previous == null || previous.scriptEnabled); - } - - public static synchronized String saveScriptSettings(String id, String title, String content, - boolean enabled, String trigger, boolean scriptEnabled) throws IOException { - migrateManagedScripts(); - File source = safeScriptFile(id); - if (!source.isFile()) throw missingProfile(); - String displayName = TextUtils.isEmpty(title) ? fileName(id) : title.trim(); - String outputName = safeScriptName(displayName); - if (!hasExtension(displayName)) { - String original = fileName(id); - int dot = original.lastIndexOf('.'); - if (dot > 0) outputName = outputName.substring(0, outputName.lastIndexOf('.')) + original.substring(dot); - } - File output = source; - if (!TextUtils.equals(id, outputName)) { - output = new File(scriptLibraryDir(), outputName); - if (output.exists()) throw new IOException(App.get().getString(R.string.mpv_config_script_exists)); - writeTextChecked(output, readText(source)); - if (!source.delete()) { - output.delete(); - throw writeFailed(); - } - renameScriptButton(id, outputName, displayName); - } - validateContent(content); - writeTextChecked(output, content); - writeScriptSettings(outputName, displayName, content, enabled, trigger, scriptEnabled); - return outputName; - } - - /** Apply creation/import options without renaming the newly allocated script file. */ - public static synchronized void saveScriptTrigger(String id, boolean enabled, String trigger) throws IOException { - migrateManagedScripts(); - String content = readText(safeScriptFile(id)); - CustomButton previous = scriptButton(id); - String title = previous == null ? id.substring(0, id.lastIndexOf('.')) : previous.title; - writeScriptSettings(id, title, content, enabled, trigger, previous == null || previous.scriptEnabled); - } - - private static void writeScriptSettings(String script, String title, String content, - boolean enabled, String trigger, boolean scriptEnabled) throws IOException { - List buttons = customButtons(); - CustomButton button = null; - for (CustomButton item : buttons) { - if (TextUtils.equals(item.script, script)) { - button = item; - break; - } - } - if (button == null) { - button = new CustomButton(); - button.id = UUID.randomUUID().toString().replace("-", ""); - buttons.add(button); - } - button.script = script; - button.title = title; - button.enabled = enabled; - button.scriptEnabled = scriptEnabled; - button.trigger = normalizeScriptTrigger(enabled, trigger); - button.content = "click".equals(button.trigger) ? content : ""; - button.longPressContent = "long".equals(button.trigger) ? content : ""; - button.onStartup = "startup".equals(button.trigger) ? content : ""; - writeCustomButtons(buttons); - } - - public static String normalizeScriptTrigger(boolean buttonEnabled, String trigger) { - if (!buttonEnabled) return "startup"; - return "long".equals(trigger) || "startup".equals(trigger) ? trigger : "click"; - } - - private static boolean hasExtension(String name) { - String value = fileName(name); - int dot = value.lastIndexOf('.'); - return dot > 0 && dot < value.length() - 1; - } - - private static void removeScriptButton(String script) throws IOException { - List buttons = customButtons(); - boolean changed = buttons.removeIf(item -> TextUtils.equals(item.script, script)); - if (changed) writeCustomButtons(buttons); - } - - private static void renameScriptButton(String oldScript, String newScript, String title) throws IOException { - List buttons = customButtons(); - boolean changed = false; - for (CustomButton button : buttons) { - if (!TextUtils.equals(button.script, oldScript)) continue; - button.script = newScript; - button.title = title; - changed = true; - } - if (changed) writeCustomButtons(buttons); - } - - public static synchronized String saveCustomButton(String id, String title, String content, - String longPressContent, String onStartup, - boolean enabled) throws IOException { - migrateManagedScripts(); - String displayName = title == null ? "" : title.trim(); - if (TextUtils.isEmpty(displayName)) throw new IOException(App.get().getString(R.string.mpv_config_name_required)); - if (!TextUtils.isEmpty(id) && !isSafeCustomButtonId(id)) throw new IOException(App.get().getString(R.string.mpv_config_custom_buttons_invalid)); - String buttonId = TextUtils.isEmpty(id) ? UUID.randomUUID().toString().replace("-", "") : id; - String shortCode = content == null ? "" : content; - String longCode = longPressContent == null ? "" : longPressContent; - String startupCode = onStartup == null ? "" : onStartup; - validateContent(shortCode); - validateContent(longCode); - validateContent(startupCode); - List buttons = customButtons(); - CustomButton target = null; - for (CustomButton item : buttons) if (TextUtils.equals(item.id, buttonId)) target = item; - if (target == null) { - target = new CustomButton(); - target.id = buttonId; - buttons.add(target); - } - target.title = displayName; - target.content = shortCode; - target.longPressContent = longCode; - target.onStartup = startupCode; - target.enabled = enabled; - writeCustomButtons(buttons); - return buttonId; - } - - public static synchronized boolean deleteCustomButton(String id) throws IOException { - if (!isSafeCustomButtonId(id)) return false; - List buttons = customButtons(); - boolean removed = buttons.removeIf(item -> TextUtils.equals(item.id, id)); - if (removed) writeCustomButtons(buttons); - return removed; - } - - public static synchronized void ensureCustomButtonScript() { - try { - migrateManagedScripts(); - writeCustomButtonScript(customButtons()); - } catch (IOException ignored) { - } - } - - private static void writeCustomButtons(List buttons) throws IOException { - String json = serializeCustomButtons(buttons); - validateContent(json); - writeTextChecked(customButtonsFile(), json); - writeCustomButtonScript(buttons); - } - - static String serializeCustomButtons(List buttons) { - JsonArray array = new JsonArray(); - for (CustomButton button : buttons) { - if (button == null || !isSafeCustomButtonId(button.id) || value(button.title).isEmpty()) continue; - JsonObject object = new JsonObject(); - object.addProperty("id", button.id); - object.addProperty("title", button.title); - object.addProperty("script", value(button.script)); - object.addProperty("trigger", value(button.trigger)); - object.addProperty("content", value(button.content)); - object.addProperty("longPressContent", value(button.longPressContent)); - object.addProperty("onStartup", value(button.onStartup)); - object.addProperty("enabled", button.enabled); - object.addProperty("scriptEnabled", button.scriptEnabled); - array.add(object); - } - return array.toString(); - } - - private static void writeCustomButtonScript(List buttons) throws IOException { - File output = new File(scriptsDir(), CUSTOM_BUTTON_SCRIPT); - String lua = buildCustomButtonScript(buttons, script -> readText(safeScriptFile(script))); - if (lua.isEmpty()) { - output.delete(); - return; - } - writeTextChecked(output, lua); - } - - interface ScriptContentReader { - String read(String script) throws IOException; - } - - static String buildCustomButtonScript(List buttons, ScriptContentReader scripts) { - StringBuilder lua = new StringBuilder("-- WebHTV managed custom buttons\nlocal buttons = {}\n" - + "local webhtv_active_buttons = {}\n" - + "local function webhtv_publish_button_state()\n" - + " local ids = {}\n" - + " for id, active in pairs(webhtv_active_buttons) do if active then ids[#ids + 1] = id end end\n" - + " table.sort(ids)\n" - // UI feedback must not interrupt script execution if the property is unavailable. - + " pcall(mp.set_property_native, " + luaString(CUSTOM_BUTTON_STATE_PROPERTY) + ", table.concat(ids, ','))\n" - + "end\n" - + "local function webhtv_toggle_button_state(id)\n" - + " webhtv_active_buttons[id] = not webhtv_active_buttons[id]\n" - + " webhtv_publish_button_state()\n" - + "end\n" - + "webhtv_publish_button_state()\n" - + "local function run(fn)\n" - + " if not fn then return false end\n" - + " local ok, err = pcall(fn)\n" - + " if not ok then mp.msg.error('WebHTV custom button failed: ' .. tostring(err)) end\n" - + " return ok\n" - + "end\n"); - int scriptCount = 0; - for (CustomButton button : buttons) { - if (button == null || !button.scriptEnabled || !isSafeCustomButtonId(button.id)) continue; - boolean managed = !value(button.script).isEmpty(); - // Preserve the legacy multi-action button contract; managed files use the new timing rules. - if (!managed && !button.enabled) continue; - String key = luaString(button.id); - String content = button.content; - String longContent = button.longPressContent; - String startupContent = button.onStartup; - boolean startupButton = false; - if (managed) { - try { - String scriptContent = scripts.read(button.script); - String trigger = normalizeScriptTrigger(button.enabled, button.trigger); - startupButton = button.enabled && "startup".equals(trigger); - content = button.enabled && !"long".equals(trigger) ? scriptContent : ""; - longContent = button.enabled && "long".equals(trigger) ? scriptContent : ""; - startupContent = !button.enabled ? scriptContent : ""; - } catch (IOException ignored) { - continue; - } - } - scriptCount++; - if (!button.enabled) { - lua.append("run(function()\n").append(value(startupContent)).append("\nend)\n"); - continue; - } - lua.append("buttons[").append(key).append("] = {}\n"); - // Legacy startup locals must remain visible to their short/long closures. - if (!value(startupContent).isEmpty()) lua.append(startupContent).append('\n'); - if (!value(content).isEmpty()) { - lua.append("buttons[").append(key).append("].short = function()\n") - .append(content).append("\nend\n"); - } - if (!value(longContent).isEmpty()) { - lua.append("buttons[").append(key).append("].long = function()\n") - .append(longContent).append("\nend\n"); - } - if (startupButton) lua.append("if run(buttons[").append(key).append("].short) then webhtv_toggle_button_state(").append(key).append(") end\n"); - } - if (scriptCount == 0) return ""; - lua.append("mp.register_script_message(").append(luaString(CUSTOM_BUTTON_MESSAGE)).append(", function(id, phase)\n") - .append(" local button = buttons[id]\n") - .append(" if run(button and button[phase]) then webhtv_toggle_button_state(id) end\n") - .append("end)\n"); - return lua.toString(); - } - - private static File customButtonsFile() { - return new File(scriptsDir(), CUSTOM_BUTTONS_FILE); - } - - private static boolean isSafeCustomButtonId(String id) { - return id != null && id.matches("[A-Za-z0-9_-]{1,64}"); - } - - private static boolean isCustomButtonProfileId(String id) { - return id != null && id.startsWith("custom:") && isSafeCustomButtonId(id.substring("custom:".length())); - } - - private static String luaString(String value) { - return "\"" + String.valueOf(value).replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "\\r").replace("\n", "\\n") + "\""; - } - private static void clearScripts() { File[] files = scriptsDir().listFiles(file -> file.isFile() && isScriptName(file.getName())); - if (files != null) for (File file : files) file.delete(); - File[] managed = scriptLibraryDir().listFiles(); - if (managed != null) for (File file : managed) { - if (file.isFile()) file.delete(); - } - scriptLibraryDir().delete(); - customButtonsFile().delete(); - new File(scriptsDir(), CUSTOM_BUTTON_SCRIPT).delete(); + if (files == null) return; + for (File file : files) file.delete(); } private static String key(String base, String target) { @@ -1407,42 +970,20 @@ public final class MpvConfigStore { public String content; public long time; public boolean active; - public boolean scriptEnabled = true; public boolean isDefault() { return TYPE_DEFAULT.equals(type); } - public boolean isCustomButton() { - return TYPE_CUSTOM_BUTTON.equals(type); - } - public boolean isImported() { return TYPE_FILE.equals(type) || TYPE_URL.equals(type); } public String typeLabel() { if (isDefault()) return ResUtil.getString(R.string.mpv_config_default); - if (isCustomButton()) return ResUtil.getString(R.string.mpv_config_custom_buttons); if (TYPE_URL.equals(type)) return ResUtil.getString(R.string.mpv_config_url); if (TYPE_FILE.equals(type)) return ResUtil.getString(R.string.mpv_config_local); return ResUtil.getString(R.string.mpv_config_text); } } - - public static final class CustomButton { - public String id; - public String title; - public String script; - public String trigger; - public String content; - public String longPressContent; - public String onStartup; - public boolean enabled = true; - public boolean scriptEnabled = true; - - public boolean isButtonVisible() { - return scriptEnabled && enabled; - } - } } diff --git a/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvDirectAudioPolicy.java b/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvDirectAudioPolicy.java index a4b7b2eefb..e2901950e8 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvDirectAudioPolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/player/mpv/MpvDirectAudioPolicy.java @@ -17,21 +17,12 @@ public final class MpvDirectAudioPolicy { public static Selection select(List candidates, String currentId, String passthroughCodecs) { - return select(candidates, currentId, passthroughCodecs, false); - } - - public static Selection select(List candidates, String currentId, - String passthroughCodecs, - boolean multichannelPcm) { Candidate current = findById(candidates, currentId); if (current == null) return new Selection(currentId, false, "current-track-unavailable"); if (supportsPassthrough(current, passthroughCodecs)) { return new Selection(current.id(), false, "current-track-passthrough"); } if (isStereo(current)) return new Selection(current.id(), false, "current-track-stereo"); - if (multichannelPcm) { - return new Selection(current.id(), false, "current-track-multichannel-pcm"); - } Candidate stereo = bestCandidate(candidates, current, true); if (stereo != null) return new Selection(stereo.id(), true, "same-language-stereo"); diff --git a/app/src/main/java/com/fongmi/android/tv/player/mpv/PlaybackRecoveryMonitor.java b/app/src/main/java/com/fongmi/android/tv/player/mpv/PlaybackRecoveryMonitor.java deleted file mode 100644 index ac7b4fe026..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/mpv/PlaybackRecoveryMonitor.java +++ /dev/null @@ -1,251 +0,0 @@ -package com.fongmi.android.tv.player.mpv; - -import android.app.Activity; -import android.app.Application; -import android.app.KeyguardManager; -import android.content.Context; -import android.content.Intent; -import android.os.Build; -import android.os.Looper; -import android.os.Process; -import android.os.SystemClock; -import android.util.AtomicFile; -import android.util.Log; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.player.PlaybackTrace; -import com.fongmi.android.tv.ui.activity.PlaybackRecoveryActivity; -import com.github.catvod.crawler.SpiderDebug; - -import java.io.DataInputStream; -import java.io.DataOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.channels.FileLock; -import java.nio.charset.StandardCharsets; -import java.util.UUID; - -/** Called only from mpv's watchdog thread. No JNI, player locks or UI-thread I/O. */ -public final class PlaybackRecoveryMonitor { - public static final String PROCESS_SUFFIX = ":playback_recovery"; - public static final String EXTRA_TOKEN = "recovery_token"; - private static final int FILE_VERSION = 1; - private final Context context; - private String token; - private boolean prompted; - - public record Request(String token, PlaybackRecoveryPolicy.Target target, - long heartbeatMs, boolean armed, String status, String trace) {} - - public PlaybackRecoveryMonitor(Context context) { - this.context = context.getApplicationContext(); - } - - public static String mainProcessName(Context context) { - return context.getApplicationInfo().processName; - } - - public static boolean isRecoveryProcess(Context context) { - String name = Build.VERSION.SDK_INT >= 28 ? Application.getProcessName() - : readSmallFile("/proc/self/cmdline"); - int nul = name == null ? -1 : name.indexOf('\0'); - if (nul >= 0) name = name.substring(0, nul); - return (mainProcessName(context) + PROCESS_SUFFIX).equals(name); - } - - public void onStall(long heartbeatPostedMs, long nowMs, String trace) { - if (prompted || heartbeatPostedMs <= 0 - || nowMs - heartbeatPostedMs < PlaybackRecoveryPolicy.STALL_MS) return; - Activity foreground = App.activity(); - boolean visible = foreground != null && !foreground.isFinishing() && !foreground.isDestroyed(); - KeyguardManager keyguard = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); - if (keyguard != null && keyguard.isKeyguardLocked()) visible = false; - if (!PlaybackRecoveryPolicy.shouldPrompt(nowMs, heartbeatPostedMs, visible, prompted)) return; - prompted = true; // At most one prompt for this uninterrupted stall. - PlaybackRecoveryPolicy.Target target = readTarget(Process.myPid()); - if (target == null || target.uid() != Process.myUid() - || !mainProcessName(context).equals(target.processName())) { - PlaybackTrace.log("mpv-recovery", trace, "not armed: process identity unavailable"); - return; - } - token = UUID.randomUUID().toString(); // No random/IO work on normal player startup. - Request request = new Request(token, target, heartbeatPostedMs, true, "stalled", trace); - if (!writeRequest(context, request, false)) { - PlaybackTrace.log("mpv-recovery", trace, "not armed: recovery state could not be persisted"); - return; - } - StringBuilder stack = new StringBuilder(); - StackTraceElement[] frames = Looper.getMainLooper().getThread().getStackTrace(); - for (int i = 0; i < Math.min(frames.length, 20); i++) { - if (i > 0) stack.append(" <- "); - stack.append(frames[i]); - } - PlaybackTrace.log("mpv-recovery", trace, - "main heartbeat stalled=%dms pid=%d start=%d launching separate recovery process; stack=%s", - nowMs - heartbeatPostedMs, target.pid(), target.startTicks(), stack); - try { - context.startActivity(new Intent(context, PlaybackRecoveryActivity.class) - .putExtra(EXTRA_TOKEN, token).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - } catch (RuntimeException error) { - PlaybackTrace.log("mpv-recovery", trace, "recovery launch rejected: %s", error.getClass().getSimpleName()); - // Background-activity restrictions are not permission to kill the process. - disarm("launch-rejected"); - } - } - - public void onHeartbeat() { - if (!prompted) return; - disarm("responsive-again"); - prompted = false; - } - - public void close() { - if (prompted) disarm("watchdog-stopped"); - } - - private void disarm(String status) { - if (token == null) return; - // Only watchdog threads write the request. Serialize old/new watchdogs - // and cross-process readers, including AtomicFile's legacy .bak path. - try (RandomAccessFile lockFile = lockFile(context); - FileLock ignored = lockFile.getChannel().lock()) { - Request current = readUnlocked(stateFile(context, false)); - if (current != null && token.equals(current.token()) && current.armed()) { - writeUnlocked(stateFile(context, false), new Request(token, current.target(), - current.heartbeatMs(), false, status, current.trace())); - PlaybackTrace.log("mpv-recovery", current.trace(), "recovery disarmed: %s", status); - } - } catch (IOException | RuntimeException error) { - Log.w("MpvRecovery", "cannot disarm recovery request", error); - } - } - - public static Request requestForToken(Context context, String token) { - Request request = readRequest(context, false); - return token != null && request != null && token.equals(request.token()) ? request : null; - } - - public static boolean mayEndProcess(Context context, Request request) { - Request current = request == null ? null : requestForToken(context, request.token()); - return current != null && current.armed() && request.equals(current) - && SystemClock.elapsedRealtime() - current.heartbeatMs() >= PlaybackRecoveryPolicy.STALL_MS - && PlaybackRecoveryPolicy.mayTerminate(current.target(), readTarget(current.target().pid()), - Process.myPid(), Process.myUid(), mainProcessName(context)); - } - - /** Persist the user's action before termination; the next main process logs it. */ - public static boolean recordUserExit(Context context, Request request) { - return mayEndProcess(context, request) && writeRequest(context, new Request(request.token(), - request.target(), request.heartbeatMs(), false, "user-confirmed-exit", request.trace()), true); - } - - public static void logPreviousResult(Context context) { - Request request = readRequest(context, true); - if (request == null) request = readRequest(context, false); - if (request == null) return; - SpiderDebug.log("mpv-recovery", "previous pid=%d start=%d status=%s trace=%s; no automatic playback", - request.target().pid(), request.target().startTicks(), request.status(), request.trace()); - } - - public static PlaybackRecoveryPolicy.Target readTarget(int pid) { - if (pid <= 0) return null; - try { - String prefix = "/proc/" + pid; - long ticks = PlaybackRecoveryPolicy.processStartTicks(readSmallFile(prefix + "/stat")); - String name = readSmallFile(prefix + "/cmdline"); - if (name == null || ticks <= 0) return null; - int nul = name.indexOf('\0'); - if (nul >= 0) name = name.substring(0, nul); - int uid = PlaybackRecoveryPolicy.processUid(readSmallFile(prefix + "/status")); - if (uid < 0) return null; - if (ticks != PlaybackRecoveryPolicy.processStartTicks(readSmallFile(prefix + "/stat"))) return null; - return new PlaybackRecoveryPolicy.Target(pid, uid, ticks, name); - } catch (Exception error) { - return null; - } - } - - private static String readSmallFile(String path) { - try (FileInputStream input = new FileInputStream(path)) { - byte[] buffer = new byte[4096]; - int size = input.read(buffer); - return size < 0 ? "" : new String(buffer, 0, size, StandardCharsets.UTF_8); - } catch (IOException error) { - return null; - } - } - - private static AtomicFile stateFile(Context context, boolean result) { - return new AtomicFile(new File(context.getCacheDir(), - result ? "mpv-playback-recovery.result" : "mpv-playback-recovery.state")); - } - - private static RandomAccessFile lockFile(Context context) throws IOException { - return new RandomAccessFile(new File(context.getCacheDir(), "mpv-playback-recovery.lock"), "rw"); - } - - private static Request readRequest(Context context, boolean result) { - AtomicFile file = stateFile(context, result); - if (!file.getBaseFile().isFile() - && !new File(file.getBaseFile().getPath() + ".bak").isFile()) return null; - // Recovery UI never waits behind a disk writer. Unknown/busy means no - // authorization to terminate, not permission to use a stale request. - try (RandomAccessFile lockFile = lockFile(context); - FileLock lock = lockFile.getChannel().tryLock()) { - return lock == null ? null : readUnlocked(file); - } catch (IOException | RuntimeException error) { - return null; - } - } - - private static Request readUnlocked(AtomicFile file) { - try (DataInputStream input = new DataInputStream(file.openRead())) { - if (input.readInt() != FILE_VERSION) return null; - String token = input.readUTF(); - PlaybackRecoveryPolicy.Target target = new PlaybackRecoveryPolicy.Target( - input.readInt(), input.readInt(), input.readLong(), input.readUTF()); - return new Request(token, target, input.readLong(), input.readBoolean(), - input.readUTF(), input.readUTF()); - } catch (IOException | RuntimeException error) { - return null; - } - } - - private static boolean writeRequest(Context context, Request request, boolean result) { - try (RandomAccessFile lockFile = lockFile(context); - FileLock ignored = lockFile.getChannel().lock()) { - return writeUnlocked(stateFile(context, result), request); - } catch (IOException | RuntimeException error) { - Log.w("MpvRecovery", "cannot lock recovery state", error); - return false; - } - } - - private static boolean writeUnlocked(AtomicFile file, Request request) { - FileOutputStream stream = null; - try { - stream = file.startWrite(); - DataOutputStream output = new DataOutputStream(stream); - output.writeInt(FILE_VERSION); - output.writeUTF(request.token()); - output.writeInt(request.target().pid()); - output.writeInt(request.target().uid()); - output.writeLong(request.target().startTicks()); - output.writeUTF(request.target().processName()); - output.writeLong(request.heartbeatMs()); - output.writeBoolean(request.armed()); - output.writeUTF(request.status()); - output.writeUTF(request.trace() == null ? "" : request.trace()); - output.flush(); - file.finishWrite(stream); - return true; - } catch (IOException | RuntimeException error) { - if (stream != null) file.failWrite(stream); - Log.w("MpvRecovery", "cannot save recovery state", error); - return false; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/player/mpv/PlaybackRecoveryPolicy.java b/app/src/main/java/com/fongmi/android/tv/player/mpv/PlaybackRecoveryPolicy.java deleted file mode 100644 index beac0957ba..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/player/mpv/PlaybackRecoveryPolicy.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.fongmi.android.tv.player.mpv; - -/** Pure decisions shared by the watchdog and the separate recovery process. */ -public final class PlaybackRecoveryPolicy { - public static final long STALL_MS = 8000; - - private PlaybackRecoveryPolicy() {} - - public record Target(int pid, int uid, long startTicks, String processName) {} - - public static boolean shouldPrompt(long nowMs, long heartbeatPostedMs, - boolean foreground, boolean alreadyPrompted) { - return foreground && !alreadyPrompted && heartbeatPostedMs > 0 - && nowMs >= heartbeatPostedMs && nowMs - heartbeatPostedMs >= STALL_MS; - } - - public static boolean mayTerminate(Target expected, Target actual, int selfPid, - int selfUid, String mainProcessName) { - return expected != null && actual != null && expected.equals(actual) - && expected.pid() > 0 && expected.pid() != selfPid - && expected.uid() == selfUid && expected.startTicks() > 0 - && mainProcessName != null && mainProcessName.equals(expected.processName()); - } - - /** /proc/PID ownership may be root for non-dumpable release processes. */ - public static int processUid(String status) { - if (status == null) return -1; - int uid = -1; - for (String line : status.split("\n")) { - if (!line.startsWith("Uid:")) continue; - if (uid >= 0) return -1; // ambiguous or malformed status - String[] fields = line.substring(4).trim().split("\\s+"); - if (fields.length != 4) return -1; - try { - for (String field : fields) { - if (!field.matches("[0-9]+") || Integer.parseInt(field) < 0) return -1; - } - uid = Integer.parseInt(fields[0]); // real UID, like Android Process - } catch (NumberFormatException error) { - return -1; - } - } - return uid; - } - - public static long processStartTicks(String stat) { - if (stat == null) return -1; - int endName = stat.lastIndexOf(')'); - if (endName < 0) return -1; - String[] fields = stat.substring(endName + 1).trim().split("\\s+"); - if (fields.length <= 19) return -1; - try { - long value = Long.parseLong(fields[19]); // /proc/PID/stat field 22 - return value > 0 ? value : -1; - } catch (NumberFormatException error) { - return -1; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/remote/RemoteSyncTransfer.java b/app/src/main/java/com/fongmi/android/tv/remote/RemoteSyncTransfer.java index 5aaf6e9a1d..3b36163e72 100644 --- a/app/src/main/java/com/fongmi/android/tv/remote/RemoteSyncTransfer.java +++ b/app/src/main/java/com/fongmi/android/tv/remote/RemoteSyncTransfer.java @@ -9,7 +9,6 @@ import com.fongmi.android.tv.remote.RemoteModels.RemoteCommand; import com.fongmi.android.tv.remote.RemoteModels.RemoteCommandResult; import com.fongmi.android.tv.remote.RemoteModels.RemoteProfile; import com.fongmi.android.tv.utils.LoginStateSync; -import com.fongmi.android.tv.utils.MpvConfigSync; import com.fongmi.android.tv.utils.SyncFiles; import com.github.catvod.utils.Path; import com.google.gson.JsonObject; @@ -30,7 +29,6 @@ public final class RemoteSyncTransfer { String completeUrl = string(payload, "completeUrl"); if (TextUtils.isEmpty(uploadBase) || TextUtils.isEmpty(completeUrl)) return RemoteCommandResult.failure("Missing sync upload endpoint"); SyncFiles.Archive archive = null; - MpvConfigSync.Archive mpvArchive = null; LoginStateSync.Archive loginArchive = null; try { String backup = Backup.create(options).toString(); @@ -40,10 +38,6 @@ public final class RemoteSyncTransfer { archive = SyncFiles.createArchive(SyncFiles.getPaths(options)); if (archive != null) client.uploadSyncFile(uploadBase, "syncFiles", archive.getFile()); } - if (options.isMpvConfig()) { - mpvArchive = MpvConfigSync.createArchive(); - if (mpvArchive != null) client.uploadSyncFile(uploadBase, MpvConfigSync.PART_NAME, mpvArchive.getFile()); - } if (options.isLoginState()) { loginArchive = LoginStateSync.createArchive(); if (loginArchive != null) client.uploadSyncFile(uploadBase, "loginStateFiles", loginArchive.getFile()); @@ -52,14 +46,12 @@ public final class RemoteSyncTransfer { manifest.add("options", App.gson().toJsonTree(options)); manifest.addProperty("syncId", string(payload, "syncId")); manifest.addProperty("syncFiles", archive == null ? 0 : archive.getCount()); - manifest.addProperty("mpvFiles", mpvArchive == null ? 0 : mpvArchive.getCount()); manifest.addProperty("loginStateFiles", loginArchive == null ? 0 : loginArchive.getCount()); client.uploadSyncText(uploadBase, "manifest", App.gson().toJson(manifest)); client.completeSync(completeUrl, true, "Export complete", manifest); return RemoteCommandResult.success("Export complete", manifest); } finally { if (archive != null) Path.clear(archive.getFile()); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) Path.clear(loginArchive.getFile()); } } catch (Throwable e) { @@ -71,7 +63,6 @@ public final class RemoteSyncTransfer { public static RemoteCommandResult restore(RemoteProfile profile, RemoteCommand command) { File syncFiles = null; - File mpvConfigFiles = null; File loginStateFiles = null; try { JsonObject payload = payload(command); @@ -82,16 +73,13 @@ public final class RemoteSyncTransfer { if (TextUtils.isEmpty(backup)) throw new IllegalStateException("Missing sync backup"); String remoteRelay = downloads.has("remoteRelay") ? client.downloadSyncText(downloads.get("remoteRelay").getAsString()) : ""; if (downloads.has("syncFiles")) syncFiles = client.downloadSyncFile(downloads.get("syncFiles").getAsString(), "webhtv-remote-sync-", ".zip"); - if (downloads.has(MpvConfigSync.PART_NAME)) mpvConfigFiles = client.downloadSyncFile(downloads.get(MpvConfigSync.PART_NAME).getAsString(), "webhtv-remote-mpv-", ".zip"); if (downloads.has("loginStateFiles")) loginStateFiles = client.downloadSyncFile(downloads.get("loginStateFiles").getAsString(), "webhtv-remote-login-", ".zip"); int syncCount = syncFiles == null ? 0 : SyncFiles.restoreArchive(syncFiles); - int mpvCount = mpvConfigFiles == null ? 0 : MpvConfigSync.restoreArchive(mpvConfigFiles); int loginCount = loginStateFiles == null ? 0 : LoginStateSync.restoreArchive(loginStateFiles); Backup.objectFrom(backup).restore(options, true); if (options.isRemoteRelay()) RemoteStore.importRelayConfig(remoteRelay); JsonObject result = new JsonObject(); result.addProperty("syncFiles", syncCount); - result.addProperty("mpvFiles", mpvCount); result.addProperty("loginStateFiles", loginCount); client.completeSync(string(payload, "completeUrl"), true, "Restore complete", result); return RemoteCommandResult.success("Restore complete", result); @@ -101,7 +89,6 @@ public final class RemoteSyncTransfer { return RemoteCommandResult.failure(e.getMessage()); } finally { Path.clear(syncFiles); - Path.clear(mpvConfigFiles); Path.clear(loginStateFiles); } } diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/Action.java b/app/src/main/java/com/fongmi/android/tv/server/process/Action.java index 6f946d0fb2..1205643d57 100644 --- a/app/src/main/java/com/fongmi/android/tv/server/process/Action.java +++ b/app/src/main/java/com/fongmi/android/tv/server/process/Action.java @@ -24,7 +24,6 @@ import com.fongmi.android.tv.server.impl.Process; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.LoginStateSync; -import com.fongmi.android.tv.utils.MpvConfigSync; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ProgressRequestBody; import com.fongmi.android.tv.utils.ResUtil; @@ -331,13 +330,11 @@ public class Action implements Process { try { SyncOptions options = SyncOptions.objectFrom(params.get("options")); SyncFiles.Archive archive = SyncFiles.hasPaths(options) ? SyncFiles.createArchive(SyncFiles.getPaths(options)) : null; - MpvConfigSync.Archive mpvArchive = options.isMpvConfig() ? MpvConfigSync.createArchive() : null; LoginStateSync.Archive loginArchive = options.isLoginState() ? LoginStateSync.createArchive() : null; try { - return post(device, "backup", getBackupBody(options, archive, mpvArchive, loginArchive)); + return post(device, "backup", getBackupBody(options, archive, loginArchive)); } finally { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); } } catch (Exception e) { @@ -346,8 +343,8 @@ public class Action implements Process { } } - private RequestBody getBackupBody(SyncOptions options, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive) { - if (archive == null && mpvArchive == null && loginArchive == null) { + private RequestBody getBackupBody(SyncOptions options, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive) { + if (archive == null && loginArchive == null) { FormBody.Builder body = new FormBody.Builder(); body.add("options", options.toString()); body.add("backup", Backup.create(options).toString()); @@ -359,7 +356,6 @@ public class Action implements Process { body.addFormDataPart("backup", Backup.create(options).toString()); if (options.isRemoteRelay()) body.addFormDataPart("remoteRelay", RemoteStore.exportRelayConfig()); if (archive != null) body.addFormDataPart(SyncFiles.PART_NAME, archive.getFile().getName(), new ProgressRequestBody(archive.getFile(), ZIP, null)); - if (mpvArchive != null) body.addFormDataPart(MpvConfigSync.PART_NAME, mpvArchive.getFile().getName(), new ProgressRequestBody(mpvArchive.getFile(), ZIP, null)); if (loginArchive != null) body.addFormDataPart(LoginStateSync.PART_NAME, loginArchive.getFile().getName(), new ProgressRequestBody(loginArchive.getFile(), ZIP, null)); return body.build(); } @@ -377,16 +373,6 @@ public class Action implements Process { Path.clear(archive); } } - if (options.isMpvConfig() && files.containsKey(MpvConfigSync.PART_NAME)) { - File archive = new File(files.get(MpvConfigSync.PART_NAME)); - try { - MpvConfigSync.restoreArchive(archive); - } catch (Exception e) { - throw new IllegalStateException(e); - } finally { - Path.clear(archive); - } - } if (options.isLoginState() && files.containsKey(LoginStateSync.PART_NAME)) { File archive = new File(files.get(LoginStateSync.PART_NAME)); try { diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/DebugLogs.java b/app/src/main/java/com/fongmi/android/tv/server/process/DebugLogs.java index 52709d69fa..9945a3dbe4 100644 --- a/app/src/main/java/com/fongmi/android/tv/server/process/DebugLogs.java +++ b/app/src/main/java/com/fongmi/android/tv/server/process/DebugLogs.java @@ -9,16 +9,11 @@ import com.fongmi.android.tv.server.impl.Process; import com.fongmi.android.tv.service.AiLogDiagnosisService; import com.fongmi.android.tv.setting.Setting; import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticLogBuffer; -import com.github.catvod.crawler.diagnostics.DiagnosticReport; -import com.github.catvod.crawler.diagnostics.DiagnosticAccess; -import com.github.catvod.crawler.diagnostics.DiagnosticCapture; -import com.fongmi.android.tv.player.DiagnosticControls; -import com.google.gson.JsonParser; -import com.google.gson.JsonObject; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; import java.util.Map; import fi.iki.elonen.NanoHTTPD; @@ -26,24 +21,14 @@ import fi.iki.elonen.NanoHTTPD.IHTTPSession; import fi.iki.elonen.NanoHTTPD.Response; public class DebugLogs implements Process { - private static final java.util.concurrent.atomic.AtomicBoolean archiveBusy = new java.util.concurrent.atomic.AtomicBoolean(); - private static final java.util.concurrent.ScheduledExecutorService exportTimer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(r -> { - Thread thread = new Thread(r, "diagnostic-export-expiry"); thread.setDaemon(true); return thread; - }); @Override public boolean isRequest(IHTTPSession session, String url) { - return url.startsWith("/debug/diagnose") || url.startsWith("/debug/diag/") || url.startsWith("/debug/logs") || url.startsWith("/debug/mpd") || url.startsWith("/debug/stream") || url.startsWith("/debug/clear") || url.startsWith("/debug/enable") || url.startsWith("/debug/disable"); + return url.startsWith("/debug/logs") || url.startsWith("/debug/diagnose") || url.startsWith("/debug/mpd") || url.startsWith("/debug/stream") || url.startsWith("/debug/clear") || url.startsWith("/debug/enable") || url.startsWith("/debug/disable"); } @Override public Response doResponse(IHTTPSession session, String url, Map files) { - if (url.startsWith("/debug/diagnose")) return diagnose(); - if (url.startsWith("/debug/diag/")) return diagnosticAction(session, url, files); - if (url.startsWith("/debug/enable") || url.startsWith("/debug/disable") || url.startsWith("/debug/clear")) { - Response rejection = controlRequestError(session); - if (rejection != null) return rejection; - } if (url.startsWith("/debug/enable")) { Setting.putDebugLog(true); return noCache(NanoHTTPD.newFixedLengthResponse(Response.Status.REDIRECT, NanoHTTPD.MIME_HTML, ""), "/debug/logs"); @@ -54,9 +39,9 @@ public class DebugLogs implements Process { } if (url.startsWith("/debug/clear")) { DebugLogStore.clear(); - if (DebugLogStore.isEnabled()) Setting.logDebugEnvironment("clear"); return noCache(NanoHTTPD.newFixedLengthResponse(Response.Status.REDIRECT, NanoHTTPD.MIME_HTML, ""), "/debug/logs"); } + if (url.startsWith("/debug/diagnose")) return diagnose(); if (url.startsWith("/debug/stream")) return stream(session); if (url.startsWith("/debug/mpd")) return mpd(); if (url.startsWith("/debug/logs.txt")) return download(); @@ -78,105 +63,23 @@ public class DebugLogs implements Process { } private Response download() { - DiagnosticLogBuffer.Export export = DiagnosticReport.text(DebugLogStore.export()); - Response response = NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "text/plain; charset=utf-8", export.input, export.length); + String text = DebugLogStore.text(); + byte[] data = text.getBytes(StandardCharsets.UTF_8); + Response response = NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "text/plain; charset=utf-8", new ByteArrayInputStream(data), data.length); response.addHeader("Content-Disposition", "attachment; filename=webhtv-debug-log.txt"); response.addHeader("X-Content-Type-Options", "nosniff"); - response.addHeader("X-Diagnostic-Completeness", export.partial ? "partial" : "declared-window"); return noCache(response, null); } private Response diagnose() { String result = new AiLogDiagnosisService(AiConfig.objectFrom(Setting.getAiConfig())).diagnose(DebugLogStore.text()); String html = "AI诊断" - + "

AI诊断

" + + "

AI诊断

返回日志下载日志
" + "
" + escape(result) + "
"; return noCache(NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html), null); } - private boolean originAllowed(IHTTPSession session) { - java.util.Set hosts = new java.util.HashSet<>(); - for (String address : new String[]{Server.get().getAddress("/debug/logs"), Server.get().getAddress(false)}) { - try { hosts.add(java.net.URI.create(address).getRawAuthority().toLowerCase(java.util.Locale.ROOT)); } catch (RuntimeException ignored) {} - } - return DiagnosticAccess.sameOrigin(session.getHeaders().get("origin"), session.getHeaders().get("host"), hosts); - } - - private Response controlRequestError(IHTTPSession session) { - if (session.getMethod() != NanoHTTPD.Method.POST) return diagnosticMessage(Response.Status.METHOD_NOT_ALLOWED, "此操作需要 POST"); - if (!originAllowed(session)) return diagnosticMessage(Response.Status.FORBIDDEN, "请求来源不匹配,请从设备日志页面操作"); - if (!DiagnosticControls.ACCESS.allowAction()) return diagnosticMessage(Response.Status.TOO_MANY_REQUESTS, "操作过快,请稍后重试"); - return null; - } - - private Response diagnosticAction(IHTTPSession session, String url, Map files) { - if (url.equals("/debug/diag/status") && session.getMethod() == NanoHTTPD.Method.GET) - return noCache(NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", DiagnosticControls.status().toString()), null); - Response rejection = controlRequestError(session); - if (rejection != null) return rejection; - try { - if (Long.parseLong(session.getHeaders().getOrDefault("content-length", "0")) > 2048) return diagnosticMessage(Response.Status.BAD_REQUEST, "请求过长"); - String raw = files.getOrDefault("postData", "{}"); - if (raw.length() > 2048) return diagnosticMessage(Response.Status.BAD_REQUEST, "请求过长"); - JsonObject data = JsonParser.parseString(raw.isEmpty() ? "{}" : raw).getAsJsonObject(); - switch (url) { - case "/debug/diag/mark" -> DiagnosticControls.mark(field(data, "symptom")); - case "/debug/diag/deep" -> { - if (!data.has("consent") || !data.get("consent").getAsBoolean()) return diagnosticMessage(Response.Status.BAD_REQUEST, "需要确认限时统计"); - DiagnosticControls.startDepth(data.has("seconds") ? data.get("seconds").getAsInt() : 60); - } - case "/debug/diag/stop" -> DiagnosticCapture.stop("user-stopped"); - case "/debug/diag/fel-bind" -> { - if (!data.has("consent") || !data.get("consent").getAsBoolean()) return diagnosticMessage(Response.Status.BAD_REQUEST, "需要确认暂停播放进行对照"); - DiagnosticControls.startFelBindProbe(); - } - case "/debug/diag/fel-bind-stop" -> DiagnosticControls.stopFelBindProbe(); - case "/debug/diag/export" -> { return archive(); } - default -> { return diagnosticMessage(Response.Status.NOT_FOUND, "未找到操作"); } - } - return diagnosticMessage(Response.Status.OK, "已记录"); - } catch (RuntimeException error) { - return diagnosticMessage(Response.Status.BAD_REQUEST, error instanceof IllegalStateException || error instanceof IllegalArgumentException ? error.getMessage() : "无效请求"); - } - } - - private String field(JsonObject value, String key) { return value.has(key) && value.get(key).isJsonPrimitive() ? value.get(key).getAsString() : ""; } - - private Response diagnosticMessage(Response.Status status, String message) { - JsonObject value = new JsonObject(); value.addProperty("ok", status == Response.Status.OK); value.addProperty("message", message); - return noCache(NanoHTTPD.newFixedLengthResponse(status, "application/json; charset=utf-8", value.toString()), null); - } - - private Response archive() { - if (!DebugLogStore.isEnabled()) return diagnosticMessage(Response.Status.BAD_REQUEST, "请先开启调试日志"); - if (!archiveBusy.compareAndSet(false, true)) return diagnosticMessage(Response.Status.SERVICE_UNAVAILABLE, "已有诊断包正在导出,请稍后重试"); - DiagnosticLogBuffer.Export source = DebugLogStore.export(); - try { - try (java.io.InputStream ignored = source.openAgain()) { /* Reject a non-repeatable snapshot before HTTP 200. */ } - java.io.PipedInputStream input = new java.io.PipedInputStream(64 << 10); - java.io.PipedOutputStream output = new java.io.PipedOutputStream(input); - java.util.concurrent.ScheduledFuture expiry = exportTimer.schedule(() -> { - try { input.close(); output.close(); } catch (java.io.IOException ignored) {} - }, 60, java.util.concurrent.TimeUnit.SECONDS); - Thread worker = new Thread(() -> { - try (source; output) { DiagnosticReport.archive(source, output); } - catch (java.io.IOException | RuntimeException ignored) { DebugLogStore.collectorFailure(); } - finally { expiry.cancel(false); archiveBusy.set(false); } - }, "diagnostic-report-export"); - worker.setDaemon(true); worker.start(); - Response response = NanoHTTPD.newChunkedResponse(Response.Status.OK, "application/zip", input); - response.addHeader("Content-Disposition", "attachment; filename=webhtv-av-report.zip"); - response.addHeader("X-Content-Type-Options", "nosniff"); - return noCache(response, null); - } catch (java.io.IOException | RuntimeException error) { - try { source.close(); } catch (java.io.IOException ignored) {} - archiveBusy.set(false); - return diagnosticMessage(Response.Status.INTERNAL_ERROR, "诊断包生成失败,请重试 TXT 下载"); - } - } - private Response stream(IHTTPSession session) { - if (session.getParms().containsKey("afterSeq")) return incrementalStream(session); long version = DebugLogStore.version(); boolean unchanged = version == paramLong(session, "v", -1); String text = unchanged ? null : DebugLogStore.text(); @@ -184,26 +87,6 @@ public class DebugLogs implements Process { return noCache(response, null); } - private Response incrementalStream(IHTTPSession session) { - DiagnosticLogBuffer.Snapshot snapshot = DebugLogStore.incremental(paramLong(session, "afterSeq", -1), - session.getParms().get("run"), paramLong(session, "generation", -1)); - JsonObject result = new JsonObject(); - result.addProperty("enabled", DebugLogStore.isEnabled()); - if (snapshot == null) { - result.addProperty("reset", true); result.addProperty("text", "调试日志未开启\n"); - result.addProperty("runId", ""); result.addProperty("generation", -1); - result.addProperty("newestSeq", -1); result.addProperty("size", 0); result.addProperty("bytes", 0); - } else { - result.addProperty("runId", snapshot.runId()); result.addProperty("generation", snapshot.generation()); - result.addProperty("version", snapshot.version()); result.addProperty("oldestSeq", snapshot.oldestSeq()); - result.addProperty("newestSeq", snapshot.newestSeq()); result.addProperty("reset", snapshot.reset()); - result.addProperty("gap", snapshot.gap()); result.addProperty("text", snapshot.text()); - result.addProperty("size", snapshot.lines().size()); result.addProperty("bytes", snapshot.health().get("diskBytes").getAsLong()); - result.add("health", snapshot.health()); - } - return noCache(NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", result.toString()), null); - } - private long paramLong(IHTTPSession session, String key, long fallback) { try { return Long.parseLong(session.getParms().get(key)); @@ -224,82 +107,48 @@ public class DebugLogs implements Process { String localUrl = Server.get().getAddress("/debug/logs"); String lanUrl = Server.get().getAddress(false) + "/debug/logs"; boolean enabled = DebugLogStore.isEnabled(); - return "" + return "" + + "" + "" - + "调试日志" - + "

调试日志

" + (enabled ? "记录中" : "已关闭") + " · " + DebugLogStore.bytes() / 1024 + " KB
" - + "
AI诊断下载
" - + "" - + "
" + + "调试日志" + + "" + + "

调试日志

刷新下载AI诊断清空" + (enabled ? "关闭" : "开启") + "" + (enabled ? "开启" : "关闭") + " · " + DebugLogStore.size() + " 行 · " + DebugLogStore.bytes() / 1024 + " KB
" + + "
地址和说明

本页显示 App 当前进程内调试日志。开启后记录安卓系统版本、设备型号、WebView 版本、WebHome、SDK、HTTP 服务、爬虫请求和播放链路;关闭会自动清空。

" + + "
" + + "
" + + "
" + "
" + logs + "
" - + diagnosticHtml(localUrl, lanUrl, enabled) + "" - + ""; + + "" + + ""; } private String css() { - return """ - html{box-sizing:border-box;overflow-x:hidden;background:#f4f6f8;color:#1f2328;font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif} - *,*:before,*:after{box-sizing:inherit;min-width:0}[hidden]{display:none!important} - body{margin:0}body.drawer-open{overflow:hidden}main{width:100%;padding:0 8px 8px} - button,a,input,select{font:inherit}button,a{touch-action:manipulation} - button,a.action{display:inline-flex;align-items:center;justify-content:center;min-height:40px;appearance:none;border:1px solid #d0d7de;border-radius:7px;background:#fff;color:#24292f;padding:6px 12px;cursor:pointer;white-space:nowrap;text-decoration:none} - button:hover,a.action:hover{background:#f1f5f9}button.on,button.filtered{background:#ddf4ff;border-color:#80ccff;color:#0969da} - a:focus-visible,button:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid #0969da;outline-offset:2px} - .console-head{position:sticky;top:0;z-index:20;display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"brand actions" "tabs tabs" "search search";align-items:center;column-gap:8px;padding:4px 8px 0;margin-bottom:8px;background:#fff;border-bottom:1px solid #d8dee4;box-shadow:0 2px 8px #1f232808} - .brand{grid-area:brand;overflow:hidden}h1{margin:0;font-size:16px;line-height:21px;font-weight:650}.meta{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:11px;line-height:16px;color:#656d76}.meta.partial{color:#9a6700} - .primary-actions{grid-area:actions;display:flex;gap:4px}.primary-actions button,.primary-actions a.action,#search-toggle{min-width:44px;min-height:44px;padding:6px 9px;border-color:transparent;font-size:13px}.primary-actions .danger{color:#cf222e} - .count{margin-left:4px;min-width:16px;padding:0 4px;border-radius:8px;background:#0969da;color:#fff;font-size:11px;line-height:16px} - .category-row{grid-area:tabs;display:flex;align-items:center}.category-row>button{flex:0 0 auto}.tabs{flex:1;display:flex;gap:0;overflow-x:auto;overflow-y:hidden;flex-wrap:nowrap;white-space:nowrap;scrollbar-width:none;-webkit-overflow-scrolling:touch}.tabs::-webkit-scrollbar{display:none} - .tabs .chip{flex:0 0 auto;min-height:44px;padding:7px 12px;border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;color:#57606a;font-size:13px} - .tabs .chip.on{color:#0969da;border-bottom-color:#0969da;font-weight:650}.tabs .chip:focus-visible{outline-offset:-3px} - .search-panel{grid-area:search;display:flex;align-items:center;gap:6px;padding:4px 0 8px} - input:not([type=checkbox]),select{min-width:0;width:100%;max-width:100%;min-height:40px;border:1px solid #d0d7de;border-radius:7px;padding:8px 10px;background:#fff;color:inherit} - #filter{flex:1;width:100%;font-size:16px}.search-panel button{min-height:40px;font-size:13px} - .drawer-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:100;background:#1f232866} - .log-drawer{position:fixed;top:0;right:0;bottom:0;left:auto;z-index:101;display:none;flex-direction:column;width:460px;max-width:100%;height:100%;max-height:100%;margin:0;padding:0;border:0;background:#fff;color:inherit;box-shadow:-8px 0 32px #1f232826} - .log-drawer[open]{display:flex}.log-drawer::backdrop{background:#1f232866} - .drawer-head{display:flex;flex:0 0 auto;align-items:center;justify-content:space-between;gap:8px;padding:10px 16px 4px}.drawer-head h2{margin:0;font-size:18px}.drawer-head button{width:44px;height:44px;padding:0;border:0;font-size:24px} - .tool-tabs{display:flex;flex:0 0 auto;padding:0 16px;border-bottom:1px solid #d8dee4}.tool-tabs button{flex:1;min-height:44px;border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;color:#656d76}.tool-tabs button[aria-selected=true]{border-bottom-color:#0969da;color:#0969da;font-weight:650} - .drawer-body{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:16px}.drawer-body h3{margin:0 0 10px;font-size:14px} - .field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.field{display:flex;flex-direction:column;gap:5px;color:#57606a;font-size:13px} - .field select,.field input{color:#24292f}.form-section+.form-section{margin-top:20px;padding-top:16px;border-top:1px solid #eaeef2} - .action-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:10px}.action-row>input{flex:1;min-width:120px}.action-row>select{flex:1;min-width:100px}.action-row>.primary{background:#0969da;border-color:#0969da;color:white} - .simple{display:flex;align-items:center;gap:9px;min-height:44px;cursor:pointer}.simple input{flex:0 0 18px;width:18px;height:18px;margin:0;padding:0} - .hint{margin:8px 0;color:#656d76;font-size:12px;line-height:1.6}.hint:empty{display:none}.summary{white-space:normal;overflow-wrap:anywhere} - .drawer-feedback{flex:0 0 auto;max-height:100px;overflow:auto;margin:0;padding:10px 16px;color:#0969da;background:#f0f7ff;font-size:13px;line-height:1.5}.drawer-feedback:empty{display:none} - .addr{display:grid;gap:8px;font-size:12px}.addr a{display:block;padding:10px;border-radius:7px;background:#f6f8fa;color:#0969da;overflow-wrap:anywhere;word-break:break-all;text-decoration:none} - .logs{display:grid;gap:6px;width:100%;overflow:hidden}.fallback{margin:0} - .entry,.fallback{width:100%;overflow:hidden;background:#fff;border:1px solid #d8dee4;border-radius:7px;padding:8px 10px} - .entry.ok{border-left:4px solid #1a7f37}.entry.warn{border-left:4px solid #bf8700}.entry.err{border-left:4px solid #cf222e}.entry.raw{border-left:4px solid #8c959f} - .top{display:flex;gap:8px;align-items:center;overflow:hidden}.badge{flex:0 0 auto;border-radius:999px;padding:2px 7px;background:#eaeef2;color:#57606a;font-size:12px} - .entry.ok .badge{background:#dafbe1;color:#116329}.entry.warn .badge{background:#fff8c5;color:#7d4e00}.entry.err .badge{background:#ffebe9;color:#cf222e} - .title{font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.time{margin-left:auto;color:#8c959f;font-size:12px;white-space:nowrap} - .detail,.rawline,.fallback{overflow-wrap:anywhere;word-break:break-all;white-space:pre-wrap}.detail{margin-top:4px;color:#57606a} - .rawline{display:block;margin-top:6px;padding-top:6px;border-top:1px dashed #d8dee4;color:#6e7781} - .rawline,.fallback{font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}body.simple .rawline{display:none} - .backtop{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom));z-index:20;height:38px;min-width:72px;padding:0 13px;border-radius:999px;border-color:#d0d7de;background:rgba(255,255,255,.94);color:#0969da;font-size:14px;font-weight:650;line-height:1;box-shadow:0 6px 18px rgba(31,35,40,.16)}.backtop:active{background:#f6f8fa}.backtop:focus-visible{outline:3px solid rgba(9,105,218,.22);outline-offset:2px} - @media(max-width:680px){.backtop{right:12px;bottom:calc(12px + env(safe-area-inset-bottom));height:36px;min-width:64px;padding:0 11px;font-size:13px}} - @media(min-width:800px){.console-head{grid-template-columns:minmax(0,1fr) 260px auto;grid-template-areas:"brand search actions" "tabs tabs tabs"}.search-panel{padding:0}#search-hide{display:none}.tabs .chip{min-height:36px}.primary-actions button,.primary-actions a.action{min-height:40px}} - @media(max-width:600px){.log-drawer{top:auto;right:0;bottom:0;left:0;width:100%;height:auto;max-height:88vh;max-height:88dvh;border-radius:16px 16px 0 0;box-shadow:0 -8px 32px #1f232826}.drawer-body{padding-bottom:20px;padding-bottom:max(20px,env(safe-area-inset-bottom))}.drawer-body button,.drawer-body a.action,.drawer-body select,.drawer-body input:not([type=checkbox]){min-height:44px}} - @media(max-width:480px){main{padding:0 4px 4px}.console-head{padding:4px 6px 0}.title{white-space:normal}.time{display:none}.entry{padding:7px 8px}.detail{font-size:13px}} - """; + return "html,body{box-sizing:border-box;width:100%;max-width:100%;margin:0;overflow-x:hidden;background:#f4f6f8;color:#1f2328;font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;}*,*:before,*:after{box-sizing:inherit;min-width:0}body{position:relative}" + + ".topbar{box-sizing:border-box;display:flex;flex-wrap:wrap;gap:8px;align-items:center;width:100%;max-width:100%;overflow:hidden;margin:0 0 8px;padding:8px;background:#fff;border:1px solid #d8dee4;border-radius:8px;box-shadow:0 2px 10px rgba(31,35,40,.04)}" + + "h1{margin:0 10px 0 0;font-size:17px;font-weight:650;white-space:nowrap}.meta{margin-left:auto;color:#656d76;font-size:12px;white-space:nowrap}" + + "a,button{appearance:none;border:1px solid #d0d7de;border-radius:7px;background:#fff;color:#24292f;padding:6px 9px;text-decoration:none;font:inherit;cursor:pointer;white-space:nowrap}button.on,.chip.on{background:#0969da;border-color:#0969da;color:#fff}a:active,button:active{background:#eaeef2}main{box-sizing:border-box;width:100%;max-width:1280px;margin:0 auto;padding:8px;overflow-x:hidden}.info{box-sizing:border-box;max-width:100%;overflow:hidden;margin:0 0 8px;padding:7px 9px;background:#fff;border:1px solid #d8dee4;border-radius:8px}.info summary{cursor:pointer;color:#57606a;font-size:12px}.hint{margin:8px 0;color:#656d76;font-size:12px}" + + ".addr{display:grid;grid-template-columns:minmax(0,1fr);gap:6px;margin:0;color:#57606a;font-size:12px}.addr a{display:block;min-width:0;max-width:100%;overflow:hidden;white-space:normal;overflow-wrap:anywhere;word-break:break-all;padding:7px 9px;background:#f6f8fa}" + + ".tools{box-sizing:border-box;width:100%;max-width:100%;overflow:hidden;margin:0 0 8px;padding:8px;background:#fff;border:1px solid #d8dee4;border-radius:8px;box-shadow:0 2px 10px rgba(31,35,40,.04)}.chips{display:flex;flex-wrap:wrap;gap:6px;overflow:hidden;padding-bottom:2px}.chip{flex:0 0 auto}.search{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px;align-items:center;margin-top:6px}input{box-sizing:border-box;width:100%;min-width:0;border:1px solid #d0d7de;border-radius:7px;padding:7px 9px;font:inherit}.simple{display:flex;align-items:center;gap:4px;color:#57606a;font-size:12px;white-space:nowrap}.summary{margin-top:6px;color:#57606a;font-size:12px;white-space:normal;overflow-wrap:anywhere;word-break:break-all}" + + ".logs{box-sizing:border-box;display:grid;gap:8px;width:100%;max-width:100%;min-width:0;overflow:hidden}.fallback{box-sizing:border-box;max-width:100%;overflow:hidden;margin:0;background:#fff;border:1px solid #d8dee4;border-radius:8px;padding:10px;color:#57606a;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-all}.entry{box-sizing:border-box;width:100%;max-width:100%;min-width:0;overflow:hidden;background:#fff;border:1px solid #d8dee4;border-radius:8px;padding:9px 10px}.entry.ok{border-left:4px solid #1a7f37}.entry.warn{border-left:4px solid #bf8700}.entry.err{border-left:4px solid #cf222e}.entry.raw{border-left:4px solid #8c959f}.top{display:flex;gap:8px;align-items:center;max-width:100%;min-width:0;overflow:hidden}.badge{flex:0 0 auto;border-radius:999px;padding:2px 7px;background:#eaeef2;color:#57606a;font-size:12px}.entry.ok .badge{background:#dafbe1;color:#116329}.entry.err .badge{background:#ffebe9;color:#cf222e}.title{min-width:0;font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.time{margin-left:auto;color:#8c959f;font-size:12px;white-space:nowrap}.detail{box-sizing:border-box;max-width:100%;min-width:0;overflow:hidden;margin-top:5px;color:#57606a;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-all}.rawline{display:block;box-sizing:border-box;width:100%;max-width:100%;min-width:0;overflow:hidden;margin-top:6px;padding-top:6px;border-top:1px dashed #d8dee4;color:#6e7781;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-all}body.simple .rawline{display:none}" + + ".backtop{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom));z-index:20;height:38px;min-width:72px;padding:0 13px;border-radius:999px;border-color:#d0d7de;background:rgba(255,255,255,.94);color:#0969da;font-size:14px;font-weight:650;line-height:1;box-shadow:0 6px 18px rgba(31,35,40,.16)}.backtop:active{background:#f6f8fa}.backtop:focus-visible{outline:3px solid rgba(9,105,218,.22);outline-offset:2px}" + + "@media(max-width:680px){.topbar{padding:7px 8px}h1{font-size:16px}.meta{flex-basis:100%;margin-left:0}.addr{grid-template-columns:1fr}.search{grid-template-columns:minmax(0,1fr) auto auto}.title{white-space:normal}.time{display:none}.entry{padding:8px}.detail{font-size:13px}.backtop{right:12px;bottom:calc(12px + env(safe-area-inset-bottom));height:36px;min-width:64px;padding:0 11px;font-size:13px}}"; } private String scriptEnhanced() { - return "const rawEl=document.getElementById('raw'),logs=document.getElementById('logs'),meta=document.getElementById('meta'),summary=document.getElementById('summary'),filter=document.getElementById('filter'),simple=document.getElementById('simple'),pause=document.getElementById('pause'),download=document.getElementById('download');" - + "let raw=rawEl.textContent,mode='all',paused=false,stick=true,lastVersion=Number(meta.dataset.version||0),lastSeq=-1,lastRun='',lastGeneration=-1;simple.checked=false;document.body.classList.remove('simple');addEventListener('scroll',()=>{if(toolsOpen())return;resetX();stick=(innerHeight+scrollY)>=(document.body.scrollHeight-80)},{passive:true});setInterval(resetX,500);" - + "document.querySelectorAll('.chip').forEach(b=>{b.setAttribute('aria-pressed',b.classList.contains('on'));b.tabIndex=b.classList.contains('on')?0:-1;b.onclick=()=>selectCategory(b)});filter.oninput=()=>{render();updateFilterState()};simple.onchange=()=>{document.body.classList.toggle('simple',simple.checked);render();resetX()};pause.onclick=()=>{paused=!paused;pause.textContent=paused?'继续':'暂停';pause.classList.toggle('on',paused);pause.setAttribute('aria-pressed',paused)};" - + "download.onclick=()=>{paused=true;pause.textContent='继续';pause.classList.add('on');pause.setAttribute('aria-pressed','true')};" + return "const rawEl=document.getElementById('raw'),logs=document.getElementById('logs'),meta=document.getElementById('meta'),summary=document.getElementById('summary'),filter=document.getElementById('filter'),simple=document.getElementById('simple'),pause=document.getElementById('pause'),download=document.getElementById('download'),topBtn=document.getElementById('topBtn');" + + "let raw=rawEl.textContent,mode='all',paused=false,stick=true,lastVersion=Number(meta.dataset.version||0);simple.checked=false;document.body.classList.remove('simple');addEventListener('scroll',()=>{resetX();stick=(innerHeight+scrollY)>=(document.body.scrollHeight-80)},{passive:true});setInterval(resetX,500);" + + "document.querySelectorAll('.chip').forEach(b=>b.onclick=()=>{document.querySelectorAll('.chip').forEach(x=>x.classList.remove('on'));b.classList.add('on');mode=b.dataset.mode;render()});filter.oninput=render;simple.onchange=()=>{document.body.classList.toggle('simple',simple.checked);render();resetX()};pause.onclick=()=>{paused=!paused;pause.textContent=paused?'继续':'暂停';pause.classList.toggle('on',paused)};" + + "download.onclick=()=>{paused=true;pause.textContent='继续';pause.classList.add('on')};topBtn.onclick=scrollToTop;" + "function esc(s){return String(s||'').replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]))}" + "function resetX(){try{const y=scrollY||pageYOffset||0;(document.scrollingElement||document.documentElement).scrollLeft=0;document.documentElement.scrollLeft=0;document.body.scrollLeft=0;if((document.scrollingElement||document.documentElement).scrollLeft||document.body.scrollLeft)scrollTo(0,y)}catch(e){}}" + + "function scrollToTop(){stick=false;try{scrollTo({top:0,behavior:'smooth'})}catch(e){scrollTo(0,0);document.documentElement.scrollTop=0;document.body.scrollTop=0}resetX()}" + "function part(s,k){const i=s.indexOf(k);if(i<0)return'';let v=s.slice(i+k.length),e=v.length;[' ',',',']'].forEach(c=>{const p=v.indexOf(c);if(p>=0&&p','')}" + "function parse(line){const a=line.indexOf(' ['),b=line.indexOf('] ',a+2),c=line.indexOf(': ',b+2);return{line,time:a>0?line.slice(0,a):'',thread:a>0&&b>0?line.slice(a+2,b):'',tag:b>0&&c>0?line.slice(b+2,c):'',msg:c>0?line.slice(c+2):line}}" - + "function base(r){const e={kind:'raw',state:'raw',badge:r.tag||'日志',title:r.tag||'原始日志',detail:r.msg||r.line,raw:r.line,time:r.time};if(r.tag==='av-diag'){try{e.diag=JSON.parse(r.msg)}catch(ignore){}}return e}" - + "function crawlerConsole(r){const name=r.tag==='quickjs'?'QuickJS':r.tag==='python-spider'?'Python':'';if(!name)return null;const e=base(r),match=/^\\[(DEBUG|INFO|WARN|ERROR|STDERR)\\] ?/.exec(r.msg),level=match?match[1]:'';e.kind='console';e.badge=name;e.detail=match?r.msg.slice(match[0].length):r.msg;e.state=level==='ERROR'?'err':level==='WARN'?'warn':level==='STDERR'?'raw':'ok';if(!match)e.state=/error|exception|uncaught/i.test(r.msg)?'err':/warning|warn/i.test(r.msg)?'warn':'raw';e.title=name+(level==='STDERR'?' 标准错误流':level?' · '+level:' 控制台输出');return e}" - + "function explain(r){const crawler=crawlerConsole(r);if(crawler)return crawler;const text=(r.tag+': '+r.msg),low=text.toLowerCase();let e=base(r);if(r.tag==='av-diag'){try{const d=JSON.parse(r.msg);if(d.schemaVersion===1&&typeof d.event==='string'){e.kind='diagnostic';e.state=d.level==='error'?'err':d.level==='warn'?'warn':'raw';e.badge='音视频诊断';e.title=d.event;e.detail=JSON.stringify(d);return e}}catch(ignore){}}if(r.tag==='webview-console'||r.tag==='webhome-console'){e.kind='console';e.state=(low.includes('error')||low.includes('exception')||low.includes('uncaught'))?'err':(low.includes('warning')||low.includes('warn'))?'warn':'ok';e.badge='Console';e.title=r.tag==='webhome-console'?'WebHome 控制台输出':'网页控制台输出';e.detail=r.msg;return e}if(r.tag==='server'){e.kind='server';e.state='raw';e.badge='服务';e.title='App 本机 HTTP 服务收到请求';e.detail=r.msg;return e}if(r.tag&&r.tag.startsWith('ai-')){e.kind='ai';e.state=low.includes('failed')||low.includes('error')||low.includes('success=false')?'err':'ok';e.badge='AI';e.title=r.msg.includes('ai request')?'AI 调用入参':r.msg.includes('ai response')?'AI 调用出参':r.msg.includes('ai error')?'AI 调用异常':'AI 调用状态';e.detail=r.msg;return e}if(low.includes('error')||low.includes('exception')||low.includes('failed')||low.includes('timeout')||low.includes('失败')||low.includes('崩溃')||low.includes('异常')){e.kind='error';e.state='err';e.badge='错误';e.title='发现错误或异常';return e}" + + "function base(r){return{kind:'raw',state:'raw',badge:r.tag||'日志',title:r.tag||'原始日志',detail:r.msg||r.line,raw:r.line,time:r.time}}" + + "function explain(r){const text=(r.tag+': '+r.msg),low=text.toLowerCase();let e=base(r);if(r.tag==='webview-console'||r.tag==='webhome-console'){e.kind='console';e.state=(low.includes('error')||low.includes('exception')||low.includes('uncaught'))?'err':(low.includes('warning')||low.includes('warn'))?'warn':'ok';e.badge='Console';e.title=r.tag==='webhome-console'?'WebHome 控制台输出':'网页控制台输出';e.detail=r.msg;return e}if(r.tag==='server'){e.kind='server';e.state='raw';e.badge='服务';e.title='App 本机 HTTP 服务收到请求';e.detail=r.msg;return e}if(r.tag&&r.tag.startsWith('ai-')){e.kind='ai';e.state=low.includes('failed')||low.includes('error')||low.includes('success=false')?'err':'ok';e.badge='AI';e.title=r.msg.includes('ai request')?'AI 调用入参':r.msg.includes('ai response')?'AI 调用出参':r.msg.includes('ai error')?'AI 调用异常':'AI 调用状态';e.detail=r.msg;return e}if(low.includes('error')||low.includes('exception')||low.includes('failed')||low.includes('timeout')||low.includes('失败')||low.includes('崩溃')||low.includes('异常')){e.kind='error';e.state='err';e.badge='错误';e.title='发现错误或异常';return e}" + "if(r.tag==='startup'){e.kind='startup';e.state='ok';e.badge='启动';e.title='启动阶段耗时';e.detail=r.msg;return e}" + "if(r.tag==='debug'){e.kind='server';e.state='ok';e.badge='调试';e.title=r.msg.includes('ready')?'调试日志服务已准备':'调试日志状态变化';e.detail=r.msg;return e}" + "if(r.tag==='env'){e.kind='startup';e.state='ok';e.badge='环境';e.title='设备和系统环境';e.detail=r.msg;return e}" @@ -321,101 +170,9 @@ public class DebugLogs implements Process { + "if(r.tag==='okhttp-player'&&r.msg.includes('response')){e.kind='player';e.state='ok';e.badge='响应';e.title='播放器收到响应';e.detail='状态 '+(part(r.msg,'code=')||'-')+' · '+(part(r.msg,'contentType=')||'')+' · '+(part(r.msg,'url=')||'');return e}" + "if(r.tag==='okhttp-player'&&r.msg.includes('start')){e.kind='player';e.state='raw';e.badge='播放';e.title='播放器开始请求';e.detail=part(r.msg,'url=')||r.msg;return e}" + "if(['player','player-engine','playback-flow','exo-source'].includes(r.tag)){e.kind='player';e.state=low.includes('error')?'err':'ok';e.badge='播放';e.title=r.tag==='playback-flow'?'播放页面/服务链路':r.tag==='player-engine'?'播放器内核事件':r.tag==='exo-source'?'媒体源创建':'播放解析/状态';e.detail=r.msg;return e}return e}" - + "function pass(e,key){if(!diagPass(e))return false;const all=(e.raw+' '+e.title+' '+e.detail).toLowerCase();if(key&&!all.includes(key))return false;if(mode==='all')return !(e.kind==='server'&&e.state==='raw');if(mode==='error')return e.kind==='error'||e.state==='err'||e.diag&&e.diag.level==='fatal';return e.kind===mode}" - + "function render(){try{const key=filter.value.trim().toLowerCase();const rows=raw.split('\\n').filter(Boolean).map(parse).map(explain);let shown=0,hit=0,err=0,playerProxy=0,webview=0,consoleCount=0,api=0;const html=[];rows.forEach(e=>{if(e.kind==='proxy'&&e.title.includes('命中'))hit++;if(e.kind==='error'||e.state==='err')err++;if(e.kind==='player'&&(e.title.includes('代理')||e.raw.includes('via proxy')))playerProxy++;if(e.kind==='webview')webview++;if(e.kind==='console')consoleCount++;if(e.kind==='api')api++;if(!pass(e,key))return;shown++;html.push('
'+esc(e.badge)+''+esc(e.title)+''+esc(e.time)+'
'+esc(e.detail)+'
'+esc(e.raw)+'
')});logs.innerHTML=html.join('')||'
没有匹配日志
';summary.textContent='显示 '+shown+'/'+rows.length+' 行 · 错误 '+err+' 条 · 代理命中 '+hit+' 次 · 播放代理链路 '+playerProxy+' 次 · Console '+consoleCount+' 条 · WebView '+webview+' 条 · 站源 '+api+' 条';summary.title=summary.textContent;rawEl.hidden=true;resetX()}catch(err){rawEl.hidden=false;logs.innerHTML='
日志页面渲染失败,已显示原始日志:'+esc(err&&err.message?err.message:err)+'
';summary.textContent='渲染失败 · 已显示原始日志';resetX()}}" - + "async function poll(){try{if(!paused){const r=await fetch('/debug/stream?afterSeq='+lastSeq+'&run='+encodeURIComponent(lastRun)+'&generation='+lastGeneration,{cache:'no-store'});const j=await r.json();lastSeq=j.newestSeq;lastRun=j.runId;lastGeneration=j.generation;const h=j.health||{};meta.textContent=(h.completeness==='partial'?'日志不完整':j.enabled?'记录中':'已关闭')+' · '+Math.ceil((j.bytes||0)/1024)+' KB';meta.classList.toggle('partial',h.completeness==='partial');meta.title=h.completeness==='partial'?'日志不完整,详见下载说明':meta.textContent;if(j.reset||j.gap||j.text){raw=(j.reset||j.gap?'':raw)+(j.text||'');if(raw.length>524288){const cut=raw.indexOf('\\n',raw.length-524288);raw=cut<0?'':raw.slice(cut+1)}rawEl.textContent=raw;render();if(stick&&!toolsOpen())scrollTo(0,document.body.scrollHeight)}}}catch(e){meta.textContent='日志连接失败,稍后重试'}setTimeout(poll,1500)}render();poll();"; - } - - private String diagnosticHtml(String localUrl, String lanUrl, boolean enabled) { - return String.format(java.util.Locale.ROOT, """ - - -

日志工具

- -
-
-

音视频筛选

- - - - -

按播放记录和音视频链路缩小范围。

-

显示方式

-
- - -
-

-
- """, enabled ? "/debug/disable" : "/debug/enable", enabled ? "关闭采集" : "开启采集", - escape(localUrl), escape(localUrl), escape(lanUrl), escape(lanUrl)); - } - - private String diagnosticScript() { - return """ - function diagEl(id){return document.getElementById('diag-'+id)} - function diagSay(text){diagEl('feedback').textContent=text;if(!toolsOpen())openTools('diagnostics')} - async function diagPost(path,data,binary){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data||{}),cache:'no-store'});if(!r.ok){let j;try{j=await r.json()}catch(e){}throw Error(j&&j.message||'操作失败,请重试')}return binary?r.blob():r.json()} - function diagRun(action){return Promise.resolve().then(action).catch(e=>diagSay(e.message||'操作失败'))} - diagEl('mark').onclick=()=>diagRun(async()=>{await diagPost('/debug/diag/mark',{symptom:diagEl('symptom').value});diagSay('已标记,继续记录后 15 秒;随后下载可包含故障前后上下文')}); - diagEl('deep').onclick=()=>{if(confirm('开启本次播放的 60 秒深度统计?只记录低分辨率画面和 PCM 数值,不保存图像或声音;到期自动停止。'))diagRun(async()=>{await diagPost('/debug/diag/deep',{seconds:60,consent:true});diagSay('已开启限时统计')})}; - diagEl('stop').onclick=()=>diagRun(async()=>{await diagPost('/debug/diag/stop');diagSay('深度统计已停止')}); - diagEl('fel-bind').onclick=()=>{if(confirm('暂时暂停当前播放,运行最多 30 秒的 FEL 绑定对照?结束后恢复播放,不读取或保存画面。'))diagRun(async()=>{await diagPost('/debug/diag/fel-bind',{consent:true});diagEl('fel-bind').disabled=true;diagSay('已请求绑定对照,结果将写入日志')})}; - diagEl('fel-cancel').onclick=()=>diagRun(async()=>{await diagPost('/debug/diag/fel-bind-stop');diagSay('已请求取消,正在释放诊断资源')}); - diagEl('zip').onclick=()=>diagRun(async()=>{diagSay('正在生成诊断包…');const blob=await diagPost('/debug/diag/export',{},true);if(!blob.size)throw Error('诊断包为空,请重试 TXT 下载');const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='webhtv-av-report.zip';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);diagSay('诊断包已生成,含日志、可读报告、结构化事件和校验信息')}); - document.querySelectorAll('[data-debug-action]').forEach(button=>button.onclick=()=>diagRun(async()=>{const r=await fetch(button.dataset.debugAction,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}',cache:'no-store'});if(!r.ok){const j=await r.json();throw Error(j.message)}location.reload()})); - function diagPass(e){const trace=diagEl('trace').value,attempt=diagEl('attempt').value,domain=diagEl('domain').value,priority=diagEl('priority').value;if(!trace&&!attempt&&!domain&&!priority)return true;const d=e.diag;if(!d)return false;if(trace&&d.trace!==trace||attempt&&String(d.attemptId)!==attempt)return false;const name=d.event||'',p=d.observed&&d.observed.property&&d.observed.property.value||'';if(domain==='video'&&!/video|surface|display|pixel/.test(name+' '+p))return false;if(domain==='audio'&&!/audio|pcm|volume|mute|avsync/.test(name+' '+p))return false;if(priority==='critical'&&d.priority!=='critical'&&!/error|fatal|warn/.test(d.level))return false;if(priority==='error'&&!/error|fatal/.test(d.level))return false;return true} - ['trace','attempt','domain','priority'].forEach(id=>diagEl(id).onchange=()=>{updateFilterState();render()}); - function diagOptions(id,values,label){const el=diagEl(id),selected=el.value,all=Array.from(values).slice(-100);if(selected&&!all.includes(selected))all.push(selected);const signature=all.join('|');if(el.dataset.signature===signature)return;el.dataset.signature=signature;el.textContent='';const first=document.createElement('option');first.value='';first.textContent=label;el.appendChild(first);all.forEach(value=>{const option=document.createElement('option');option.value=value;option.textContent=value;el.appendChild(option)});el.value=selected} - function diagFilters(){const traces=new Set(),attempts=new Set();raw.split('\\n').forEach(line=>{const r=parse(line);if(r.tag!=='av-diag')return;try{const d=JSON.parse(r.msg);if(d.trace&&d.trace!=='none')traces.add(d.trace);if(d.attemptId)attempts.add(String(d.attemptId))}catch(e){}});diagOptions('trace',traces,'所有播放记录');diagOptions('attempt',attempts,'所有尝试')} - async function diagStatus(){try{const r=await fetch('/debug/diag/status',{cache:'no-store'}),j=await r.json();diagEl('status').textContent=(j.engine||'暂无播放')+(j.deepRemainingMs>0?' · 深度统计剩余 '+Math.ceil(j.deepRemainingMs/1000)+' 秒':' · 标准记录');const s=j.felBindProbeState||'unavailable',labels={unavailable:'当前播放不支持对照',ready:'可运行',queued:'等待播放器响应',armed:'等待下一帧',quiescing:'准备暂停播放',running:'正在测量',cancelling:'正在取消',completed:'已完成,结果见日志',cancelled:'已取消',failed:'未完成,请查看日志并确认正在播放', 'timed-out':'已到时间上限,结果见日志'};diagEl('fel-status').textContent=labels[s]||s;diagEl('fel-bind').disabled=s==='unavailable'||j.felBindProbeActive||j.deepRemainingMs>0;diagEl('fel-cancel').disabled=!j.felBindProbeActive;diagEl('deep').disabled=!!j.felBindProbeActive;diagFilters()}catch(e){}setTimeout(diagStatus,3000)}diagStatus(); - """; - } - - private String toolbarScript() { - return """ - const drawer=document.getElementById('log-drawer'),toolsButton=document.getElementById('tools-open'),backdrop=document.getElementById('drawer-backdrop'),searchPanel=document.getElementById('search-panel'),searchToggle=document.getElementById('search-toggle'),wideSearch=matchMedia('(min-width:800px)'); - let drawerOpener=null,activeTool='filters',searchExpanded=false; - function toolsOpen(){return document.getElementById('log-drawer').hasAttribute('open')} - function selectToolTab(name,focus){activeTool=name;document.querySelectorAll('[data-tool]').forEach(button=>{const active=button.dataset.tool===name;button.setAttribute('aria-selected',active);button.tabIndex=active?0:-1;document.getElementById(button.getAttribute('aria-controls')).hidden=!active;if(active&&focus)button.focus()});document.querySelector('.drawer-body').scrollTop=0} - function openTools(name){const opening=!toolsOpen();if(opening){drawerOpener=document.activeElement;document.body.classList.add('drawer-open');if(typeof drawer.showModal==='function')drawer.showModal();else{drawer.setAttribute('open','');backdrop.hidden=false;document.querySelector('main').setAttribute('aria-hidden','true')}}selectToolTab(name||activeTool,opening)} - function afterToolsClose(){document.body.classList.remove('drawer-open');backdrop.hidden=true;document.querySelector('main').removeAttribute('aria-hidden');if(drawerOpener&&document.contains(drawerOpener))drawerOpener.focus({preventScroll:true})} - function closeTools(){if(!toolsOpen())return;if(typeof drawer.close==='function')drawer.close();else{drawer.removeAttribute('open');afterToolsClose()}} - toolsButton.onclick=()=>openTools();document.getElementById('tools-close').onclick=closeTools;backdrop.onclick=closeTools; - drawer.addEventListener('close',afterToolsClose);drawer.addEventListener('cancel',event=>{event.preventDefault();closeTools()}); - drawer.addEventListener('click',event=>{if(event.target===drawer){const r=drawer.getBoundingClientRect();if(event.clientXr.right||event.clientYr.bottom)closeTools()}}); - drawer.addEventListener('keydown',event=>{if(event.key==='Escape'){event.preventDefault();closeTools()}if(event.key==='Tab'){const nodes=Array.from(drawer.querySelectorAll('button,a[href],input,select,summary,[tabindex]')).filter(el=>!el.disabled&&el.tabIndex>=0&&el.getClientRects().length);const first=nodes[0],last=nodes[nodes.length-1];if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus()}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus()}}}); - function arrowItem(event,buttons){const index=buttons.indexOf(event.target);if(index<0)return null;let next;if(event.key==='ArrowRight')next=(index+1)%buttons.length;else if(event.key==='ArrowLeft')next=(index+buttons.length-1)%buttons.length;else if(event.key==='Home')next=0;else if(event.key==='End')next=buttons.length-1;else return null;event.preventDefault();return buttons[next]} - document.querySelectorAll('[data-tool]').forEach(button=>button.onclick=()=>selectToolTab(button.dataset.tool,false)); - document.querySelector('.tool-tabs').onkeydown=event=>{const button=arrowItem(event,Array.from(document.querySelectorAll('[data-tool]')));if(button)selectToolTab(button.dataset.tool,true)}; - function selectCategory(button){document.querySelectorAll('.chip').forEach(item=>{const active=item===button;item.classList.toggle('on',active);item.setAttribute('aria-pressed',active);item.tabIndex=active?0:-1});mode=button.dataset.mode;render();if(!toolsOpen())button.scrollIntoView({block:'nearest',inline:'nearest'})} - document.querySelector('.tabs').onkeydown=event=>{const button=arrowItem(event,Array.from(document.querySelectorAll('.chip')));if(button){button.focus({preventScroll:true});selectCategory(button)}}; - function layoutSearch(){const open=wideSearch.matches||searchExpanded;searchPanel.hidden=!open;searchToggle.hidden=wideSearch.matches;searchToggle.setAttribute('aria-expanded',open)} - function hideSearch(){searchExpanded=false;layoutSearch();searchToggle.focus({preventScroll:true})} - searchToggle.onclick=()=>{searchExpanded=!searchExpanded;layoutSearch();if(searchExpanded)filter.focus()};document.getElementById('search-hide').onclick=hideSearch; - filter.addEventListener('keydown',event=>{if(event.key==='Escape'&&!wideSearch.matches){event.preventDefault();hideSearch()}}); - function scrollToTop(){stick=false;try{scrollTo({top:0,behavior:'smooth'})}catch(e){scrollTo(0,0);document.documentElement.scrollTop=0;document.body.scrollTop=0}resetX()} - topBtn.onclick=scrollToTop; - if(wideSearch.addEventListener)wideSearch.addEventListener('change',layoutSearch);else wideSearch.addListener(layoutSearch); - function updateFilterState(){const count=['trace','attempt','domain','priority'].filter(id=>diagEl(id).value).length+(filter.value.trim()?1:0),badge=document.getElementById('filter-count');badge.textContent=count;badge.hidden=!count;toolsButton.classList.toggle('filtered',count>0);toolsButton.setAttribute('aria-label',count?'日志工具,'+count+'项筛选生效':'日志工具');searchToggle.classList.toggle('on',!!filter.value.trim());searchToggle.title=filter.value.trim()?'搜索:'+filter.value.trim():'搜索日志'} - document.getElementById('filter-reset').onclick=()=>{filter.value='';['trace','attempt','domain','priority'].forEach(id=>diagEl(id).value='');updateFilterState();selectCategory(document.querySelector('[data-mode=all]'))}; - document.getElementById('info-toggle').onclick=()=>{const panel=document.getElementById('address-panel'),button=document.getElementById('info-toggle');panel.hidden=!panel.hidden;button.setAttribute('aria-expanded',!panel.hidden)}; - layoutSearch();updateFilterState(); - """; + + "function pass(e,key){const all=(e.raw+' '+e.title+' '+e.detail).toLowerCase();if(key&&!all.includes(key))return false;if(mode==='all')return !(e.kind==='server'&&e.state==='raw');if(mode==='error')return e.kind==='error'||e.state==='err';return e.kind===mode}" + + "function render(){try{const key=filter.value.trim().toLowerCase();const rows=raw.split('\\n').filter(Boolean).map(parse).map(explain);let shown=0,hit=0,err=0,playerProxy=0,webview=0,consoleCount=0,api=0,ai=0;const html=[];rows.forEach(e=>{if(e.kind==='proxy'&&e.title.includes('命中'))hit++;if(e.kind==='error'||e.state==='err')err++;if(e.kind==='player'&&(e.title.includes('代理')||e.raw.includes('via proxy')))playerProxy++;if(e.kind==='webview')webview++;if(e.kind==='console')consoleCount++;if(e.kind==='api')api++;if(e.kind==='ai')ai++;if(!pass(e,key))return;shown++;html.push('
'+esc(e.badge)+''+esc(e.title)+''+esc(e.time)+'
'+esc(e.detail)+'
'+esc(e.raw)+'
')});logs.innerHTML=html.join('')||'
没有匹配日志
';summary.textContent='显示 '+shown+'/'+rows.length+' 行 · 错误 '+err+' 条 · AI '+ai+' 条 · 代理命中 '+hit+' 次 · 播放代理链路 '+playerProxy+' 次 · Console '+consoleCount+' 条 · WebView '+webview+' 条 · 站源 '+api+' 条';rawEl.hidden=true;resetX()}catch(err){rawEl.hidden=false;logs.innerHTML='
日志页面渲染失败,已显示原始日志:'+esc(err&&err.message?err.message:err)+'
';summary.textContent='渲染失败 · 已显示原始日志';resetX()}}" + + "async function poll(){try{if(!paused){const r=await fetch('/debug/stream?v='+lastVersion+'&_='+Date.now(),{cache:'no-store'});const j=await r.json();lastVersion=j.version||lastVersion;meta.textContent=(j.enabled?'开启':'关闭')+' · '+j.size+' 行 · '+Math.ceil((j.bytes||0)/1024)+' KB';if(j.text!==null&&j.text!==undefined){raw=j.text||'';render();if(stick)scrollTo(0,document.body.scrollHeight)}}}catch(e){}setTimeout(poll,1500)}render();poll();"; } private String json(String text) { diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/M3u8.java b/app/src/main/java/com/fongmi/android/tv/server/process/M3u8.java index 297fc2d74f..a83eb632ba 100644 --- a/app/src/main/java/com/fongmi/android/tv/server/process/M3u8.java +++ b/app/src/main/java/com/fongmi/android/tv/server/process/M3u8.java @@ -2,16 +2,12 @@ package com.fongmi.android.tv.server.process; import android.text.TextUtils; -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.server.Nano; import com.fongmi.android.tv.server.impl.Process; import com.fongmi.android.tv.api.config.HlsRuleConfig; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.utils.HlsManifestCleaner; import com.fongmi.android.tv.utils.HlsAdblockPipeline; -import com.fongmi.android.tv.utils.HlsAdblockNotice; -import com.fongmi.android.tv.utils.Notify; import com.github.catvod.crawler.SpiderDebug; import com.github.catvod.net.OkHttp; @@ -91,16 +87,9 @@ public class M3u8 implements Process { if (!upstream.isSuccessful()) return error(status(upstream.code()), "Playlist HTTP " + upstream.code()); String text = body.string(); if (!looksLikePlaylist(text)) return Nano.error(Response.Status.BAD_REQUEST, "Invalid playlist"); - List rules = List.of(); - boolean legacyFallback = false; - if (Setting.isAdblock()) { - rules = hlsRules(); - legacyFallback = HlsRuleConfig.isLegacyFallbackEnabled(); - } HlsAdblockPipeline.Outcome clean = Setting.isAdblock() - ? HlsAdblockPipeline.apply(upstream.request().url().toString(), text, rules, legacyFallback) + ? HlsAdblockPipeline.apply(upstream.request().url().toString(), text, hlsRules(), true) : new HlsAdblockPipeline.Outcome(text, false, false, 0, 0); - recordAndNotify(upstream.request().url(), clean); String rewritten = rewrite(upstream.request().url(), clean.manifest()); byte[] bytes = rewritten.getBytes(StandardCharsets.UTF_8); SpiderDebug.log(TAG, "playlist bytes=%s rewritten=%s removed=%s structured=%s legacy=%s url=%s", @@ -109,19 +98,6 @@ public class M3u8 implements Process { } } - private void recordAndNotify(HttpUrl url, HlsAdblockPipeline.Outcome clean) { - if (!clean.structured() && !clean.legacy()) return; - long fallbackCount = clean.legacy() ? Math.max(1, clean.removedSegments()) : 0; - AdBlockStatsStore.recordBlocks(url.host(), "HLS", clean.ruleCounts(), fallbackCount, - url.host(), clean.removedDurationSec(), clean.removedSegmentDetails()); - if (!HlsAdblockNotice.shouldNotify(url.toString(), System.currentTimeMillis())) return; - int removed = clean.removedSegments() > 0 ? clean.removedSegments() : (int) fallbackCount; - String message = clean.structured() && clean.removedDurationSec() > 0 - ? String.format(Locale.US, "已跳过 %d 个广告片段(%.1f 秒)", removed, clean.removedDurationSec()) - : "已跳过 " + removed + " 个广告片段"; - App.post(() -> Notify.show(message)); - } - private List hlsRules() { return HlsRuleConfig.getRules(); } diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/Manage.java b/app/src/main/java/com/fongmi/android/tv/server/process/Manage.java index e0ddcd58e0..a7f7aaa2f1 100644 --- a/app/src/main/java/com/fongmi/android/tv/server/process/Manage.java +++ b/app/src/main/java/com/fongmi/android/tv/server/process/Manage.java @@ -22,7 +22,6 @@ import com.fongmi.android.tv.setting.CustomCspSetting; import com.fongmi.android.tv.setting.ProxySetting; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.utils.LoginStateSync; -import com.fongmi.android.tv.utils.MpvConfigSync; import com.fongmi.android.tv.utils.ProgressRequestBody; import com.fongmi.android.tv.utils.ScanTask; import com.fongmi.android.tv.utils.SyncFiles; @@ -556,13 +555,11 @@ public class Manage implements Process { SyncOptions options = SyncOptions.objectFrom(params.get("options")); if (params.containsKey("paths")) options.paths(params.get("paths")); SyncFiles.Archive archive = null; - MpvConfigSync.Archive mpvArchive = null; LoginStateSync.Archive loginArchive = null; try { if (!pull && SyncFiles.hasPaths(options)) archive = SyncFiles.createArchive(SyncFiles.getPaths(options)); - if (!pull && options.isMpvConfig()) mpvArchive = MpvConfigSync.createArchive(); if (!pull && options.isLoginState()) loginArchive = LoginStateSync.createArchive(); - RequestBody body = buildSyncBody(pull, options, archive, mpvArchive, loginArchive); + RequestBody body = buildSyncBody(pull, options, archive, loginArchive); String remote = device.replaceAll("/+$", "") + "/action?do=sync&mode=" + (pull ? "2" : "1") + "&type=backup"; SpiderDebug.log("sync", "manage start direction=%s device=%s options=%s archive=%s", pull ? "pull" : "push", device, options, archive == null ? "none" : archive.getFile().getAbsolutePath()); try (okhttp3.Response response = OkHttp.client(Constant.TIMEOUT_SYNC_TRANSFER).newCall(new Request.Builder().url(remote).post(body).build()).execute()) { @@ -581,11 +578,6 @@ public class Manage implements Process { object.addProperty("rawSize", archive.getRawSize()); object.addProperty("zipSize", archive.getZipSize()); } - if (mpvArchive != null) { - object.addProperty("mpvFiles", mpvArchive.getCount()); - object.addProperty("mpvRawSize", mpvArchive.getRawSize()); - object.addProperty("mpvZipSize", mpvArchive.getZipSize()); - } if (loginArchive != null) { object.addProperty("loginFiles", loginArchive.getCount()); object.addProperty("loginRawSize", loginArchive.getRawSize()); @@ -594,12 +586,11 @@ public class Manage implements Process { return json(object); } finally { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); } } - private RequestBody buildSyncBody(boolean pull, SyncOptions options, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive) { + private RequestBody buildSyncBody(boolean pull, SyncOptions options, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive) { if (pull) { FormBody.Builder body = new FormBody.Builder(); body.add("options", options.toString()); @@ -607,7 +598,7 @@ public class Manage implements Process { body.add("device", Device.get().toString()); return body.build(); } - if (archive == null && mpvArchive == null && loginArchive == null) { + if (archive == null && loginArchive == null) { FormBody.Builder body = new FormBody.Builder(); body.add("options", options.toString()); body.add("force", "false"); @@ -621,7 +612,6 @@ public class Manage implements Process { body.addFormDataPart("backup", Backup.create(options).toString()); if (options.isRemoteRelay()) body.addFormDataPart("remoteRelay", RemoteStore.exportRelayConfig()); if (archive != null) body.addFormDataPart(SyncFiles.PART_NAME, archive.getFile().getName(), new ProgressRequestBody(archive.getFile(), ZIP, null)); - if (mpvArchive != null) body.addFormDataPart(MpvConfigSync.PART_NAME, mpvArchive.getFile().getName(), new ProgressRequestBody(mpvArchive.getFile(), ZIP, null)); if (loginArchive != null) body.addFormDataPart(LoginStateSync.PART_NAME, loginArchive.getFile().getName(), new ProgressRequestBody(loginArchive.getFile(), ZIP, null)); return body.build(); } diff --git a/app/src/main/java/com/fongmi/android/tv/service/AiTitleExtractionService.java b/app/src/main/java/com/fongmi/android/tv/service/AiTitleExtractionService.java index 22cf941867..88cbf153f4 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/AiTitleExtractionService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/AiTitleExtractionService.java @@ -28,8 +28,6 @@ public final class AiTitleExtractionService { private static final int READ_TIMEOUT_SECONDS = 25; private static final int CALL_TIMEOUT_SECONDS = 35; private static final int MAX_LEARNING_EXAMPLES = 5; - private static final int MAX_REASONABLE_SEASON = 100; - private static final int MAX_REASONABLE_EPISODE = 10000; private final AiConfig config; @@ -78,16 +76,9 @@ public final class AiTitleExtractionService { input.addProperty("ruleTitle", rule.getRuleTitle()); input.addProperty("rawRemarks", request.getRawRemarks()); input.addProperty("episodeName", request.getEpisodeName()); - input.addProperty("folderName", request.getFolderName()); - JsonArray contextTitles = new JsonArray(); - for (String title : request.getContextTitles()) contextTitles.add(title); - input.add("contextTitles", contextTitles); input.addProperty("year", rule.getYear()); input.addProperty("seasonNumber", rule.getSeasonNumber()); input.addProperty("episodeNumber", rule.getEpisodeNumber()); - input.addProperty("ruleSource", rule.getRuleSource()); - input.addProperty("contextConfidence", rule.getContextConfidence()); - input.addProperty("contextGroupSize", rule.getContextGroupSize()); JsonArray examples = new JsonArray(); List learning = request.getLearningExamples(); @@ -120,21 +111,19 @@ public final class AiTitleExtractionService { JsonObject object = element.getAsJsonObject(); MediaTitleParser parser = new MediaTitleParser(); String rawTitle = firstString(object, "canonicalTitle", "title", "name"); - if (!isUsableAiTitle(rawTitle) || containsNoise(rawTitle)) return null; + if (containsNoise(rawTitle)) return null; String title = parser.cleanTitle(rawTitle); - if (!isUsableAiTitle(title) || containsNoise(title)) return null; + if (title.isEmpty() || containsNoise(title)) return null; MediaTitleResolution result = copyRule(fallback); result.setCanonicalTitle(title); result.setOriginalTitle(firstString(object, "originalTitle", "originalName")); - String mediaType = firstString(object, "mediaType", "type").toLowerCase(Locale.ROOT); - if ("tv".equals(mediaType) || "movie".equals(mediaType)) result.setMediaType(mediaType); - result.setYear(firstValidInt(object, fallback.getYear(), 1900, 2099, "year", "releaseYear")); - result.setSeasonNumber(firstValidInt(object, fallback.getSeasonNumber(), 1, MAX_REASONABLE_SEASON, "seasonNumber", "season")); - result.setEpisodeNumber(firstValidInt(object, fallback.getEpisodeNumber(), 1, MAX_REASONABLE_EPISODE, "episodeNumber", "episode")); + result.setMediaType(firstString(object, "mediaType", "type")); + result.setYear(firstInt(object, "year", "releaseYear")); + result.setSeasonNumber(firstInt(object, "seasonNumber", "season")); + result.setEpisodeNumber(firstInt(object, "episodeNumber", "episode")); result.setEpisodeTitle(parser.cleanTitle(firstString(object, "episodeTitle"))); - result.setConfidence(confidence(object, fallback)); + result.setConfidence((float) firstDouble(object, "confidence", "score")); result.setSource(MediaTitleResolution.SOURCE_AI); - result.setAiReasonCode(firstString(object, "reasonCode", "reason")); result.setNeedsVerification(!normalize(title).equals(normalize(fallback.getRuleTitle())) && result.getConfidence() < 0.9f); result.addCandidate(MediaTitleCandidate.of(title, MediaTitleCandidate.SOURCE_AI, result.getConfidence())); for (JsonElement alias : array(object, "aliases")) { @@ -166,12 +155,6 @@ public final class AiTitleExtractionService { result.setEpisodeTitle(source.getEpisodeTitle()); result.setConfidence(source.getConfidence()); result.setSource(source.getSource()); - result.setAiReasonCode(source.getAiReasonCode()); - result.setContextConfidence(source.getContextConfidence()); - result.setContextGroupSize(source.getContextGroupSize()); - result.setRuleSource(source.getRuleSource()); - result.setConfidenceBreakdown(source.getConfidenceBreakdown()); - result.setDegradationReason(source.getDegradationReason()); result.setNeedsVerification(source.isNeedsVerification()); for (String alias : source.getAliases()) result.addAlias(alias); for (MediaTitleCandidate candidate : source.getCandidates()) result.addCandidate(candidate); @@ -203,38 +186,24 @@ public final class AiTitleExtractionService { return Objects.toString(object.get(key).getAsString(), "").trim(); } - private static int firstValidInt(JsonObject object, int fallback, int min, int max, String... keys) { + private static int firstInt(JsonObject object, String... keys) { for (String key : keys) { try { - if (object == null || !object.has(key) || object.get(key).isJsonNull()) continue; - int value = object.get(key).getAsInt(); - if (value >= min && value <= max) return value; + if (object != null && object.has(key) && !object.get(key).isJsonNull()) return object.get(key).getAsInt(); } catch (Throwable ignored) { } } - return fallback; + return -1; } - private static float confidence(JsonObject object, MediaTitleResolution fallback) { - for (String key : new String[]{"confidence", "score"}) { + private static double firstDouble(JsonObject object, String... keys) { + for (String key : keys) { try { - if (object == null || !object.has(key) || object.get(key).isJsonNull()) continue; - double value = object.get(key).getAsDouble(); - if (Double.isNaN(value) || Double.isInfinite(value) || value < 0d || value > 1d) return 0f; - return (float) value; + if (object != null && object.has(key) && !object.get(key).isJsonNull()) return object.get(key).getAsDouble(); } catch (Throwable ignored) { - return 0f; } } - return Math.max(0.65f, fallback == null ? 0f : fallback.getConfidence()); - } - - private static boolean isUsableAiTitle(String text) { - String value = Objects.toString(text, "").trim(); - if (value.isEmpty() || value.length() > 80 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) return false; - if (value.matches(".*https?://.*") || value.matches(".*[\\\\/].*")) return false; - if (!value.matches(".*[\\u4e00-\\u9fffA-Za-z].*")) return false; - return !value.matches("(?i)^(更新至|更至|连载至|全集|合集|完结|电影|电视剧|剧集|动漫|动画|综艺|tv)$"); + return 0.0; } private static String extractJson(String text) { @@ -266,4 +235,7 @@ public final class AiTitleExtractionService { return text == null ? "" : text.replaceAll("[\\s·•::\\-_/\\\\|()()\\[\\]【】]+", "").trim().toLowerCase(Locale.ROOT); } + private static boolean isBlank(String text) { + return text == null || text.trim().isEmpty(); + } } diff --git a/app/src/main/java/com/fongmi/android/tv/service/DriveCheckService.java b/app/src/main/java/com/fongmi/android/tv/service/DriveCheckService.java index 728efc55b5..c5f39f21fb 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/DriveCheckService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/DriveCheckService.java @@ -151,7 +151,7 @@ public class DriveCheckService { if (diskType.isEmpty() || item.getUrl().isEmpty()) return result(diskType, item.getUrl(), normalized, STATE_UNCERTAIN, false, "type 和 url 不能为空"); if (normalized.isEmpty()) return result(diskType, item.getUrl(), "", STATE_UNCERTAIN, false, "链接格式无效"); - String key = cacheKey(diskType, normalized); + String key = diskType + "|" + normalized; DriveCheckResult cached = getCached(key); if (cached != null) { SpiderDebug.log("pan-check", "cache type=%s state=%s url=%s", diskType, cached.getState(), normalized); @@ -449,9 +449,13 @@ public class DriveCheckService { JsonObject data = obj(object, "data"); JsonObject shareInfo = obj(data, "shareinfo"); if (state && errno == 0) { - String shareState = classify115ShareState(data); - String summary = STATE_OK.equals(shareState) ? "链接有效" : coalesce(str(shareInfo, "forbid_reason"), "链接状态异常"); - return result(item, normalized, shareState, false, summary); + int size = data.has("list") && data.get("list").isJsonArray() ? data.getAsJsonArray("list").size() : 0; + if (size > 0 || integer(data, "count", 0) > 0 || !str(shareInfo, "snap_id").isEmpty() || !str(shareInfo, "share_title").isEmpty()) return result(item, normalized, STATE_OK, false, "链接有效"); + int shareState = integer(data, "share_state", integer(shareInfo, "share_state", 0)); + if (shareState == 1) return result(item, normalized, STATE_OK, false, "链接有效"); + String reason = coalesce(str(shareInfo, "forbid_reason"), "链接状态异常"); + if (containsAny(reason.toLowerCase(Locale.ROOT), "密码", "提取码")) return result(item, normalized, STATE_LOCKED, false, reason); + return result(item, normalized, STATE_BAD, false, reason); } String error = str(object, "error"); if (containsAny(error.toLowerCase(Locale.ROOT), "密码", "提取码", "receive_code")) return result(item, normalized, STATE_LOCKED, false, coalesce(error, "需要提取码")); @@ -459,19 +463,6 @@ public class DriveCheckService { return result(item, normalized, error.isEmpty() ? STATE_UNCERTAIN : STATE_BAD, false, coalesce(error, "无法确认链接状态")); } - static String classify115ShareState(JsonObject data) { - JsonObject shareInfo = obj(data, "shareinfo"); - int shareState = integer(data, "share_state", integer(shareInfo, "share_state", 0)); - String reason = str(shareInfo, "forbid_reason"); - // Expired or forbidden shares may still include a file list and share metadata. - if (shareState == 7) return STATE_BAD; - if (!reason.isEmpty()) return containsAny(reason.toLowerCase(Locale.ROOT), "密码", "提取码") ? STATE_LOCKED : STATE_BAD; - if (shareState == 1) return STATE_OK; - int size = data.has("list") && data.get("list").isJsonArray() ? data.getAsJsonArray("list").size() : 0; - if (size > 0 || integer(data, "count", 0) > 0 || !str(shareInfo, "snap_id").isEmpty() || !str(shareInfo, "share_title").isEmpty()) return STATE_OK; - return STATE_BAD; - } - private DriveCheckResult checkMobile(DriveCheckItem item, String normalized) throws Exception { String shareId = extractMobileShareID(normalized); if (shareId.isEmpty()) return result(item, normalized, STATE_UNCERTAIN, false, "无法解析分享地址"); @@ -564,11 +555,6 @@ public class DriveCheckService { } } - static String cacheKey(String diskType, String normalized) { - // Do not reuse 115 results cached before explicit share-state checks took priority. - return ("115".equals(diskType) ? "115:v2" : diskType) + "|" + normalized; - } - private DriveCheckResult getCached(String key) { DriveCheckResult result = memory.get(key); if (result == null) return null; diff --git a/app/src/main/java/com/fongmi/android/tv/service/IntroSkipService.java b/app/src/main/java/com/fongmi/android/tv/service/IntroSkipService.java index 7d0ef52d4e..2c442fbae3 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/IntroSkipService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/IntroSkipService.java @@ -1,7 +1,5 @@ package com.fongmi.android.tv.service; -import android.os.SystemClock; - import androidx.annotation.NonNull; import com.fongmi.android.tv.utils.Task; @@ -43,9 +41,6 @@ public class IntroSkipService { private static final long MAX_TIME_BASE_DRIFT_MS = TimeUnit.MINUTES.toMillis(5); /** 起点差在此范围内视为两家在说同一段。 */ private static final long SAME_START_TOLERANCE_MS = 3000; - /** 外部时间轴超过一周即视为异常输入,不进入播放器。 */ - private static final long MAX_MEDIA_TIME_MS = TimeUnit.DAYS.toMillis(7); - private static final int MAX_SUBMISSION_COUNT = 1_000_000; /** * 下集预告的字段名。两家文档都取不到(域名被网络策略拦),这里按常见拼法都试一遍; * 命中不了只是没有预告段,不影响其余三类。实机日志确认字段名后可收敛成一个。 @@ -58,98 +53,57 @@ public class IntroSkipService { * 进键:进了键就意味着同一集在 onPrepare(时长还是 0)和 STATE_READY(时长已知)会各发 * 一轮请求,HLS 时长抖动 1 秒也会穿透缓存,更没法预载还没开播、时长未知的邻集。 */ - private static final Map CACHE = Collections.synchronizedMap(new LinkedHashMap<>(32, 0.75f, true) { + private static final Map> CACHE = Collections.synchronizedMap(new LinkedHashMap<>(32, 0.75f, true) { @Override - protected boolean removeEldestEntry(Map.Entry eldest) { + protected boolean removeEldestEntry(Map.Entry> eldest) { return size() > MAX_CACHE; } }); - /** 固定数量的锁条带,避免按外部剧集 key 建无限增长的锁表。 */ - private static final Object[] CACHE_LOCKS = createCacheLocks(); - - private static Object[] createCacheLocks() { - Object[] locks = new Object[32]; - for (int i = 0; i < locks.length; i++) locks[i] = new Object(); - return locks; - } public IntroSkipPlan load(@NonNull Query query) { - return loadResult(query).getPlan(); + List raw = loadRaw(query); + if (raw.isEmpty()) return IntroSkipPlan.empty(); + IntroSkipPlan plan = IntroSkipPlan.from(raw, query.durationMs); + SpiderDebug.log("intro-skip", "plan resolved raw=%d openings=%d endings=%d durationMs=%d", raw.size(), plan.getOpenings().size(), plan.getEndings().size(), query.durationMs); + return plan; } /** 只把结果灌进缓存,不折算——用于预热邻集,此时时长还无从得知。 */ public void preload(@NonNull Query query) { - loadResult(query); + loadRaw(query); } - /** 返回计划以及本次是否完整,供播放层决定是否允许抑制后续重试。 */ - public LoadResult loadResult(@NonNull Query query) { - if (!query.hasLookupKey()) return new LoadResult(IntroSkipPlan.empty(), true); + private List loadRaw(@NonNull Query query) { + if (!query.hasLookupKey()) return Collections.emptyList(); String key = query.cacheKey(); - Object lock = CACHE_LOCKS[key.hashCode() & (CACHE_LOCKS.length - 1)]; - synchronized (lock) { - CacheEntry cached = CACHE.get(key); - if (cached != null && canUseCachedResponse(cached.durationMs, query.durationMs)) { - IntroSkipPlan plan = IntroSkipPlan.from(cached.segments, query.durationMs); - SpiderDebug.log("intro-skip", "cache hit key=%s segments=%d cachedDurationMs=%d durationMs=%d", key, cached.segments.size(), cached.durationMs, query.durationMs); - return new LoadResult(plan, true); - } - if (cached != null) SpiderDebug.log("intro-skip", "cache refresh key=%s cachedDurationMs=%d durationMs=%d", key, cached.durationMs, query.durationMs); - SpiderDebug.log("intro-skip", "query start tmdbId=%d imdbId=%s mediaType=%s season=%d episode=%d durationMs=%d", query.tmdbId, query.imdbId, query.mediaType, query.season, query.episode, query.durationMs); - RemoteResult result = loadRemote(query); - // 部分 provider 失败时不写入缓存,下一次请求仍能重试失败的一方。 - if (result.cacheable && (cached == null || shouldReplaceCachedResponse(cached.durationMs, query.durationMs))) { - CACHE.put(key, new CacheEntry(result.segments, query.durationMs)); - } - IntroSkipPlan plan = IntroSkipPlan.from(result.segments, query.durationMs); - SpiderDebug.log("intro-skip", "query done key=%s segments=%d cacheable=%s durationMs=%d", key, result.segments.size(), result.cacheable, query.durationMs); - return new LoadResult(plan, result.cacheable); + List cached = CACHE.get(key); + if (cached != null) { + SpiderDebug.log("intro-skip", "cache hit key=%s segments=%d", key, cached.size()); + return cached; } + SpiderDebug.log("intro-skip", "query start tmdbId=%d imdbId=%s mediaType=%s season=%d episode=%d", query.tmdbId, query.imdbId, query.mediaType, query.season, query.episode); + Result result = loadRemote(query); + // 只缓存「问到了」的结果。超时/报错时缓存空表等于把这一集永久标记成没有数据: + // 键里没有时长、缓存也没有 TTL,之后每次都命中这条空记录,连重试的机会都没有; + // 预载还会提前把这种毒化结果种到用户即将播的那几集上。 + if (result.answered) CACHE.put(key, result.segments); + SpiderDebug.log("intro-skip", "query done key=%s segments=%d answered=%s", key, result.segments.size(), result.answered); + return result.segments; } - public static final class LoadResult { - - private final IntroSkipPlan plan; - private final boolean cacheable; - - private LoadResult(IntroSkipPlan plan, boolean cacheable) { - this.plan = plan == null ? IntroSkipPlan.empty() : plan; - this.cacheable = cacheable; - } - - public IntroSkipPlan getPlan() { - return plan; - } - - public boolean isCacheable() { - return cacheable; - } - } - - /** 一次远端查询的结果;cacheable=false 表示至少一家 provider 没有可靠完成。 */ - private static final class RemoteResult { + /** 一次远端查询的结果。answered 为假表示两家都没给出有效答复,不该写进缓存。 */ + private static final class Result { private final List segments; - private final boolean cacheable; + private final boolean answered; - private RemoteResult(List segments, boolean cacheable) { - this.segments = segments == null ? Collections.emptyList() : segments; - this.cacheable = cacheable; + private Result(List segments, boolean answered) { + this.segments = segments; + this.answered = answered; } } - private static final class CacheEntry { - - private final List segments; - private final long durationMs; - - private CacheEntry(List segments, long durationMs) { - this.segments = segments == null ? Collections.emptyList() : segments; - this.durationMs = Math.max(0, durationMs); - } - } - - private RemoteResult loadRemote(Query query) { + private Result loadRemote(Query query) { List>> futures = new ArrayList<>(); ExecutorCompletionService> completion = new ExecutorCompletionService<>(Task.largeExecutor()); if (!isEmpty(query.imdbId) && query.season > 0 && query.episode > 0) { @@ -159,38 +113,26 @@ public class IntroSkipService { futures.add(completion.submit(() -> fetchTheIntroDb(query))); } // 没有可用的查询条件不是失败,是确定的「查不了」,可以缓存以免反复进来 - if (futures.isEmpty()) return new RemoteResult(Collections.emptyList(), true); + if (futures.isEmpty()) return new Result(Collections.emptyList(), true); - long deadline = SystemClock.elapsedRealtime() + TIMEOUT_MS; + long deadline = System.currentTimeMillis() + TIMEOUT_MS; List segments = new ArrayList<>(); - int completed = 0; - boolean failed = false; + boolean answered = false; for (int i = 0; i < futures.size(); i++) { - long waitMs = deadline - SystemClock.elapsedRealtime(); - if (waitMs <= 0) { - failed = true; - break; - } + long waitMs = Math.max(1, deadline - System.currentTimeMillis()); try { Future> future = completion.poll(waitMs, TimeUnit.MILLISECONDS); - if (future == null) { - failed = true; // 超时,剩下的也别等了 - break; - } + if (future == null) continue; // 超时,剩下的也别等了 List part = future.get(); - completed++; - if (part == null) { - failed = true; // 该家失败 - continue; - } + if (part == null) continue; // 该家失败 + answered = true; segments.addAll(part); } catch (Throwable e) { - failed = true; SpiderDebug.log("intro-skip", "provider failed error=%s", e.getMessage()); } } for (Future> future : futures) if (!future.isDone()) future.cancel(true); - return new RemoteResult(segments, isCacheableResponse(futures.size(), completed, failed)); + return new Result(segments, answered); } private List fetchIntroDb(Query query) { @@ -219,7 +161,7 @@ public class IntroSkipService { /** @return null 表示这家没答上来(超时/报错/非 2xx),调用方据此决定要不要缓存。 */ private List fetch(HttpUrl url, String provider) { - long start = SystemClock.elapsedRealtime(); + long start = System.currentTimeMillis(); SpiderDebug.log("intro-skip", "%s request url=%s", provider, url.toString()); Request request = new Request.Builder().url(url).get().build(); try (Response response = OkHttp.client(TIMEOUT_MS).newCall(request).execute()) { @@ -229,15 +171,11 @@ public class IntroSkipService { return response.code() == 404 ? Collections.emptyList() : null; } String body = response.body().string(); - if (parseObject(body) == null) { - SpiderDebug.log("intro-skip", "%s invalid json url=%s", provider, url.toString()); - return null; - } List raw = PROVIDER_INTRO_DB.equals(provider) ? parseIntroDbRaw(body) : parseTheIntroDbRaw(body); - SpiderDebug.log("intro-skip", "%s loaded segments=%d cost=%dms url=%s", provider, raw.size(), SystemClock.elapsedRealtime() - start, url.toString()); + SpiderDebug.log("intro-skip", "%s loaded segments=%d cost=%dms url=%s", provider, raw.size(), System.currentTimeMillis() - start, url.toString()); return raw; } catch (Throwable e) { - SpiderDebug.log("intro-skip", "%s failed error=%s cost=%dms url=%s", provider, e.getMessage(), SystemClock.elapsedRealtime() - start, url.toString()); + SpiderDebug.log("intro-skip", "%s failed error=%s cost=%dms url=%s", provider, e.getMessage(), System.currentTimeMillis() - start, url.toString()); return null; } } @@ -288,8 +226,7 @@ public class IntroSkipService { if (ms != null && ms > 0) return ms; Double sec = doubleValue(root, "duration_sec"); if (sec == null) sec = doubleValue(root, "runtime_sec"); - Long millis = secondsToMillis(sec); - return millis == null ? 0 : millis; + return sec == null || sec <= 0 ? 0 : Math.round(sec * 1000.0); } private static JsonObject parseObject(String body) { @@ -306,18 +243,17 @@ public class IntroSkipService { if (object == null) return; Long start = millis(object, "start_ms", "start_sec"); Long end = millis(object, "end_ms", "end_sec"); - segments.add(new RawSegment(kind, PROVIDER_INTRO_DB, key, start, end, referenceDurationMs, number(object, "confidence", 0.5), integer(object, "submission_count", 0))); + segments.add(new RawSegment(kind, PROVIDER_INTRO_DB, start, end, referenceDurationMs, number(object, "confidence", 0.5), integer(object, "submission_count", 0))); } private static void addTheIntroDbSegments(List segments, JsonObject root, String key, Segment.Kind kind, long referenceDurationMs) { JsonArray array = array(root, key); - for (int index = 0; index < array.size(); index++) { - JsonElement element = array.get(index); + for (JsonElement element : array) { if (!element.isJsonObject()) continue; JsonObject object = element.getAsJsonObject(); Long start = millis(object, "start_ms", null); Long end = millis(object, "end_ms", null); - segments.add(new RawSegment(kind, PROVIDER_THE_INTRO_DB, key + "#" + index, start, end, referenceDurationMs, 0.5, 0)); + segments.add(new RawSegment(kind, PROVIDER_THE_INTRO_DB, start, end, referenceDurationMs, 0.5, 0)); } } @@ -336,26 +272,18 @@ public class IntroSkipService { if (ms != null) return ms; if (secKey == null) return null; Double sec = doubleValue(object, secKey); - return secondsToMillis(sec); + return sec == null ? null : Math.round(sec * 1000.0); } private static Long longValue(JsonObject object, String key) { Double value = doubleValue(object, key); - if (value == null || value < 0 || value > MAX_MEDIA_TIME_MS) return null; - return Math.round(value); - } - - private static Long secondsToMillis(Double seconds) { - if (seconds == null || seconds < 0 || seconds > MAX_MEDIA_TIME_MS / 1000.0) return null; - double millis = seconds * 1000.0; - return Double.isFinite(millis) && millis <= MAX_MEDIA_TIME_MS ? Math.round(millis) : null; + return value == null ? null : Math.round(value); } private static Double doubleValue(JsonObject object, String key) { try { if (object == null || key == null || !object.has(key) || object.get(key).isJsonNull()) return null; - double value = object.get(key).getAsDouble(); - return Double.isFinite(value) ? value : null; + return object.get(key).getAsDouble(); } catch (Throwable e) { return null; } @@ -363,40 +291,20 @@ public class IntroSkipService { private static double number(JsonObject object, String key, double fallback) { Double value = doubleValue(object, key); - return clampConfidence(value == null ? fallback : value); - } - - static double clampConfidence(double value) { - if (!Double.isFinite(value)) return 0; - return Math.max(0, Math.min(1, value)); + return value == null ? fallback : value; } /** 夹取而非 intValue():外部数据超出 int 范围时截断会绕回小值甚至 0,静默改变去重排序。 */ private static int integer(JsonObject object, String key, int fallback) { Long value = longValue(object, key); if (value == null) return fallback; - return (int) Math.min(MAX_SUBMISSION_COUNT, value); + return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, value)); } private static boolean isEmpty(String value) { return value == null || value.trim().isEmpty(); } - /** 只有所有已发起的 provider 都可靠返回,结果才允许进入无 TTL 的内存缓存。 */ - static boolean isCacheableResponse(int providerCount, int completedCount, boolean failed) { - return providerCount > 0 && providerCount == completedCount && !failed; - } - - /** 未知时长的缓存可用于预载/查询,但不能阻止正式请求获取时长感知结果。 */ - static boolean canUseCachedResponse(long cachedDurationMs, long requestedDurationMs) { - return requestedDurationMs <= 0 || cachedDurationMs > 0; - } - - /** 只允许已知时长结果替换未知时长结果,避免预载响应覆盖正式响应。 */ - static boolean shouldReplaceCachedResponse(long cachedDurationMs, long incomingDurationMs) { - return cachedDurationMs <= 0 && incomingDurationMs > 0; - } - public static final class Query { private final int tmdbId; @@ -412,7 +320,7 @@ public class IntroSkipService { this.mediaType = mediaType == null ? "" : mediaType.trim().toLowerCase(Locale.ROOT); this.season = season; this.episode = episode; - this.durationMs = Math.min(MAX_MEDIA_TIME_MS, Math.max(0, durationMs)); + this.durationMs = durationMs; } public boolean hasLookupKey() { @@ -449,17 +357,15 @@ public class IntroSkipService { private final Segment.Kind kind; private final String provider; - private final String identity; private final Long startMs; private final Long endMs; private final long referenceDurationMs; private final double confidence; private final int submissionCount; - RawSegment(Segment.Kind kind, String provider, String identity, Long startMs, Long endMs, long referenceDurationMs, double confidence, int submissionCount) { + RawSegment(Segment.Kind kind, String provider, Long startMs, Long endMs, long referenceDurationMs, double confidence, int submissionCount) { this.kind = kind; this.provider = provider; - this.identity = identity; this.startMs = startMs; this.endMs = endMs; this.referenceDurationMs = referenceDurationMs; @@ -468,7 +374,7 @@ public class IntroSkipService { } Segment resolve(long durationMs) { - return Segment.create(kind, provider, identity, startMs, endMs, durationMs, referenceDurationMs, confidence, submissionCount); + return Segment.create(kind, provider, startMs, endMs, durationMs, referenceDurationMs, confidence, submissionCount); } } @@ -556,17 +462,15 @@ public class IntroSkipService { private final Kind kind; private final String provider; - private final String identity; private final long startMs; private final long endMs; private final boolean openEnded; private final double confidence; private final int submissionCount; - private Segment(Kind kind, String provider, String identity, long startMs, long endMs, boolean openEnded, double confidence, int submissionCount) { + private Segment(Kind kind, String provider, long startMs, long endMs, boolean openEnded, double confidence, int submissionCount) { this.kind = kind; this.provider = provider; - this.identity = isEmpty(identity) ? kind + "|" + provider + "|" + startMs + "|" + endMs : identity; this.startMs = startMs; this.endMs = endMs; this.openEnded = openEnded; @@ -574,20 +478,19 @@ public class IntroSkipService { this.submissionCount = submissionCount; } - private static Segment create(Kind kind, String provider, String identity, Long startMs, Long endMs, long durationMs, long referenceDurationMs, double confidence, int submissionCount) { + private static Segment create(Kind kind, String provider, Long startMs, Long endMs, long durationMs, long referenceDurationMs, double confidence, int submissionCount) { if (kind == null) return null; boolean trailing = kind == Kind.OUTRO || kind == Kind.PREVIEW; long start = startMs == null && !trailing ? 0 : startMs == null ? -1 : startMs; long end = endMs == null ? -1 : endMs; if (start < 0) return null; - if (endMs != null && end < 0) return null; - if (trailing) return createTrailing(kind, provider, identity, start, end, durationMs, referenceDurationMs, confidence, submissionCount); + if (trailing) return createTrailing(kind, provider, start, end, durationMs, referenceDurationMs, confidence, submissionCount); if (durationMs > 0) { if (start >= durationMs) return null; if (end > durationMs) end = durationMs; } if (end <= start) return null; - return new Segment(kind, provider, identity, start, end, false, Math.max(0, confidence), Math.max(0, submissionCount)); + return new Segment(kind, provider, start, end, false, Math.max(0, confidence), Math.max(0, submissionCount)); } /** @@ -599,27 +502,23 @@ public class IntroSkipService { * 整段被丢弃——这正是片头能跳、片尾不跳的主因。参考时长缺失时无从折算, * 只能沿用原始时间戳。 */ - private static Segment createTrailing(Kind kind, String provider, String identity, long start, long end, long durationMs, long referenceDurationMs, double confidence, int submissionCount) { + private static Segment createTrailing(Kind kind, String provider, long start, long end, long durationMs, long referenceDurationMs, double confidence, int submissionCount) { long reference = plausibleReference(referenceDurationMs, durationMs); - if (reference > 0 && end > reference && end - reference > OPEN_END_TOLERANCE_MS) return null; // openEnded 只能拿参考时间轴上的结尾去比。没有参考时长时(IntroDB 从不给)无从判断, // 一律按「有界」处理:错判成 openEnded 会让 seek 变成切集,把片尾之后的正片一起扔掉。 - boolean missingEnd = end < 0; - boolean openEnded = reference > 0 - && (missingEnd || reference >= end && reference - end <= OPEN_END_TOLERANCE_MS); - if (reference > 0 && durationMs > 0 && !withinDistance(reference, durationMs, TIME_BASE_TOLERANCE_MS)) { + boolean openEnded = end < 0 || (reference > 0 && reference - end <= OPEN_END_TOLERANCE_MS); + if (reference > 0 && durationMs > 0 && Math.abs(reference - durationMs) > TIME_BASE_TOLERANCE_MS) { long shift = durationMs - reference; - start = safeAdd(start, shift); - if (end >= 0) end = safeAdd(end, shift); - if (start == Long.MIN_VALUE || end == Long.MIN_VALUE) return null; + start += shift; + if (end >= 0) end += shift; } if (durationMs > 0) { - if (missingEnd || openEnded || end > durationMs) end = durationMs; + if (openEnded || end > durationMs) end = durationMs; if (start >= durationMs) return null; } if (start <= 0) return null; // 尾部段不可能从 0 开始,整集当片尾必是错配 if (end >= 0 && end <= start) return null; - return new Segment(kind, provider, identity, start, end, openEnded, Math.max(0, confidence), Math.max(0, submissionCount)); + return new Segment(kind, provider, start, end, openEnded, Math.max(0, confidence), Math.max(0, submissionCount)); } /** @@ -633,23 +532,11 @@ public class IntroSkipService { private static long plausibleReference(long reference, long durationMs) { if (reference <= 0) return 0; if (durationMs <= 0) return reference; - boolean plausible = withinDistance(reference, durationMs, MAX_TIME_BASE_DRIFT_MS); + boolean plausible = Math.abs(reference - durationMs) <= MAX_TIME_BASE_DRIFT_MS; if (!plausible) SpiderDebug.log("intro-skip", "reject reference=%d durationMs=%d", reference, durationMs); return plausible ? reference : 0; } - static boolean withinDistance(long left, long right, long tolerance) { - if (tolerance < 0) return false; - long distance = left >= right ? left - right : right - left; - return distance >= 0 && distance <= tolerance; - } - - private static long safeAdd(long value, long delta) { - if (delta > 0 && value > Long.MAX_VALUE - delta) return Long.MIN_VALUE; - if (delta < 0 && value < Long.MIN_VALUE - delta) return Long.MIN_VALUE; - return value + delta; - } - public Kind getKind() { return kind; } @@ -658,11 +545,6 @@ public class IntroSkipService { return provider; } - /** 原始数据边界生成的稳定身份,不随本地片源时长折算而变化。 */ - public String getIdentity() { - return identity; - } - public long getStartMs() { return startMs; } @@ -707,9 +589,8 @@ public class IntroSkipService { if (isOpening() != other.isOpening()) return false; // 无界段的 end 已被拉到文件结尾,拿它算重叠会把紧随其后的段整个吞掉(片尾吞预告)。 // 这种情况只按起点判断:起点隔得远就是两段不同的内容。 - if (openEnded || other.openEnded) return withinDistance(startMs, other.startMs, SAME_START_TOLERANCE_MS); - if (withinDistance(startMs, other.startMs, SAME_START_TOLERANCE_MS) - && (endMs < 0 || other.endMs < 0 || withinDistance(endMs, other.endMs, 5000))) return true; + if (openEnded || other.openEnded) return Math.abs(startMs - other.startMs) <= SAME_START_TOLERANCE_MS; + if (Math.abs(startMs - other.startMs) <= SAME_START_TOLERANCE_MS && (endMs < 0 || other.endMs < 0 || Math.abs(endMs - other.endMs) <= 5000)) return true; if (endMs < 0 || other.endMs < 0) return false; long overlap = Math.min(endMs, other.endMs) - Math.max(startMs, other.startMs); if (overlap <= 0) return false; diff --git a/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationCache.java b/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationCache.java deleted file mode 100644 index ce8d17aceb..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationCache.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.fongmi.android.tv.service; - -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbItem; -import com.github.catvod.utils.Path; -import com.google.gson.Gson; -import com.google.gson.annotations.SerializedName; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -final class PersonalRecommendationCache { - - private static final int VERSION = 1; - private static final int MAX_CACHED_ITEMS = 128; - private static final int MAX_CACHE_FILES = 32; - private static final long TTL = TimeUnit.DAYS.toMillis(7); - private static final Gson GSON = new Gson(); - - private final File directory; - - PersonalRecommendationCache(File directory) { - this.directory = directory; - } - - synchronized PersonalRecommendationService.RecommendationPage read( - String source, String key, String fingerprint) { - File file = cacheFile(source, key); - if (file == null) return null; - Payload payload = readPayload(file); - if (payload == null || !isValid(payload, fingerprint)) return null; - return new PersonalRecommendationService.RecommendationPage( - payload.items, payload.offset, payload.nextOffset, payload.hasMore, payload.fingerprint); - } - - synchronized void write( - String source, String key, String fingerprint, - PersonalRecommendationService.RecommendationPage page) { - if (page == null || page.getItems().isEmpty()) return; - List items = page.getItems(); - if (items.size() > MAX_CACHED_ITEMS) items = new ArrayList<>(items.subList(0, MAX_CACHED_ITEMS)); - writePayload(cacheFile(source, key), new Payload( - VERSION, items, page.getOffset(), page.getNextOffset(), page.hasMore(), fingerprint)); - cleanup(); - } - - private Payload readPayload(File file) { - try { - if (file == null || !file.exists() || file.length() <= 0) return null; - if (System.currentTimeMillis() - file.lastModified() > TTL) return null; - String body = Path.read(file); - if (isBlank(body)) return null; - return GSON.fromJson(body, Payload.class); - } catch (RuntimeException e) { - return null; - } - } - - private void writePayload(@Nullable File file, Payload payload) { - try { - if (file == null) return; - File parent = file.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs()) return; - Path.write(file, GSON.toJson(payload).getBytes(StandardCharsets.UTF_8)); - } catch (RuntimeException ignored) { - } - } - - private boolean isValid(Payload payload, String fingerprint) { - return payload.version == VERSION - && payload.items != null - && !payload.items.isEmpty() - && payload.items.size() <= MAX_CACHED_ITEMS - && payload.offset >= 0 - && payload.nextOffset >= payload.offset - && Objects.equals(payload.fingerprint, fingerprint); - } - - private void cleanup() { - try { - if (directory == null || !directory.isDirectory()) return; - File[] files = directory.listFiles(file -> file != null && file.isFile() && file.getName().endsWith(".json")); - if (files == null || files.length <= MAX_CACHE_FILES) return; - Arrays.sort(files, Comparator.comparingLong(File::lastModified).reversed()); - for (int index = MAX_CACHE_FILES; index < files.length; index++) { - File file = files[index]; - if (file != null && !file.delete()) file.deleteOnExit(); - } - } catch (RuntimeException ignored) { - } - } - - @Nullable - private File cacheFile(String source, String key) { - try { - if (directory == null || isBlank(source) || isBlank(key)) return null; - return new File(directory, source + "_" + md5(key) + ".json"); - } catch (RuntimeException e) { - return null; - } - } - - private static String md5(String value) { - try { - MessageDigest digest = MessageDigest.getInstance("MD5"); - byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder builder = new StringBuilder(); - for (byte valueByte : bytes) builder.append(String.format("%02x", valueByte)); - return builder.toString(); - } catch (Exception e) { - throw new IllegalStateException("MD5 unavailable", e); - } - } - - private static boolean isBlank(String value) { - return value == null || value.trim().isEmpty(); - } - - private static final class Payload { - - @SerializedName("version") - private final int version; - @SerializedName("items") - private final List items; - @SerializedName("offset") - private final int offset; - @SerializedName("nextOffset") - private final int nextOffset; - @SerializedName("hasMore") - private final boolean hasMore; - @SerializedName("fingerprint") - private final String fingerprint; - - private Payload( - int version, List items, int offset, int nextOffset, - boolean hasMore, String fingerprint) { - this.version = version; - this.items = items == null ? new ArrayList<>() : new ArrayList<>(items); - this.offset = offset; - this.nextOffset = nextOffset; - this.hasMore = hasMore; - this.fingerprint = fingerprint == null ? "" : fingerprint; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationService.java b/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationService.java index fdf75363a1..0753fc8083 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationService.java @@ -47,8 +47,6 @@ import okhttp3.Response; public class PersonalRecommendationService { public static final int DEFAULT_PAGE_SIZE = 12; - private static final String CACHE_SOURCE_TMDB = "tmdb"; - private static final String CACHE_SOURCE_DOUBAN = "douban"; private static final int TMDB_HISTORY_SEED_BATCH = 4; private static final int MIN_TMDB_HISTORY_RESULTS = 4; private static final int DOUBAN_SEED_BATCH = 8; @@ -95,8 +93,6 @@ public class PersonalRecommendationService { } }; - private final PersonalRecommendationCache resultCache = new PersonalRecommendationCache(resultCacheDirectory()); - private static final DoubanRatingMemoryCache DOUBAN_RATING_CACHE = new DoubanRatingMemoryCache(MAX_DOUBAN_RATING_CACHE, DOUBAN_CACHE_TTL); private static final Object DOUBAN_REQUEST_LOCK = new Object(); @@ -105,7 +101,7 @@ public class PersonalRecommendationService { private static volatile boolean doubanCooldownLoaded; public PersonalRecommendationService() { - this(new TmdbService(), TmdbConfig.effectiveCurrent()); + this(new TmdbService(), TmdbConfig.objectFrom(Setting.getTmdbConfig())); } public PersonalRecommendationService(TmdbService tmdbService, TmdbConfig tmdbConfig) { @@ -123,16 +119,9 @@ public class PersonalRecommendationService { public RecommendationPages loadPage(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable JsonObject currentDetail, int offset, int pageSize) { if (!Setting.isPersonalRecommendation() || Thread.currentThread().isInterrupted()) return RecommendationPages.empty(); - RecommendationPage tmdb = loadCachedOrRefreshTmdbPage(currentVod, currentItem, currentDetail, offset, pageSize); + RecommendationPage tmdb = loadTmdbPage(currentVod, currentItem, currentDetail, offset, pageSize); if (Thread.currentThread().isInterrupted()) return new RecommendationPages(tmdb, RecommendationPage.empty(""), RecommendationPage.empty("")); - RecommendationPage douban = loadCachedOrRefreshDoubanPage(currentVod, offset, pageSize); - return new RecommendationPages(tmdb, douban, RecommendationPage.empty(aiFingerprint(currentVod, currentItem))); - } - - public RecommendationPages loadIndependentPages(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable JsonObject currentDetail, int offset, int pageSize) { - if (!Setting.isPersonalRecommendation() || Thread.currentThread().isInterrupted()) return RecommendationPages.empty(); - RecommendationPage tmdb = loadCachedOrRefreshTmdbPage(currentVod, currentItem, currentDetail, offset, pageSize); - RecommendationPage douban = loadCachedOrRefreshDoubanPage(currentVod, offset, pageSize); + RecommendationPage douban = loadDoubanPage(currentVod, offset, pageSize); return new RecommendationPages(tmdb, douban, RecommendationPage.empty(aiFingerprint(currentVod, currentItem))); } @@ -143,7 +132,7 @@ public class PersonalRecommendationService { public RecommendationPage loadTmdbPage(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable JsonObject currentDetail, int offset, int pageSize) { if (!Setting.isPersonalRecommendation() || !tmdbConfig.isReady()) return RecommendationPage.empty(historyFingerprint(currentVod, true)); - return loadCachedOrRefreshTmdbPage(currentVod, currentItem, currentDetail, offset, pageSize); + return loadFromTmdb(currentVod, currentItem, currentDetail, offset, pageSize); } public List loadDouban(@Nullable Vod currentVod) { @@ -221,25 +210,7 @@ public class PersonalRecommendationService { public RecommendationPage loadDoubanPage(@Nullable Vod currentVod, int offset, int pageSize) { if (!Setting.isPersonalRecommendation()) return RecommendationPage.empty(historyFingerprint(currentVod, false)); - return loadCachedOrRefreshDoubanPage(currentVod, offset, pageSize); - } - - public RecommendationPage loadFreshTmdbPage(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable JsonObject currentDetail, int offset, int pageSize) { - if (!Setting.isPersonalRecommendation() || !tmdbConfig.isReady()) return RecommendationPage.empty(historyFingerprint(currentVod, true)); - return writePage(CACHE_SOURCE_TMDB, tmdbCacheKey(currentVod, currentItem), tmdbFingerprint(currentVod), loadFromTmdb(currentVod, currentItem, currentDetail, offset, pageSize)); - } - - public RecommendationPage loadFreshDoubanPage(@Nullable Vod currentVod, int offset, int pageSize) { - if (!Setting.isPersonalRecommendation()) return RecommendationPage.empty(historyFingerprint(currentVod, false)); - return writePage(CACHE_SOURCE_DOUBAN, doubanCacheKey(currentVod), doubanFingerprint(currentVod), loadFromDouban(currentVod, offset, pageSize)); - } - - public void writeCachedTmdbPage(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable PersonalRecommendationService.RecommendationPage page) { - writePage(CACHE_SOURCE_TMDB, tmdbCacheKey(currentVod, currentItem), tmdbFingerprint(currentVod), page); - } - - public void writeCachedDoubanPage(@Nullable Vod currentVod, @Nullable PersonalRecommendationService.RecommendationPage page) { - writePage(CACHE_SOURCE_DOUBAN, doubanCacheKey(currentVod), doubanFingerprint(currentVod), page); + return loadFromDouban(currentVod, offset, pageSize); } public RecommendationPage loadAiPage(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, int pageSize) { @@ -267,16 +238,6 @@ public class PersonalRecommendationService { return AiRecommendationService.fingerprint(currentTitle, aiHistoryFingerprint(currentTitle), Setting.getKeyword(), AiConfig.objectFrom(Setting.getAiConfig())); } - public String tmdbFingerprint(@Nullable Vod currentVod) { - List seeds = historySeeds(currentTitle(currentVod, null), Integer.MAX_VALUE, true); - return recommendationFingerprint(seeds); - } - - public String doubanFingerprint(@Nullable Vod currentVod) { - List seeds = doubanSeeds(currentTitle(currentVod, null), Integer.MAX_VALUE); - return recommendationFingerprint(seeds); - } - private String aiHistoryFingerprint(String currentTitle) { return AiRecommendationService.historyMetadataFingerprint(safeHistory(), currentTitle) + "|feedback:" + RecommendationFeedbackStore.fingerprint(); @@ -290,72 +251,6 @@ public class PersonalRecommendationService { return recommendationFingerprint(seeds); } - private RecommendationPage loadCachedOrRefreshTmdbPage( - @Nullable Vod currentVod, @Nullable TmdbItem currentItem, - @Nullable JsonObject currentDetail, int offset, int pageSize) { - if (offset == 0) { - String key = tmdbCacheKey(currentVod, currentItem); - String fingerprint = tmdbFingerprint(currentVod); - RecommendationPage cached = resultCache.read(CACHE_SOURCE_TMDB, key, fingerprint); - if (cached != null) return cached; - } - return writePage( - CACHE_SOURCE_TMDB, - tmdbCacheKey(currentVod, currentItem), - tmdbFingerprint(currentVod), - loadFromTmdb(currentVod, currentItem, currentDetail, offset, pageSize)); - } - - private RecommendationPage loadCachedOrRefreshDoubanPage(@Nullable Vod currentVod, int offset, int pageSize) { - if (offset == 0) { - String key = doubanCacheKey(currentVod); - String fingerprint = doubanFingerprint(currentVod); - RecommendationPage cached = resultCache.read(CACHE_SOURCE_DOUBAN, key, fingerprint); - if (cached != null) return cached; - } - return writePage( - CACHE_SOURCE_DOUBAN, - doubanCacheKey(currentVod), - doubanFingerprint(currentVod), - loadFromDouban(currentVod, offset, pageSize)); - } - - private RecommendationPage writePage(String source, String key, String fingerprint, RecommendationPage page) { - resultCache.write(source, key, fingerprint, page); - return page; - } - - private String tmdbCacheKey(@Nullable Vod currentVod, @Nullable TmdbItem currentItem) { - List seeds = historySeeds(currentTitle(currentVod, currentItem), Integer.MAX_VALUE, true); - return String.join("|", - Objects.toString(currentTitle(currentVod, currentItem), ""), - historySeedFingerprint(seeds), - RecommendationFeedbackStore.fingerprint(), - Objects.toString(tmdbConfig.getApiBase(), ""), - Objects.toString(tmdbConfig.getLanguage(), "")); - } - - private String doubanCacheKey(@Nullable Vod currentVod) { - List seeds = doubanSeeds(currentTitle(currentVod, null), Integer.MAX_VALUE); - return String.join("|", - Objects.toString(currentTitle(currentVod, null), ""), - historySeedFingerprint(seeds), - RecommendationFeedbackStore.fingerprint(), - Objects.toString(tmdbConfig.getApiBase(), ""), - Objects.toString(tmdbConfig.getLanguage(), "")); - } - - @Nullable - private File resultCacheDirectory() { - try { - File dir = new File(Path.cache(), "personal_rec"); - if (!dir.exists() && !dir.mkdirs()) return null; - return dir; - } catch (Throwable e) { - return null; - } - } - private RecommendationPage loadFromTmdb(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable JsonObject currentDetail, int offset, int pageSize) { int pageOffset = Math.max(0, offset); int limit = safePageSize(pageSize); diff --git a/app/src/main/java/com/fongmi/android/tv/service/PlaybackService.java b/app/src/main/java/com/fongmi/android/tv/service/PlaybackService.java index 0ba9895f99..fa7b895ddb 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/PlaybackService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/PlaybackService.java @@ -29,7 +29,6 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.BuildConfig; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.Sub; import com.fongmi.android.tv.browse.BrowseTree; import com.fongmi.android.tv.event.ActionEvent; import com.fongmi.android.tv.event.ConfigEvent; @@ -677,11 +676,6 @@ private AudioHistory.Record audioHistoryRecord; playerCallbacks.forEach(PlayerCallback::onExoFirstFrame); } - @Override - public void onSubtitleSelected(Sub sub) { - playerCallbacks.forEach(callback -> callback.onSubtitleSelected(sub)); - } - @Override public void onPlayerRebuild(Player newPlayer, boolean resetVideoSurface) { exoPlayer.removeListener(listener); @@ -803,9 +797,6 @@ public void onIsPlayingChanged(boolean isPlaying) { default void onExoFirstFrame() { } - default void onSubtitleSelected(Sub sub) { - } - default void onPlayerRebuild(Player player, boolean resetVideoSurface) { } } diff --git a/app/src/main/java/com/fongmi/android/tv/service/TmdbConfigTestService.java b/app/src/main/java/com/fongmi/android/tv/service/TmdbConfigTestService.java deleted file mode 100644 index f1fab1aaf6..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/service/TmdbConfigTestService.java +++ /dev/null @@ -1,204 +0,0 @@ -package com.fongmi.android.tv.service; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.util.Locale; -import java.util.concurrent.TimeUnit; - -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; - -/** Tests unsaved TMDB API and image-host settings without changing persisted configuration. */ -public final class TmdbConfigTestService { - - private static final OkHttpClient CLIENT = com.github.catvod.net.OkHttp.client().newBuilder() - .callTimeout(10, TimeUnit.SECONDS) - .followRedirects(true) - .build(); - - private TmdbConfigTestService() { - } - - public static Result test(String credential, String apiHost, String imageHost, String omdbApiKey) { - return test(CLIENT, credential, apiHost, imageHost, omdbApiKey, "https://www.omdbapi.com/"); - } - - static Result test(OkHttpClient client, String credential, String apiHost, String imageHost, String omdbApiKey, String omdbBaseUrl) { - return new Result( - timed(() -> testApi(client, credential, apiHost)), - timed(() -> testImage(client, imageHost)), - timed(() -> testOmdb(client, omdbApiKey, omdbBaseUrl))); - } - - static Check testApi(OkHttpClient client, String credential, String apiHost) { - if (credential == null || credential.trim().isEmpty()) return Check.failed("API Key / Access Token is empty"); - TmdbConfig config = config(credential, apiHost, null, null); - try { - HttpUrl base = HttpUrl.parse(config.getApiBase() + "/configuration"); - if (base == null) return Check.failed("invalid URL"); - HttpUrl.Builder url = base.newBuilder(); - Request.Builder request = new Request.Builder().get(); - if (config.getAccessToken().isEmpty()) { - request.url(url.addQueryParameter("api_key", config.getApiKey()).build()); - } else { - request.url(url.build()).header("Authorization", "Bearer " + config.getAccessToken()); - } - try (Response response = client.newCall(request.build()).execute()) { - if (!response.isSuccessful()) return Check.failed("HTTP " + response.code()); - ResponseBody body = response.body(); - if (body == null) return Check.failed("empty response"); - JsonObject json = JsonParser.parseString(body.string()).getAsJsonObject(); - if (!json.has("images") || !json.get("images").isJsonObject()) { - return Check.failed("response is not TMDB configuration data"); - } - return Check.success(); - } - } catch (Exception e) { - return Check.failed(message(e)); - } - } - - static Check testImage(OkHttpClient client, String imageHost) { - TmdbConfig config = config(null, null, imageHost, null); - try { - HttpUrl url = HttpUrl.parse(config.getImageBase() + "/wwemzKWzjKYJFfCeiB57q3r4Bcm.png"); - if (url == null) return Check.failed("invalid URL"); - try (Response response = client.newCall(new Request.Builder().url(url).get().build()).execute()) { - if (!response.isSuccessful()) return Check.failed("HTTP " + response.code()); - ResponseBody body = response.body(); - String type = response.header("Content-Type", "").toLowerCase(Locale.ROOT); - if (!type.startsWith("image/")) return Check.failed("response is not an image"); - if (body == null || body.contentLength() == 0) return Check.failed("empty image"); - byte[] prefix = body.source().peek().readByteArray(16); - return hasImageSignature(prefix) ? Check.success() : Check.failed("invalid image data"); - } - } catch (Exception e) { - return Check.failed(message(e)); - } - } - - static Check testOmdb(OkHttpClient client, String apiKey, String baseUrl) { - if (apiKey == null || apiKey.trim().isEmpty()) return Check.failed("OMDb API Key is empty"); - try { - HttpUrl base = parseHost(baseUrl); - if (base == null) return Check.failed("invalid OMDb URL"); - HttpUrl url = base.newBuilder() - .addQueryParameter("i", "tt0111161") - .addQueryParameter("apikey", apiKey.trim()) - .build(); - try (Response response = client.newCall(new Request.Builder().url(url).get().build()).execute()) { - if (!response.isSuccessful()) return Check.failed("HTTP " + response.code()); - ResponseBody body = response.body(); - if (body == null) return Check.failed("empty response"); - JsonObject json = JsonParser.parseString(body.string()).getAsJsonObject(); - if (json.has("Response") && "False".equalsIgnoreCase(json.get("Response").getAsString())) { - return Check.failed(json.has("Error") ? json.get("Error").getAsString() : "OMDb rejected the request"); - } - if (!json.has("imdbID") || !json.get("imdbID").getAsString().startsWith("tt")) { - return Check.failed("response is not valid OMDb data"); - } - return Check.success(); - } - } catch (Exception e) { - return Check.failed(message(e)); - } - } - - private static TmdbConfig config(String credential, String apiHost, String imageHost, String omdbApiKey) { - JsonObject json = new JsonObject(); - String value = trim(credential); - if (value.split("\\.").length >= 3) json.addProperty("accessToken", value); - else json.addProperty("apiKey", value); - if (apiHost != null) json.addProperty("apiBase", trim(apiHost)); - if (imageHost != null) json.addProperty("imageBase", trim(imageHost)); - if (omdbApiKey != null) json.addProperty("omdbApiKey", trim(omdbApiKey)); - return TmdbConfig.objectFrom(json.toString()); - } - - private static String trim(String value) { - return value == null ? "" : value.trim(); - } - - private static Check timed(CheckSupplier supplier) { - long started = System.nanoTime(); - Check result; - try { - result = supplier.get(); - } catch (RuntimeException e) { - result = Check.failed(message(e)); - } - return result.withLatency(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)); - } - - private interface CheckSupplier { - Check get(); - } - - private static HttpUrl parseHost(String host) { - if (host == null || host.trim().isEmpty()) return null; - String value = host.trim(); - if (!value.endsWith("/")) value += "/"; - try { - HttpUrl url = HttpUrl.get(value); - return "http".equals(url.scheme()) || "https".equals(url.scheme()) ? url : null; - } catch (IllegalArgumentException e) { - return null; - } - } - - private static boolean hasImageSignature(byte[] value) { - if (value.length < 3) return false; - boolean jpeg = (value[0] & 0xff) == 0xff && (value[1] & 0xff) == 0xd8 && (value[2] & 0xff) == 0xff; - boolean png = value.length >= 8 && (value[0] & 0xff) == 0x89 && value[1] == 0x50 && value[2] == 0x4e && value[3] == 0x47; - boolean gif = value.length >= 6 && value[0] == 'G' && value[1] == 'I' && value[2] == 'F'; - boolean webp = value.length >= 12 && value[0] == 'R' && value[1] == 'I' && value[2] == 'F' && value[3] == 'F' - && value[8] == 'W' && value[9] == 'E' && value[10] == 'B' && value[11] == 'P'; - return jpeg || png || gif || webp; - } - - private static String message(Exception error) { - String value = error.getMessage(); - return value == null || value.trim().isEmpty() ? error.getClass().getSimpleName() : value; - } - - public static final class Result { - public final Check api; - public final Check image; - public final Check omdb; - - Result(Check api, Check image, Check omdb) { - this.api = api; - this.image = image; - this.omdb = omdb; - } - } - - public static final class Check { - public final boolean success; - public final String message; - public final long latencyMillis; - - private Check(boolean success, String message, long latencyMillis) { - this.success = success; - this.message = message; - this.latencyMillis = latencyMillis; - } - - static Check success() { - return new Check(true, "", 0); - } - - static Check failed(String message) { - return new Check(false, message, 0); - } - - Check withLatency(long latencyMillis) { - return new Check(success, message, Math.max(0, latencyMillis)); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/service/TmdbService.java b/app/src/main/java/com/fongmi/android/tv/service/TmdbService.java index d8b5364b62..c1cf530522 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/TmdbService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/TmdbService.java @@ -5,7 +5,6 @@ import android.text.TextUtils; import androidx.annotation.NonNull; import com.fongmi.android.tv.App; -import com.fongmi.android.tv.api.config.SubscriptionTmdbCredentialStore; import com.fongmi.android.tv.bean.TmdbConfig; import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.bean.TmdbItem; @@ -20,7 +19,6 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.io.File; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.ArrayList; @@ -125,24 +123,6 @@ public class TmdbService { return requestJson(url, config, "detail", cacheKey, fallbackKeys, DETAIL_CACHE_TTL, "TMDB 详情返回为空", "TMDB 详情失败: HTTP ", false); } - public JsonObject detailForFollowing(@NonNull TmdbItem item, @NonNull TmdbConfig config, boolean refresh) throws Exception { - ensureReady(config); - String url = detailUrl(item, config, false); - List fallbackKeys = detailCacheKeys(item, config, false); - String cacheKey = "following-" + fallbackKeys.remove(0); - long ttl = TimeUnit.MINUTES.toMillis(30); - return requestJson(url, config, "detail-following", cacheKey, fallbackKeys, ttl, - "TMDB 追更详情返回为空", "TMDB 追更详情失败: HTTP ", refresh); - } - - public JsonObject detailForSource(@NonNull TmdbItem item, int seasonNumber, @NonNull TmdbConfig config, @NonNull Set missing) throws Exception { - ensureReady(config); - boolean includeRelated = missing.contains("recommendations") || missing.contains("similar"); - String url = detailUrl(item, config, includeRelated); - List fallbackKeys = detailCacheKeys(item, config, includeRelated); - return requestJson(url, config, "detail", sourceDetailCacheKey(item, seasonNumber, config, missing), fallbackKeys, DETAIL_CACHE_TTL, "TMDB 详情返回为空", "TMDB 详情失败: HTTP ", false); - } - private String detailAppend(@NonNull TmdbItem item, boolean includeRelated) { boolean tv = "tv".equalsIgnoreCase(item.getMediaType()); String append = tv @@ -605,24 +585,14 @@ public class TmdbService { private void ensureReady(TmdbConfig config) { if (!config.sanitize().isReady()) throw new IllegalStateException("请先配置 TMDB API Key"); - ensureCredentialTransport(config); } private HttpUrl.Builder apiBuilder(String url, TmdbConfig config) { - ensureCredentialTransport(config); HttpUrl.Builder builder = HttpUrl.parse(url).newBuilder(); if (TextUtils.isEmpty(config.getAccessToken())) builder.addQueryParameter("api_key", config.getApiKey()); return builder; } - private void ensureCredentialTransport(TmdbConfig config) { - if (config == null || !config.isTransientSubscriptionCredential()) return; - if (!SubscriptionTmdbCredentialStore.isCurrent(config.getCredentialSubscriptionKey(), config.getCredentialScopeEpoch())) { - throw new IllegalStateException("TMDB 临时凭据已失效"); - } - if (!TmdbConfig.isOfficialApiBase(config.getApiBase())) throw new IllegalStateException("TMDB 临时凭据仅允许访问官方 HTTPS API"); - } - void throwIfAuthBlocked(TmdbConfig config) { if (Thread.currentThread().isInterrupted()) throw new CancellationException("TMDB request cancelled"); String key = authCircuitKey(config); @@ -638,9 +608,6 @@ public class TmdbService { RuntimeException httpFailure(TmdbConfig config, int statusCode, String message) { if (statusCode == 401 || statusCode == 403) { - if (config != null && config.isTransientSubscriptionCredential()) { - SubscriptionTmdbCredentialStore.clearIfCurrent(config.getCredentialSubscriptionKey(), config.getCredentialScopeEpoch()); - } AUTH_FAILURE_BLOCKS.put(authCircuitKey(config), System.currentTimeMillis() + AUTH_FAILURE_COOLDOWN); SpiderDebug.log("tmdb", "authentication circuit opened status=%d cooldown=%dms", statusCode, AUTH_FAILURE_COOLDOWN); return new AuthException(statusCode, message); @@ -663,18 +630,7 @@ public class TmdbService { throwIfAuthBlocked(config); Request.Builder builder = new Request.Builder().url(url); if (!TextUtils.isEmpty(config.getAccessToken())) builder.header("Authorization", "Bearer " + config.getAccessToken()); - try { - return com.github.catvod.net.OkHttp.client().newCall(builder.build()).execute(); - } catch (Exception e) { - throw new IOException(redactMessage(e.getMessage())); - } - } - - public static String redactMessage(String message) { - if (TextUtils.isEmpty(message) || message.trim().isEmpty()) return "TMDB request failed"; - String redacted = message.replaceAll("(?i)([?&](?:api_key|apikey|key|token|access_token)=)[^&\\s]+", "$1"); - redacted = redacted.replaceAll("(?i)(Bearer\\s+)[A-Za-z0-9._~+/-]+=*", "$1"); - return redacted; + return com.github.catvod.net.OkHttp.client().newCall(builder.build()).execute(); } private JsonObject requestVideoJson(String url, TmdbConfig config, String cacheKey) throws Exception { @@ -841,14 +797,6 @@ public class TmdbService { return cacheKey("detail", item.getMediaType(), item.getTmdbId(), cacheLanguage(config), includeRelated ? "full" : "core"); } - String sourceDetailCacheKey(@NonNull TmdbItem item, int seasonNumber, @NonNull TmdbConfig config, @NonNull Set missing) { - List capabilities = new ArrayList<>(); - for (String capability : missing) if (!TextUtils.isEmpty(capability)) capabilities.add(capability.trim().toLowerCase(Locale.ROOT)); - capabilities.sort(String::compareTo); - String mask = capabilities.isEmpty() ? "none" : String.join(",", capabilities); - return cacheKey("tmdb-detail", item.getMediaType(), item.getTmdbId(), "season=" + Math.max(0, seasonNumber), "lang=" + cacheLanguage(config), "include=" + mask); - } - String searchCacheKey(@NonNull String keyword, @NonNull TmdbConfig config) { return cacheKey("search", keyword, cacheLanguage(config)); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/DetailRuntimeModePolicy.java b/app/src/main/java/com/fongmi/android/tv/setting/DetailRuntimeModePolicy.java deleted file mode 100644 index a0aa8cc58e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/DetailRuntimeModePolicy.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.fongmi.android.tv.setting; - -import java.util.Objects; - -/** Resolves the detail mode for one loaded detail session without touching Android UI or network state. */ -public final class DetailRuntimeModePolicy { - - public record Input( - int configuredMode, - boolean tmdbReady, - boolean siteAllowed, - TmdbSourceState sourceState - ) { - public Input { - Objects.requireNonNull(sourceState, "sourceState"); - } - } - - public record Decision( - int runtimeMode, - boolean sourceOnly, - boolean networkAllowed - ) { - } - - public static Decision resolve(Input input) { - Objects.requireNonNull(input, "input"); - if (!Setting.isTmdbMode(input.configuredMode())) { - return new Decision(Setting.DETAIL_OPEN_DIRECT, false, false); - } - if (!input.siteAllowed()) { - return new Decision(Setting.DETAIL_OPEN_DIRECT, false, false); - } - boolean renderable = input.sourceState() == TmdbSourceState.RENDERABLE; - if (!renderable && !input.tmdbReady()) { - return new Decision(Setting.DETAIL_OPEN_DIRECT, false, false); - } - return new Decision( - input.configuredMode(), - renderable && !input.tmdbReady(), - input.tmdbReady() - ); - } - - private DetailRuntimeModePolicy() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverPolicy.java b/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverPolicy.java deleted file mode 100644 index 6ee4c8100e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverPolicy.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.fongmi.android.tv.setting; - -public final class InterfaceFailoverPolicy { - - public static final int OFF = 0; - public static final int AUTO = 1; - public static final int CONFIRM = 2; - public static final int DEFAULT_MODE = AUTO; - public static final int MAX_ATTEMPTS = 3; - - private InterfaceFailoverPolicy() { - } - - public static int clampMode(int mode) { - if (mode < OFF || mode > CONFIRM) return DEFAULT_MODE; - return mode; - } - - public static boolean isAuto(int mode) { - return clampMode(mode) == AUTO; - } - - public static boolean isConfirm(int mode) { - return clampMode(mode) == CONFIRM; - } - - public static boolean shouldFailover(int mode) { - return isAuto(mode) || isConfirm(mode); - } - - public static int attemptLimit(int candidateCount) { - if (candidateCount < 0) candidateCount = 0; - return Math.min(candidateCount, MAX_ATTEMPTS); - } - - public static int fallbackLimit(int candidateCount) { - return Math.max(0, attemptLimit(candidateCount) - 1); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverState.java b/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverState.java deleted file mode 100644 index d15a133882..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverState.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.fongmi.android.tv.setting; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -/** Pure state for one VOD interface failover round. */ -public final class InterfaceFailoverState { - - private final int mode; - private final List candidates; - private final Set attempted = new LinkedHashSet<>(); - private int nextIndex; - private boolean cancelled; - private boolean selected; - - public InterfaceFailoverState(int mode, @Nullable String originUrl, @NonNull List candidateUrls) { - this.mode = InterfaceFailoverPolicy.clampMode(mode); - this.candidates = candidates(candidateUrls); - if (originUrl != null && !originUrl.isEmpty()) attempted.add(originUrl); - } - - @Nullable - public String nextAutomatic() { - if (cancelled || selected || !InterfaceFailoverPolicy.isAuto(mode)) return null; - while (nextIndex < candidates.size()) { - String url = candidates.get(nextIndex++); - if (attempted.add(url)) return url; - } - return null; - } - - @Nullable - public String select(int index) { - if (cancelled || selected || !InterfaceFailoverPolicy.isConfirm(mode) - || index < 0 || index >= candidates.size()) return null; - selected = true; - nextIndex = candidates.size(); - String url = candidates.get(index); - return attempted.add(url) ? url : null; - } - - public boolean shouldContinueAfterFailure() { - return !cancelled && !selected && InterfaceFailoverPolicy.isAuto(mode) - && nextIndex < candidates.size(); - } - - public void cancel() { - cancelled = true; - } - - @NonNull - public List candidates() { - return candidates; - } - - @NonNull - private static List candidates(@NonNull List values) { - int limit = InterfaceFailoverPolicy.fallbackLimit(values.size() + 1); - Set unique = new LinkedHashSet<>(); - for (String value : values) { - if (value != null && !value.isEmpty() && unique.size() < limit) unique.add(value); - } - return Collections.unmodifiableList(new ArrayList<>(unique)); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceOrderStore.java b/app/src/main/java/com/fongmi/android/tv/setting/InterfaceOrderStore.java deleted file mode 100644 index 2c37a5b007..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceOrderStore.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.fongmi.android.tv.setting; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import android.text.TextUtils; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.bean.Config; -import com.github.catvod.utils.Prefers; -import com.google.gson.reflect.TypeToken; - -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; - -public final class InterfaceOrderStore { - - public static final String KEY_VOD = "interface_order_vod"; - private static final Type STRING_LIST = new TypeToken>() {}.getType(); - - private InterfaceOrderStore() { - } - - @NonNull - public static List getVodOrder() { - List values = read(); - return dedupe(values); - } - - public static void putVodOrder(@Nullable List urls) { - List normalized = urls == null ? new ArrayList<>() : dedupe(urls); - if (normalized.isEmpty()) Prefers.remove(KEY_VOD); - else Prefers.put(KEY_VOD, App.gson().toJson(normalized)); - } - - public static void saveVodConfigs(@NonNull List configs) { - List order = new ArrayList<>(); - for (Config config : configs) if (config != null) order.add(config.getUrl()); - for (String url : getVodOrder()) if (!order.contains(url)) order.add(url); - putVodOrder(order); - } - - @NonNull - public static List sortVodConfigs(@NonNull List configs) { - List available = new ArrayList<>(); - for (Config config : configs) if (config != null) available.add(config.getUrl()); - List order = sortUrls(available, getVodOrder()); - List result = new ArrayList<>(); - for (String url : order) { - Config match = find(configs, url); - if (match != null && !contains(result, match)) result.add(match); - } - for (Config item : configs) if (!contains(result, item)) result.add(item); - return result; - } - - @Nullable - public static Config findVodConfig(@NonNull List configs, @Nullable String url) { - return find(configs, url); - } - - @Nullable - private static Config find(@NonNull List configs, @Nullable String url) { - if (TextUtils.isEmpty(url)) return null; - for (Config item : configs) if (TextUtils.equals(item.getUrl(), url)) return item; - return null; - } - - private static boolean contains(@NonNull List items, @NonNull Config target) { - for (Config item : items) if (TextUtils.equals(item.getUrl(), target.getUrl())) return true; - return false; - } - - @NonNull - private static List read() { - String json = Prefers.getString(KEY_VOD, "[]"); - if (TextUtils.isEmpty(json)) return new ArrayList<>(); - try { - List values = App.gson().fromJson(json, STRING_LIST); - return values == null ? new ArrayList<>() : values; - } catch (Throwable ignored) { - return new ArrayList<>(); - } - } - - @NonNull - private static List dedupe(@NonNull List values) { - LinkedHashSet result = new LinkedHashSet<>(); - for (String value : values) if (!TextUtils.isEmpty(value)) result.add(value); - return new ArrayList<>(result); - } - - @NonNull - static List normalize(@NonNull List values) { - return Collections.unmodifiableList(dedupe(values)); - } - - @NonNull - static List sortUrls(@NonNull List available, @NonNull List savedOrder) { - List result = new ArrayList<>(); - List known = dedupe(available); - for (String url : dedupe(savedOrder)) { - if (known.contains(url) && !result.contains(url)) result.add(url); - } - for (String url : known) if (!result.contains(url)) result.add(url); - return result; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/setting/LiveSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/LiveSetting.java index 87fcb866e5..bedc8513d9 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/LiveSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/LiveSetting.java @@ -32,14 +32,6 @@ public class LiveSetting { Prefers.put("change", change); } - public static boolean isSourceFallback() { - return Prefers.getBoolean("live_source_fallback", true); - } - - public static void putSourceFallback(boolean fallback) { - Prefers.put("live_source_fallback", fallback); - } - public static boolean isInvert() { return Prefers.getBoolean("invert"); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/MpvPerformanceSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/MpvPerformanceSetting.java index 17fb53e726..ef82428e0c 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/MpvPerformanceSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/MpvPerformanceSetting.java @@ -26,8 +26,6 @@ public final class MpvPerformanceSetting { public static final int HLS_LOWEST = 3; public static final int PRIORITY_PERFORMANCE = 0; public static final int PRIORITY_CONFIG = 1; - public static final int MULTICHANNEL_STEREO_COMPAT = 0; - public static final int MULTICHANNEL_PCM = 1; // Kept only to migrate the temporary four-choice test build. Formal UI // exposes direct / legacy / stable, and old "auto" values become legacy. public static final int VULKAN_BACKEND_AUTO = 0; @@ -44,12 +42,12 @@ public final class MpvPerformanceSetting { private static final String KEY_FRAME_DROP = "perf_mpv_frame_drop"; private static final String KEY_INTERPOLATION = "perf_mpv_interpolation"; private static final String KEY_SOFT_TUNE = "perf_mpv_soft_tune"; + private static final String KEY_VERBOSE_LOG = "perf_mpv_verbose_log"; private static final String KEY_FRAME_RATE = "perf_mpv_frame_rate"; private static final String KEY_HLS_BITRATE = "perf_mpv_hls_bitrate"; private static final String KEY_REBUFFER_MS = "perf_mpv_rebuffer_ms"; private static final String KEY_OPTION_PRIORITY = "perf_mpv_option_priority"; private static final String KEY_VULKAN_BACKEND = "perf_mpv_vulkan_backend"; - private static final String KEY_MULTICHANNEL_AUDIO = "perf_mpv_multichannel_audio"; private MpvPerformanceSetting() { } @@ -215,6 +213,10 @@ public final class MpvPerformanceSetting { }; } + public static boolean isVerboseLog() { + return Prefers.getBoolean(KEY_VERBOSE_LOG); + } + public static int getFrameRateMode() { return resolveFrameRateMode(Prefers.getInt(KEY_FRAME_RATE, FRAME_RATE_SEAMLESS)); } @@ -307,27 +309,6 @@ public final class MpvPerformanceSetting { return isPerformancePriority() ? "播放性能优先" : "mpv.conf优先"; } - public static int getMultichannelAudioMode() { - return clamp(Prefers.getInt(KEY_MULTICHANNEL_AUDIO, - MULTICHANNEL_STEREO_COMPAT), - MULTICHANNEL_STEREO_COMPAT, MULTICHANNEL_PCM); - } - - public static void putMultichannelAudioMode(int value) { - Prefers.put(KEY_MULTICHANNEL_AUDIO, clamp(value, - MULTICHANNEL_STEREO_COMPAT, MULTICHANNEL_PCM)); - PlaybackPerformanceSetting.markOverride( - PlaybackPerformanceCatalog.MPV_MULTICHANNEL_AUDIO); - } - - public static boolean isMultichannelPcm() { - return getMultichannelAudioMode() == MULTICHANNEL_PCM; - } - - public static String getMultichannelAudioText() { - return isMultichannelPcm() ? "多声道 PCM" : "立体声兼容"; - } - public static int getVulkanBackend() { return normalizeVulkanBackend(Prefers.getInt( KEY_VULKAN_BACKEND, VULKAN_BACKEND_DIRECT)); @@ -369,6 +350,11 @@ public final class MpvPerformanceSetting { }; } + public static void putVerboseLog(boolean value) { + Prefers.put(KEY_VERBOSE_LOG, value); + PlaybackPerformanceSetting.markOverride(PlaybackPerformanceCatalog.MPV_VERBOSE_LOG); + } + public static void applyRecommended() { PlayerSetting.putMpvRender(PlayerSetting.MPV_RENDER_OPENGL); Prefers.put(KEY_OUTPUT_MODE, OUTPUT_AUTO); @@ -377,10 +363,10 @@ public final class MpvPerformanceSetting { Prefers.put(KEY_FRAME_DROP, FRAME_DROP_OUTPUT); Prefers.put(KEY_INTERPOLATION, false); Prefers.put(KEY_SOFT_TUNE, SOFT_TUNE_MILD); + Prefers.put(KEY_VERBOSE_LOG, false); Prefers.put(KEY_FRAME_RATE, FRAME_RATE_SEAMLESS); Prefers.put(KEY_HLS_BITRATE, HLS_HIGHEST); Prefers.put(KEY_VULKAN_BACKEND, VULKAN_BACKEND_DIRECT); - Prefers.put(KEY_MULTICHANNEL_AUDIO, MULTICHANNEL_STEREO_COMPAT); applyRebufferPreset(PlaybackPerformanceSetting.PROFILE_RECOMMENDED); } @@ -392,10 +378,10 @@ public final class MpvPerformanceSetting { Prefers.put(KEY_FRAME_DROP, FRAME_DROP_OUTPUT); Prefers.put(KEY_INTERPOLATION, false); Prefers.put(KEY_SOFT_TUNE, SOFT_TUNE_MILD); + Prefers.put(KEY_VERBOSE_LOG, false); Prefers.put(KEY_FRAME_RATE, FRAME_RATE_SEAMLESS); Prefers.put(KEY_HLS_BITRATE, HLS_HIGHEST); Prefers.put(KEY_VULKAN_BACKEND, VULKAN_BACKEND_DIRECT); - Prefers.put(KEY_MULTICHANNEL_AUDIO, MULTICHANNEL_STEREO_COMPAT); applyRebufferPreset(PlaybackPerformanceSetting.PROFILE_AUTO); } @@ -411,10 +397,10 @@ public final class MpvPerformanceSetting { Prefers.put(KEY_FRAME_DROP, FRAME_DROP_OUTPUT); Prefers.put(KEY_INTERPOLATION, false); Prefers.put(KEY_SOFT_TUNE, SOFT_TUNE_MILD); + Prefers.put(KEY_VERBOSE_LOG, false); Prefers.put(KEY_FRAME_RATE, FRAME_RATE_OFF); Prefers.put(KEY_HLS_BITRATE, HLS_8_MBPS); Prefers.put(KEY_VULKAN_BACKEND, VULKAN_BACKEND_DIRECT); - Prefers.put(KEY_MULTICHANNEL_AUDIO, MULTICHANNEL_STEREO_COMPAT); applyRebufferPreset(PlaybackPerformanceSetting.PROFILE_LIGHTWEIGHT); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceCatalog.java b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceCatalog.java index ec268fe621..96028c65b2 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceCatalog.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceCatalog.java @@ -43,11 +43,11 @@ public final class PlaybackPerformanceCatalog { public static final String MPV_FRAME_DROP = "mpv_frame_drop"; public static final String MPV_INTERPOLATION = "mpv_interpolation"; public static final String MPV_SOFT_TUNE = "mpv_soft_tune"; + public static final String MPV_VERBOSE_LOG = "mpv_verbose_log"; public static final String MPV_FRAME_RATE = "mpv_frame_rate"; public static final String MPV_HLS_BITRATE = "mpv_hls_bitrate"; public static final String MPV_REBUFFER = "mpv_rebuffer"; public static final String MPV_OPTION_PRIORITY = "mpv_option_priority"; - public static final String MPV_MULTICHANNEL_AUDIO = "mpv_multichannel_audio"; public static final String IJK_SCENE = "ijk_scene"; public static final String IJK_BUFFER = "ijk_buffer"; public static final String IJK_PACKET_BUFFERING = "ijk_packet_buffering"; @@ -139,9 +139,9 @@ public final class PlaybackPerformanceCatalog { options.add(option(MPV_FRAME_DROP, DECODE, "丢帧策略", "怎么选:保持“输出丢帧”(默认),跟不上时优先丢渲染帧以维持音画进度;卡顿仍严重可试“解码丢帧”;不要为追求完整画面关闭丢帧,除非设备性能充足。代价:策略越积极,跳帧越明显。")); options.add(option(MPV_INTERPOLATION, DECODE, "平滑运动", "怎么选:默认关闭。只有GPU余量充足、使用GPU渲染+显示重采样且想改善低帧率运动时才开启;电视4K、HDR、LUT或已经卡顿时必须关闭。代价:会明显增加GPU负载,电视直出时不生效。")); options.add(option(MPV_SOFT_TUNE, DECODE, "软解降负载", "作用:仅软件解码时减少滤波和解码工作。默认“温和”;软解仍掉帧可选“积极”;硬解视频无需靠它提速。代价:模式越积极,细节和画面连续性损失越大。")); - options.add(option(DV7_HDR10_FALLBACK, DECODE, "DV7处理", "默认“升级P8.1”和“降级HDR10”保持原有行为:设备能原生播放DV7时优先原生播放,否则转换P8.1或使用HDR10基底层。“FEL 双层重建”仅手动选择后启用:保留BL、EL和RPU,基底层按现有硬解设置解码,增强层使用软件解码,由GPU完成残差重建,不走原生直出。只对DV7生效,普通视频不改变输出路径。该模式会增加CPU/GPU负载,流畅度取决于设备和片源;无增强层的片源不会凭空增加画质。切换后当前播放会按原有设置应用流程重载并保留进度。")); + options.add(option(MPV_VERBOSE_LOG, DECODE, "详细日志", "怎么选:正常播放保持“正常”(默认);只在排查崩溃、解码或缓冲问题时临时打开详细日志。代价:增加JNI、字符串处理和日志I/O,可能干扰低性能设备的流畅度。")); + options.add(option(DV7_HDR10_FALLBACK, DECODE, "DV7处理", "作用:设备能原生播放 Dolby Vision Profile 7 时始终保留原始 DV7;否则默认尝试“升级P8.1”保留动态元数据,也可选择直接“降级HDR10”。设备不支持P8.1或转换/解码失败时会自动回退HDR10。P8.1逐帧重写RPU,会比HDR10过滤增加少量CPU处理。")); options.add(option(AUDIO_PASSTHROUGH, AUDIO, "音频直通", "怎么选:电视/功放明确支持Dolby、DTS且需要多声道时开启;出现无声、杂音或同步异常立即关闭。代价:压缩音频交给外部设备后,MPV无法完成所有混音和重采样处理。")); - options.add(option(MPV_MULTICHANNEL_AUDIO, AUDIO, "非直通多声道", "怎么选:默认“立体声兼容”,电视直出遇到不能直通的多声道音轨时优先切换同语言2.0音轨,性能和设备兼容性最好;选择“多声道 PCM”会保留当前5.1/7.1音轨并由MPV解码输出,可能增加CPU负担,最终声道数仍取决于电视或功放的PCM能力。")); options.add(option(PREFER_AAC, AUDIO, "AAC 优先", "怎么选:高级音轨无声或设备兼容性差时开启;功放支持原始多声道、希望保留最佳音轨时关闭。代价:可能从Dolby/DTS切到质量或声道较低的AAC。")); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceSetting.java index 85c7a65ef7..38a4097159 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceSetting.java @@ -19,7 +19,6 @@ public class PlaybackPerformanceSetting { public static final int DV7_HANDLING_P81 = 0; public static final int DV7_HANDLING_HDR10 = 1; - public static final int DV7_HANDLING_FEL = 2; public static final String KEY_PROFILE = "playback_performance_profile"; private static final String KEY_PROFILE_MIGRATED = "playback_performance_profile_per_kernel"; @@ -445,7 +444,7 @@ public class PlaybackPerformanceSetting { public static int getMpvDv7HandlingMode() { ensureInitialized(); if (Prefers.getPrefers().contains(KEY_MPV_DV7_HANDLING)) - return clampMpvDv7Handling(Prefers.getInt(KEY_MPV_DV7_HANDLING, DV7_HANDLING_P81)); + return clampDv7Handling(Prefers.getInt(KEY_MPV_DV7_HANDLING, DV7_HANDLING_P81)); if (Prefers.getPrefers().contains(KEY_DV7_HDR10_FALLBACK_LEGACY)) { return Prefers.getBoolean(KEY_DV7_HDR10_FALLBACK_LEGACY, true) ? DV7_HANDLING_HDR10 : DV7_HANDLING_P81; @@ -454,34 +453,18 @@ public class PlaybackPerformanceSetting { } public static void putMpvDv7HandlingMode(int mode) { - putCustom(KEY_MPV_DV7_HANDLING, clampMpvDv7Handling(mode), + putCustom(KEY_MPV_DV7_HANDLING, clampDv7Handling(mode), PlaybackPerformanceCatalog.DV7_HDR10_FALLBACK); } public static String getMpvDv7HandlingText() { - return mpvDv7HandlingText(getMpvDv7HandlingMode()); - } - - static String mpvDv7HandlingText(int mode) { - return switch (clampMpvDv7Handling(mode)) { - case DV7_HANDLING_HDR10 -> "降级HDR10"; - case DV7_HANDLING_FEL -> "FEL 双层重建"; - default -> "升级P8.1"; - }; - } - - public static int nextMpvDv7HandlingMode(int mode) { - return (clampMpvDv7Handling(mode) + 1) % 3; + return getMpvDv7HandlingMode() == DV7_HANDLING_P81 + ? "升级P8.1" : "降级HDR10"; } public static String getMpvDv7HandlingOption() { - return switch (getMpvDv7HandlingMode()) { - case DV7_HANDLING_HDR10 -> "hdr10"; - // FEL needs the original BL, EL and RPU; it must not run P8.1's - // destructive enhancement-layer filter. - case DV7_HANDLING_FEL -> "preserve"; - default -> "p81"; - }; + return getMpvDv7HandlingMode() == DV7_HANDLING_P81 + ? "p81" : "hdr10"; } public static boolean isSoftVideoTuneEnabled() { @@ -599,10 +582,6 @@ public class PlaybackPerformanceSetting { return mode == DV7_HANDLING_HDR10 ? DV7_HANDLING_HDR10 : DV7_HANDLING_P81; } - static int clampMpvDv7Handling(int mode) { - return mode == DV7_HANDLING_FEL ? DV7_HANDLING_FEL : clampDv7Handling(mode); - } - private static void put(String key, boolean value) { Prefers.put(key, value); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicy.java b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicy.java index ee8f986ca3..e04cd99bbe 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicy.java @@ -21,8 +21,7 @@ public final class PlaybackPerformanceUiPolicy { PlaybackPerformanceCatalog.MPV_HWDEC, PlaybackPerformanceCatalog.MPV_FRAME_RATE, PlaybackPerformanceCatalog.MPV_OPTION_PRIORITY, - PlaybackPerformanceCatalog.AUDIO_PASSTHROUGH, - PlaybackPerformanceCatalog.MPV_MULTICHANNEL_AUDIO); + PlaybackPerformanceCatalog.AUDIO_PASSTHROUGH); private static final Set IJK_COMMON = Set.of( PlaybackPerformanceCatalog.IJK_SCENE, PlaybackPerformanceCatalog.IJK_BUFFER, diff --git a/app/src/main/java/com/fongmi/android/tv/setting/PlayerSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/PlayerSetting.java index 3ef2131cea..ebb4dc4d58 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/PlayerSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/PlayerSetting.java @@ -25,6 +25,10 @@ public class PlayerSetting { public static final int[] KERNEL_ORDER = {EXO, IJK, MPV, SYSTEM}; public static final int RENDER_SURFACE = 0; public static final int RENDER_TEXTURE = 1; + public static final int FFMPEG_MODE_NEXTLIB = 0; + public static final int FFMPEG_MODE_OFFICIAL = 1; + public static final int FFMPEG_MODE_SIMPLE = 2; + public static final int FFMPEG_MODE_AUTO = 3; public static final int MPV_RENDER_OPENGL = 0; public static final int MPV_RENDER_VULKAN = 1; public static final int AUDIO_BACKGROUND_ARTWORK = 0; @@ -58,6 +62,7 @@ public class PlayerSetting { private static final int DEFAULT_PLAY_CACHE_OPTION = 0; private static final String KEY_IMMERSIVE_AUDIO_PLAYBACK = "immersive_audio_playback"; private static final String KEY_FAILURE_FALLBACK = "player_failure_fallback"; + private static final String KEY_FFMPEG_MODE = "ffmpeg_mode"; private static final String KEY_CUSTOM_ASPECT_WIDTH = "custom_aspect_width"; private static final String KEY_CUSTOM_ASPECT_HEIGHT = "custom_aspect_height"; private static final String KEY_BRIGHTNESS_MIGRATED = "player_brightness_migrated"; @@ -445,15 +450,6 @@ public class PlayerSetting { Prefers.put("player_auto_play", autoPlay); } - /** Whether MPV should enter the HDMV Blu-ray menu instead of the main title. */ - public static boolean isBlurayMenu() { - return Prefers.getBoolean("playback_bluray_menu", false); - } - - public static void putBlurayMenu(boolean enabled) { - Prefers.put("playback_bluray_menu", enabled); - } - public static int getBackground() { int stored = Prefers.getInt("background", BackgroundPlaybackPolicy.ON); int normalized = BackgroundPlaybackPolicy.normalize(stored); @@ -724,6 +720,67 @@ public class PlayerSetting { KernelPerformanceSetting.putVideoPrefer(getPlayer(), videoPrefer); } + // AUTO 模式失败遍历顺序:能力从全到简(NextLib 有 ffmpeg 音视频兜底+解码调度调优;Simple 有音视频兜底;Official 纯官方)。 + public static final int[] FFMPEG_AUTO_ORDER = {FFMPEG_MODE_NEXTLIB, FFMPEG_MODE_SIMPLE, FFMPEG_MODE_OFFICIAL}; + + // 进程内运行时覆盖,仅在设置为 AUTO 时生效,用于失败链遍历具体模式;不写盘。 + private static volatile int ffmpegModeOverride = NONE; + + public static int getFFmpegMode() { + int defaultMode = getDefaultFFmpegMode(); + return sanitizeFFmpegMode(Prefers.getInt(KEY_FFMPEG_MODE, defaultMode), defaultMode); + } + + public static void putFFmpegMode(int mode) { + Prefers.put(KEY_FFMPEG_MODE, sanitizeFFmpegMode(mode, getDefaultFFmpegMode())); + clearFFmpegModeOverride(); + } + + // ExoUtil 构造渲染器时使用的具体模式。AUTO 时解析成运行时覆盖,无覆盖则取遍历首项。 + public static int getEffectiveFFmpegMode() { + int mode = getFFmpegMode(); + if (mode != FFMPEG_MODE_AUTO) return mode; + int override = ffmpegModeOverride; + return isConcreteFFmpegMode(override) ? override : FFMPEG_AUTO_ORDER[0]; + } + + public static boolean isAutoFFmpegMode() { + return getFFmpegMode() == FFMPEG_MODE_AUTO; + } + + public static void setFFmpegModeOverride(int mode) { + ffmpegModeOverride = isConcreteFFmpegMode(mode) ? mode : NONE; + } + + public static void clearFFmpegModeOverride() { + ffmpegModeOverride = NONE; + } + + static int getDefaultFFmpegMode() { + return sanitizeFFmpegMode(App.get().getResources().getInteger(R.integer.default_ffmpeg_mode), FFMPEG_MODE_SIMPLE); + } + + static int sanitizeFFmpegMode(int mode, int defaultMode) { + if (isFFmpegMode(mode)) return mode; + return isFFmpegMode(defaultMode) ? defaultMode : FFMPEG_MODE_SIMPLE; + } + + private static boolean isFFmpegMode(int mode) { + return isConcreteFFmpegMode(mode) || mode == FFMPEG_MODE_AUTO; + } + + private static boolean isConcreteFFmpegMode(int mode) { + return mode == FFMPEG_MODE_NEXTLIB || mode == FFMPEG_MODE_OFFICIAL || mode == FFMPEG_MODE_SIMPLE; + } + + public static boolean useNextLibFFmpeg() { + return getEffectiveFFmpegMode() == FFMPEG_MODE_NEXTLIB; + } + + public static void putUseNextLibFFmpeg(boolean useNextLib) { + putFFmpegMode(useNextLib ? FFMPEG_MODE_NEXTLIB : FFMPEG_MODE_OFFICIAL); + } + public static boolean isPreferAAC() { return isPreferAAC(getPlayer()); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/ProxySetting.java b/app/src/main/java/com/fongmi/android/tv/setting/ProxySetting.java index dcd69d1837..f84f9dc53c 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/ProxySetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/ProxySetting.java @@ -103,7 +103,7 @@ public class ProxySetting { } private static void collectDebugUrls(Set urls) { - for (String origin : DebugLogStore.observedOrigins()) addUrls(origin, urls); + for (String line : DebugLogStore.snapshot()) addUrls(line, urls); } private static void addLocalFileUrls(String source, Set urls) { diff --git a/app/src/main/java/com/fongmi/android/tv/setting/Setting.java b/app/src/main/java/com/fongmi/android/tv/setting/Setting.java index f3ea8b4e65..0f9dc53f26 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/Setting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/Setting.java @@ -17,7 +17,6 @@ import androidx.core.content.ContextCompat; import com.fongmi.android.tv.App; import com.fongmi.android.tv.BuildConfig; -import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.AiConfig; import com.fongmi.android.tv.bean.AudioConfig; import com.fongmi.android.tv.bean.DanmakuMatchCache; @@ -27,16 +26,14 @@ import com.fongmi.android.tv.bean.TmdbMatchCache; import com.fongmi.android.tv.bean.TmdbSeasonMatchCache; import com.fongmi.android.tv.bean.Update; import com.fongmi.android.tv.utils.AppCache; +import com.fongmi.android.tv.update.GithubProxy; import com.fongmi.android.tv.update.OciMirror; import com.fongmi.android.tv.update.UpdateSource; -import com.fongmi.android.tv.utils.GithubProxy; import com.fongmi.android.tv.utils.WebViewUtil; -import com.fongmi.android.tv.utils.Util; import com.github.catvod.crawler.DebugLogStore; import com.github.catvod.crawler.SpiderDebug; import com.github.catvod.utils.Trans; import com.github.catvod.utils.Prefers; -import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; @@ -78,9 +75,6 @@ public class Setting { public static final int INTRO_SKIP_OFF = 0; public static final int INTRO_SKIP_AUTO = 1; public static final int INTRO_SKIP_CONFIRM = 2; - public static final int INTERFACE_FAILOVER_OFF = InterfaceFailoverPolicy.OFF; - public static final int INTERFACE_FAILOVER_AUTO = InterfaceFailoverPolicy.AUTO; - public static final int INTERFACE_FAILOVER_CONFIRM = InterfaceFailoverPolicy.CONFIRM; public static final int INTRO_SKIP_KIND_RECAP = 1; public static final int INTRO_SKIP_KIND_INTRO = 1 << 1; public static final int INTRO_SKIP_KIND_OUTRO = 1 << 2; @@ -106,10 +100,7 @@ public class Setting { public static final int UI_SCALE_SMALLER = 3; public static final int UI_SCALE_MILD_COMPACT = 4; public static final int UI_SCALE_MORE_COMPACT = 5; - public static final int UI_SCALE_LARGE = 6; - public static final int UI_SCALE_LARGER = 7; - public static final int UI_SCALE_LARGEST = 8; - private static final int[] UI_SCALE_OPTIONS = {UI_SCALE_FOLLOW_SYSTEM, UI_SCALE_SMALLER, UI_SCALE_MORE_COMPACT, UI_SCALE_COMPACT, UI_SCALE_MILD_COMPACT, UI_SCALE_STANDARD, UI_SCALE_LARGE, UI_SCALE_LARGER, UI_SCALE_LARGEST}; + private static final int[] UI_SCALE_OPTIONS = {UI_SCALE_FOLLOW_SYSTEM, UI_SCALE_STANDARD, UI_SCALE_MILD_COMPACT, UI_SCALE_COMPACT, UI_SCALE_MORE_COMPACT, UI_SCALE_SMALLER}; public static final int WALL_CINEMA = 5; public static final int WALL_CINEMA_WARM = 6; @@ -400,14 +391,6 @@ public class Setting { Prefers.put("incognito", incognito); } - public static boolean isTouchOptimized() { - return Util.isLeanback() && Prefers.getBoolean("touch_optimized"); - } - - public static void putTouchOptimized(boolean enabled) { - Prefers.put("touch_optimized", enabled); - } - public static int getLanguage() { int language = Prefers.getInt("language", LANGUAGE_FOLLOW_SYSTEM); return isLanguage(language) ? language : LANGUAGE_FOLLOW_SYSTEM; @@ -490,11 +473,8 @@ public class Setting { float factor = getUiScaleFactor(scale); Configuration config = new Configuration(context.getResources().getConfiguration()); DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - // Scale from the density currently selected by the system. Using the device's - // stable density makes the same option behave differently on phones, head units - // and virtual machines whose default display size overrides the stable density. - int baseDensity = metrics.densityDpi; - int densityDpi = Math.max(DisplayMetrics.DENSITY_LOW, Math.round(baseDensity * factor)); + int stableDensity = DisplayMetrics.DENSITY_DEVICE_STABLE > 0 ? DisplayMetrics.DENSITY_DEVICE_STABLE : metrics.densityDpi; + int densityDpi = Math.max(DisplayMetrics.DENSITY_LOW, Math.round(stableDensity * factor)); config.densityDpi = densityDpi; config.fontScale = 1.0f; config.screenWidthDp = pxToDp(metrics.widthPixels, densityDpi); @@ -503,16 +483,13 @@ public class Setting { return context.createConfigurationContext(config); } - public static float getUiScaleFactor(int scale) { + private static float getUiScaleFactor(int scale) { return switch (scale) { - case UI_SCALE_SMALLER -> 0.8f; - case UI_SCALE_MORE_COMPACT -> 0.85f; - case UI_SCALE_COMPACT -> 0.9f; - case UI_SCALE_MILD_COMPACT -> 0.95f; - case UI_SCALE_STANDARD -> 1.0f; - case UI_SCALE_LARGE -> 1.1f; - case UI_SCALE_LARGER -> 1.2f; - case UI_SCALE_LARGEST -> 1.3f; + case UI_SCALE_STANDARD -> 0.8f; + case UI_SCALE_MILD_COMPACT -> 0.75f; + case UI_SCALE_COMPACT -> 0.7f; + case UI_SCALE_MORE_COMPACT -> 0.65f; + case UI_SCALE_SMALLER -> 0.6f; default -> 1.0f; }; } @@ -679,20 +656,6 @@ public class Setting { public static void logDebugEnvironment(String reason) { boolean hardwareAccelerated = (App.get().getApplicationInfo().flags & ApplicationInfo.FLAG_HARDWARE_ACCELERATED) != 0; - DebugLogStore.event(new com.github.catvod.crawler.diagnostics.DiagnosticEvent("env.device", "none", "process", 0, 0) - .observed("reason", reason).observed("appVersion", BuildConfig.VERSION_NAME).observed("versionCode", BuildConfig.VERSION_CODE) - .observed("buildTime", BuildConfig.BUILD_TIME).observed("buildTag", BuildConfig.BUILD_TAG) - .observed("gitRevision", BuildConfig.GIT_REVISION).observed("state", BuildConfig.GIT_STATE) - .observed("fingerprintDigest", com.fongmi.android.tv.player.NativeLibraryDiagnostics.digestText(Build.FINGERPRINT)) - .observed("media3Version", BuildConfig.MEDIA3_VERSION).observed("flavor", BuildConfig.FLAVOR_mode) - .observed("abi", BuildConfig.FLAVOR_abi).observed("process64Bit", android.os.Process.is64Bit()) - .observed("android", Build.VERSION.RELEASE).observed("api", Build.VERSION.SDK_INT) - .observed("targetSdk", App.get().getApplicationInfo().targetSdkVersion) - .observed("manufacturer", Build.MANUFACTURER).observed("model", Build.MODEL) - .observed("device", Build.DEVICE).observed("hardwareAccelerated", hardwareAccelerated) - .pin("device")); - com.fongmi.android.tv.player.NativeLibraryDiagnostics.request(); - com.fongmi.android.tv.player.PlaybackDiagnosticSession.captureActive(android.os.SystemClock.elapsedRealtime()); SpiderDebug.log("env", "reason=%s app=%s(%s) mode=%s abi=%s debug=%s hardware=%s android=%s sdk=%s incremental=%s manufacturer=%s brand=%s model=%s device=%s product=%s supportedAbis=%s", reason, BuildConfig.VERSION_NAME, @@ -772,6 +735,30 @@ public class Setting { Prefers.put("update_source", UpdateSource.normalize(source)); } + public static String getUpdateGithubProxy() { + return GithubProxy.find(Prefers.getString("update_github_proxy", GithubProxy.DIRECT)).id; + } + + public static void putUpdateGithubProxy(String proxy) { + Prefers.put("update_github_proxy", GithubProxy.find(proxy).id); + } + + public static String getUpdateGithubProxyUrl() { + return Prefers.getString("update_github_proxy_url"); + } + + public static void putUpdateGithubProxyUrl(String url) { + Prefers.put("update_github_proxy_url", url == null ? "" : url.trim()); + } + + public static String getUpdateGithubProxyMode() { + return GithubProxy.normalizeMode(Prefers.getString("update_github_proxy_mode", GithubProxy.MODE_FULL_URL)); + } + + public static void putUpdateGithubProxyMode(String mode) { + Prefers.put("update_github_proxy_mode", GithubProxy.normalizeMode(mode)); + } + public static String getUpdateOciMirror() { return OciMirror.find(Prefers.getString("update_oci_mirror", OciMirror.DEFAULT)).id; } @@ -789,7 +776,6 @@ public class Setting { } public static String getGithubProxy() { - migrateLegacyGithubProxy(); return Prefers.getString("github_proxy", com.fongmi.android.tv.utils.GithubProxy.defaultSources()); } @@ -797,14 +783,6 @@ public class Setting { Prefers.put("github_proxy", com.fongmi.android.tv.utils.GithubProxy.normalizeConfig(value)); } - public static String getGithubProxyMode() { - migrateLegacyGithubProxy(); - return GithubProxy.normalizeMode(Prefers.getString("github_proxy_mode", GithubProxy.MODE_FULL_URL)); - } - - public static void putGithubProxyMode(String mode) { - Prefers.put("github_proxy_mode", GithubProxy.normalizeMode(mode)); - } public static boolean isGithubProxyEnabled() { return Prefers.getBoolean("github_proxy_enabled", true); @@ -814,66 +792,6 @@ public class Setting { Prefers.put("github_proxy_enabled", enabled); } - /** - * 把旧的单选 GitHub 代理设置迁移成新的多源列表。 - * - *

{@code synchronized}:两个 getter 都会调它,而它先读后删旧键。不加锁时 - * 「关于」对话框与探测线程可能同时进来,两边都看到旧键存在,后写的 {@code putGithubProxy} - * 会盖掉前一次已经扩好的列表。窗口只有升级后第一次读,但那正是唯一一次机会。 - */ - private static synchronized void migrateLegacyGithubProxy() { - if (!Prefers.getPrefers().contains("update_github_proxy")) return; - - String proxy = Prefers.getString("update_github_proxy"); - if (GithubProxy.DIRECT.equals(proxy)) { - putGithubProxyEnabled(false); - } else { - String url = "custom".equals(proxy) - ? Prefers.getString("update_github_proxy_url") - : legacyGithubProxyUrl(proxy); - if (!url.isEmpty()) { - // 旧选择成为列表首位(即生效源),其余内置源保留在后面备用。 - // 先前这里用 putGithubProxy(url) 覆盖,会把刚合并出来的整份列表抹成一条。 - String merged = GithubProxy.addSources(Prefers.getString("github_proxy"), legacyGithubProxySources(url)); - putGithubProxy(GithubProxy.addSources(url, merged)); - putGithubProxyEnabled(true); - putGithubProxyMode(Prefers.getString("update_github_proxy_mode", GithubProxy.MODE_FULL_URL)); - } - } - - Prefers.remove("update_github_proxy"); - Prefers.remove("update_github_proxy_url"); - Prefers.remove("update_github_proxy_mode"); - } - - private static String legacyGithubProxyUrl(String proxy) { - return switch (proxy) { - case "github_chenc" -> "https://github.chenc.dev"; - case "gh_acmsz" -> "https://gh.acmsz.top"; - case "ghfast" -> "https://ghfast.top"; - case "gh_monlor" -> "https://gh.monlor.com"; - default -> ""; - }; - } - - /** - * 旧版本内置的四个代理加上用户自填的那个,作为迁移时要并入的候选源。 - * - *

{@code selectedUrl} 放在最前:并入后它就是生效源,与用户升级前的选择一致。 - * 自填地址无论当时选没选中都保留 —— 它是用户手输的,丢掉就再也找不回来。 - */ - private static String legacyGithubProxySources(String selectedUrl) { - List list = new ArrayList<>(); - if (selectedUrl != null && !selectedUrl.isEmpty()) list.add(selectedUrl); - list.add("https://github.chenc.dev"); - list.add("https://gh.acmsz.top"); - list.add("https://ghfast.top"); - list.add("https://gh.monlor.com"); - String custom = Prefers.getString("update_github_proxy_url"); - if (!custom.isEmpty()) list.add(custom); - return String.join("\n", list); - } - public static boolean isAdblock() { return Prefers.getBoolean("adblock", true); } @@ -1154,10 +1072,6 @@ public class Setting { return isTmdbMode(getDetailOpenMode()) && getTmdbModel() == TMDB_MODEL_NATIVE && TmdbConfig.objectFrom(getTmdbConfig()).isReady(); } - public static boolean isTmdbDetailModeConfigured() { - return isTmdbMode(getDetailOpenMode()) && getTmdbModel() == TMDB_MODEL_NATIVE; - } - public static int getDetailOpenMode() { int mode; if (Prefers.getPrefers().contains("detail_open_mode")) { @@ -1179,7 +1093,7 @@ public class Setting { mode = isTmdbEnabled() ? DETAIL_OPEN_ORIGINAL_ENHANCED : DETAIL_OPEN_DIRECT; migrateCurrentDetailTheme(mode); } - return mode; + return isTmdbMode(mode) && !isTmdbReady() ? DETAIL_OPEN_DIRECT : mode; } public static void putDetailOpenMode(int mode) { @@ -1337,16 +1251,6 @@ public class Setting { Prefers.put("global_history_mode", clampGlobalHistoryMode(mode)); } - public static int getInterfaceFailoverMode() { - return InterfaceFailoverPolicy.clampMode(Prefers.getInt("interface_failover_mode", InterfaceFailoverPolicy.DEFAULT_MODE)); - } - - public static void putInterfaceFailoverMode(int mode) { - int value = InterfaceFailoverPolicy.clampMode(mode); - Prefers.put("interface_failover_mode", value); - if (value == InterfaceFailoverPolicy.OFF) VodConfig.cancelFailover(); - } - public static boolean isGlobalHistoryEnabled() { return getGlobalHistoryMode() != GLOBAL_HISTORY_OFF; } @@ -1424,21 +1328,14 @@ public class Setting { Prefers.put("play_back_to_detail", backToDetail); } - public static boolean isSubtitleAutoMatchEnabled() { - return Prefers.getBoolean("subtitle_auto_match", false); - } + public static boolean isSubtitleAutoMatchEnabled() { + return Prefers.getBoolean("subtitle_auto_match", false); + } - public static void putSubtitleAutoMatchEnabled(boolean enabled) { - Prefers.put("subtitle_auto_match", enabled); - } + public static void putSubtitleAutoMatchEnabled(boolean enabled) { + Prefers.put("subtitle_auto_match", enabled); + } - public static boolean isPlaybackOverlayEnabled() { - return Prefers.getBoolean("playback_overlay_enabled", true); - } - - public static void putPlaybackOverlayEnabled(boolean enabled) { - Prefers.put("playback_overlay_enabled", enabled); - } public static String getSubtitlePreferredLanguage() { return Prefers.getString("subtitle_preferred_language", "zh"); } @@ -1469,51 +1366,12 @@ public class Setting { case null, default -> false; }; } - public static String getSubtitleSourceEnvironment() { - String environment = Prefers.getString("subtitle_source_environment"); - if (!environment.isEmpty()) return environment; - String legacyToken = Prefers.getString("subtitle_assrt_token"); - if (legacyToken.isEmpty()) return ""; - JsonObject migrated = new JsonObject(); - migrated.addProperty("ASSRT_TOKEN", legacyToken); - environment = migrated.toString(); - Prefers.put("subtitle_source_environment", environment); - Prefers.put("subtitle_assrt_token", ""); - return environment; + public static String getSubtitleAssrtToken() { + return Prefers.getString("subtitle_assrt_token"); } - public static void putSubtitleSourceEnvironment(String environment) { - Prefers.put("subtitle_source_environment", environment == null ? "" : environment.trim()); - } - - public static String getSubtitleSourceEnvironment(String sourceKey) { - if (sourceKey == null || sourceKey.trim().isEmpty()) return ""; - try { - JsonObject root = com.google.gson.JsonParser.parseString(getSubtitleSourceEnvironment()).getAsJsonObject(); - if (root.has(sourceKey) && root.get(sourceKey).isJsonObject()) return root.getAsJsonObject(sourceKey).toString(); - if ("assrt".equals(sourceKey) && root.has("ASSRT_TOKEN")) { - JsonObject migrated = new JsonObject(); - migrated.add("ASSRT_TOKEN", root.get("ASSRT_TOKEN").deepCopy()); - return migrated.toString(); - } - } catch (RuntimeException ignored) { - } - return ""; - } - - public static void putSubtitleSourceEnvironment(String sourceKey, String environment) { - if (sourceKey == null || sourceKey.trim().isEmpty()) return; - JsonObject root; - try { - root = com.google.gson.JsonParser.parseString(getSubtitleSourceEnvironment()).getAsJsonObject(); - } catch (RuntimeException ignored) { - root = new JsonObject(); - } - if ("assrt".equals(sourceKey)) root.remove("ASSRT_TOKEN"); - String value = environment == null ? "" : environment.trim(); - if (value.isEmpty()) root.remove(sourceKey); - else root.add(sourceKey, com.google.gson.JsonParser.parseString(value).getAsJsonObject()); - putSubtitleSourceEnvironment(root.size() == 0 ? "" : root.toString()); + public static void putSubtitleAssrtToken(String token) { + Prefers.put("subtitle_assrt_token", token); } public static int getSubtitleAiMaxConcurrency() { diff --git a/app/src/main/java/com/fongmi/android/tv/setting/SiteHealthStore.java b/app/src/main/java/com/fongmi/android/tv/setting/SiteHealthStore.java index 6f7c2eb3b2..c0b0c67179 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/SiteHealthStore.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/SiteHealthStore.java @@ -39,14 +39,6 @@ public class SiteHealthStore { private static boolean loaded; private static boolean dirty; - public static void recordHome(Site site, boolean success, int count, long cost, String error) { - recordProtocol(site == null ? null : site.getKey(), ProtocolStage.HOME, success, count, cost, error); - } - - public static void recordCategory(String key, boolean success, int count, long cost, String error) { - recordProtocol(key, ProtocolStage.CATEGORY, success, count, cost, error); - } - public static void recordSearch(Site site, boolean success, int count, long cost, String error) { if (skip(site)) return; synchronized (SiteHealthStore.class) { @@ -110,16 +102,6 @@ public class SiteHealthStore { } } - public static void recordPlayAttempt(String key) { - if (skip(key)) return; - synchronized (SiteHealthStore.class) { - Health health = get(key); - health.playAttempts++; - health.updatedAt = System.currentTimeMillis(); - markDirty(); - } - } - public static void recordPlay(String key, boolean success, String error) { if (skip(key)) return; synchronized (SiteHealthStore.class) { @@ -138,53 +120,6 @@ public class SiteHealthStore { } } - private static void recordProtocol(String key, ProtocolStage stage, boolean success, int count, long cost, String error) { - if (skip(key)) return; - synchronized (SiteHealthStore.class) { - Health health = get(key); - health.updatedAt = System.currentTimeMillis(); - boolean home = stage == ProtocolStage.HOME; - if (home) { - health.lastHomeCost = Math.max(0, cost); - health.lastHomeCount = Math.max(0, count); - if (success) { - health.homeSuccess++; - health.lastHomeSuccessAt = health.updatedAt; - if (count <= 0) { - health.homeEmpty++; - health.lastHomeError = REASON_EMPTY_RESULT; - health.addHomeReason(REASON_EMPTY_RESULT); - } - } else { - health.homeFail++; - health.lastHomeFailAt = health.updatedAt; - health.lastHomeError = trim(error); - health.addHomeReason(reason("HOME", error)); - } - } else { - health.lastCategoryCost = Math.max(0, cost); - health.lastCategoryCount = Math.max(0, count); - if (success) { - health.categorySuccess++; - health.lastCategorySuccessAt = health.updatedAt; - if (count <= 0) { - health.categoryEmpty++; - health.lastCategoryError = REASON_EMPTY_RESULT; - health.addCategoryReason(REASON_EMPTY_RESULT); - } - } else { - health.categoryFail++; - health.lastCategoryFailAt = health.updatedAt; - health.lastCategoryError = trim(error); - health.addCategoryReason(reason("CATEGORY", error)); - } - } - markDirty(); - } - } - - private enum ProtocolStage { HOME, CATEGORY } - public static void sortSites(List sites) { if (!Setting.isSiteHealthSort()) return; if (sites == null || sites.size() < 2) return; @@ -242,7 +177,7 @@ public class SiteHealthStore { rows.add(Row.from(siteKey, displayName(siteKey, names), health)); } rows.sort((a, b) -> Long.compare(b.updatedAt, a.updatedAt)); - return new Report(cid, rows, AdBlockStatsStore.getStats()); + return new Report(cid, rows); } } @@ -392,19 +327,11 @@ public class SiteHealthStore { public final Summary summary; public final List rows; public final String cid; - public final long adBlockedTotal; - public final Map adBlockedBySite; - public final Map adBlockedByRule; - public final Map adBlockedByPipeline; - private Report(String cid, List rows, com.fongmi.android.tv.bean.AdBlockStats adStats) { + private Report(String cid, List rows) { this.cid = cid; this.rows = Collections.unmodifiableList(rows); this.summary = Summary.from(rows); - this.adBlockedTotal = adStats.getTotalBlocked(); - this.adBlockedBySite = Collections.unmodifiableMap(new LinkedHashMap<>(adStats.getSiteBlocked())); - this.adBlockedByRule = Collections.unmodifiableMap(new LinkedHashMap<>(adStats.getRuleCounts())); - this.adBlockedByPipeline = Collections.unmodifiableMap(new LinkedHashMap<>(adStats.getPipelineCounts())); } public boolean isEmpty() { @@ -451,48 +378,39 @@ public class SiteHealthStore { public final String siteKey; public final String siteName; public final Status status; - public final Stage home; - public final Stage category; public final Stage search; public final Stage detail; public final Stage parse; public final Stage play; - public final int playAttempts; public final long updatedAt; - private Row(String siteKey, String siteName, Status status, Stage home, Stage category, Stage search, Stage detail, Stage parse, Stage play, int playAttempts, long updatedAt) { + private Row(String siteKey, String siteName, Status status, Stage search, Stage detail, Stage parse, Stage play, long updatedAt) { this.siteKey = siteKey; this.siteName = siteName; this.status = status; - this.home = home; - this.category = category; this.search = search; this.detail = detail; this.parse = parse; this.play = play; - this.playAttempts = playAttempts; this.updatedAt = updatedAt; } private static Row from(String siteKey, String siteName, Health health) { double adPenalty = calculateAdPenalty(siteKey); return new Row(siteKey, siteName, health.status(adPenalty), - new Stage("HOME", Math.max(0, health.homeSuccess - health.homeEmpty), health.homeFail + health.homeEmpty, health.homeEmpty, health.lastHomeCost, health.lastHomeFailAt, health.lastHomeError, health.homeReasons), - new Stage("CATEGORY", Math.max(0, health.categorySuccess - health.categoryEmpty), health.categoryFail + health.categoryEmpty, health.categoryEmpty, health.lastCategoryCost, health.lastCategoryFailAt, health.lastCategoryError, health.categoryReasons), new Stage("SEARCH", Math.max(0, health.searchSuccess - health.searchEmpty), health.searchFail + health.searchEmpty, health.searchEmpty, health.lastSearchCost, health.lastSearchFailAt, health.lastSearchError, health.searchReasons), new Stage("DETAIL", health.detailSuccess, health.detailFail, 0, health.lastDetailCost, health.lastDetailFailAt, health.lastDetailError, health.detailReasons), new Stage("PARSE", health.parseSuccess, health.parseFail, 0, health.lastParseCost, health.lastParseFailAt, health.lastParseError, health.parseReasons), new Stage("PLAY", health.playSuccess, health.playFail, 0, 0, health.lastPlayFailAt, health.lastPlayError, health.playReasons), - health.playAttempts, health.updatedAt); } public int sampleCount() { - return home.sampleCount() + category.sampleCount() + search.sampleCount() + detail.sampleCount() + parse.sampleCount() + play.sampleCount(); + return search.sampleCount() + detail.sampleCount() + parse.sampleCount() + play.sampleCount(); } public int failureCount() { - return home.failureCount() + category.failureCount() + search.failureCount() + detail.failureCount() + parse.failureCount() + play.failureCount(); + return search.failureCount() + detail.failureCount() + parse.failureCount() + play.failureCount(); } public String topFailureReason() { @@ -508,8 +426,6 @@ public class SiteHealthStore { } private void addReasonsTo(Map reasons) { - home.addReasonsTo(reasons); - category.addReasonsTo(reasons); search.addReasonsTo(reasons); detail.addReasonsTo(reasons); parse.addReasonsTo(reasons); @@ -593,12 +509,6 @@ public class SiteHealthStore { public static class Health { - private int homeSuccess; - private int homeFail; - private int homeEmpty; - private int categorySuccess; - private int categoryFail; - private int categoryEmpty; private int searchSuccess; private int searchFail; private int searchEmpty; @@ -608,19 +518,10 @@ public class SiteHealthStore { private int parseFail; private int playSuccess; private int playFail; - private int playAttempts; - private int lastHomeCount; - private int lastCategoryCount; private int lastSearchCount; - private long lastHomeCost; - private long lastCategoryCost; private long lastSearchCost; private long lastDetailCost; private long lastParseCost; - private long lastHomeSuccessAt; - private long lastHomeFailAt; - private long lastCategorySuccessAt; - private long lastCategoryFailAt; private long lastSearchSuccessAt; private long lastSearchFailAt; private long lastDetailSuccessAt; @@ -630,21 +531,17 @@ public class SiteHealthStore { private long lastPlaySuccessAt; private long lastPlayFailAt; private long updatedAt; - private String lastHomeError; - private String lastCategoryError; private String lastSearchError; private String lastDetailError; private String lastParseError; private String lastPlayError; - private Map homeReasons; - private Map categoryReasons; private Map searchReasons; private Map detailReasons; private Map parseReasons; private Map playReasons; private int total() { - return homeSuccess + homeFail + categorySuccess + categoryFail + searchSuccess + searchFail + detailSuccess + detailFail + parseSuccess + parseFail + playSuccess + playFail; + return searchSuccess + searchFail + detailSuccess + detailFail + parseSuccess + parseFail + playSuccess + playFail; } private Status status() { @@ -661,8 +558,8 @@ public class SiteHealthStore { } private double score() { - int success = homeSuccess + categorySuccess + searchSuccess + detailSuccess * 2 + playSuccess * 5; - int fail = homeFail + categoryFail + searchFail + detailFail * 2 + playFail * 5; + int success = searchSuccess + detailSuccess * 2 + playSuccess * 5; + int fail = searchFail + detailFail * 2 + playFail * 5; if (success + fail == 0) return 0; double score = 60.0 * (success - fail) / (success + fail + 4.0); score += Math.min(lastSearchCount, 20) * 1.2; @@ -673,14 +570,6 @@ public class SiteHealthStore { return score; } - private void addHomeReason(String reason) { - homeReasons = addReason(homeReasons, reason); - } - - private void addCategoryReason(String reason) { - categoryReasons = addReason(categoryReasons, reason); - } - private void addSearchReason(String reason) { searchReasons = addReason(searchReasons, reason); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/TmdbSourceState.java b/app/src/main/java/com/fongmi/android/tv/setting/TmdbSourceState.java deleted file mode 100644 index 909751c709..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/TmdbSourceState.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.fongmi.android.tv.setting; - -/** Availability of user-visible TMDB data embedded in a source detail response. */ -public enum TmdbSourceState { - ABSENT_OR_INVALID, - IDENTITY_ONLY, - RENDERABLE -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizer.java b/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizer.java index 35eff30baa..f4c5016c4b 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizer.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizer.java @@ -25,6 +25,7 @@ import java.io.File; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -43,7 +44,6 @@ final class RealtimeSubtitleRecognizer { private static final long UNSET_US = Long.MIN_VALUE; private final RealtimeSubtitleModelCatalog.ModelSpec spec; - private final SpeechRecognitionFactory.ExecutionProfile profile; private final Listener listener; private final ArrayBlockingQueue speechQueue = new ArrayBlockingQueue<>(SPEECH_QUEUE_CAPACITY); private final AtomicInteger decodeGeneration = new AtomicInteger(); @@ -52,21 +52,14 @@ final class RealtimeSubtitleRecognizer { private OfflineRecognizer offlineRecognizer; private Vad vad; private ExecutorService recognitionExecutor; + private Future recognitionFuture; private volatile boolean released; private long streamBaseUs = UNSET_US; private long vadBaseUs = UNSET_US; private int speechSamples; static RealtimeSubtitleRecognizer create(File modelDir, File vadFile, RealtimeSubtitleModelCatalog.ModelSpec spec, Listener listener) { - return create(modelDir, vadFile, spec, - SpeechRecognitionFactory.ExecutionProfile.SUBTITLE, listener); - } - - static RealtimeSubtitleRecognizer create(File modelDir, File vadFile, - RealtimeSubtitleModelCatalog.ModelSpec spec, - SpeechRecognitionFactory.ExecutionProfile profile, - Listener listener) { - return new RealtimeSubtitleRecognizer(modelDir, vadFile, spec, profile, listener); + return new RealtimeSubtitleRecognizer(modelDir, vadFile, spec, listener); } static boolean isStreaming(RealtimeSubtitleModelCatalog.ModelSpec spec) { @@ -84,36 +77,22 @@ final class RealtimeSubtitleRecognizer { return isStreaming(spec) ? 0 : (int) (SAMPLE_RATE * 2.2f); } - private RealtimeSubtitleRecognizer(File modelDir, File vadFile, - RealtimeSubtitleModelCatalog.ModelSpec spec, - SpeechRecognitionFactory.ExecutionProfile profile, - Listener listener) { + private RealtimeSubtitleRecognizer(File modelDir, File vadFile, RealtimeSubtitleModelCatalog.ModelSpec spec, Listener listener) { this.spec = spec; - this.profile = profile; this.listener = listener; try { if (isStreaming(spec)) { - onlineRecognizer = new OnlineRecognizer(buildOnlineRecognizer(modelDir, spec, profile)); + onlineRecognizer = new OnlineRecognizer(buildOnlineRecognizer(modelDir, spec)); onlineStream = onlineRecognizer.createStream(); } else { - offlineRecognizer = new OfflineRecognizer(buildOfflineRecognizer(modelDir, spec, profile)); + offlineRecognizer = new OfflineRecognizer(buildOfflineRecognizer(modelDir, spec)); vad = new Vad(buildVad(vadFile)); recognitionExecutor = Executors.newSingleThreadExecutor(r -> { - Runnable worker = () -> { - if (profile == SpeechRecognitionFactory.ExecutionProfile.AD_AUDIO) { - try { - android.os.Process.setThreadPriority( - android.os.Process.THREAD_PRIORITY_BACKGROUND); - } catch (RuntimeException ignored) { - } - } - r.run(); - }; - Thread thread = new Thread(worker, "realtime-subtitle-offline-asr"); + Thread thread = new Thread(r, "realtime-subtitle-offline-asr"); thread.setPriority(Thread.NORM_PRIORITY - 1); return thread; }); - recognitionExecutor.execute(this::recognizeLoop); + recognitionFuture = recognitionExecutor.submit(this::recognizeLoop); } } catch (RuntimeException | Error error) { release(); @@ -146,7 +125,15 @@ final class RealtimeSubtitleRecognizer { released = true; decodeGeneration.incrementAndGet(); speechQueue.clear(); - awaitRecognitionTermination(recognitionExecutor); + if (recognitionFuture != null) { + try { + recognitionFuture.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Throwable ignored) { + } + } + if (recognitionExecutor != null) recognitionExecutor.shutdownNow(); if (onlineStream != null) onlineStream.release(); if (onlineRecognizer != null) onlineRecognizer.release(); if (vad != null) vad.release(); @@ -157,25 +144,6 @@ final class RealtimeSubtitleRecognizer { offlineRecognizer = null; } - // A thread interruption is not proof that JNI decode has returned. Keep native objects - // alive and the owner occupied until the actual worker exits; preserve interrupt status. - static void awaitRecognitionTermination(ExecutorService executor) { - if (executor == null) return; - executor.shutdown(); - boolean interrupted = false; - try { - while (true) { - try { - if (executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS)) return; - } catch (InterruptedException e) { - interrupted = true; - } - } - } finally { - if (interrupted) Thread.currentThread().interrupt(); - } - } - private void acceptStreaming(float[] samples, long startUs, long endUs, int timelineToken) { if (streamBaseUs == UNSET_US) streamBaseUs = startUs; onlineStream.acceptWaveform(samples, SAMPLE_RATE); @@ -262,9 +230,7 @@ final class RealtimeSubtitleRecognizer { } } - private static OnlineRecognizerConfig buildOnlineRecognizer( - File modelDir, RealtimeSubtitleModelCatalog.ModelSpec spec, - SpeechRecognitionFactory.ExecutionProfile profile) { + private static OnlineRecognizerConfig buildOnlineRecognizer(File modelDir, RealtimeSubtitleModelCatalog.ModelSpec spec) { RealtimeSubtitleModelCatalog.ModelFile[] files = spec.files(); OnlineTransducerModelConfig transducer = OnlineTransducerModelConfig.builder() .setEncoder(new File(modelDir, files[0].relativePath()).getPath()) @@ -275,7 +241,7 @@ final class RealtimeSubtitleRecognizer { .setTransducer(transducer) .setTokens(new File(modelDir, files[3].relativePath()).getPath()) .setModelType(onlineModelType(spec)) - .setNumThreads(threadCount(profile)) + .setNumThreads(threadCount()) .setProvider("cpu") .setDebug(false) .build(); @@ -294,12 +260,10 @@ final class RealtimeSubtitleRecognizer { .build(); } - private static OfflineRecognizerConfig buildOfflineRecognizer( - File modelDir, RealtimeSubtitleModelCatalog.ModelSpec spec, - SpeechRecognitionFactory.ExecutionProfile profile) { + private static OfflineRecognizerConfig buildOfflineRecognizer(File modelDir, RealtimeSubtitleModelCatalog.ModelSpec spec) { RealtimeSubtitleModelCatalog.ModelFile[] files = spec.files(); OfflineModelConfig.Builder model = OfflineModelConfig.builder() - .setNumThreads(threadCount(profile)) + .setNumThreads(threadCount()) .setProvider("cpu") .setDebug(false); switch (spec.engine()) { @@ -354,11 +318,6 @@ final class RealtimeSubtitleRecognizer { return Math.max(1, Math.min(4, Runtime.getRuntime().availableProcessors() / 2)); } - static int threadCount(SpeechRecognitionFactory.ExecutionProfile profile) { - return profile == SpeechRecognitionFactory.ExecutionProfile.AD_AUDIO - ? 1 : threadCount(); - } - private static boolean containsSpeechText(String text) { for (int i = 0; i < text.length(); ) { int codePoint = text.codePointAt(i); diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactory.java b/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactory.java index 0e15af925e..dbb12416d0 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactory.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactory.java @@ -44,17 +44,10 @@ public final class RealtimeSubtitleSpeechRecognitionFactory implements SpeechRec @Override public Session create(Listener listener) { - return create(listener, ExecutionProfile.SUBTITLE); - } - - @Override - public Session create(Listener listener, ExecutionProfile profile) { Objects.requireNonNull(listener, "listener"); - Objects.requireNonNull(profile, "profile"); if (!isReady()) throw new IllegalStateException("speech model is not ready"); RealtimeSubtitleRecognizer recognizer = RealtimeSubtitleRecognizer.create( - modelDirectory(), vadFile(), spec, profile, - new RealtimeSubtitleRecognizer.Listener() { + modelDirectory(), vadFile(), spec, new RealtimeSubtitleRecognizer.Listener() { @Override public void onResult(String text, long startUs, long endUs, int timelineToken) { @@ -110,4 +103,4 @@ public final class RealtimeSubtitleSpeechRecognitionFactory implements SpeechRec private static RealtimeSubtitleModelCatalog.ModelSpec selectedModel() { return RealtimeSubtitleModelCatalog.find(Setting.getRealtimeSubtitleModel()); } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/SpeechRecognitionFactory.java b/app/src/main/java/com/fongmi/android/tv/subtitle/SpeechRecognitionFactory.java index 39ca7cc1d4..2c65c6f55e 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/SpeechRecognitionFactory.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/SpeechRecognitionFactory.java @@ -2,11 +2,6 @@ package com.fongmi.android.tv.subtitle; public interface SpeechRecognitionFactory { - enum ExecutionProfile { - SUBTITLE, - AD_AUDIO - } - interface Listener { void onResult(String text, long startUs, long endUs, int timelineToken); @@ -25,9 +20,4 @@ public interface SpeechRecognitionFactory { boolean isReady(); Session create(Listener listener); - - /** Creates a session with an optional native scheduling/resource profile. */ - default Session create(Listener listener, ExecutionProfile profile) { - return create(listener); - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleMatchService.java b/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleMatchService.java index 2384186bd3..59c7a66892 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleMatchService.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleMatchService.java @@ -33,7 +33,7 @@ public final class SubtitleMatchService { private final Map generations; public SubtitleMatchService() { - this(new SubtitleContextBuilder(), new SubtitleQueryPlanner(), SubtitleProviderRegistry.get(), new SubtitleRanker()); + this(new SubtitleContextBuilder(), new SubtitleQueryPlanner(), new SubtitleProviderRegistry(), new SubtitleRanker()); } SubtitleMatchService(SubtitleContextBuilder contextBuilder, SubtitleQueryPlanner queryPlanner, SubtitleProviderRegistry registry, SubtitleRanker ranker) { diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/AssrtSubtitleProvider.java b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/AssrtSubtitleProvider.java index bb679bd1b8..e50ade470e 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/AssrtSubtitleProvider.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/AssrtSubtitleProvider.java @@ -4,7 +4,7 @@ import android.text.TextUtils; import android.util.Log; import com.fongmi.android.tv.App; -import com.fongmi.android.tv.subtitle.source.SubtitleSourceEnvironment; +import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.subtitle.SubtitleTitleParser; import com.fongmi.android.tv.subtitle.cache.SubtitleAssetStore; import com.fongmi.android.tv.subtitle.model.SubtitleAsset; @@ -55,7 +55,7 @@ public final class AssrtSubtitleProvider implements SubtitleProvider { @Override public boolean isEnabled() { - boolean enabled = !TextUtils.isEmpty(SubtitleSourceEnvironment.resolveToken("assrt", "ASSRT_TOKEN")); + boolean enabled = !TextUtils.isEmpty(Setting.getSubtitleAssrtToken()); if (!enabled) Log.w(TAG, "assrt disabled reason=empty_token"); return enabled; } @@ -65,7 +65,7 @@ public final class AssrtSubtitleProvider implements SubtitleProvider { List items = new ArrayList<>(); if (!isEnabled() || query == null || TextUtils.isEmpty(query.getText())) return items; Log.i(TAG, "assrt search request q=" + query.getText()); - String url = API_BASE + "/sub/search?token=" + encode(SubtitleSourceEnvironment.resolveToken("assrt", "ASSRT_TOKEN")) + "&q=" + encode(query.getText()) + "&is_file=1&cnt=15"; + String url = API_BASE + "/sub/search?token=" + encode(Setting.getSubtitleAssrtToken()) + "&q=" + encode(query.getText()) + "&is_file=1&cnt=15"; try (Response response = OkHttp.client().newCall(new Request.Builder().url(url).get().build()).execute()) { if (!response.isSuccessful() || response.body() == null) { Log.w(TAG, "assrt search http_failed code=" + response.code() + " q=" + query.getText()); @@ -110,7 +110,7 @@ public final class AssrtSubtitleProvider implements SubtitleProvider { String id = safeString(payload, "id"); if (TextUtils.isEmpty(id)) id = candidate.getCandidateId(); Log.i(TAG, "assrt detail request id=" + id); - String url = API_BASE + "/sub/detail?token=" + encode(SubtitleSourceEnvironment.resolveToken("assrt", "ASSRT_TOKEN")) + "&id=" + encode(id); + String url = API_BASE + "/sub/detail?token=" + encode(Setting.getSubtitleAssrtToken()) + "&id=" + encode(id); try (Response response = OkHttp.client().newCall(new Request.Builder().url(url).get().build()).execute()) { if (!response.isSuccessful() || response.body() == null) { Log.w(TAG, "assrt detail http_failed code=" + response.code() + " id=" + id); diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProvider.java b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProvider.java index 1a7c4b7bc0..f81c41bb5a 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProvider.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProvider.java @@ -11,10 +11,6 @@ public interface SubtitleProvider { String getName(); - default String getKey() { - return getName(); - } - boolean isEnabled(); default boolean isQueryIndependent() { diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProviderRegistry.java b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProviderRegistry.java index ab12f41a87..18bc406e7a 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProviderRegistry.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProviderRegistry.java @@ -2,15 +2,6 @@ package com.fongmi.android.tv.subtitle.provider; import android.util.Log; -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.subtitle.source.SubtitleScriptRuntimeFactory; -import com.fongmi.android.tv.subtitle.source.SubtitleSourceManager; -import com.github.catvod.utils.Asset; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - import com.fongmi.android.tv.subtitle.model.SubtitleAsset; import com.fongmi.android.tv.subtitle.model.SubtitleCandidate; import com.fongmi.android.tv.subtitle.model.SubtitleContext; @@ -31,18 +22,11 @@ public final class SubtitleProviderRegistry { private static final String TAG = "SubtitleMatch"; private static final int MAX_SEARCH_THREADS = 8; - private static final SubtitleProviderRegistry INSTANCE = new SubtitleProviderRegistry(); private final Map providers; - private SubtitleSourceManager builtinSourceManager; public SubtitleProviderRegistry() { - this.providers = new LinkedHashMap<>(); - if (!installBuiltinSources()) registerLegacyProviders(); - } - - public static SubtitleProviderRegistry get() { - return INSTANCE; + this(new AssrtSubtitleProvider(), new XunleiSubtitleProvider(), new ShooterSubtitleProvider()); } SubtitleProviderRegistry(SubtitleProvider... providers) { @@ -50,52 +34,11 @@ public final class SubtitleProviderRegistry { if (providers != null) for (SubtitleProvider provider : providers) register(provider); } - private boolean installBuiltinSources() { - if (App.get() == null) return false; - try { - JsonObject manifest = JsonParser.parseString(Asset.read("subtitle_sources/builtin/manifest.json")).getAsJsonObject(); - JsonArray subtitles = manifest.has("subtitles") && manifest.get("subtitles").isJsonArray() - ? manifest.getAsJsonArray("subtitles") : new JsonArray(); - builtinSourceManager = new SubtitleSourceManager(this, new SubtitleScriptRuntimeFactory()); - return !builtinSourceManager.install("builtin", manifest.toString(), "assets://subtitle_sources/builtin/manifest.json").isEmpty(); - } catch (Throwable error) { - try { - Log.w(TAG, "builtin subtitle sources unavailable; using legacy providers", error); - } catch (Throwable ignored) { - } - builtinSourceManager = null; - return false; - } - } - - private void registerLegacyProviders() { - register(new AssrtSubtitleProvider()); - register(new XunleiSubtitleProvider()); - register(new ShooterSubtitleProvider()); - } - - public void destroy() { - if (builtinSourceManager != null) builtinSourceManager.destroy(); - } - public void register(SubtitleProvider provider) { if (provider == null || isEmpty(provider.getName())) return; providers.put(provider.getName(), provider); } - public void unregister(String providerName) { - if (isEmpty(providerName)) return; - providers.remove(providerName); - } - - public List providers() { - return new ArrayList<>(providers.values()); - } - - public List providerNames() { - return new ArrayList<>(providers.keySet()); - } - public List enabledProviders() { List items = new ArrayList<>(); for (SubtitleProvider provider : providers.values()) if (provider.isEnabled()) items.add(provider); diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/T3SubtitleProvider.java b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/T3SubtitleProvider.java deleted file mode 100644 index e15d98ee43..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/T3SubtitleProvider.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.fongmi.android.tv.subtitle.provider; - -import com.fongmi.android.tv.subtitle.model.SubtitleAsset; -import com.fongmi.android.tv.subtitle.model.SubtitleCandidate; -import com.fongmi.android.tv.subtitle.model.SubtitleContext; -import com.fongmi.android.tv.subtitle.model.SubtitleQuery; -import com.fongmi.android.tv.subtitle.source.SubtitleProtocolMapper; -import com.fongmi.android.tv.subtitle.source.SubtitleScriptRuntime; -import com.fongmi.android.tv.subtitle.source.SubtitleSourceConfig; -import com.fongmi.android.tv.subtitle.source.SubtitleSourceEnvironment; - -import java.util.List; - -public final class T3SubtitleProvider implements SubtitleProvider { - - private final SubtitleSourceConfig source; - private final SubtitleScriptRuntime runtime; - private final String runtimeKey; - private final com.google.gson.JsonObject templateParams; - - public T3SubtitleProvider(SubtitleSourceConfig source, SubtitleScriptRuntime runtime) { - this(source, runtime, source == null ? "" : source.getKey()); - } - - public T3SubtitleProvider(SubtitleSourceConfig source, SubtitleScriptRuntime runtime, String runtimeKey) { - if (source == null) throw new IllegalArgumentException("source is null"); - if (runtime == null) throw new IllegalArgumentException("runtime is null"); - this.source = source; - this.runtime = runtime; - this.runtimeKey = runtimeKey == null ? "" : runtimeKey; - this.templateParams = source.getParams() == null ? new com.google.gson.JsonObject() : source.getParams().deepCopy(); - } - - public void initialize(String scriptPath) throws Exception { - refreshSensitiveParams(); - SubtitleProtocolMapper.requireSuccess(runtime.init(runtimeKey, scriptPath, source.getParams().toString())); - } - - public void destroy() { - runtime.destroy(runtimeKey); - } - - @Override - public String getName() { - return source.getKey(); - } - - @Override - public String getKey() { - return source.getKey(); - } - - @Override - public boolean isEnabled() { - return source.isEnabled(); - } - - @Override - public boolean isQueryIndependent() { - return source.isQueryIndependent(); - } - - @Override - public List search(SubtitleQuery query, SubtitleContext context) throws Exception { - refreshSensitiveParams(); - String request = SubtitleProtocolMapper.createSearchRequest(source, query, context); - return SubtitleProtocolMapper.parseCandidates(source, query, runtime.search(runtimeKey, request)); - } - - @Override - public SubtitleAsset resolve(SubtitleCandidate candidate, SubtitleContext context) throws Exception { - refreshSensitiveParams(); - String request = SubtitleProtocolMapper.createResolveRequest(source, candidate, context); - return SubtitleProtocolMapper.parseAsset(source, candidate, runtime.resolve(runtimeKey, request)); - } - - private void refreshSensitiveParams() { - source.setParams(SubtitleSourceEnvironment.resolve(source.getKey(), templateParams)); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/PythonSubtitleScriptRuntime.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/PythonSubtitleScriptRuntime.java deleted file mode 100644 index 7e9752b68e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/PythonSubtitleScriptRuntime.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import com.fongmi.chaquo.Loader; -import com.fongmi.chaquo.Spider; - -import java.util.concurrent.ConcurrentHashMap; - -public final class PythonSubtitleScriptRuntime implements SubtitleScriptRuntime { - - private final Loader loader; - private final ConcurrentHashMap spiders; - - public PythonSubtitleScriptRuntime() { - this(new Loader()); - } - - PythonSubtitleScriptRuntime(Loader loader) { - this.loader = loader; - this.spiders = new ConcurrentHashMap<>(); - } - - @Override - public String init(String sourceKey, String scriptPath, String config) { - if (sourceKey == null || sourceKey.isBlank()) { - throw new IllegalArgumentException("sourceKey is empty"); - } - if (scriptPath == null || scriptPath.isBlank()) { - throw new IllegalArgumentException("scriptPath is empty"); - } - Spider previous = spiders.remove(sourceKey); - destroy(previous); - Spider spider = loader.spider(scriptPath); - spiders.put(sourceKey, spider); - try { - return spider.subtitleInit(config); - } catch (Throwable error) { - spiders.remove(sourceKey, spider); - destroy(spider); - throw error; - } - } - - @Override - public String search(String sourceKey, String request) { - return require(sourceKey).subtitleSearch(request); - } - - @Override - public String resolve(String sourceKey, String request) { - return require(sourceKey).subtitleResolve(request); - } - - @Override - public void destroy(String sourceKey) { - destroy(spiders.remove(sourceKey)); - } - - public void destroyAll() { - for (String sourceKey : spiders.keySet()) destroy(sourceKey); - } - - private Spider require(String sourceKey) { - Spider spider = spiders.get(sourceKey); - if (spider == null) throw new IllegalStateException("Subtitle source is not initialized: " + sourceKey); - return spider; - } - - private static void destroy(Spider spider) { - if (spider == null) return; - try { - spider.destroy(); - } catch (Throwable ignored) { - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleProtocolMapper.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleProtocolMapper.java deleted file mode 100644 index a273fbb7a7..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleProtocolMapper.java +++ /dev/null @@ -1,203 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import androidx.media3.common.C; - -import com.fongmi.android.tv.player.PlayerHelper; -import com.fongmi.android.tv.subtitle.model.SubtitleAsset; -import com.fongmi.android.tv.subtitle.model.SubtitleCandidate; -import com.fongmi.android.tv.subtitle.model.SubtitleContext; -import com.fongmi.android.tv.subtitle.model.SubtitleMatchType; -import com.fongmi.android.tv.subtitle.model.SubtitleQuery; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.UUID; - -public final class SubtitleProtocolMapper { - - private SubtitleProtocolMapper() { - } - - public static void requireSuccess(String response) { - envelope(response); - } - - public static String createSearchRequest(SubtitleSourceConfig source, SubtitleQuery query, SubtitleContext context) { - JsonObject request = baseRequest("search", source, context); - JsonObject value = new JsonObject(); - value.addProperty("text", query == null ? "" : query.getText()); - value.addProperty("language", query == null ? "" : query.getLanguage()); - value.addProperty("source", query == null || query.getSource() == null ? "" : query.getSource().name()); - value.addProperty("strictness", query == null || query.getStrictness() == null ? "" : query.getStrictness().name()); - value.addProperty("year", query == null ? 0 : query.getYear()); - value.addProperty("season", query == null ? -1 : query.getSeasonNumber()); - value.addProperty("episode", query == null ? -1 : query.getEpisodeNumber()); - request.add("query", value); - return request.toString(); - } - - public static String createResolveRequest(SubtitleSourceConfig source, SubtitleCandidate candidate, SubtitleContext context) { - JsonObject request = baseRequest("resolve", source, context); - JsonObject value = new JsonObject(); - if (candidate != null) { - value.addProperty("id", candidate.getCandidateId()); - value.addProperty("name", candidate.getDisplayName()); - value.addProperty("language", candidate.getLanguage()); - value.addProperty("format", candidate.getFormat()); - value.add("payload", parsePayload(candidate.getProviderPayload())); - } - request.add("candidate", value); - return request.toString(); - } - - public static List parseCandidates(SubtitleSourceConfig source, SubtitleQuery query, String response) { - JsonElement data = envelope(response); - JsonArray array; - if (data.isJsonArray()) array = data.getAsJsonArray(); - else if (data.isJsonObject() && data.getAsJsonObject().has("items") && data.getAsJsonObject().get("items").isJsonArray()) array = data.getAsJsonObject().getAsJsonArray("items"); - else array = new JsonArray(); - List result = new ArrayList<>(); - for (JsonElement element : array) { - if (!element.isJsonObject()) continue; - JsonObject item = element.getAsJsonObject(); - String id = string(item, "id"); - if (id.isEmpty()) continue; - String payload = item.has("payload") ? item.get("payload").toString() : ""; - result.add(new SubtitleCandidate( - source.getKey(), id, string(item, "name"), string(item, "language"), - string(item, "format"), string(item, "releaseInfo"), integer(item, "score"), - integer(item, "year"), integer(item, "season", -1), integer(item, "episode", -1), - matchType(string(item, "matchType")), query == null ? "" : query.getText(), - bool(item, "requiresResolve"), payload)); - } - return result; - } - - public static SubtitleAsset parseAsset(SubtitleSourceConfig source, SubtitleCandidate candidate, String response) { - JsonElement data = envelope(response); - if (!data.isJsonObject()) throw new IllegalStateException("Subtitle resolve data must be an object"); - JsonObject item = data.getAsJsonObject(); - String uri = string(item, "url"); - if (uri.isEmpty()) throw new IllegalStateException("Subtitle resolve response has no URL"); - String name = first(string(item, "fileName"), candidate == null ? "" : candidate.getDisplayName()); - String language = first(string(item, "language"), candidate == null ? "" : candidate.getLanguage()); - String format = first(string(item, "format"), candidate == null ? "" : candidate.getFormat()); - long expiresAt = longValue(item, "expiresAt"); - String mime = PlayerHelper.getSubtitleMimeType(name.isEmpty() ? "subtitle." + format : name); - return new SubtitleAsset(uri, "", name, language, mime, C.SELECTION_FLAG_DEFAULT, false, expiresAt); - } - - private static JsonObject baseRequest(String action, SubtitleSourceConfig source, SubtitleContext context) { - JsonObject request = new JsonObject(); - request.addProperty("action", action); - request.addProperty("requestId", UUID.randomUUID().toString()); - JsonObject sourceValue = new JsonObject(); - sourceValue.addProperty("key", source == null ? "" : source.getKey()); - sourceValue.add("params", source == null ? new JsonObject() : source.getParams()); - request.add("source", sourceValue); - request.add("context", context(context)); - return request; - } - - private static JsonObject context(SubtitleContext context) { - JsonObject value = new JsonObject(); - if (context == null) return value; - value.addProperty("playbackKey", context.getPlaybackKey()); - value.addProperty("siteKey", context.getSiteKey()); - value.addProperty("vodId", context.getVodId()); - value.addProperty("mediaType", context.getMediaType()); - value.addProperty("mediaPath", context.getMediaPath()); - JsonArray aliases = new JsonArray(); - for (String alias : context.getAliases()) aliases.add(alias == null ? "" : alias); - value.add("aliases", aliases); - value.addProperty("canonicalTitle", context.getCanonicalTitle()); - value.addProperty("originalTitle", context.getOriginalTitle()); - value.addProperty("year", context.getYear()); - value.addProperty("season", context.getSeasonNumber()); - value.addProperty("episode", context.getEpisodeNumber()); - value.addProperty("episodeTitle", context.getEpisodeTitle()); - value.addProperty("preferredLanguage", context.getPreferredLanguage()); - value.addProperty("originalLanguage", context.getOriginalLanguage()); - value.addProperty("originCountry", context.getOriginCountry()); - value.addProperty("networkStream", context.isNetworkStream()); - return value; - } - - private static JsonElement envelope(String response) { - if (response == null || response.isBlank()) throw new IllegalStateException("Empty subtitle protocol response"); - JsonElement parsed; - try { - parsed = JsonParser.parseString(response); - } catch (Exception error) { - throw new IllegalStateException("Invalid subtitle protocol response", error); - } - if (!parsed.isJsonObject()) throw new IllegalStateException("Subtitle protocol response must be an object"); - JsonObject object = parsed.getAsJsonObject(); - int code = integer(object, "code"); - if (code != 0) throw new IllegalStateException(first(string(object, "message"), "subtitle_protocol_error_" + code)); - return object.has("data") && !object.get("data").isJsonNull() ? object.get("data") : new JsonObject(); - } - - private static JsonElement parsePayload(String payload) { - if (payload == null || payload.isBlank()) return new JsonObject(); - try { - return JsonParser.parseString(payload); - } catch (Exception ignored) { - return new com.google.gson.JsonPrimitive(payload); - } - } - - private static SubtitleMatchType matchType(String value) { - if (value == null || value.isBlank()) return SubtitleMatchType.METADATA_FUZZY; - try { - return SubtitleMatchType.valueOf(value.trim().toUpperCase(Locale.ROOT)); - } catch (Exception ignored) { - return SubtitleMatchType.METADATA_FUZZY; - } - } - - private static String string(JsonObject object, String key) { - try { - return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : ""; - } catch (Exception ignored) { - return ""; - } - } - - private static int integer(JsonObject object, String key) { - return integer(object, key, 0); - } - - private static int integer(JsonObject object, String key, int fallback) { - try { - return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsInt() : fallback; - } catch (Exception ignored) { - return fallback; - } - } - - private static long longValue(JsonObject object, String key) { - try { - return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsLong() : 0L; - } catch (Exception ignored) { - return 0L; - } - } - - private static boolean bool(JsonObject object, String key) { - try { - return object.has(key) && !object.get(key).isJsonNull() && object.get(key).getAsBoolean(); - } catch (Exception ignored) { - return false; - } - } - - private static String first(String first, String second) { - return first == null || first.isEmpty() ? second == null ? "" : second : first; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntime.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntime.java deleted file mode 100644 index e770006002..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntime.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -public interface SubtitleScriptRuntime { - String init(String sourceKey, String scriptPath, String config) throws Exception; - String search(String sourceKey, String request) throws Exception; - String resolve(String sourceKey, String request) throws Exception; - void destroy(String sourceKey); -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntimeFactory.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntimeFactory.java deleted file mode 100644 index 08df0815a3..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntimeFactory.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -public final class SubtitleScriptRuntimeFactory { - - private final SubtitleScriptRuntime pythonRuntime; - - public SubtitleScriptRuntimeFactory() { - this(new PythonSubtitleScriptRuntime()); - } - - SubtitleScriptRuntimeFactory(SubtitleScriptRuntime pythonRuntime) { - this.pythonRuntime = pythonRuntime; - } - - public SubtitleScriptRuntime create(String api) { - String value = api == null ? "" : api.trim().toLowerCase(); - if ("py".equals(value) || "python".equals(value)) return pythonRuntime; - throw new IllegalArgumentException("Unsupported subtitle runtime: " + api); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceConfig.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceConfig.java deleted file mode 100644 index ef999257ec..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import com.google.gson.JsonObject; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -public final class SubtitleSourceConfig { - private String key="", name="", api="", ext="", sha256=""; - private int type, enabled=1, priority, timeout=15, protocolVersion=1; - private boolean queryIndependent; - private List languages=Collections.emptyList(), formats=Collections.emptyList(); - private Map headers=Collections.emptyMap(); - private JsonObject params=new JsonObject(); - public String getKey(){return key;} public String getName(){return name;} public String getApi(){return api;} public String getExt(){return ext;} public String getSha256(){return sha256;} - public int getType(){return type;} public boolean isEnabled(){return enabled!=0;} public int getPriority(){return priority;} public int getTimeout(){return Math.max(1,Math.min(timeout,60));} public int getProtocolVersion(){return protocolVersion;} public boolean isQueryIndependent(){return queryIndependent;} - public List getLanguages(){return languages==null?Collections.emptyList():languages;} public List getFormats(){return formats==null?Collections.emptyList():formats;} public Map getHeaders(){return headers==null?Collections.emptyMap():headers;} public JsonObject getParams(){return params==null?new JsonObject():params;} public void setParams(JsonObject params){this.params=params==null?new JsonObject():params;} - public boolean valid(){return !key.trim().isEmpty()&&!name.trim().isEmpty()&&(type==3||type==4)&&!api.trim().isEmpty()&&(type!=3||!ext.trim().isEmpty())&&protocolVersion==1;} -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceEnvironment.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceEnvironment.java deleted file mode 100644 index 8c608ccdfc..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceEnvironment.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import com.fongmi.android.tv.setting.Setting; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.util.Map; - -public final class SubtitleSourceEnvironment { - - private SubtitleSourceEnvironment() { - } - - public static JsonObject resolve(String sourceKey, JsonObject params) { - return resolve(params, load(sourceKey)); - } - - static JsonObject resolve(JsonObject params, JsonObject environment) { - JsonObject result = params == null ? new JsonObject() : params.deepCopy(); - environment = environment == null ? new JsonObject() : environment; - for (Map.Entry entry : result.entrySet()) { - if (!entry.getValue().isJsonPrimitive() || !entry.getValue().getAsJsonPrimitive().isString()) continue; - String name = placeholderName(entry.getValue().getAsString()); - if (name == null) continue; - JsonElement value = environment.get(name); - entry.setValue(value == null || value.isJsonNull() ? new com.google.gson.JsonPrimitive("") : value.deepCopy()); - } - return result; - } - - private static String placeholderName(String value) { - if (value == null || value.length() < 4 || !value.startsWith("${") || !value.endsWith("}")) return null; - String name = value.substring(2, value.length() - 1); - if (name.isEmpty() || name.charAt(0) < 'A' || name.charAt(0) > 'Z') return null; - for (int i = 1; i < name.length(); i++) { - char valueChar = name.charAt(i); - if ((valueChar < 'A' || valueChar > 'Z') && (valueChar < '0' || valueChar > '9') && valueChar != '_') return null; - } - return name; - } - - public static String resolveToken(String sourceKey, String name) { - JsonElement value = load(sourceKey).get(name); - return value == null || value.isJsonNull() ? "" : value.getAsString(); - } - - private static JsonObject load(String sourceKey) { - String json = Setting.getSubtitleSourceEnvironment(sourceKey); - if (json.isEmpty()) return new JsonObject(); - try { - JsonElement element = JsonParser.parseString(json); - return element.isJsonObject() ? element.getAsJsonObject() : new JsonObject(); - } catch (RuntimeException e) { - return new JsonObject(); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceManager.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceManager.java deleted file mode 100644 index e60cef9463..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceManager.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import com.fongmi.android.tv.subtitle.provider.SubtitleProviderRegistry; -import com.fongmi.android.tv.subtitle.provider.T3SubtitleProvider; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -public final class SubtitleSourceManager { - - private final SubtitleProviderRegistry registry; - private final SubtitleScriptRuntimeFactory runtimeFactory; - private final Set reservedProviderNames; - private final List activeProviders = new ArrayList<>(); - - public SubtitleSourceManager(SubtitleProviderRegistry registry, SubtitleScriptRuntimeFactory runtimeFactory) { - if (registry == null) throw new IllegalArgumentException("registry is null"); - if (runtimeFactory == null) throw new IllegalArgumentException("runtimeFactory is null"); - this.registry = registry; - this.runtimeFactory = runtimeFactory; - this.reservedProviderNames = new HashSet<>(); - reservedProviderNames.addAll(registry.providerNames()); - } - - public synchronized List install(String subscriptionId, String json, String baseUrl) { - String namespace = subscriptionId == null || subscriptionId.isBlank() ? "subscription" : subscriptionId.trim(); - SubtitleSubscription subscription = SubtitleSubscription.parse(json, baseUrl); - List staged = new ArrayList<>(); - Set names = new HashSet<>(reservedProviderNames); - try { - for (SubtitleSourceConfig source : subscription.getSubtitles()) { - if (!source.isEnabled() || source.getType() != 3 || !names.add(source.getKey())) continue; - SubtitleScriptRuntime runtime = runtimeFactory.create(source.getApi()); - T3SubtitleProvider provider = new T3SubtitleProvider(source, runtime, namespace + ":" + source.getKey()); - provider.initialize(SubtitleSubscription.resolve(baseUrl, source.getExt())); - staged.add(provider); - } - } catch (Exception error) { - destroy(staged); - throw new IllegalStateException("Unable to initialize subtitle subscription", error); - } - unregister(activeProviders); - destroy(activeProviders); - activeProviders.clear(); - activeProviders.addAll(staged); - for (T3SubtitleProvider provider : activeProviders) registry.register(provider); - return Collections.unmodifiableList(new ArrayList<>(activeProviders)); - } - - public synchronized List getActiveProviders() { - return Collections.unmodifiableList(new ArrayList<>(activeProviders)); - } - - public synchronized void destroy() { - unregister(activeProviders); - destroy(activeProviders); - activeProviders.clear(); - } - - private void unregister(List providers) { - for (T3SubtitleProvider provider : providers) registry.unregister(provider.getName()); - } - - private static void destroy(List providers) { - for (T3SubtitleProvider provider : providers) { - try { - provider.destroy(); - } catch (Throwable ignored) { - } - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSubscription.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSubscription.java deleted file mode 100644 index e727c71906..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSubscription.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import com.google.gson.Gson; -import java.net.URI; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -public final class SubtitleSubscription { - private String protocol="webhtv-subtitle", name=""; private int version=1; private List subtitles=Collections.emptyList(); - public static SubtitleSubscription parse(String json,String base){ SubtitleSubscription v=new Gson().fromJson(json,SubtitleSubscription.class); if(v==null||v.version!=1) throw new IllegalArgumentException("PROTOCOL_UNSUPPORTED"); List out=new ArrayList<>(); Set keys=new HashSet<>(); if(v.subtitles!=null) for(SubtitleSourceConfig s:v.subtitles) if(s!=null&&s.valid()&&keys.add(s.getKey())) out.add(s); out.sort((a,b)->Integer.compare(b.getPriority(),a.getPriority())); v.subtitles=out; return v; } - public List getSubtitles(){return Collections.unmodifiableList(subtitles);} public int getVersion(){return version;} public String getName(){return name;} public static String resolve(String base,String ext){ if(ext==null||ext.isEmpty()) return ""; if(ext.matches("^[a-zA-Z][a-zA-Z0-9+.-]*:.*")) return ext; return URI.create(base).resolve(ext).toString(); } -} diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCache.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCache.java index afed9e04af..4b1dfbb125 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCache.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCache.java @@ -50,38 +50,18 @@ public final class MediaTitleCache { AiConfig safe = config == null ? new AiConfig().sanitize() : config.sanitize(); String rawTitle = request == null ? "" : request.getRawTitle(); if (rawTitle.trim().isEmpty()) return ""; - MediaTitleRequest safeRequest = request == null ? MediaTitleRequest.builder().build() : request; - StringBuilder value = new StringBuilder("v3|"); - appendPart(value, safeRequest.getSiteKey()); - appendPart(value, safeRequest.getVodId()); - appendPart(value, rawTitle); - appendPart(value, safeRequest.getRawRemarks()); - appendPart(value, safeRequest.getSearchKeyword()); - appendPart(value, safeRequest.getVodYear()); - appendPart(value, safeRequest.getEpisodeName()); - appendPart(value, safeRequest.getFolderName()); - for (String title : safeRequest.getContextTitles()) appendPart(value, title); - appendPart(value, safe.getProtocol()); - appendPart(value, safe.getEndpoint()); - appendPart(value, safe.getModel()); - appendPart(value, safe.getTitleExtractionPrompt()); - for (MediaTitleLearningExample example : safeRequest.getLearningExamples()) { - if (example == null) continue; - appendPart(value, example.getRawTitle()); - appendPart(value, example.getRuleTitle()); - appendPart(value, example.getExpectedTitle()); - appendPart(value, example.getMediaType()); - appendPart(value, Integer.toString(example.getYear())); - appendPart(value, Integer.toString(example.getSeasonNumber())); - appendPart(value, example.getSource()); - } - appendPart(value, Integer.toString(PROMPT_VERSION)); - return md5(value.toString()); - } - - private static void appendPart(StringBuilder value, String part) { - String text = Objects.toString(part, ""); - value.append(text.length()).append(':').append(text).append('|'); + String value = "v1|" + + Objects.toString(request.getSiteKey(), "") + "|" + + Objects.toString(request.getVodId(), "") + "|" + + Objects.toString(rawTitle, "") + "|" + + Objects.toString(request.getSearchKeyword(), "") + "|" + + Objects.toString(request.getEpisodeName(), "") + "|" + + Objects.toString(safe.getProtocol(), "") + "|" + + Objects.toString(safe.getEndpoint(), "") + "|" + + Objects.toString(safe.getModel(), "") + "|" + + Objects.toString(safe.getTitleExtractionPrompt(), "") + "|" + + PROMPT_VERSION; + return md5(value); } private static String md5(String text) { diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCandidate.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCandidate.java index 4bf0eeb09e..ffc1286053 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCandidate.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCandidate.java @@ -10,7 +10,6 @@ public final class MediaTitleCandidate { public static final String SOURCE_AI = "ai"; public static final String SOURCE_RULE = "rule"; public static final String SOURCE_ALIAS = "alias"; - public static final String SOURCE_CONTEXT = "context"; public static final String SOURCE_RAW = "raw"; private String title; diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleParser.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleParser.java index 2984bfd030..9a977f2ad7 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleParser.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleParser.java @@ -27,18 +27,11 @@ public final class MediaTitleParser { String raw = first(safe.getRawTitle(), safe.getRawRemarks(), safe.getEpisodeName()); String combined = join(raw, safe.getRawRemarks(), safe.getEpisodeName()); String ruleTitle = cleanTitle(raw); - float ruleConfidence = ruleConfidence(raw, ruleTitle); - List contextCandidates = contextTitles(safe); - String contextTitle = contextConsensusTitle(contextCandidates); - float contextConfidence = contextConfidence(contextCandidates, ruleTitle, contextTitle); MediaTitleLearningExample learning = bestLearningExample(ruleTitle, combined, safe.getLearningExamples()); MediaTitleResolution resolution = new MediaTitleResolution(); resolution.setRawTitle(raw); resolution.setRuleTitle(ruleTitle); - resolution.setRuleSource(ruleSource(raw, ruleTitle)); - resolution.setContextConfidence(contextConfidence); - resolution.setContextGroupSize(contextCandidates.isEmpty() ? 0 : contextCandidates.size() + 1); resolution.setYear(firstPositive(firstYear(safe.getVodYear()), firstYear(combined))); resolution.setSeasonNumber(firstPositive(seasonNumber(combined), inferTrailingSeason(raw))); resolution.setEpisodeNumber(episodeNumber(combined)); @@ -55,24 +48,15 @@ public final class MediaTitleParser { resolution.addCandidate(MediaTitleCandidate.of(learning.getExpectedTitle(), MediaTitleCandidate.SOURCE_MANUAL, 0.98f)); resolution.addAlias(learning.getRuleTitle()); } else { - boolean useContext = !contextTitle.isEmpty() && contextConfidence >= 0.85f - && (ruleTitle.isEmpty() || ruleConfidence < 0.75f); - resolution.setCanonicalTitle(useContext ? contextTitle : ruleTitle); - resolution.setConfidence(useContext - ? Math.max(ruleConfidence, Math.min(0.88f, 0.55f + contextConfidence * 0.35f)) - : ruleConfidence); + resolution.setCanonicalTitle(ruleTitle); + resolution.setConfidence(ruleConfidence(raw, ruleTitle)); resolution.setSource(MediaTitleResolution.SOURCE_RULE); - if (useContext) resolution.addCandidate(MediaTitleCandidate.of(contextTitle, MediaTitleCandidate.SOURCE_CONTEXT, contextConfidence)); } resolution.addCandidate(MediaTitleCandidate.of(resolution.getCanonicalTitle(), learning != null ? MediaTitleCandidate.SOURCE_MANUAL : MediaTitleCandidate.SOURCE_RULE, resolution.getConfidence())); resolution.addCandidate(MediaTitleCandidate.of(ruleTitle, MediaTitleCandidate.SOURCE_RULE, Math.min(0.8f, resolution.getConfidence()))); - if (learning == null && !contextTitle.isEmpty() && contextConfidence >= 0.85f) { - resolution.addCandidate(MediaTitleCandidate.of(contextTitle, MediaTitleCandidate.SOURCE_CONTEXT, contextConfidence)); - } if (!raw.equals(ruleTitle)) resolution.addCandidate(MediaTitleCandidate.of(raw, MediaTitleCandidate.SOURCE_RAW, 0.25f)); resolution.addAlias(ruleTitle); - resolution.setConfidenceBreakdown(String.format(Locale.ROOT, "rule=%.2f,context=%.2f,final=%.2f", ruleConfidence, contextConfidence, resolution.getConfidence())); return resolution; } @@ -142,54 +126,6 @@ public final class MediaTitleParser { return cleanTitle(text).replaceAll("[\\s·•::\\-_/\\\\|()()\\[\\]【】]+", "").toLowerCase(Locale.ROOT); } - private List contextTitles(MediaTitleRequest request) { - List result = new ArrayList<>(); - for (String raw : request.getContextTitles()) { - List candidates = cleanSearchTitles(MediaTitleRequest.builder().rawTitle(raw).build()); - if (!candidates.isEmpty()) result.add(candidates.get(0)); - } - return result; - } - - private String contextConsensusTitle(List titles) { - if (titles.isEmpty()) return ""; - String best = ""; - int bestCount = 0; - for (String title : titles) { - int count = 0; - String normalized = normalizeSearchText(title); - for (String candidate : titles) if (normalized.equals(normalizeSearchText(candidate))) count++; - if (count > bestCount) { - best = title; - bestCount = count; - } - } - return best; - } - - private float contextConfidence(List titles, String ruleTitle, String consensusTitle) { - if (titles.isEmpty() || consensusTitle.isEmpty()) return 0f; - int matches = 0; - String consensus = normalizeSearchText(consensusTitle); - for (String title : titles) if (consensus.equals(normalizeSearchText(title))) matches++; - float consensusScore = matches / (float) titles.size(); - double ruleScore = similarity(normalizeSearchText(ruleTitle), consensus); - if (titles.size() == 1 && ruleScore < 0.9d) return 0.65f; - return (float) Math.min(1d, Math.max(consensusScore, ruleScore)); - } - - private String ruleSource(String raw, String ruleTitle) { - if (isBlank(ruleTitle)) return "empty"; - List rules = new ArrayList<>(); - if (EPISODE_PATTERN.matcher(raw == null ? "" : raw).find()) rules.add("episode"); - if (SEASON_PATTERN.matcher(raw == null ? "" : raw).find()) rules.add("season"); - if (YEAR_PATTERN.matcher(raw == null ? "" : raw).find()) rules.add("year"); - if (NOISE_WORDS.matcher(raw == null ? "" : raw).find() || FRAME_RATE_PATTERN.matcher(raw == null ? "" : raw).find()) rules.add("media-noise"); - if (BRACKET_PATTERN.matcher(raw == null ? "" : raw).find()) rules.add("bracket"); - if (SUSPICIOUS_MARKERS.matcher(raw == null ? "" : raw).find()) rules.add("obfuscation"); - return rules.isEmpty() ? "plain" : String.join("+", rules); - } - private void addCleanSearchTitles(List result, String text) { if (isBlank(text)) return; String source = normalizePlaybackName(URL_PATTERN.matcher(text.trim()).replaceAll(" ")); @@ -344,7 +280,6 @@ public final class MediaTitleParser { String normalized = normalize(value); if (normalized.length() < 2) return false; if (normalized.matches("(?i)(?:更新至|更至|连载至|全集|合集|完结|电影|电视剧|剧集|动漫|动画|综艺|tv)")) return false; - if (value.matches("(?i)^(?:更新至|更至|连载至|全|共)?\\s*[0-9零〇一二三四五六七八九十两百]+\\s*[集话話回期章节節](?:完结|更新中)?$")) return false; return !value.matches("^第\\s*[0-9零〇一二三四五六七八九十两百]+\\s*[集话話回期章节節].*"); } diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleRequest.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleRequest.java index 6247ec47e4..f88d388a9f 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleRequest.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleRequest.java @@ -6,8 +6,6 @@ import java.util.List; public final class MediaTitleRequest { - private static final int MAX_CONTEXT_TITLES = 16; - private String siteKey; private String vodId; private String rawTitle; @@ -17,10 +15,6 @@ public final class MediaTitleRequest { private String episodeName; private String flag; private String source; - private String folderName; - private List contextTitles; - private int tmdbId; - private int tmdbSeasonNumber; private List learningExamples; private boolean allowAi; @@ -34,10 +28,6 @@ public final class MediaTitleRequest { this.episodeName = clean(builder.episodeName); this.flag = clean(builder.flag); this.source = clean(builder.source); - this.folderName = cleanFolderName(builder.folderName); - this.contextTitles = cleanContextTitles(builder.contextTitles); - this.tmdbId = builder.tmdbId; - this.tmdbSeasonNumber = builder.tmdbSeasonNumber; this.learningExamples = new ArrayList<>(builder.learningExamples); this.allowAi = builder.allowAi; } @@ -82,29 +72,6 @@ public final class MediaTitleRequest { return source; } - /** - * Returns only the final directory component. Title recognition must not - * send a complete local path or URL to an AI provider. - */ - public String getFolderName() { - return folderName; - } - - /** - * Returns bounded, de-duplicated sibling/context titles in caller order. - */ - public List getContextTitles() { - return Collections.unmodifiableList(contextTitles); - } - - public int getTmdbId() { - return tmdbId; - } - - public int getTmdbSeasonNumber() { - return tmdbSeasonNumber; - } - public List getLearningExamples() { return Collections.unmodifiableList(learningExamples); } @@ -128,10 +95,6 @@ public final class MediaTitleRequest { private String episodeName; private String flag; private String source; - private String folderName; - private List contextTitles = new ArrayList<>(); - private int tmdbId; - private int tmdbSeasonNumber; private final List learningExamples = new ArrayList<>(); private boolean allowAi; @@ -180,27 +143,6 @@ public final class MediaTitleRequest { return this; } - public Builder folderName(String folderName) { - this.folderName = folderName; - return this; - } - - public Builder contextTitles(List contextTitles) { - this.contextTitles.clear(); - if (contextTitles != null) this.contextTitles.addAll(contextTitles); - return this; - } - - public Builder tmdbId(int tmdbId) { - this.tmdbId = tmdbId; - return this; - } - - public Builder tmdbSeasonNumber(int tmdbSeasonNumber) { - this.tmdbSeasonNumber = tmdbSeasonNumber; - return this; - } - public Builder learningExamples(List values) { learningExamples.clear(); if (values != null) for (MediaTitleLearningExample value : values) if (value != null) learningExamples.add(value); @@ -216,32 +158,4 @@ public final class MediaTitleRequest { return new MediaTitleRequest(this); } } - - private static String cleanFolderName(String value) { - String text = clean(value).replace('\\', '/'); - while (text.length() > 1 && text.endsWith("/")) text = text.substring(0, text.length() - 1); - int slash = text.lastIndexOf('/'); - if (slash >= 0) text = text.substring(slash + 1); - return text.trim(); - } - - private static List cleanContextTitles(List values) { - List result = new ArrayList<>(); - if (values == null) return result; - for (String value : values) { - String text = clean(value).replaceAll("(?i)https?://\\S+", " ").trim(); - String pathText = text.replace('\\', '/'); - int slash = pathText.lastIndexOf('/'); - if (slash >= 0) text = pathText.substring(slash + 1).trim(); - if (text.isEmpty() || text.length() > 160) continue; - boolean exists = false; - for (String item : result) if (item.equalsIgnoreCase(text)) { - exists = true; - break; - } - if (!exists) result.add(text); - if (result.size() >= MAX_CONTEXT_TITLES) break; - } - return result; - } } diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolution.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolution.java index 6f34ed79f4..098419a13e 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolution.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolution.java @@ -25,13 +25,7 @@ public final class MediaTitleResolution { private List aliases = new ArrayList<>(); private List candidates = new ArrayList<>(); private float confidence; - private float contextConfidence; - private int contextGroupSize; private String source; - private String ruleSource; - private String aiReasonCode; - private String confidenceBreakdown; - private String degradationReason; private boolean needsVerification; public String getRawTitle() { @@ -136,23 +130,6 @@ public final class MediaTitleResolution { this.confidence = Float.isNaN(confidence) || confidence < 0f ? 0f : Math.min(1f, confidence); } - public float getContextConfidence() { - if (Float.isNaN(contextConfidence) || contextConfidence < 0f) return 0f; - return Math.min(1f, contextConfidence); - } - - public void setContextConfidence(float contextConfidence) { - this.contextConfidence = Float.isNaN(contextConfidence) || contextConfidence < 0f ? 0f : Math.min(1f, contextConfidence); - } - - public int getContextGroupSize() { - return Math.max(0, contextGroupSize); - } - - public void setContextGroupSize(int contextGroupSize) { - this.contextGroupSize = Math.max(0, contextGroupSize); - } - public String getSource() { return clean(source); } @@ -161,38 +138,6 @@ public final class MediaTitleResolution { this.source = clean(source); } - public String getRuleSource() { - return clean(ruleSource); - } - - public void setRuleSource(String ruleSource) { - this.ruleSource = clean(ruleSource); - } - - public String getAiReasonCode() { - return clean(aiReasonCode); - } - - public void setAiReasonCode(String aiReasonCode) { - this.aiReasonCode = clean(aiReasonCode); - } - - public String getConfidenceBreakdown() { - return clean(confidenceBreakdown); - } - - public void setConfidenceBreakdown(String confidenceBreakdown) { - this.confidenceBreakdown = clean(confidenceBreakdown); - } - - public String getDegradationReason() { - return clean(degradationReason); - } - - public void setDegradationReason(String degradationReason) { - this.degradationReason = clean(degradationReason); - } - public boolean isNeedsVerification() { return needsVerification; } diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolver.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolver.java index 828d1a54e5..e15485ef79 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolver.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolver.java @@ -8,7 +8,6 @@ import com.github.catvod.crawler.SpiderDebug; import java.util.ArrayList; import java.util.List; -import java.util.Locale; public final class MediaTitleResolver { @@ -39,11 +38,9 @@ public final class MediaTitleResolver { MediaTitleResolution resolution = parser.parse(safe); if (!forceAi) applyTmdbCache(safe, resolution); String skipReason = aiSkipReason(safe, resolution, forceAi); - SpiderDebug.log("ai-title", "rule source=%s ruleSource=%s raw=%s canonical=%s confidence=%.2f context=%.2f/%d force=%s breakdown=%s degradation=%s candidates=%s", - resolution.getSource(), resolution.getRuleSource(), safe.getRawTitle(), resolution.getCanonicalTitle(), resolution.getConfidence(), - resolution.getContextConfidence(), resolution.getContextGroupSize(), forceAi, resolution.getConfidenceBreakdown(), resolution.getDegradationReason(), resolution.queryTitles()); + SpiderDebug.log("ai-title", "rule source=%s raw=%s canonical=%s confidence=%.2f force=%s candidates=%s", + resolution.getSource(), safe.getRawTitle(), resolution.getCanonicalTitle(), resolution.getConfidence(), forceAi, resolution.queryTitles()); if (!skipReason.isEmpty()) { - if (resolution.getDegradationReason().isEmpty()) resolution.setDegradationReason(skipReason); SpiderDebug.log("ai-title", "skip title-extraction reason=%s raw=%s canonical=%s allowAi=%s enabled=%s ready=%s", skipReason, safe.getRawTitle(), resolution.getCanonicalTitle(), safe.isAllowAi(), Setting.isAiTitleExtraction(), Setting.isAiConfigReady()); return resolution; @@ -56,15 +53,7 @@ public final class MediaTitleResolver { ai = new AiTitleExtractionService(config).extract(safe, resolution); if (ai != null) cache.write(safe, config, ai); } - if (ai == null) { - resolution.setDegradationReason("ai-unavailable"); - return resolution; - } - if (!forceAi && ai.getConfidence() < 0.55f) { - resolution.setDegradationReason("ai-low-confidence"); - return resolution; - } - return merge(resolution, ai); + return ai == null || (!forceAi && ai.getConfidence() < 0.55f) ? resolution : merge(resolution, ai); } public List queryTitles(MediaTitleRequest request, int limit) { @@ -104,8 +93,6 @@ public final class MediaTitleResolver { List examples = new ArrayList<>(safe.getLearningExamples()); examples.addAll(MediaTitleLearningStore.load().find(safe, 5)); examples.addAll(Setting.getDanmakuMatchCache().learningExamples(parser.cleanTitle(safe.getRawTitle()))); - List contextTitles = new ArrayList<>(safe.getContextTitles()); - addContextTitle(contextTitles, safe.getSearchKeyword(), safe.getRawTitle()); return MediaTitleRequest.builder() .siteKey(safe.getSiteKey()) .vodId(safe.getVodId()) @@ -116,21 +103,11 @@ public final class MediaTitleResolver { .episodeName(safe.getEpisodeName()) .flag(safe.getFlag()) .source(safe.getSource()) - .folderName(safe.getFolderName()) - .contextTitles(contextTitles) - .tmdbId(safe.getTmdbId()) - .tmdbSeasonNumber(safe.getTmdbSeasonNumber()) .allowAi(safe.isAllowAi()) .learningExamples(examples) .build(); } - private void addContextTitle(List values, String candidate, String rawTitle) { - if (candidate == null || candidate.trim().isEmpty() || candidate.equalsIgnoreCase(rawTitle)) return; - for (String value : values) if (value.equalsIgnoreCase(candidate)) return; - values.add(candidate); - } - private void applyTmdbCache(MediaTitleRequest request, MediaTitleResolution resolution) { TmdbItem item = Setting.getTmdbMatchCache().find(request.getSiteKey(), request.getVodId(), request.getRawTitle()); if (item == null || item.getTitle().isEmpty()) return; @@ -139,8 +116,6 @@ public final class MediaTitleResolver { resolution.setMediaType(item.getMediaType()); resolution.setConfidence(0.95f); resolution.setSource(MediaTitleResolution.SOURCE_TMDB_CACHE); - resolution.setDegradationReason(""); - resolution.setConfidenceBreakdown(resolution.getConfidenceBreakdown() + ",tmdb=1.00,final=0.95"); resolution.addCandidate(MediaTitleCandidate.of(item.getTitle(), MediaTitleCandidate.SOURCE_TMDB, 0.96f)); } @@ -171,10 +146,6 @@ public final class MediaTitleResolver { merged.addCandidate(MediaTitleCandidate.of(rule.getCanonicalTitle(), MediaTitleCandidate.SOURCE_RULE, Math.min(0.75f, rule.getConfidence()))); merged.addCandidate(MediaTitleCandidate.of(rule.getRuleTitle(), MediaTitleCandidate.SOURCE_RULE, Math.min(0.7f, rule.getConfidence()))); if (!rule.getRawTitle().equals(rule.getRuleTitle())) merged.addCandidate(MediaTitleCandidate.of(rule.getRawTitle(), MediaTitleCandidate.SOURCE_RAW, 0.25f)); - String breakdown = rule.getConfidenceBreakdown(); - if (!breakdown.isEmpty()) breakdown += ","; - merged.setConfidenceBreakdown(breakdown + String.format(Locale.ROOT, "ai=%.2f,final=%.2f", ai.getConfidence(), merged.getConfidence())); - merged.setDegradationReason(""); return merged; } @@ -190,13 +161,7 @@ public final class MediaTitleResolver { result.setEpisodeNumber(source.getEpisodeNumber()); result.setEpisodeTitle(source.getEpisodeTitle()); result.setConfidence(source.getConfidence()); - result.setContextConfidence(source.getContextConfidence()); - result.setContextGroupSize(source.getContextGroupSize()); result.setSource(source.getSource()); - result.setRuleSource(source.getRuleSource()); - result.setAiReasonCode(source.getAiReasonCode()); - result.setConfidenceBreakdown(source.getConfidenceBreakdown()); - result.setDegradationReason(source.getDegradationReason()); result.setNeedsVerification(source.isNeedsVerification()); for (String alias : source.getAliases()) result.addAlias(alias); for (MediaTitleCandidate candidate : source.getCandidates()) result.addCandidate(candidate); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java index 427ad7d3da..e1d48366ac 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java @@ -1,6 +1,8 @@ package com.fongmi.android.tv.ui.activity; import android.os.Bundle; +import android.text.TextUtils; + import androidx.appcompat.app.AlertDialog; import androidx.viewbinding.ViewBinding; @@ -8,7 +10,7 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.ActivityCrashBinding; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.utils.AppVersion; -import com.fongmi.android.tv.utils.CrashRestartMode; +import com.github.catvod.utils.Prefers; import java.util.Objects; @@ -31,20 +33,26 @@ public class CrashActivity extends BaseActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + setCrash(); } @Override protected void initEvent() { mBinding.details.setOnClickListener(v -> showError()); - mBinding.restart.setOnClickListener(v -> restartApplication()); - mBinding.silentRestart.setOnClickListener(v -> { - CrashRestartMode.arm(); - restartApplication(); - }); + mBinding.restart.setOnClickListener(v -> CustomActivityOnCrash.restartApplication(this, Objects.requireNonNull(CustomActivityOnCrash.getConfigFromIntent(getIntent())))); } - private void restartApplication() { - CustomActivityOnCrash.restartApplication(this, Objects.requireNonNull(CustomActivityOnCrash.getConfigFromIntent(getIntent()))); + private void setCrash() { + String log = CustomActivityOnCrash.getActivityLogFromIntent(getIntent()); + if (TextUtils.isEmpty(log)) return; + String[] lines = log.split("\n"); + for (int i = lines.length - 1; i >= 0; i--) { + if (lines[i].isEmpty()) continue; + if (lines[i].contains(HomeActivity.class.getSimpleName())) { + Prefers.put("crash", true); + break; + } + } } private void showError() { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java deleted file mode 100644 index 8e79710d2a..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java +++ /dev/null @@ -1,570 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import android.Manifest; -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.os.Build; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.View; -import android.widget.EditText; -import android.widget.LinearLayout; - -import androidx.annotation.Nullable; -import androidx.annotation.NonNull; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.databinding.ActivityFollowingBinding; -import com.fongmi.android.tv.following.AlistSubscriptionImporter; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingNotifier; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingIdentity; -import com.fongmi.android.tv.following.FollowingMetadataSnapshot; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingSource; -import com.fongmi.android.tv.following.FollowingStore; -import com.fongmi.android.tv.following.FollowingUpdateCoordinator; -import com.fongmi.android.tv.following.FollowingUpdatePolicy; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.ui.adapter.FollowingAdapter; -import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.PermissionUtil; -import com.fongmi.android.tv.utils.Task; -import com.fongmi.android.tv.utils.Util; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.Comparator; -import java.util.List; -import java.util.Set; - -public class FollowingActivity extends AppCompatActivity implements FollowingAdapter.Listener { - - public static final String EXTRA_IDENTITY_KEY = "following_identity_key"; - private static final int FILTER_ALL = 0; - private static final int FILTER_UPDATES = 1; - private static final int FILTER_UNWATCHED = 2; - private static final int FILTER_ENDED = 3; - private static final int FILTER_FAILED = 4; - private static final int FILTER_COUNT = 5; - private static final long VISIBLE_READ_DELAY_MS = 800; - private static final Comparator LIST_ORDER = Comparator - .comparing((Following item) -> !item.hasUpdate) - .thenComparing(Comparator.comparingInt((Following item) -> item.unwatchedCount).reversed()) - .thenComparing(Comparator.comparingLong((Following item) -> item.updatedAt).reversed()) - .thenComparing(Comparator.comparingLong((Following item) -> item.metadataUpdatedAt).reversed()) - .thenComparing(Comparator.comparingLong((Following item) -> item.createdAt).reversed()); - private ActivityFollowingBinding binding; - private FollowingAdapter adapter; - private String focusIdentity; - private String pendingNotifyIdentity; - private final Set readPending = new HashSet<>(); - private final Runnable visibleReadRunnable = this::markVisibleReadNow; - private int filterIndex = FILTER_ALL; - private final ActivityResultLauncher notificationPermission = registerForActivityResult( - new ActivityResultContracts.RequestPermission(), granted -> { - if (pendingNotifyIdentity == null) return; - if (granted) { - FollowingSettings.setNotificationsEnabled(true); - String identityKey = pendingNotifyIdentity; - FollowingPlaybackBridge.setNotifyEnabledAsync(identityKey, true, error -> load()); - pendingNotifyIdentity = null; - return; - } - pendingNotifyIdentity = null; - load(); - }); - - public static Intent intent(Context context, String identityKey) { - Intent intent = new Intent(context, FollowingActivity.class); - if (!TextUtils.isEmpty(identityKey)) intent.putExtra(EXTRA_IDENTITY_KEY, identityKey); - return intent; - } - - public static void start(Activity activity, String identityKey) { - activity.startActivity(intent(activity, identityKey)); - } - - public static void start(Context context) { - context.startActivity(intent(context, "")); - } - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - setTheme(R.style.Theme_App); - super.onCreate(savedInstanceState); - if (!FollowingSettings.isEnabled()) { - Notify.show(R.string.following_enabled_hint); - finish(); - return; - } - binding = ActivityFollowingBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - focusIdentity = getIntent().getStringExtra(EXTRA_IDENTITY_KEY); - FollowingNotifier.createChannel(); - FollowingScheduler.ensurePeriodic(this); - initView(); - FollowingScheduler.enqueueDueNow(this); - } - - private void initView() { - binding.recycler.setLayoutManager(new LinearLayoutManager(this)); - binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 8)); - binding.recycler.setAdapter(adapter = new FollowingAdapter(this)); - binding.recycler.addOnScrollListener(new RecyclerView.OnScrollListener() { - @Override - public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { - if (newState == RecyclerView.SCROLL_STATE_IDLE) scheduleVisibleRead(); - } - }); - binding.check.setOnClickListener(view -> checkAll()); - binding.filter.setOnClickListener(view -> { - filterIndex = (filterIndex + 1) % FILTER_COUNT; - load(); - }); - binding.alistImport.setOnClickListener(view -> showServerImportDialog()); - load(); - } - - @Override - protected void onResume() { - super.onResume(); - if (binding != null) load(); - } - - @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - if (hasFocus) Util.hideSystemUI(this); - } - - private void load() { - binding.loading.setVisibility(View.VISIBLE); - Task.execute(() -> { - List following = FollowingStore.list(); - List rows = new ArrayList<>(); - for (Following item : following) { - if (!matchesFilter(item)) continue; - rows.add(new FollowingAdapter.Row(item, FollowingStore.preferredSource(item.identityKey))); - } - rows.sort((left, right) -> LIST_ORDER.compare(left.following, right.following)); - int unread = FollowingStore.unreadCount(); - App.post(() -> render(rows, unread)); - }); - } - - private void render(List rows, int unread) { - if (isFinishing() || binding == null) return; - binding.loading.setVisibility(View.GONE); - binding.empty.setVisibility(rows.isEmpty() ? View.VISIBLE : View.GONE); - binding.filter.setText(filterLabel()); - binding.emptyText.setText(filterIndex == FILTER_ALL ? R.string.following_empty : R.string.following_filter_empty); - binding.recycler.setVisibility(rows.isEmpty() ? View.GONE : View.VISIBLE); - binding.summary.setText(getString(R.string.following_summary, rows.size(), unread)); - adapter.setItems(rows); - scheduleVisibleRead(); - int index = TextUtils.isEmpty(focusIdentity) ? -1 : adapter.indexOf(focusIdentity); - if (index >= 0) { - binding.recycler.post(() -> { - binding.recycler.scrollToPosition(index); - focusIdentity = null; - }); - } - } - - private void scheduleVisibleRead() { - if (binding == null) return; - binding.recycler.removeCallbacks(visibleReadRunnable); - binding.recycler.postDelayed(visibleReadRunnable, VISIBLE_READ_DELAY_MS); - } - - private void markVisibleReadNow() { - if (binding == null || isFinishing() || isDestroyed()) return; - RecyclerView.LayoutManager manager = binding.recycler.getLayoutManager(); - if (!(manager instanceof LinearLayoutManager linear)) return; - int first = linear.findFirstVisibleItemPosition(); - int last = linear.findLastVisibleItemPosition(); - List keys = new ArrayList<>(); - for (int i = first; i <= last; i++) { - FollowingAdapter.Row row = adapter.rowAt(i); - if (row == null || !row.following.hasUpdate || readPending.contains(row.following.identityKey)) continue; - keys.add(row.following.identityKey); - } - markReadNow(keys); - } - - private void markReadNow(List keys) { - if (keys == null || keys.isEmpty()) return; - List pending = new ArrayList<>(); - for (String key : keys) if (!TextUtils.isEmpty(key) && readPending.add(key)) pending.add(key); - if (pending.isEmpty()) return; - adapter.markReadLocally(pending); - FollowingPlaybackBridge.markReadAllAsync(pending, error -> { - readPending.removeAll(pending); - if (error != null) load(); - else refreshUnreadSummary(); - }); - } - - private void refreshUnreadSummary() { - FollowingPlaybackBridge.refreshUnreadCountAsync(unread -> { - if (binding == null || isFinishing() || isDestroyed()) return; - binding.summary.setText(getString(R.string.following_summary, adapter.getItemCount(), unread)); - }); - } - - private boolean matchesFilter(Following item) { - return switch (filterIndex) { - case FILTER_UPDATES -> item.hasUpdate || item.unwatchedCount > 0; - case FILTER_UNWATCHED -> item.unwatchedCount > 0; - case FILTER_ENDED -> FollowingMetadataSnapshot.ENDED.equals(item.officialStatus) - || FollowingMetadataSnapshot.CANCELED.equals(item.officialStatus); - case FILTER_FAILED -> item.lastError != null && !item.lastError.isBlank(); - default -> true; - }; - } - - private int filterLabel() { - return switch (filterIndex) { - case FILTER_UPDATES -> R.string.following_filter_updates; - case FILTER_UNWATCHED -> R.string.following_filter_unwatched; - case FILTER_ENDED -> R.string.following_filter_ended; - case FILTER_FAILED -> R.string.following_filter_failed; - default -> R.string.following_filter_all; - }; - } - - private void checkAll() { - binding.check.setEnabled(false); - Notify.show(R.string.following_checking); - Task.execute(() -> { - List items = FollowingStore.list(); - if (items.isEmpty()) { - App.post(() -> { - if (binding == null) return; - binding.check.setEnabled(true); - load(); - }); - return; - } - FollowingUpdateCoordinator coordinator = new FollowingUpdateCoordinator(); - int limit = Math.min(5, items.size()); - boolean success = false; - for (int i = 0; i < limit; i++) success |= coordinator.checkNow(items.get(i).identityKey, true); - boolean finalSuccess = success; - App.post(() -> { - if (binding == null) return; - binding.check.setEnabled(true); - Notify.show(finalSuccess ? R.string.following_check_done : R.string.following_check_failed); - load(); - }); - }); - } - - private void showServerImportDialog() { - LinearLayout form = new LinearLayout(this); - form.setOrientation(LinearLayout.VERTICAL); - int horizontal = dp(20); - form.setPadding(horizontal, dp(8), horizontal, 0); - EditText url = field(R.string.following_server_url); - EditText token = field(R.string.following_server_token); - token.setInputType(android.text.InputType.TYPE_CLASS_TEXT | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD); - form.addView(url, new LinearLayout.LayoutParams(-1, -2)); - form.addView(token, new LinearLayout.LayoutParams(-1, -2)); - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.following_server_import) - .setView(form) - .setNegativeButton(R.string.dialog_negative, null) - .setNeutralButton(R.string.following_server_preview, (dialog, which) -> startServerImport(url, token, true)) - .setPositiveButton(R.string.following_server_import_action, (dialog, which) -> startServerImport(url, token, false)) - .show(); - } - - private EditText field(int hint) { - EditText field = new EditText(this); - field.setHint(hint); - field.setSingleLine(true); - return field; - } - - private void startServerImport(EditText urlView, EditText tokenView, boolean preview) { - String url = urlView.getText().toString().trim(); - if (!url.startsWith("http://") && !url.startsWith("https://")) { - Notify.show(R.string.following_server_invalid); - return; - } - String token = tokenView.getText().toString(); - FollowingSettings.setServerImportEnabled(true); - binding.loading.setVisibility(View.VISIBLE); - Task.execute(() -> { - try { - List candidates = AlistSubscriptionImporter.fetch(url, token); - if (preview) { - App.post(() -> { - if (binding == null) return; - binding.loading.setVisibility(View.GONE); - showServerPreview(candidates); - }); - return; - } - AlistSubscriptionImporter.ImportResult result = AlistSubscriptionImporter.importCandidates(candidates); - App.post(() -> { - if (binding == null) return; - binding.loading.setVisibility(View.GONE); - Notify.show(getString(R.string.following_server_result, result.created, result.updated)); - load(); - }); - } catch (Throwable error) { - App.post(() -> { - if (binding != null) binding.loading.setVisibility(View.GONE); - Notify.show(error.getMessage()); - }); - } - }); - } - - private void showServerPreview(List candidates) { - if (candidates == null || candidates.isEmpty()) { - Notify.show(R.string.following_server_empty); - return; - } - StringBuilder message = new StringBuilder(); - int limit = Math.min(20, candidates.size()); - for (int i = 0; i < limit; i++) { - AlistSubscriptionImporter.Candidate candidate = candidates.get(i); - if (i > 0) message.append('\n'); - message.append(candidate.title).append(" · S").append(candidate.season + 1); - if (candidate.currentEpisodes > 0) message.append(" · E").append(candidate.currentEpisodes); - } - if (candidates.size() > limit) message.append("\n… +").append(candidates.size() - limit); - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.following_server_preview_title) - .setMessage(message) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.following_server_import_action, (dialog, which) -> { - Task.execute(() -> { - AlistSubscriptionImporter.ImportResult result = AlistSubscriptionImporter.importCandidates(candidates); - App.post(() -> { - Notify.show(getString(R.string.following_server_result, result.created, result.updated)); - load(); - }); - }); - }) - .show(); - } - - private int dp(int value) { - return Math.round(value * getResources().getDisplayMetrics().density); - } - - @Override - public void onOpenDetail(Following item, FollowingSource source) { - if (item == null) return; - markReadNow(List.of(item.identityKey)); - StringBuilder message = new StringBuilder(); - int released = FollowingUpdatePolicy.releasedEpisode(item); - message.append(released > 0 ? getString(R.string.following_official, released) : getString(R.string.following_official_unknown)); - message.append('\n'); - message.append(source != null && source.playableEpisode > 0 - ? getString(R.string.following_source, source.playableEpisode) - : getString(R.string.following_source_unknown)); - message.append('\n'); - message.append(item.watchedEpisode > 0 ? getString(R.string.following_watched, item.watchedEpisode) : getString(R.string.following_source_unknown)); - if (item.unwatchedCount > 0) message.append('\n').append(getString(R.string.following_unwatched, item.unwatchedCount)); - else if (released > 0) message.append('\n').append(getString(R.string.following_unwatched_none)); - if (item.lastError != null && !item.lastError.isBlank()) message.append('\n').append(item.lastError); - String[] actions = new String[]{ - getString(R.string.following_continue), - getString(R.string.following_check), - getString(R.string.following_read), - getString(R.string.following_change_source), - getString(R.string.following_cancel) - }; - new MaterialAlertDialogBuilder(this) - .setTitle(item.vodName) - .setMessage(message) - .setNegativeButton(R.string.dialog_close, null) - .setItems(actions, (dialog, which) -> { - if (which == 0) onContinue(item, source); - else if (which == 1) onCheck(item); - else if (which == 2) onRead(item); - else if (which == 3) onChangeSource(item); - else if (which == 4) onDelete(item); - }) - .show(); - } - - @Override - public void onContinue(Following item, FollowingSource source) { - Task.execute(() -> { - FollowingStore.reconcile(item); - History history = FollowingStore.historyFor(item, source); - App.post(() -> { - if (isFinishing() || isDestroyed()) return; - if (history != null) { - TmdbDetailActivity.startFromHistory(this, history); - return; - } - FollowingSource target = preferred(item, source); - if (target == null || TextUtils.isEmpty(target.siteKey) || TextUtils.isEmpty(target.vodId)) { - Notify.show(R.string.following_check_failed); - return; - } - TmdbItem tmdb = item.tmdbId > 0 ? new TmdbItem(item.tmdbId, item.mediaType, item.vodName, - "", "", item.vodPic, "", "", 0.0) : null; - TmdbDetailActivity.start(this, target.siteKey, target.vodId, item.vodName, item.vodPic, "", tmdb, Setting.getDetailOpenMode()); - }); - }); - } - - @Override - public void onFollowNextSeason(Following item) { - if (item == null || item.latestReleasedSeason <= item.trackedSeason || TextUtils.isEmpty(item.seriesKey)) return; - String identityKey = FollowingIdentity.identityKey(item.seriesKey, item.latestReleasedSeason); - long now = System.currentTimeMillis(); - Task.execute(() -> { - if (FollowingStore.find(identityKey) != null) { - App.post(() -> FollowingActivity.start(this, identityKey)); - return; - } - Following next = item.copy(); - next.identityKey = identityKey; - next.trackedSeason = item.latestReleasedSeason; - next.trackedEpisode = 0; - next.watchedSeason = item.latestReleasedSeason; - next.watchedEpisode = 0; - next.position = 0; - next.duration = 0; - next.latestReleasedSeason = item.latestReleasedSeason; - next.latestReleasedEpisode = 0; - next.seasonTotalEpisodes = 0; - next.seasonReleasedEpisodes = 0; - next.nextAirSeason = item.nextAirSeason == next.trackedSeason ? item.nextAirSeason : 0; - next.nextAirEpisode = item.nextAirSeason == next.trackedSeason ? item.nextAirEpisode : 0; - next.nextAirAt = item.nextAirSeason == next.trackedSeason ? item.nextAirAt : 0; - next.readWatermarkEpisode = 0; - next.lastNotifiedEpisode = 0; - next.lastNotifiedAt = 0; - next.lastObservedEpisode = 0; - next.hasUpdate = false; - next.unwatchedCount = 0; - next.failureCount = 0; - next.lastError = ""; - next.createdAt = now; - next.updatedAt = now; - next.nextCheckAt = now; - FollowingSource source = FollowingStore.preferredSource(item.identityKey); - if (source != null) { - source = source.copy(); - source.followingKey = identityKey; - source.playableSeason = next.trackedSeason; - source.playableEpisode = 0; - source.playableCount = 0; - source.lastProbeAt = 0; - source.lastError = ""; - } - if (source == null) { - source = new FollowingSource(); - source.followingKey = identityKey; - source.cid = next.cid; - source.siteKey = next.siteKey; - source.vodId = next.vodId; - source.vodName = next.vodName; - source.vodPic = next.vodPic; - source.preferred = true; - } - try { - FollowingStore.saveNew(next, source); - App.post(() -> { - FollowingScheduler.enqueueDueNow(this); - Notify.show(R.string.following_added); - load(); - }); - } catch (Throwable error) { - App.post(() -> Notify.show(error.getMessage())); - } - }); - } - - @Override - public void onCheck(Following item) { - binding.loading.setVisibility(View.VISIBLE); - Task.execute(() -> { - boolean success = new FollowingUpdateCoordinator().checkNow(item.identityKey, true); - App.post(() -> { - if (binding == null) return; - Notify.show(success ? R.string.following_check_done : R.string.following_check_failed); - load(); - }); - }); - } - - @Override - public void onRead(Following item) { - if (item != null) markReadNow(List.of(item.identityKey)); - } - - @Override - public void onToggleNotify(Following item) { - boolean enabled = !item.notifyEnabled; - if (enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - pendingNotifyIdentity = item.identityKey; - notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS); - return; - } - if (enabled) FollowingSettings.setNotificationsEnabled(true); - FollowingPlaybackBridge.setNotifyEnabledAsync(item.identityKey, enabled, error -> load()); - } - - @Override - public void onChangeSource(Following item) { - try { - Class type = Class.forName("com.fongmi.android.tv.ui.activity.SearchActivity"); - type.getMethod("start", Activity.class, String.class).invoke(null, this, item.vodName); - } catch (Throwable error) { - Notify.show(error.getMessage()); - } - } - - @Override - public void onDelete(Following item) { - new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_WebHTV_FollowingConfirmDialog) - .setTitle(R.string.following_delete_title) - .setMessage(R.string.following_delete_message) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.following_cancel, (dialog, which) -> { - FollowingScheduler.cancelNext(this, item.identityKey); - FollowingPlaybackBridge.deleteAsync(item.identityKey, error -> load()); - }) - .show(); - } - - private FollowingSource preferred(Following item, FollowingSource source) { - if (source != null) return source; - FollowingSource target = new FollowingSource(); - target.followingKey = item.identityKey; - target.cid = item.cid; - target.siteKey = item.siteKey; - target.vodId = item.vodId; - target.vodName = item.vodName; - target.vodPic = item.vodPic; - target.preferred = true; - return target; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/HistoryResumeCoordinator.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/HistoryResumeCoordinator.java index 458df4aaa2..18cb0451e5 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/HistoryResumeCoordinator.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/HistoryResumeCoordinator.java @@ -20,7 +20,6 @@ import com.fongmi.android.tv.history.HistorySourceResolver; import com.fongmi.android.tv.playback.HistoryResumePayload; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.ui.dialog.LightDialog; -import com.fongmi.android.tv.ui.novel.NovelRouter; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; @@ -40,8 +39,6 @@ public final class HistoryResumeCoordinator { if (activity == null || history == null) return; REQUESTS.incrementAndGet(); if (!Setting.isGlobalHistoryEnabled() || history.isCurrentSourceAvailable()) { - if (NovelRouter.openHistory(activity, history, VodConfig.getCid(), - () -> VideoActivity.startFromHistory(activity, history))) return; VideoActivity.startFromHistory(activity, history); return; } @@ -87,9 +84,6 @@ public final class HistoryResumeCoordinator { Notify.show(R.string.history_source_episode_missing); return; } - if (NovelRouter.openHistory(activity, current, resolved.vod(), resolved.flag(), resolved.episode(), targetCid, - () -> VideoActivity.startFromResolvedHistory(activity, current, - resolved.vod(), resolved.flag(), resolved.episode()))) return; VideoActivity.startFromResolvedHistory(activity, current, resolved.vod(), resolved.flag(), resolved.episode()); }); }); @@ -137,9 +131,6 @@ public final class HistoryResumeCoordinator { showSearchFallback(activity, current, targetCid); return; } - if (NovelRouter.openHistory(activity, current, resolved.vod(), resolved.flag(), resolved.episode(), targetCid, - () -> VideoActivity.startFromResolvedHistory(activity, current, - resolved.vod(), resolved.flag(), resolved.episode()))) return; VideoActivity.startFromResolvedHistory(activity, current, resolved.vod(), resolved.flag(), resolved.episode()); }); }); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java index dd5404b081..f9ef3a89e3 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java @@ -15,9 +15,6 @@ import android.view.TextureView; import android.view.View; import android.view.ViewTreeObserver; import android.view.WindowManager; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.ViewConfiguration; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; @@ -27,8 +24,6 @@ import androidx.media3.common.MediaMetadata; import androidx.media3.common.Format; import androidx.media3.common.Player; import androidx.media3.common.VideoSize; -import androidx.media3.mpvplayer.MpvPlayer; -import androidx.media3.mpvplayer.MpvDiscMenuPolicy; import androidx.media3.exoplayer.drm.FrameworkMediaDrm; import androidx.media3.session.MediaController; import androidx.media3.session.SessionToken; @@ -36,9 +31,7 @@ import androidx.media3.ui.AspectRatioFrameLayout; import androidx.media3.ui.PlayerView; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.Sub; import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.player.PlaybackAutoContext; import com.fongmi.android.tv.player.PlaybackServiceReleasePolicy; @@ -51,13 +44,10 @@ import com.fongmi.android.tv.player.exo.ExoOutputModePolicy; import com.fongmi.android.tv.player.exo.ExoUtil; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; -import com.fongmi.android.tv.player.exo.ass.ExoAssSession; -import com.fongmi.android.tv.player.exo.subtitle.ExoSubtitleSession; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.subtitle.RealtimeSubtitleController; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.AdSkipPromptPresenter; -import com.fongmi.android.tv.ui.dialog.DiscMenuDialog; import com.fongmi.android.tv.ui.dialog.VideoAspectModeDialog; import com.fongmi.android.tv.ui.novel.NovelRouter; import com.fongmi.android.tv.ui.custom.CustomSeekView; @@ -88,10 +78,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont private int requestedAspectMode = VideoAspectMode.ORIGINAL; private ExoOutputModeManager exoOutputModeManager; private AdSkipPromptPresenter adSkipPromptPresenter; - private ExoAssSession attachedAssSession; - private ExoSubtitleSession attachedSubtitleSession; - private final com.fongmi.android.tv.player.SurfaceDiagnosticCollector surfaceDiagnostics = - new com.fongmi.android.tv.player.SurfaceDiagnosticCollector(); protected MediaController controller() { return mController; @@ -276,73 +262,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont return !isBuffering() && !isIdle(); } - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - if (dispatchDiscMenuKey(event)) return true; - return super.dispatchKeyEvent(event); - } - - protected boolean dispatchDiscMenuKey(KeyEvent event) { - if (mService == null || !isOwner() || event == null) return false; - Player active = player().getPlayer(); - if (!(active instanceof MpvPlayer mpv)) return false; - String action = MpvDiscMenuPolicy.keyAction(event.getKeyCode()); - if (action == null) return false; - if (!mpv.isDiscMenuActive() - && !(event.getKeyCode() == KeyEvent.KEYCODE_MENU && mpv.isDiscMenuAvailable())) return false; - if (event.getAction() != KeyEvent.ACTION_DOWN || event.getRepeatCount() > 0) return true; - mpv.sendDiscNav(action); - return true; - } - - protected boolean hasDiscMenu() { - return mService != null && isOwner() && player().getPlayer() instanceof MpvPlayer mpv - && mpv.isDiscMenuAvailable(); - } - - protected boolean hasDiscNavigationTimeline() { - return mService != null && isOwner() && player().getPlayer() instanceof MpvPlayer mpv - && mpv.hasDiscNavigationTimeline(); - } - - protected boolean canSavePlaybackHistory(History history) { - if (history == null) return false; - boolean navigationStarted = mService != null && isOwner() - && player().getPlayer() instanceof MpvPlayer mpv - && mpv.hasStartedDiscNavigation(); - return MpvDiscMenuPolicy.canSaveHistory(history.canSave(), navigationStarted); - } - - protected void showDiscMenuControls() { - if (hasDiscMenu()) DiscMenuDialog.show(this, (MpvPlayer) player().getPlayer()); - } - - protected void openDiscMenu() { - if (hasDiscMenu()) ((MpvPlayer) player().getPlayer()).sendDiscNav("menu"); - } - - protected boolean dispatchDiscMenuTouch(MotionEvent event) { - if (isLock() || mService == null || !isOwner()) return false; - Player active = player().getPlayer(); - if (!(active instanceof MpvPlayer mpv) || !mpv.isDiscMenuActive()) return false; - View surface = getExoView().getVideoSurfaceView(); - if (surface == null || event.getPointerCount() != 1) return false; - int[] origin = new int[2]; - surface.getLocationOnScreen(origin); - int pointerX = Math.round(event.getRawX() - origin[0]); - int pointerY = Math.round(event.getRawY() - origin[1]); - if (pointerX < 0 || pointerY < 0 || pointerX >= surface.getWidth() || pointerY >= surface.getHeight()) return false; - if (event.getActionMasked() == MotionEvent.ACTION_UP - && event.getEventTime() - event.getDownTime() >= ViewConfiguration.getLongPressTimeout()) { - showDiscMenuControls(); - } else if (event.getActionMasked() == MotionEvent.ACTION_DOWN - || event.getActionMasked() == MotionEvent.ACTION_MOVE - || event.getActionMasked() == MotionEvent.ACTION_UP) { - mpv.sendDiscNavPointer(pointerX, pointerY, event.getActionMasked() == MotionEvent.ACTION_UP); - } - return true; - } - protected void onServiceConnected() { } @@ -369,13 +288,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont protected void onTracksChanged() { } - /** - * 用户选中了一个外挂字幕。有本地历史语义的子类覆写它把来源记下来, - * 好让下次从历史进来时自动挂回同一个字幕。 - */ - protected void onSubtitleSelected(Sub sub) { - } - protected void onTitlesChanged() { } @@ -481,10 +393,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont return PlayerSetting.isBackgroundOff(); } - protected boolean shouldAutoPlay() { - return PlayerSetting.isAutoPlay(); - } - protected boolean seekTo(long deltaMs) { onSeekStarted(); long targetMs = Math.max(0, player().getPosition() + deltaMs); @@ -520,12 +428,12 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont preparedPlaybackKey = null; pinnedPlaybackKey = key; attachSurface(); - player().parse(key, result, useParse, metadata, shouldAutoPlay(), startPositionMs); + player().parse(key, result, useParse, metadata, PlayerSetting.isAutoPlay(), startPositionMs); } else { preparedPlaybackKey = null; pinnedPlaybackKey = key; attachSurface(); - player().start(PlaySpec.from(result, key, metadata), timeout, shouldAutoPlay(), startPositionMs); + player().start(PlaySpec.from(result, key, metadata), timeout, PlayerSetting.isAutoPlay(), startPositionMs); } syncKeepScreenOn(); } @@ -672,7 +580,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont else hideVideoShutter(); logSurfaceState("attach after setRender target=" + targetRender); } - surfaceDiagnostics.bind(getExoView(), player().getPlaybackTraceId()); if (getExoView().getPlayer() == null) { getExoView().setPlayer(player().getPlayer()); logSurfaceState("attach after setPlayer"); @@ -682,18 +589,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont if (player().isNativePlayer()) getExoView().post(this::syncShutter); } publishRenderTarget(getExoView().getVideoSurfaceView()); - ExoAssSession assSession = player().getAssSession(); - if (attachedAssSession != assSession) { - detachAssSurface(); - attachedAssSession = assSession; - } - if (attachedAssSession != null) attachedAssSession.attach(getExoView()); - ExoSubtitleSession subtitleSession = player().getSubtitleSession(); - if (attachedSubtitleSession != subtitleSession) { - if (attachedSubtitleSession != null) attachedSubtitleSession.detach(); - attachedSubtitleSession = subtitleSession; - } - if (attachedSubtitleSession != null) attachedSubtitleSession.attach(getExoView()); onSurfaceAttached(); logSurfaceState("attach done"); } @@ -703,7 +598,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont View surface = getExoView().getVideoSurfaceView(); if (!(surface instanceof SurfaceView surfaceView)) return; if (!PlaybackPerformanceSetting.isSurfaceFixedSizeEnabled() || getRender() != PlayerSetting.RENDER_SURFACE || player().isNativePlayer()) { - surfaceDiagnostics.resize("layout", -1, -1); surfaceView.getHolder().setSizeFromLayout(); logSurfaceState("syncVideoSurfaceSize layout size=" + (size == null ? "null" : size.width + "x" + size.height)); return; @@ -717,7 +611,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont width = Math.max(1, Math.round(width * scale)); height = Math.max(1, Math.round(height * scale)); } - surfaceDiagnostics.resize("fixed", width, height); surfaceView.getHolder().setFixedSize(width, height); logSurfaceState("syncVideoSurfaceSize fixed=" + width + "x" + height); } @@ -794,7 +687,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont getExoView().setShutterBackgroundColor(Color.BLACK); if (shutter != null) shutter.setVisibility(View.VISIBLE); } - surfaceDiagnostics.snapshot("shutter-policy"); } private void hideVideoShutter() { @@ -804,21 +696,11 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont } private void detachSurface() { - surfaceDiagnostics.unbind(); - detachAssSurface(); getExoView().setPlayer(null); if (mService != null) player().publishPlaybackRenderTarget(PlaybackAutoContext.RenderTarget.DETACHED); } - private void detachAssSurface() { - if (attachedAssSession != null) attachedAssSession.detach(); - attachedAssSession = null; - if (attachedSubtitleSession != null) attachedSubtitleSession.detach(); - attachedSubtitleSession = null; - } - private void resetVideoSurfaceForDecoderSwitch() { - detachAssSurface(); int targetRender = getRender(); int temporaryRender = targetRender == PlayerSetting.RENDER_TEXTURE ? PlayerSetting.RENDER_SURFACE : PlayerSetting.RENDER_TEXTURE; if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-flow", "reset video surface for decoder switch temp=%d target=%d", temporaryRender, targetRender); @@ -1002,12 +884,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont if (shutter != null) shutter.setVisibility(View.INVISIBLE); getExoView().setShutterBackgroundColor(Color.TRANSPARENT); PlaybackActivity.this.onExoFirstFrame(); - PlaybackActivity.this.onFirstFrameRendered(); - } - - @Override - public void onSubtitleSelected(Sub sub) { - if (isOwner()) PlaybackActivity.this.onSubtitleSelected(sub); } @Override @@ -1072,7 +948,6 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { @Override public void onPlaybackStateChanged(int state) { - if (mService != null && isOwner()) surfaceDiagnostics.bind(getExoView(), player().getPlaybackTraceId()); if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "state changed state=%d %s", state, lifecycleState()); syncKeepScreenOn(); if (!isOwner()) return; @@ -1120,8 +995,6 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { private void publishDisplayFacts(ExoOutputModeManager.Result result) { if (mService == null || result == null) return; - surfaceDiagnostics.snapshot("display-mode-request"); - surfaceDiagnostics.display(result); player().publishPlaybackDisplayFacts( toDisplayMode(result.currentMode()), toDisplayMode(result.requestedMode())); @@ -1272,8 +1145,6 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { @Override protected void onDestroy() { - surfaceDiagnostics.unbind(); - detachAssSurface(); if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "activity destroy beforeRelease %s", lifecycleState()); unbindAdAudioPrompt(); if (adSkipPromptPresenter != null) adSkipPromptPresenter.close(); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackRecoveryActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackRecoveryActivity.java deleted file mode 100644 index e91b2c3e57..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackRecoveryActivity.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import android.app.Activity; -import android.content.Intent; -import android.graphics.Color; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.os.Process; -import android.os.SystemClock; -import android.view.Gravity; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.LinearLayout; -import android.widget.TextView; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.player.mpv.PlaybackRecoveryMonitor; -import com.fongmi.android.tv.player.mpv.PlaybackRecoveryPolicy; - -/** Deliberately not BaseActivity: this process must never load a player or start servers. */ -public final class PlaybackRecoveryActivity extends Activity { - private final Handler handler = new Handler(Looper.getMainLooper()); - private volatile PlaybackRecoveryMonitor.Request request; - private TextView description; - private Button exit; - private volatile boolean destroyed; - private final Runnable recoveryPoll = this::pollRecovery; - - @Override public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - request = PlaybackRecoveryMonitor.requestForToken(this, - getIntent().getStringExtra(PlaybackRecoveryMonitor.EXTRA_TOKEN)); - if (request == null) { - finishAndRemoveTask(); - return; - } - LinearLayout content = new LinearLayout(this); - content.setOrientation(LinearLayout.VERTICAL); - content.setGravity(Gravity.CENTER); - int padding = Math.round(32 * getResources().getDisplayMetrics().density); - content.setPadding(padding, padding, padding, padding); - content.setBackgroundColor(Color.rgb(24, 24, 24)); - TextView title = new TextView(this); - title.setText(R.string.playback_recovery_title); - title.setTextColor(Color.WHITE); - title.setTextSize(24); - content.addView(title); - description = new TextView(this); - description.setText(R.string.playback_recovery_description); - description.setTextColor(Color.LTGRAY); - description.setTextSize(16); - description.setPadding(0, padding / 2, 0, padding / 2); - content.addView(description); - Button wait = new Button(this); - wait.setText(R.string.playback_recovery_wait); - wait.setOnClickListener(view -> description.setText(R.string.playback_recovery_waiting)); - content.addView(wait); - exit = new Button(this); - exit.setText(R.string.playback_recovery_exit); - exit.setOnClickListener(view -> endPlayback()); - content.addView(exit); - setContentView(content, new ViewGroup.LayoutParams(-1, -1)); - wait.requestFocus(); // Never default remote focus to a destructive action. - handler.postDelayed(recoveryPoll, 1000); - } - - private void endPlayback() { - exit.setEnabled(false); - PlaybackRecoveryMonitor.Request selected = request; - // Filesystem verification and process exit can never block this page. - new Thread(() -> endPlaybackOffUi(selected), "mpv-user-recovery").start(); - } - - private void endPlaybackOffUi(PlaybackRecoveryMonitor.Request selected) { - // Revalidate the private request and process generation at the moment - // of consent. Reused PIDs, already recovered playback and stale pages - // must not terminate anything. - if (!PlaybackRecoveryMonitor.mayEndProcess(this, selected)) { - PlaybackRecoveryMonitor.Request current = PlaybackRecoveryMonitor.requestForToken(this, selected.token()); - int message = current != null && !current.armed() - ? R.string.playback_recovery_already_responsive : R.string.playback_recovery_refused; - handler.post(() -> showCannotEnd(message)); - return; - } - if (!PlaybackRecoveryMonitor.recordUserExit(this, selected)) { - handler.post(() -> showCannotEnd(R.string.playback_recovery_refused)); - return; - } - if (destroyed || isFinishing() || request != selected - || !PlaybackRecoveryMonitor.mayEndProcess(this, selected)) { - handler.post(() -> showCannotEnd(R.string.playback_recovery_refused)); - return; - } - try { - Process.killProcess(selected.target().pid()); - } catch (RuntimeException error) { - handler.post(() -> showCannotEnd(R.string.playback_recovery_refused)); - return; - } - long startedMs = SystemClock.elapsedRealtime(); - handler.post(() -> awaitExit(startedMs)); - } - - private void showCannotEnd(int message) { - if (destroyed || isFinishing()) return; - description.setText(message); - exit.setEnabled(true); - if (message == R.string.playback_recovery_already_responsive) { - exit.setText(R.string.playback_recovery_home); - exit.setOnClickListener(view -> returnHome()); - } else { - exit.setText(R.string.playback_recovery_exit); - exit.setOnClickListener(view -> endPlayback()); - } - } - - private void pollRecovery() { - if (destroyed || isFinishing() || request == null) return; - PlaybackRecoveryMonitor.Request current = PlaybackRecoveryMonitor.requestForToken(this, request.token()); - if (current != null && !current.armed()) { - finishAndRemoveTask(); - return; - } - handler.postDelayed(recoveryPoll, 1000); - } - - @Override protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - request = PlaybackRecoveryMonitor.requestForToken(this, - intent.getStringExtra(PlaybackRecoveryMonitor.EXTRA_TOKEN)); - if (request == null) { - finishAndRemoveTask(); - return; - } - description.setText(R.string.playback_recovery_description); - exit.setText(R.string.playback_recovery_exit); - exit.setEnabled(true); - exit.setOnClickListener(view -> endPlayback()); - } - - private void awaitExit(long startedMs) { - if (isFinishing() || isDestroyed()) return; - PlaybackRecoveryPolicy.Target current = PlaybackRecoveryMonitor.readTarget(request.target().pid()); - if (!request.target().equals(current)) { - returnHome(); - } else if (SystemClock.elapsedRealtime() - startedMs >= 5000) { - description.setText(R.string.playback_recovery_refused); - exit.setText(R.string.playback_recovery_home); - exit.setEnabled(true); - exit.setOnClickListener(view -> returnHome()); - } else { - handler.postDelayed(() -> awaitExit(startedMs), 100); - } - } - - private void returnHome() { - Intent launch = getPackageManager().getLaunchIntentForPackage(getApplicationInfo().packageName); - if (launch == null) launch = getPackageManager().getLeanbackLaunchIntentForPackage(getApplicationInfo().packageName); - if (launch != null) { - launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - startActivity(launch); // Launcher only: never carry the failed media Intent. - } - finishAndRemoveTask(); - } - - @Override protected void onDestroy() { - destroyed = true; - handler.removeCallbacksAndMessages(null); - super.onDestroy(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java index 09f9b9596f..2d9fc8d434 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java @@ -42,7 +42,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.core.widget.NestedScrollView; -import androidx.core.view.OneShotPreDrawListener; import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; @@ -68,9 +67,11 @@ import com.fongmi.android.tv.api.SiteApi; import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.api.config.UserAdRuleStore; import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.api.config.SubscriptionTmdbCredentialStore; -import com.fongmi.android.tv.bean.AdDetectionRequest; -import com.fongmi.android.tv.bean.AdDetectionResult; +import com.fongmi.android.tv.ad.feedback.AdFeedbackController; +import com.fongmi.android.tv.ad.feedback.AdFeedbackHostAdapter; +import com.fongmi.android.tv.ad.feedback.AdFeedbackSession; +import com.fongmi.android.tv.ad.feedback.AdRulePlanApplier; +import com.fongmi.android.tv.ui.dialog.AdFeedbackDialog; import com.fongmi.android.tv.bean.AiConfig; import com.fongmi.android.tv.bean.Danmaku; import com.fongmi.android.tv.bean.Episode; @@ -93,25 +94,12 @@ import com.fongmi.android.tv.bean.TmdbSeasonProgress; import com.fongmi.android.tv.bean.TmdbSeasonScope; import com.fongmi.android.tv.bean.TmdbSeasonSegment; import com.fongmi.android.tv.bean.TmdbPerson; -import com.fongmi.android.tv.bean.TmdbSourcePayload; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.databinding.ActivityTmdbDetailBinding; import com.fongmi.android.tv.databinding.DialogTmdbEpisodeBinding; import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingIdentity; -import com.fongmi.android.tv.following.FollowingMetadataSnapshot; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingSource; -import com.fongmi.android.tv.following.FollowingStore; -import com.fongmi.android.tv.following.FollowingUpdatePolicy; -import com.fongmi.android.tv.event.ConfigEvent; -import com.fongmi.android.tv.setting.DetailRuntimeModePolicy; -import com.fongmi.android.tv.setting.TmdbSourceState; import com.fongmi.android.tv.ui.detail.DetailModeHost; import com.fongmi.android.tv.ui.detail.EnhancedDetailController; import com.fongmi.android.tv.ui.detail.FusionDetailController; @@ -121,14 +109,11 @@ import com.fongmi.android.tv.ui.host.TmdbDetailHost; import com.fongmi.android.tv.playback.PlaybackEventCollector; import com.fongmi.android.tv.playback.HistoryResumePayload; import com.fongmi.android.tv.playback.PlaybackOrientation; -import com.fongmi.android.tv.playback.SubtitleRestoreCoordinator; import com.fongmi.android.tv.player.IntroSkipKinds; import com.fongmi.android.tv.player.IntroSkipPlayback; -import com.fongmi.android.tv.player.PlaybackResourceClassifier; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.PlayerHelper; -import com.fongmi.android.tv.player.mpv.MpvConfigStore; -import com.fongmi.android.tv.service.AiAdDetectionService; +import com.fongmi.android.tv.player.exo.MediaSourceFactory; import com.fongmi.android.tv.service.AiEpisodeSeasonService; import com.fongmi.android.tv.service.AiRecommendationService; import com.fongmi.android.tv.service.PersonalRecommendationService; @@ -148,7 +133,6 @@ import com.fongmi.android.tv.setting.TmdbSitePolicy; import com.fongmi.android.tv.title.MediaTitleLearningExample; import com.fongmi.android.tv.title.MediaTitleLearningStore; import com.fongmi.android.tv.title.MediaTitleParser; -import com.fongmi.android.tv.utils.PushParser; import com.fongmi.android.tv.title.MediaTitleRequest; import com.fongmi.android.tv.title.MediaTitleResolution; import com.fongmi.android.tv.title.MediaTitleResolver; @@ -167,7 +151,6 @@ import com.fongmi.android.tv.ui.custom.EpisodeTitlePopup; import com.fongmi.android.tv.ui.custom.PlayerGesture; import com.fongmi.android.tv.ui.custom.PlayerOsdController; import com.fongmi.android.tv.ui.dialog.AdRulePreviewDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.ui.dialog.CodecCapabilityDialog; import com.fongmi.android.tv.ui.dialog.DanmakuDialog; import com.fongmi.android.tv.ui.dialog.DisplayDialog; @@ -188,7 +171,6 @@ import com.fongmi.android.tv.ui.helper.EpisodeSeasonSnapshot; import com.fongmi.android.tv.ui.helper.PipExitDecision; import com.fongmi.android.tv.ui.helper.PlayerControlFocusHelper; import com.fongmi.android.tv.ui.helper.TmdbCinemaTheme; -import com.fongmi.android.tv.ui.helper.TmdbBundle; import com.fongmi.android.tv.ui.helper.TmdbDetailLabels; import com.fongmi.android.tv.ui.helper.TmdbEpisodeGridPolicy; import com.fongmi.android.tv.ui.helper.TmdbEpisodeInfo; @@ -198,11 +180,6 @@ import com.fongmi.android.tv.ui.helper.TmdbEpisodeMatcher; import com.fongmi.android.tv.ui.helper.TmdbMatchPolicy; import com.fongmi.android.tv.ui.helper.TmdbMatcher; import com.fongmi.android.tv.ui.helper.TmdbRecommendationRows; -import com.fongmi.android.tv.ui.helper.TmdbSourceAdapter; -import com.fongmi.android.tv.ui.helper.TmdbSourceAvailability; -import com.fongmi.android.tv.ui.helper.TmdbSourceCapabilityPlanner; -import com.fongmi.android.tv.ui.helper.TmdbSourceMerger; -import com.fongmi.android.tv.ui.helper.TmdbSourcePayloadParser; import com.fongmi.android.tv.ui.helper.TmdbUIAdapter; import com.fongmi.android.tv.ui.player.VodPlayerChrome; import com.fongmi.android.tv.ui.player.VodPlayerUiController; @@ -351,9 +328,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private History mHistory; private TmdbConfig tmdbConfig; private TmdbBundle activeTmdbBundle; - private TmdbSourcePayload activeSourcePayload; - private TmdbItem activeSourceItem; - private DetailRuntimeModePolicy.Decision activeRuntimeDecision; private TmdbItem initialTmdbItem; private TmdbItem matchedTmdbItem; private JsonObject matchedTmdbDetail; @@ -383,24 +357,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private boolean overviewExpanded; private boolean useParse; private boolean inlineStarted; - private boolean inlinePlaybackPending; - private final Runnable inlineLoadingSpeedRefresh = new Runnable() { - @Override - public void run() { - if (binding == null || binding.playerLoading.getVisibility() != View.VISIBLE) return; - updateInlineLoadingSpeed(); - binding.playerLoading.postDelayed(this, 1000L); - } - }; private boolean inlineHttpRefreshAttempted; private boolean detailPlayerActive; - private boolean detailPlayerFullscreenPending; private boolean autoPlayed; private boolean defaultPlaybackLaunchPending; private boolean inlineFullscreen; private boolean inlineShortDramaMode; private boolean inlinePauseInfo; + private boolean inlinePlaybackLoading; private boolean inlinePlaybackReconnectPending; + private boolean inlinePlayerSwitchLoading; private boolean savingTmdbPhoto; private boolean pendingInlineLutImport; private PlayerGesture inlineGestureDetector; @@ -428,8 +394,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private float inlineGestureSpeed = 1.0f; private boolean inlineStartPositionApplied; private boolean inlineFirstReady; - private boolean inlinePlayHealthRecorded; - private String inlinePlayHealthKey = ""; private boolean inlineButtonsReordered; private View mNightModeOverlay; private int mNightModeLevel = PlayerSetting.NIGHT_MODE_OFF; @@ -437,8 +401,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private boolean inlinePiPSourceFrozen; private long inlineStartPosition = C.TIME_UNSET; private int selectedSeasonNumber = -1; - private int followingUiGeneration; - private boolean followingActionPending; private TmdbSeasonMatchCache.Entry tmdbSeasonBinding; private TmdbItem pendingTmdbSeasonChoice; private int lastEpisodeMediaSeason = Integer.MIN_VALUE; @@ -448,15 +410,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private int statusBarInsetTop; private int detailThemeMode; private int loadGeneration; - private SubscriptionTmdbCredentialStore.Scope tmdbCredentialScope; /** 本次取详情的起始时刻,用来认出「猫源开内嵌页」是不是自己这次导航触发的。volatile:加载在后台线程发起,事件在主线程读。 */ private volatile long detailLoadStart; private int inlinePlaybackGeneration; - private int mAdFeedbackGeneration; + private AdFeedbackController mAdFeedback; + private AdFeedbackHostAdapter mAdFeedbackHost; + private AdFeedbackDialog mAdFeedbackDialog; + /** 标记模式下已打的起点,-1 表示未进入标记模式。 */ + private long mAdMarkStartMs = -1; private int tmdbDialogGeneration; private AiEpisodeSeasonService aiSeasonService; - private final List inlineCustomActionViews = new ArrayList<>(); - private boolean inlineCustomButtonsInitialized; private AlertDialog aiSeasonLoadingDialog; private int tmdbApplyGeneration; private int tmdbEpisodeDetailGeneration; @@ -584,10 +547,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Nullable TmdbItem tmdbItem, int detailMode, boolean autoPlay, @Nullable History resumeHistory, String playFlag, String playFlagKey, String playEpisodeName, String playEpisodeUrl, String searchKeyword) { - if (SiteApi.PUSH.equals(key) && PushParser.fromId(id).shouldSkipAutoTmdbMatch()) { - startDirectFromHistory(activity, key, id, name, pic, mark, playFlag, playFlagKey, playEpisodeName, playEpisodeUrl, resumeHistory); - return; - } if (!TextUtils.isEmpty(key) && !SiteApi.PUSH.equals(key) && AudioUtil.isAudioSiteEnabled(key)) { startDirectFromHistory(activity, key, id, name, pic, mark, playFlag, playFlagKey, playEpisodeName, playEpisodeUrl, resumeHistory); return; @@ -699,22 +658,20 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected boolean shouldBindPlaybackService() { - return modeController.shouldBindPlaybackService(); + return isFusionMode() || isPlayerMode(); } @Override protected void initView(Bundle savedInstanceState) { inflateMobileInlineControl(); - initModeController(); super.initView(savedInstanceState); - tmdbConfig = TmdbConfig.effectiveCurrent(); - tmdbCredentialScope = SubscriptionTmdbCredentialStore.currentScope(); + tmdbConfig = TmdbConfig.objectFrom(Setting.getTmdbConfig()); initialTmdbItem = getIntentTmdbItem(); detailThemeMode = Setting.getTmdbDetailTheme(); applyDetailEdgeToEdge(); applySystemBarInsets(); initPage(); - bindModeController(); + initModeController(); setLoadingOnlyBeforeDefaultPlayback(shouldUseLoadingOnlyBeforeDefaultPlayback()); loadContent(null); } @@ -732,8 +689,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void resetDetailState() { cancelAiSeasonAnalysis(false); closeInlineSearch(); - tmdbConfig = TmdbConfig.effectiveCurrent(); - tmdbCredentialScope = SubscriptionTmdbCredentialStore.currentScope(); + tmdbConfig = TmdbConfig.objectFrom(Setting.getTmdbConfig()); initialTmdbItem = getIntentTmdbItem(); vod = null; matchedTmdbItem = null; @@ -751,8 +707,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. clearEpisodeRenderCaches(); resetEpisodeRange(); inlineStarted = false; - inlinePlaybackPending = false; - detailPlayerFullscreenPending = false; detailPlayerActive = false; autoPlayed = false; inlinePlaybackGeneration++; @@ -760,13 +714,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. tmdbApplyGeneration++; tmdbEpisodeDetailGeneration++; sourceSearchGeneration++; + inlinePlaybackLoading = false; + inlinePlayerSwitchLoading = false; inlineStartPosition = C.TIME_UNSET; pendingInlineResult = null; currentInlineResult = null; activeTmdbBundle = null; - activeSourcePayload = null; - activeSourceItem = null; - activeRuntimeDecision = null; useParse = false; detailTmdbPhotos.clear(); detailTmdbPosters.clear(); @@ -787,6 +740,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. updateNavigationKey(); binding.loading.setVisibility(View.VISIBLE); setLoadingOnlyBeforeDefaultPlayback(shouldUseLoadingOnlyBeforeDefaultPlayback()); + hideInlineLoading(); binding.playerError.setVisibility(View.GONE); binding.playerControls.setVisibility(View.GONE); binding.detailControlHost.setVisibility(View.GONE); @@ -818,9 +772,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.keep.setOnClickListener(view -> onKeep()); binding.keepTop.setOnClickListener(view -> onKeep()); binding.keepFusion.setOnClickListener(view -> onKeep()); - binding.following.setOnClickListener(view -> onFollowing()); - binding.followingTop.setOnClickListener(view -> onFollowing()); - binding.followingFusion.setOnClickListener(view -> onFollowing()); binding.rematch.setOnClickListener(view -> showManualTmdbMatchDialog()); binding.rematchTop.setOnClickListener(view -> showManualTmdbMatchDialog()); binding.rematchFusion.setOnClickListener(view -> showManualTmdbMatchDialog()); @@ -858,11 +809,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.overviewToggle.setVisibility(View.GONE); binding.play.setText(R.string.detail_play_now); binding.keep.setText(R.string.keep); - binding.following.setText(R.string.following); lightTheme = resolveLightTheme(); updateThemeModeButtonLabels(); binding.keepTop.setVisibility(View.GONE); - binding.followingTop.setVisibility(View.GONE); binding.rematchTop.setVisibility(View.GONE); binding.headerBar.setVisibility(Util.isMobile() ? View.VISIBLE : View.GONE); updateDetailThemeButtonVisibility(); @@ -877,9 +826,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } @Override - public void onItemLongClick(View anchor, Episode episode, int episodeNumber, TmdbEpisode tmdbEpisode) { + public void onItemLongClick(View anchor, Episode episode, int episodeNumber) { anchor.setPressed(false); - showTmdbEpisodeDetail(episode, episodeNumber, tmdbEpisode, binding.episodeContainer); + showTmdbEpisodeDetail(episode, episodeNumber, binding.episodeContainer); } }); episodeAdapter.setNativeEnhanced(true); @@ -954,26 +903,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. public ViewBinding binding() { return binding; } - - @Override - public void closeDetailFullscreenPlayer() { - TmdbDetailActivity.this.closeDetailFullscreenPlayer(); - } - - @Override - public void playInline() { - TmdbDetailActivity.this.playInline(); - } - - @Override - public void playDetailFullscreen() { - TmdbDetailActivity.this.playDetailFullscreen(); - } - - @Override - public void playDefaultPlayback() { - TmdbDetailActivity.this.playDefaultPlayback(); - } }; if (isFusionMode()) { @@ -984,9 +913,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. modeController = new EnhancedDetailController(host); } - } - - private void bindModeController() { modeController.bind(); modeController.applyInitialLayout(); } @@ -1280,12 +1206,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. setupInlineControlFocus(); setupInlineFocusNavigation(); binding.playerAdFeedback.setOnClickListener(guarded(this::onInlineAdFeedback)); + binding.playerAdFeedback.setOnLongClickListener(view -> onInlineAdFeedbackLongPress()); binding.playerMultiThreadProxy.setOnClickListener(guarded(this::showInlineMultiThreadProxy)); binding.playerSearch.setOnClickListener(view -> openInlineSourceSearch()); binding.playerSearch.setOnLongClickListener(view -> openGlobalSourceSearch()); inlinePlayerUi.bindInlineActions(); setupMobileInlineControl(); - setupInlineCustomButtons(); hideInlineControls(); updateInlineButtons(false); focusInlinePlayerPanel(); @@ -1339,6 +1265,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. detailActionView(R.id.ending, View.class).setOnLongClickListener(view -> resetInlineEnding()); detailActionView(R.id.danmaku, View.class).setOnClickListener(guarded(this::showInlineDanmaku)); detailActionView(R.id.adFeedback, View.class).setOnClickListener(guarded(this::onInlineAdFeedback)); + detailActionView(R.id.adFeedback, View.class).setOnLongClickListener(view -> onInlineAdFeedbackLongPress()); detailActionView(R.id.chapter, View.class).setOnClickListener(guarded(this::showInlineTitle)); detailActionView(R.id.episodes, View.class).setOnClickListener(guarded(this::showInlineEpisodes)); setupMobileInlineParse(); @@ -1354,92 +1281,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. parse.setAdapter(inlineParseAdapter = new InlineParseAdapter()); } - /** - * 影视原生模式和详情页使用不同的控制条。原生模式会在 VideoActivity - * 中创建 scripts 按钮,详情页此前只绑定了内置按钮,因此沉浸融合模式 - * 虽然 MPV 已加载脚本桥接文件,却没有任何入口发送 script-message。 - */ - private void setupInlineCustomButtons() { - if (inlineCustomButtonsInitialized) return; - inlineCustomButtonsInitialized = true; - List buttons = MpvConfigStore.customButtons(); - if (buttons.isEmpty()) return; - - ViewGroup playerActions = (ViewGroup) binding.playerActionRow.getChildAt(0); - ViewGroup mobileActions = Util.isMobile() && detailActionRoot != null - ? detailActionRoot.findViewById(R.id.container) : null; - for (MpvConfigStore.CustomButton button : buttons) { - if (button == null || !button.enabled) continue; - addInlineCustomButton(playerActions, button); - if (mobileActions != null) addInlineCustomButton(mobileActions, button); - } - setupInlineCustomButtonFocus(); - updateInlineCustomButtonVisibility(); - } - - private void addInlineCustomButton(ViewGroup container, MpvConfigStore.CustomButton button) { - TextView view = new TextView(this); - view.setId(View.generateViewId()); - view.setTextSize(13); - view.setTextColor(Color.WHITE); - view.setGravity(Gravity.CENTER); - view.setMinHeight(ResUtil.dp2px(40)); - view.setMinWidth(ResUtil.dp2px(56)); - view.setPadding(ResUtil.dp2px(8), ResUtil.dp2px(4), ResUtil.dp2px(8), ResUtil.dp2px(4)); - view.setBackgroundResource(R.drawable.selector_control_sheet_button); - view.setText(button.title); - view.setSingleLine(true); - view.setMaxWidth(ResUtil.dp2px(144)); - view.setEllipsize(TextUtils.TruncateAt.END); - view.setContentDescription(button.title); - view.setOnClickListener(item -> dispatchInlineCustomButton(item, button.id, false)); - view.setOnLongClickListener(item -> { - dispatchInlineCustomButton(item, button.id, true); - return true; - }); - view.setFocusable(true); - if (Util.isLeanback()) view.setFocusableInTouchMode(true); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, ResUtil.dp2px(40)); - params.setMargins(ResUtil.dp2px(4), ResUtil.dp2px(2), ResUtil.dp2px(4), ResUtil.dp2px(2)); - container.addView(view, params); - inlineCustomActionViews.add(view); - } - - private void dispatchInlineCustomButton(View view, String id, boolean longPress) { - if (service() == null || player() == null || !player().isMpv()) return; - if (player().sendMpvCustomButton(id, longPress)) view.setSelected(!view.isSelected()); - setInlineHideCallback(); - } - - private void updateInlineCustomButtonVisibility() { - boolean visible = service() != null && player() != null - && !player().isEmpty() && player().isMpv(); - for (View view : inlineCustomActionViews) view.setVisibility(visible ? View.VISIBLE : View.GONE); - } - - /** - * 自定义按钮不属于 PlayerButtonSetting 的固定按钮集合,不能依赖固定按钮的焦点链。 - * 否则 TV 端最后一个内置按钮的右焦点仍然是 NO_ID,动态追加的按钮无法用方向键到达。 - */ - private void setupInlineCustomButtonFocus() { - if (Util.isMobile()) return; - ViewGroup container = (ViewGroup) binding.playerActionRow.getChildAt(0); - List focusable = new ArrayList<>(); - for (int i = 0; i < container.getChildCount(); i++) { - View view = container.getChildAt(i); - if (view.getVisibility() == View.VISIBLE && view.isEnabled() && view.isFocusable()) focusable.add(view); - } - for (int i = 0; i < focusable.size(); i++) { - View current = focusable.get(i); - View previous = focusable.get(i == 0 ? focusable.size() - 1 : i - 1); - View next = focusable.get(i == focusable.size() - 1 ? 0 : i + 1); - current.setNextFocusLeftId(previous.getId()); - current.setNextFocusRightId(next.getId()); - current.setNextFocusUpId(current.getId()); - } - } - private void inflateMobileInlineControl() { if (!Util.isMobile() || detailControlRoot != null) return; detailControlRoot = getLayoutInflater().inflate(R.layout.view_control_vod_tmdb, binding.detailControlHost, false); @@ -1906,9 +1747,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. setDetailActionButton(binding.keep, colors); setDetailActionButton(binding.keepTop, colors); setDetailActionButton(binding.keepFusion, colors); - setDetailActionButton(binding.following, colors); - setDetailActionButton(binding.followingTop, colors); - setDetailActionButton(binding.followingFusion, colors); setDetailActionButton(binding.rematch, colors); setDetailActionButton(binding.rematchTop, colors); setDetailActionButton(binding.rematchFusion, colors); @@ -1941,6 +1779,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.playerError.setTextColor(0xFFFFFFFF); tintInlineControl(inlineControlsView()); tintInlineDisplay(); + tintInlineLoading(); } if (episodeAdapter != null) { episodeAdapter.setTheme(lightTheme, colors.accent); @@ -2056,9 +1895,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.title.setTextSize(28f); binding.overview.setTextSize(13f); TmdbDetailLayoutUtils.setHeightDp(binding.episodePhotoList, 124); - TmdbDetailLayoutUtils.setHeightDp(binding.posterList, 238); - binding.posterList.setClipToOutline(false); - binding.posterList.setClipChildren(false); TmdbDetailLayoutUtils.setHeightDp(binding.castList, 180); TmdbDetailLayoutUtils.setHeightDp(binding.creatorList, 180); TmdbDetailLayoutUtils.setHeightDp(binding.relatedList, 262); @@ -2110,12 +1946,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.subtitle.setTextSize(compact ? 13f : 14f); binding.overview.setTextSize(compact ? 14.5f : 16f); binding.overview.setLineSpacing(ResUtil.dp2px(compact ? 4 : 3), 1f); - // 剧照卡片固定为 124dp 高;容器保持同高,避免大屏剧幕主题在剧照与下一栏之间留下额外空白。 - TmdbDetailLayoutUtils.setHeightDp(binding.episodePhotoList, 124); - // 海报卡片本体为 148x222dp,列表额外保留焦点放大空间,确保完整显示四角圆角。 - TmdbDetailLayoutUtils.setHeightDp(binding.posterList, 238); - binding.posterList.setClipToOutline(false); - binding.posterList.setClipChildren(false); + TmdbDetailLayoutUtils.setHeightDp(binding.episodePhotoList, compact ? 128 : 160); TmdbDetailLayoutUtils.setHeightDp(binding.castList, compact ? 90 : 90); TmdbDetailLayoutUtils.setHeightDp(binding.creatorList, compact ? 90 : 90); TmdbDetailLayoutUtils.setHeightDp(binding.relatedList, compact ? 160 : 160); @@ -2187,6 +2018,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.playerDisplayTraffic.setTextColor(0xFFFFFFFF); } + private void tintInlineLoading() { + binding.playerProgressTraffic.setTextColor(0xFFFFFFFF); + binding.playerProgressTraffic.setAlpha(1f); + } + private void tintInlineGestureOverlay() { tintInlineControl(binding.gestureSeek); tintInlineControl(binding.gestureBright); @@ -2355,7 +2191,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void loadContent(@Nullable TmdbBundle reusableBundle) { int generation = ++loadGeneration; - tmdbCredentialScope = SubscriptionTmdbCredentialStore.currentScope(); detailTasks.cancelAll(); int mode = getDetailMode(); String key = getKeyText(); @@ -2367,6 +2202,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. detailTasks.submit(() -> { if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; boolean tmdbAllowed = isTmdbAllowedForCurrentSite(); + Future tmdbFuture = reusableBundle == null && tmdbConfig.isReady() && tmdbAllowed + ? detailTasks.submitCallable(Task.largeExecutor(), this::loadTmdbResult) + : null; + boolean singlePassStandaloneTmdb = shouldLoadInitialStandaloneTmdbDetailInSinglePass(reusableBundle, tmdbFuture); + SpiderDebug.log("tmdb-detail-flow", "load tasks mode=%d tmdbAllowed=%s singlePass=%s reusable=%s", mode, tmdbAllowed, singlePassStandaloneTmdb, reusableBundle != null); Vod loadedVod = null; String error = null; long sourceStart = System.currentTimeMillis(); @@ -2377,6 +2217,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (com.fongmi.android.tv.api.CatAction.shouldYieldDetail(key, loadStart, result)) { SpiderDebug.log("tmdb-detail-flow", "yield to cat webview key=%s id=%s", key, id); runOnAliveUi(this::finish); + if (tmdbFuture != null) tmdbFuture.cancel(true); return; } if (result != null && !result.getList().isEmpty()) { @@ -2390,88 +2231,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } SpiderDebug.log("tmdb-detail-flow", "source detail cost=%dms mode=%d key=%s id=%s hit=%s error=%s", System.currentTimeMillis() - sourceStart, mode, key, id, loadedVod != null, TextUtils.isEmpty(error) ? "" : error); - if (generation != loadGeneration || Thread.currentThread().isInterrupted() - || !SubscriptionTmdbCredentialStore.isCurrent(tmdbCredentialScope)) { + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) { + if (tmdbFuture != null) tmdbFuture.cancel(true); return; } - tmdbConfig = TmdbConfig.effectiveCurrent(); - tmdbCredentialScope = SubscriptionTmdbCredentialStore.currentScope(); Vod finalVod = loadedVod; String finalError = error; - - TmdbSourcePayload sourcePayload = finalVod == null ? null : TmdbSourcePayloadParser.parse(finalVod.getTmdb()); - TmdbBundle sourceBundle = sourcePayload == null ? null : TmdbSourceAdapter.toBundle(sourcePayload, finalVod, tmdbConfig); - TmdbSourceState sourceState = TmdbSourceAvailability.classify(finalVod, sourcePayload, sourceBundle); - boolean tmdbReady = tmdbConfig != null && tmdbConfig.isReady(); - DetailRuntimeModePolicy.Decision decision = DetailRuntimeModePolicy.resolve(new DetailRuntimeModePolicy.Input( - mode, - tmdbReady, - tmdbAllowed, - sourceState - )); - SpiderDebug.log("tmdb-detail-flow", "runtime policy mode=%d runtime=%d source=%s tmdbReady=%s siteAllowed=%s sourceOnly=%s network=%s", - mode, decision.runtimeMode(), sourceState, tmdbReady, tmdbAllowed, decision.sourceOnly(), decision.networkAllowed()); - if (decision.runtimeMode() == Setting.DETAIL_OPEN_DIRECT) { - String reason = !tmdbAllowed ? "site_tmdb_disabled" - : sourceState == TmdbSourceState.IDENTITY_ONLY ? "source_identity_only_without_key" - : "source_absent_or_invalid"; - runOnAliveUi(() -> { - if (generation != loadGeneration) return; - if (finalVod == null) applyLoaded(null, null, new ArrayList<>(), finalError, false); - else fallbackToOriginalDetail(finalVod, reason); - }); - return; - } - if (sourceBundle != null) { - if (reusableBundle != null) sourceBundle = TmdbSourceMerger.merge(sourceBundle, sourcePayload, reusableBundle, null).bundle(); - TmdbBundle initialBundle = sourceBundle; - runOnAliveUi(() -> { - if (generation != loadGeneration) return; - activeRuntimeDecision = decision; - activeSourcePayload = sourcePayload; - activeSourceItem = initialBundle.item(); - applyLoaded(finalVod, initialBundle, new ArrayList<>(), finalError, false); - }); - if (!decision.networkAllowed()) return; - TmdbSourceCapabilityPlanner.Plan plan = TmdbSourceCapabilityPlanner.plan(initialBundle, sourcePayload, TmdbSourceCapabilityPlanner.UiState.initialScreen()); - SpiderDebug.log("tmdb-detail-flow", "source payload plan required=%s missing=%s total=%dms", plan.required(), plan.missing(), System.currentTimeMillis() - loadStart); - if (!plan.hasInitialNetworkGaps()) return; - try { - long sourceFillStart = System.currentTimeMillis(); - JsonObject detail = tmdbService.detailForSource(initialBundle.item(), sourcePayload.getSeasonNumber(), tmdbConfig, plan.missing()); - TmdbBundle networkBundle = TmdbSourceAdapter.fromNetwork(initialBundle.item(), detail, tmdbConfig); - TmdbBundle mergedBundle = TmdbSourceMerger.fillOnly(initialBundle, sourcePayload, networkBundle); - SpiderDebug.log("tmdb-detail-flow", "source payload network fill cost=%dms groups=%s total=%dms", System.currentTimeMillis() - sourceFillStart, plan.missing(), System.currentTimeMillis() - loadStart); - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - runOnAliveUi(() -> { - if (generation != loadGeneration || vod == null || !isSameTmdbItem(mergedBundle.item(), matchedTmdbItem)) return; - applyTmdbResultNow(new TmdbLoadResult(mergedBundle, List.of())); - }); - } catch (Throwable ignored) { - SpiderDebug.log("tmdb-detail-flow", "source payload network fill failed mode=%d total=%dms", mode, System.currentTimeMillis() - loadStart); - } - return; - } - - if (reusableBundle != null) { - runOnAliveUi(() -> { - if (generation != loadGeneration) return; - activeRuntimeDecision = decision; - applyLoaded(finalVod, reusableBundle, new ArrayList<>(), finalError, false); - }); - return; - } - - Future tmdbFuture = decision.networkAllowed() - ? detailTasks.submitCallable(Task.largeExecutor(), this::loadTmdbResult) - : null; - boolean singlePassStandaloneTmdb = shouldLoadInitialStandaloneTmdbDetailInSinglePass(reusableBundle, tmdbFuture); - SpiderDebug.log("tmdb-detail-flow", "load tasks mode=%d tmdbAllowed=%s singlePass=%s reusable=%s", mode, tmdbAllowed, singlePassStandaloneTmdb, reusableBundle != null); if (!singlePassStandaloneTmdb || finalVod == null) { runOnAliveUi(() -> { if (generation != loadGeneration) return; - activeRuntimeDecision = decision; - applyLoaded(finalVod, null, new ArrayList<>(), finalError, false); + applyLoaded(finalVod, reusableBundle, new ArrayList<>(), finalError, false); }); } if (finalVod == null || tmdbFuture == null) { @@ -2519,7 +2288,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. TmdbLoadResult finalResult = loadedResult; runOnAliveUi(() -> { if (generation != loadGeneration || (!singlePassStandaloneTmdb && vod == null)) return; - activeRuntimeDecision = decision; if (singlePassStandaloneTmdb) { applyLoaded(finalVod, finalResult == null ? null : finalResult.bundle(), finalResult == null ? new ArrayList<>() : finalResult.searchItems(), finalError, true); } else { @@ -2532,7 +2300,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (singlePassStandaloneTmdb) { runOnAliveUi(() -> { if (generation != loadGeneration) return; - activeRuntimeDecision = decision; applyLoaded(finalVod, null, new ArrayList<>(), finalError, false); }); } @@ -2669,23 +2436,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return first.getTmdbId() == second.getTmdbId() && TextUtils.equals(first.getMediaType(), second.getMediaType()); } - private boolean hasCompleteSourceSeason(int seasonNumber) { - return activeSourcePayload != null && activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.season(seasonNumber)); - } - - private boolean hasCompleteSourceEpisode(int seasonNumber, int episodeNumber) { - return activeSourcePayload != null && activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.episode(seasonNumber, episodeNumber)); - } - - private boolean hasCompleteSourceVideos(@Nullable TmdbItem item, int seasonNumber, int episodeNumber) { - if (activeSourcePayload == null || item == null) return false; - if (item.isMovie()) return activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.VIDEOS); - if (!item.isTv() || !activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.VIDEOS)) return false; - if (seasonNumber < 0) return true; - if (!activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.seasonVideos(seasonNumber))) return false; - return episodeNumber <= 0 || activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.episodeVideos(seasonNumber, episodeNumber)); - } - private boolean isInlinePlaybackRequestCurrent(int generation, String key, String flag, String episodeUrl) { return generation == inlinePlaybackGeneration && TextUtils.equals(key, getKeyText()) @@ -2696,8 +2446,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void cancelPendingInlinePlayback() { + if (!inlinePlaybackLoading && !inlinePlayerSwitchLoading) return; inlinePlaybackGeneration++; - inlinePlaybackPending = false; + inlinePlaybackLoading = false; + inlinePlayerSwitchLoading = false; + hideInlineLoading(); updateInlineDisplayPanel(); } @@ -2720,48 +2473,19 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. TmdbEpisodeSorter.sort(vod); clearEpisodeRenderCaches(); applyTmdbBundle(bundle); - if (bundle != null && isTmdbNetworkAllowed()) saveTmdbMatch(bundle.item()); + if (bundle != null) saveTmdbMatch(bundle.item()); enrichVod(); clearEpisodeRenderCaches(); initHistory(); if (history == null) return; bindPage(); - applySourceOnlyActionVisibility(); focusInlinePlayerPanel(); maybeAutoPlayInline(); - if (bundle != null) { - if (isTmdbNetworkAllowed()) loadTmdbMediaBlocks(bundle); - else bindTmdbSection(); - } + if (bundle != null) loadTmdbMediaBlocks(bundle); if (allowMatchDialog && shouldShowAutoTmdbMatchDialog(bundle)) showTmdbMatchDialog(searchItems); SpiderDebug.log("tmdb-detail-flow", "apply loaded cost=%dms mode=%d bundle=%s name=%s flags=%d", System.currentTimeMillis() - start, getDetailMode(), bundle != null, loadedVod.getName(), loadedVod.getFlags().size()); } - private void fallbackToOriginalDetail(Vod loadedVod, String reason) { - if (isFinishing() || isDestroyed()) return; - SpiderDebug.log("tmdb-detail-flow", "fallback direct reason=%s key=%s id=%s", reason, getKeyText(), getIdText()); - VideoActivity.startDirectResolved(this, loadedVod); - finish(); - } - - private boolean isTmdbNetworkAllowed() { - return activeRuntimeDecision == null || activeRuntimeDecision.networkAllowed(); - } - - private boolean isTmdbSourceOnly() { - return activeRuntimeDecision != null && activeRuntimeDecision.sourceOnly(); - } - - private void applySourceOnlyActionVisibility() { - if (!isTmdbSourceOnly()) return; - binding.rematch.setVisibility(View.GONE); - binding.rematchTop.setVisibility(View.GONE); - binding.rematchFusion.setVisibility(View.GONE); - binding.episodeTitle.setVisibility(View.GONE); - binding.episodeTitle.setClickable(false); - binding.episodeTitle.setFocusable(false); - } - private TmdbLoadResult loadTmdbResult() { TmdbBundle tmdbBundle = null; List searchItems = new ArrayList<>(); @@ -2826,7 +2550,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (selectedFlag == null || selectedFlag.getEpisodes() == null || selectedFlag.getEpisodes().isEmpty()) bindTmdbSection(); focusInlinePlayerPanel(); if (bundle != null) binding.getRoot().post(() -> loadTmdbMediaBlocks(bundle)); - updateFollowingState(); if (shouldShowAutoTmdbMatchDialog(bundle)) showTmdbMatchDialog(result == null ? List.of() : result.searchItems()); SpiderDebug.log("tmdb-detail-flow", "apply tmdb result cost=%dms mode=%d bundle=%s search=%d", System.currentTimeMillis() - start, getDetailMode(), bundle != null, result == null ? 0 : result.searchItems().size()); } @@ -2881,10 +2604,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void loadTmdbMediaBlocks(TmdbBundle bundle) { if (bundle == null || bundle.item() == null || bundle.detail() == null) return; - if (!isTmdbNetworkAllowed()) { - bindTmdbSection(); - return; - } int generation = loadGeneration; Vod currentVod = vod; tmdbMediaLoading = true; @@ -2900,38 +2619,24 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. List personalTmdb = new ArrayList<>(); List personalDouban = new ArrayList<>(); PersonalRecommendationService recommendationService = new PersonalRecommendationService(tmdbService, tmdbConfig); - if (bundle.cast().isEmpty()) { - try { - cast = tmdbService.cast(bundle.detail(), tmdbConfig); - } catch (Throwable ignored) { - } - } else { - cast = new ArrayList<>(bundle.cast()); + try { + cast = tmdbService.cast(bundle.detail(), tmdbConfig); + } catch (Throwable ignored) { } - if (bundle.creators().isEmpty()) { - try { - creators = tmdbService.creators(bundle.detail(), tmdbConfig); - } catch (Throwable ignored) { - } - } else { - creators = new ArrayList<>(bundle.creators()); + try { + creators = tmdbService.creators(bundle.detail(), tmdbConfig); + } catch (Throwable ignored) { } try { backdrops = tmdbService.backdrops(bundle.detail(), tmdbConfig); posters = tmdbService.posters(bundle.detail(), tmdbConfig); - backgroundSlides = bundle.photos().isEmpty() - ? tmdbService.photos(bundle.detail(), tmdbConfig, preferLandscapeBackground()) - : new ArrayList<>(bundle.photos()); + backgroundSlides = tmdbService.photos(bundle.detail(), tmdbConfig, preferLandscapeBackground()); } catch (Throwable ignored) { } try { - if (bundle.related().isEmpty()) { - List recommendations = tmdbService.recommendations(bundle.detail(), tmdbConfig); - List similar = tmdbService.similar(bundle.detail(), tmdbConfig); - related = TmdbRecommendationRows.rankedRelated(bundle.detail(), recommendations, similar); - } else { - related = new ArrayList<>(bundle.related()); - } + List recommendations = tmdbService.recommendations(bundle.detail(), tmdbConfig); + List similar = tmdbService.similar(bundle.detail(), tmdbConfig); + related = TmdbRecommendationRows.rankedRelated(bundle.detail(), recommendations, similar); } catch (Throwable ignored) { } try { @@ -3057,10 +2762,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void applyTmdbBundle(TmdbBundle bundle) { - if (bundle == null || !isSameTmdbItem(bundle.item(), activeSourceItem)) { - activeSourcePayload = null; - activeSourceItem = null; - } activeTmdbBundle = bundle; matchedTmdbItem = bundle == null ? null : bundle.item(); matchedTmdbDetail = bundle == null ? null : bundle.detail(); @@ -3140,7 +2841,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void showManualTmdbSeasonDialog() { - if (!isTmdbNetworkAllowed()) return; if (matchedTmdbItem == null || !matchedTmdbItem.isTv() || seasonNumbers.isEmpty()) { Notify.show(R.string.detail_tmdb_empty); return; @@ -3333,10 +3033,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void loadTmdbSeasonBinding() { - if (!isTmdbNetworkAllowed()) { - tmdbSeasonBinding = null; - return; - } synchronized (Setting.class) { tmdbSeasonBinding = null; if (matchedTmdbItem == null || vod == null) return; @@ -3438,7 +3134,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void saveTmdbSeasonBinding(Integer seasonNumber, TmdbSeasonMatchCache.Mode mode) { - if (!isTmdbNetworkAllowed()) return; if (matchedTmdbItem == null || vod == null) return; String sourceTitle = !TextUtils.isEmpty(sourceVodName) ? sourceVodName : vod.getName(); int sourceEpisodeCount = selectedFlag == null || selectedFlag.getEpisodes() == null ? 0 : selectedFlag.getEpisodes().size(); @@ -3457,7 +3152,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void clearTmdbSeasonBinding() { - if (!isTmdbNetworkAllowed()) return; if (matchedTmdbItem == null || vod == null) return; String sourceTitle = !TextUtils.isEmpty(sourceVodName) ? sourceVodName : vod.getName(); synchronized (Setting.class) { @@ -3502,7 +3196,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void showManualTmdbMatchDialog() { - if (!isTmdbNetworkAllowed()) return; if (!tmdbConfig.isReady()) { Notify.show(getString(R.string.detail_tmdb_need_key)); return; @@ -3561,7 +3254,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean canMatchTmdb() { - return isTmdbNetworkAllowed() && tmdbConfig != null && tmdbConfig.isReady() && isTmdbAllowedForCurrentSite(); + return tmdbConfig != null && tmdbConfig.isReady() && isTmdbAllowedForCurrentSite(); } private boolean isTmdbAllowedForCurrentSite() { @@ -3899,7 +3592,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. loadRelatedVideosForCurrentContext(); bindTmdbSection(); updateKeepState(); - updateFollowingState(); requestDetailPlayFocus(); } @@ -4277,10 +3969,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. String tmdb = tmdbRatingValue(); if (!TextUtils.isEmpty(tmdb)) addRatingChip(key, "TMDB", tmdb + "/10", 0xFF21D07A); addBoxOfficeChip(key); - if (!isTmdbSourceOnly()) { - fetchDoubanRating(key); - fetchOmdbRating(key); - } + fetchDoubanRating(key); + fetchOmdbRating(key); bindExternalLinks(); } @@ -4455,7 +4145,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void fetchDoubanRating(String key) { - if (isTmdbSourceOnly()) return; String title = ratingTitle(); if (TextUtils.isEmpty(title)) return; String mediaType = matchedTmdbItem == null ? "" : matchedTmdbItem.getMediaType(); @@ -4472,7 +4161,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void fetchOmdbRating(String key) { - if (isTmdbSourceOnly()) return; String imdb = string(object(matchedTmdbDetail, "external_ids"), "imdb_id"); String omdbApiKey = tmdbConfig == null ? "" : tmdbConfig.getOmdbApiKey(); if (TextUtils.isEmpty(imdb) || TextUtils.isEmpty(omdbApiKey)) return; @@ -4886,10 +4574,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (rv.getVisibility() != View.VISIBLE) return; RecyclerView.Adapter adapter = rv.getAdapter(); if (adapter == null || adapter.getItemCount() == 0) return; - // 主选集卡片仍可见时,未消费的刷新也会使其 adapter position 持续为 NO_POSITION。 - // 其它 TMDB 列表保持原有的无子 View 恢复条件。 - boolean pendingEpisodeLayout = binding != null && rv == binding.episodeContainer && rv.hasPendingAdapterUpdates(); - if (rv.getChildCount() > 0 && !pendingEpisodeLayout) return; + if (rv.getChildCount() > 0) return; // 已有子 View,未处于错位态 if (rv.isComputingLayout()) { rv.post(() -> recoverRecyclerViewIfDetached(rv)); return; @@ -5083,12 +4768,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. clearDetailEpisodeFocusRowIfNeeded(view); return; } + if (!episodeGridMode) return; int position = binding.episodeContainer.getChildAdapterPosition(view); if (position == RecyclerView.NO_POSITION) return; - if (!episodeGridMode) { - alignDetailEpisodeFocusedRow(view, position); - return; - } int rowStart = detailEpisodeRowStart(position); boolean sameFocusedRow = rowStart == lastDetailEpisodeFocusRowStart; lastDetailEpisodeFocusRowStart = rowStart; @@ -5394,7 +5076,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (visibleHolder != null) { binding.episodeContainer.stopScroll(); visibleHolder.itemView.requestFocus(); - if (episodeGridMode) alignDetailEpisodeFocusedRow(visibleHolder.itemView, target); + alignDetailEpisodeFocusedRow(visibleHolder.itemView, target); return true; } binding.episodeContainer.post(() -> { @@ -5407,7 +5089,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } holder.itemView.requestFocus(); - if (episodeGridMode) alignDetailEpisodeFocusedRow(holder.itemView, target); + alignDetailEpisodeFocusedRow(holder.itemView, target); }, 80); }); return true; @@ -5428,15 +5110,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void alignDetailEpisodeFocusedRow(View focusedView, int position) { if (binding == null || focusedView == null) return; RecyclerView.LayoutManager layoutManager = binding.episodeContainer.getLayoutManager(); - if (layoutManager instanceof LinearLayoutManager linearLayoutManager - && linearLayoutManager.getOrientation() == LinearLayoutManager.HORIZONTAL) { - focusedView.post(() -> { - if (binding == null || episodeGridMode || getCurrentFocus() != focusedView) return; - if (binding.episodeContainer.getChildAdapterPosition(focusedView) != position) return; - alignDetailEpisodeFocusedCardHorizontallyNow(focusedView); - }); - return; - } if (!(layoutManager instanceof GridLayoutManager)) return; focusedView.post(() -> { if (binding == null || getCurrentFocus() != focusedView) return; @@ -5445,17 +5118,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. }); } - private void alignDetailEpisodeFocusedCardHorizontallyNow(View focusedView) { - if (binding == null || binding.episodeContainer.getWidth() <= 0) return; - int contentLeft = binding.episodeContainer.getPaddingLeft(); - int contentRight = binding.episodeContainer.getWidth() - binding.episodeContainer.getPaddingRight(); - int viewportCenter = contentLeft + (contentRight - contentLeft) / 2; - int cardCenter = focusedView.getLeft() + focusedView.getWidth() / 2; - int delta = cardCenter - viewportCenter; - if (Math.abs(delta) <= ResUtil.dp2px(2)) return; - binding.episodeContainer.smoothScrollBy(delta, 0); - } - private void alignDetailEpisodeFocusedCardNow(View focusedView) { if (binding == null || binding.scroll.getHeight() <= 0) return; Rect rect = new Rect(); @@ -5751,7 +5413,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void fetchSeasonIfNeeded(int seasonNumber, boolean refresh) { if (seasonNumber < 0 || (!refresh && tmdbSeasonEpisodes.containsKey(seasonNumber)) || loadingSeasons.contains(seasonNumber) || matchedTmdbItem == null || !"tv".equalsIgnoreCase(matchedTmdbItem.getMediaType()) || !canMatchTmdb()) return; - if (!refresh && hasCompleteSourceSeason(seasonNumber)) return; int generation = loadGeneration; TmdbItem item = matchedTmdbItem; JsonObject detail = matchedTmdbDetail; @@ -5794,7 +5455,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void refreshFirstSeasonIfStaleSplit(List sourceEpisodes) { if (sourceEpisodes == null || sourceEpisodes.isEmpty() || hasExplicitSeasonNumbers(sourceEpisodes)) return; int firstSeason = firstSeasonNumber(matchedTmdbDetail); - if (firstSeason < 0 || refreshedSingleSeasonProbes.contains(firstSeason) || loadingSeasons.contains(firstSeason) || hasCompleteSourceSeason(firstSeason)) return; + if (firstSeason < 0 || refreshedSingleSeasonProbes.contains(firstSeason) || loadingSeasons.contains(firstSeason)) return; int expectedCount = Math.max(0, seasonEpisodeCounts.getOrDefault(firstSeason, 0)); List cachedEpisodes = tmdbSeasonEpisodes.get(firstSeason); int cachedCount = cachedEpisodes == null ? 0 : cachedEpisodes.size(); @@ -6072,7 +5733,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void loadRelatedVideosForCurrentContext() { - if (matchedTmdbItem == null || tmdbConfig == null || !isTmdbAllowedForCurrentSite()) return; + if (matchedTmdbItem == null || tmdbConfig == null || !tmdbConfig.isReady() || !isTmdbAllowedForCurrentSite()) return; int seasonNumber = -1; int episodeNumber = -1; if (matchedTmdbItem.isTv()) { @@ -6081,39 +5742,20 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. episodeNumber = tmdbEpisode != null && tmdbEpisode.getNumber() > 0 ? tmdbEpisode.getNumber() : sourceEpisodeNumber(selectedEpisode); } String contextKey = matchedTmdbItem.getMediaType() + ":" + matchedTmdbItem.getTmdbId() + ":" + seasonNumber + ":" + episodeNumber + ":" + tmdbConfig.getLanguage(); - if (isTmdbSourceOnly()) { - relatedVideoGeneration++; - relatedVideoLoading = false; - relatedVideoContextKey = contextKey; - relatedVideoItems.clear(); - relatedVideoItems.addAll(TmdbSourceAdapter.videos( - matchedTmdbDetail, - matchedTmdbItem.getMediaType(), - seasonNumber, - episodeNumber, - tmdbConfig.getLanguage())); - bindTmdbSection(); - return; - } - if (!isTmdbNetworkAllowed() || !tmdbConfig.isReady()) return; if (contextKey.equals(relatedVideoContextKey)) return; int generation = loadGeneration; int videoGeneration = ++relatedVideoGeneration; TmdbItem item = matchedTmdbItem; - JsonObject sourceDetail = matchedTmdbDetail; final int requestSeasonNumber = seasonNumber; final int requestEpisodeNumber = episodeNumber; relatedVideoContextKey = contextKey; relatedVideoLoading = true; relatedVideoItems.clear(); bindTmdbSection(); - boolean sourceComplete = hasCompleteSourceVideos(item, requestSeasonNumber, requestEpisodeNumber); detailTasks.submit(() -> { List loaded = new ArrayList<>(); try { - loaded = sourceComplete - ? TmdbSourceAdapter.videos(sourceDetail, item.getMediaType(), requestSeasonNumber, requestEpisodeNumber, tmdbConfig.getLanguage()) - : tmdbService.relatedVideos(item, requestSeasonNumber, requestEpisodeNumber, tmdbConfig); + loaded = tmdbService.relatedVideos(item, requestSeasonNumber, requestEpisodeNumber, tmdbConfig); } catch (Throwable e) { SpiderDebug.log("tmdb-video", "standalone related failed media=%s id=%d season=%d episode=%d error=%s", item.getMediaType(), item.getTmdbId(), requestSeasonNumber, requestEpisodeNumber, e.getMessage()); } @@ -6144,58 +5786,56 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean hasPersonalDouban = !personalDoubanItems.isEmpty(); boolean hasPersonalAi = !personalAiItems.isEmpty(); boolean hasExternalLinks = binding.externalLinksContainer.getChildCount() > 0; - boolean tmdbSectionData = hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb || hasPersonalDouban || hasPersonalAi || hasExternalLinks; - binding.tmdbSection.setVisibility(tmdbSectionData || canMatchTmdb() || (!isTmdbSourceOnly() && matchedTmdbDetail != null) ? View.VISIBLE : View.GONE); + binding.tmdbSection.setVisibility(hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb || hasPersonalDouban || hasPersonalAi || hasExternalLinks || matchedTmdbDetail != null || canMatchTmdb() ? View.VISIBLE : View.GONE); binding.episodePhotoTitle.setVisibility(hasPhotos ? View.VISIBLE : View.GONE); binding.episodePhotoList.setVisibility(hasPhotos ? View.VISIBLE : View.GONE); episodePhotoAdapter.setItems(tmdbEpisodePhotos); episodePhotoAdapter.rebindAttached(binding.episodePhotoList); - int sectionGapDp = isPlayerMode() && !isCinemaMode() ? 12 : 20; - setTopMargin(binding.posterTitle, hasPhotos ? sectionGapDp : 0); + setTopMargin(binding.posterTitle, hasPhotos ? 20 : 0); binding.posterTitle.setVisibility(hasPosters ? View.VISIBLE : View.GONE); binding.posterList.setVisibility(hasPosters ? View.VISIBLE : View.GONE); posterAdapter.setItems(detailTmdbPosters); posterAdapter.rebindAttached(binding.posterList); - setTopMargin(binding.relatedVideoTitle, hasPhotos || hasPosters ? sectionGapDp : 0); + setTopMargin(binding.relatedVideoTitle, hasPhotos || hasPosters ? 20 : 0); binding.relatedVideoTitle.setVisibility(hasRelatedVideos ? View.VISIBLE : View.GONE); binding.relatedVideoList.setVisibility(hasRelatedVideos ? View.VISIBLE : View.GONE); relatedVideoAdapter.setItems(relatedVideoItems); relatedVideoAdapter.rebindAttached(binding.relatedVideoList); - setTopMargin(binding.castTitle, hasPhotos || hasPosters || hasRelatedVideos ? sectionGapDp : 0); + setTopMargin(binding.castTitle, hasPhotos || hasPosters || hasRelatedVideos ? 20 : 0); binding.castTitle.setVisibility(hasCast ? View.VISIBLE : View.GONE); binding.castList.setVisibility(hasCast ? View.VISIBLE : View.GONE); castAdapter.setItems(castItems); castAdapter.rebindAttached(binding.castList); - setTopMargin(binding.creatorTitle, hasPhotos || hasPosters || hasRelatedVideos || hasCast ? sectionGapDp : 0); + setTopMargin(binding.creatorTitle, hasPhotos || hasPosters || hasRelatedVideos || hasCast ? 20 : 0); binding.creatorTitle.setVisibility(hasCreators ? View.VISIBLE : View.GONE); binding.creatorList.setVisibility(hasCreators ? View.VISIBLE : View.GONE); creatorAdapter.setItems(creatorItems); creatorAdapter.rebindAttached(binding.creatorList); - setTopMargin(binding.relatedTitle, hasPhotos || hasPosters || hasRelatedVideos || hasCast || hasCreators ? sectionGapDp : 0); + setTopMargin(binding.relatedTitle, hasPhotos || hasPosters || hasRelatedVideos || hasCast || hasCreators ? 20 : 0); binding.relatedTitle.setVisibility(hasRelated ? View.VISIBLE : View.GONE); binding.relatedList.setVisibility(hasRelated ? View.VISIBLE : View.GONE); relatedAdapter.setItems(relatedItems); relatedAdapter.rebindAttached(binding.relatedList); - setTopMargin(binding.personalTmdbTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos ? sectionGapDp : 0); + setTopMargin(binding.personalTmdbTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos ? 20 : 0); binding.personalTmdbTitle.setVisibility(hasPersonalTmdb ? View.VISIBLE : View.GONE); binding.personalTmdbList.setVisibility(hasPersonalTmdb ? View.VISIBLE : View.GONE); personalTmdbAdapter.setItems(personalTmdbItems); personalTmdbAdapter.rebindAttached(binding.personalTmdbList); - setTopMargin(binding.personalDoubanTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb ? sectionGapDp : 0); + setTopMargin(binding.personalDoubanTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb ? 20 : 0); binding.personalDoubanTitle.setVisibility(hasPersonalDouban ? View.VISIBLE : View.GONE); binding.personalDoubanList.setVisibility(hasPersonalDouban ? View.VISIBLE : View.GONE); personalDoubanAdapter.setItems(personalDoubanItems); personalDoubanAdapter.rebindAttached(binding.personalDoubanList); - setTopMargin(binding.personalAiTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb || hasPersonalDouban ? sectionGapDp : 0); + setTopMargin(binding.personalAiTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb || hasPersonalDouban ? 20 : 0); binding.personalAiTitle.setVisibility(hasPersonalAi ? View.VISIBLE : View.GONE); binding.personalAiList.setVisibility(hasPersonalAi ? View.VISIBLE : View.GONE); personalAiAdapter.setItems(personalAiItems); @@ -6211,9 +5851,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.externalLinksTitle.setVisibility(hasExternalLinks ? View.VISIBLE : View.GONE); binding.externalLinksContainer.setVisibility(hasExternalLinks ? View.VISIBLE : View.GONE); - if (isTmdbSourceOnly()) { - binding.tmdbStatus.setVisibility(View.GONE); - } else if (!tmdbConfig.isReady()) { + if (!tmdbConfig.isReady()) { binding.tmdbStatus.setVisibility(View.VISIBLE); binding.tmdbStatus.setText(R.string.detail_tmdb_need_key); } else if (!isTmdbAllowedForCurrentSite()) { @@ -6313,7 +5951,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (enterInlineFullscreenIfCurrentInlinePlayback(selectedEpisode)) return; saveInlineHistory(); updateInlineHistory(selectedEpisode); - modeController.play(); + if (isFusionMode()) playInline(); + else if (isPlayerMode()) playDetailFullscreen(); + else playDefaultPlayback(); } private void playDefaultPlayback() { @@ -6365,23 +6005,15 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. view.setLayoutParams(marginParams); } - private void showTmdbEpisodeDetail(Episode episode, int episodeNumber, TmdbEpisode boundTmdbEpisode, RecyclerView returnRecycler) { + private void showTmdbEpisodeDetail(Episode episode, int episodeNumber, RecyclerView returnRecycler) { + // 对话框关闭后完整重渲染剧集列表,防止焦点状态紊乱导致按钮失效 android.content.DialogInterface.OnDismissListener dismissListener = d -> { - if (binding == null || returnRecycler == null) return; - returnRecycler.post(() -> { - if (binding == null || isFinishing() || isDestroyed() || !returnRecycler.isAttachedToWindow()) return; + if (binding == null || binding.episodeContainer == null || returnRecycler == null) return; + // RecyclerView 可能仍在恢复布局,下一帧再完整重渲染(参考原生增强的 render 机制) + binding.episodeContainer.post(() -> { + // 完整重建列表 + 分组按钮(类似原生增强 render[0].run()) rerenderEpisodeViewportOnly(false, true, true); - if (returnRecycler != binding.episodeContainer) { - // 独立选集面板保留原有恢复路径。 - returnRecycler.post(() -> restoreEpisodeDetailFocus(returnRecycler, episode)); - return; - } - // post/postOnAnimation 不保证刷新已布局;在真实布局后的 pre-draw 恢复精确卡片。 - OneShotPreDrawListener.add(returnRecycler, () -> { - if (binding == null || isFinishing() || isDestroyed() || !returnRecycler.isShown()) return; - restoreEpisodeDetailFocus(returnRecycler, episode); - }); - recoverEpisodeViewportIfDetached(); + returnRecycler.post(() -> restoreEpisodeDetailFocus(returnRecycler, episode)); }); }; @@ -6391,27 +6023,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, null, null, null, dismissListener); return; } - // 剧集场景:详情请求优先使用长按卡片绑定且已通过匹配校验的 TMDB 集。 - // 手动选择季度时,同一线路可能是扁平集列表,卡片绑定对象才是可靠的请求上下文。 - List detailEpisodes = selectedFlag == null ? null : selectedFlag.getEpisodes(); - int detailSeasonNumber = tmdbEpisodeDataSeason(detailEpisodes); - int detailEpisodeNumber = episodeNumber; - if (boundTmdbEpisode != null) { - if (boundTmdbEpisode.getSeasonNumber() >= 0) detailSeasonNumber = boundTmdbEpisode.getSeasonNumber(); - if (boundTmdbEpisode.getNumber() > 0) detailEpisodeNumber = boundTmdbEpisode.getNumber(); - } - // 卡片没有有效 TMDB 映射时也必须有反馈;EpisodeDetailDialog 会展示源集名称, - // 不能只弹 Notify 后结束,否则手动选季下超范围/不匹配的卡片看起来像长按失效。 - if (boundTmdbEpisode == null) { - com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, getSite(), null, null, dismissListener); - return; - } - if (matchedTmdbItem == null || !"tv".equalsIgnoreCase(matchedTmdbItem.getMediaType()) || detailSeasonNumber < 0 || detailEpisodeNumber <= 0 || !canMatchTmdb()) { - com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, boundTmdbEpisode, getSite(), null, null, dismissListener); - return; - } - if (hasCompleteSourceEpisode(detailSeasonNumber, detailEpisodeNumber)) { - com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, boundTmdbEpisode, getSite(), null, null, dismissListener); + // 剧集场景:原有逻辑 + int detailSeasonNumber = tmdbEpisodeDataSeason(selectedFlag == null ? null : selectedFlag.getEpisodes()); + if (matchedTmdbItem == null || !"tv".equalsIgnoreCase(matchedTmdbItem.getMediaType()) || detailSeasonNumber < 0 || episodeNumber <= 0 || !canMatchTmdb()) { + Notify.show(R.string.detail_tmdb_empty); return; } binding.loading.setVisibility(View.VISIBLE); @@ -6419,13 +6034,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. int detailGeneration = ++tmdbEpisodeDetailGeneration; int displaySeasonNumber = detailSeasonNumber; int seasonNumber = detailSeasonNumber; - int requestEpisodeNumber = detailEpisodeNumber; TmdbItem item = matchedTmdbItem; JsonObject baseDetail = matchedTmdbDetail; TmdbConfig config = tmdbConfig; detailTasks.submit(Task.largeExecutor(), () -> { try { - JsonObject detail = tmdbService.episode(item, seasonNumber, requestEpisodeNumber, config, baseDetail); + JsonObject detail = tmdbService.episode(item, seasonNumber, episodeNumber, config, baseDetail); List photos = tmdbService.episodePhotos(detail, config); List guests = tmdbService.episodeGuests(detail, config); runOnAliveUi(() -> { @@ -6435,9 +6049,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } binding.loading.setVisibility(View.GONE); - if (!isTmdbEpisodeDetailSeasonCurrent(displaySeasonNumber)) return; + if (displaySeasonNumber != selectedSeasonNumber) return; // 复用 EpisodeDetailDialog,传入已拉取的 photos/guests 避免重复 API 请求 - com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, boundTmdbEpisode, getSite(), photos, guests, dismissListener); + com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, getSite(), photos, guests, dismissListener); }); } catch (Throwable e) { runOnAliveUi(() -> { @@ -6447,21 +6061,13 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } binding.loading.setVisibility(View.GONE); - if (!isTmdbEpisodeDetailSeasonCurrent(displaySeasonNumber)) return; - // API 失败也保留详情弹窗,至少让用户看到源集名称,而不是把长按吞掉。 - com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, boundTmdbEpisode, getSite(), null, null, dismissListener); + if (displaySeasonNumber != selectedSeasonNumber) return; + Notify.show(TextUtils.isEmpty(e.getMessage()) ? getString(R.string.detail_tmdb_empty) : e.getMessage()); }); } }); } - private boolean isTmdbEpisodeDetailSeasonCurrent(int seasonNumber) { - if (seasonNumber < 0) return false; - if (seasonNumber == selectedSeasonNumber) return true; - List episodes = selectedFlag == null ? null : selectedFlag.getEpisodes(); - return seasonNumber == tmdbEpisodeDataSeason(episodes); - } - private void restoreEpisodeDetailFocus(RecyclerView recycler, Episode episode) { if (recycler == null || episode == null) return; RecyclerView.Adapter adapter = recycler.getAdapter(); @@ -7229,9 +6835,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private int getDetailMode() { // 返回原始模式,不做 normalize,否则 isPlayerMode() 永远返回 false if (getIntent().hasExtra("detail_mode")) return getIntent().getIntExtra("detail_mode", Setting.DETAIL_OPEN_ENHANCED); - // 详情直放没有内嵌播放界面;若既无 detail_mode 也无 fusion 标记,只能按当前设置还原, - // 不能把无标记默认成炫彩详情,否则点击播放会误走融合内嵌播放。 - return Setting.getDetailOpenMode(); + return getIntent().getBooleanExtra("fusion", false) ? Setting.DETAIL_OPEN_FUSION : Setting.DETAIL_OPEN_ENHANCED; } private int detailModeTitle() { @@ -7278,7 +6882,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void maybeAutoPlayInline() { - if (!modeController.shouldAutoPlay() || autoPlayed) return; + if ((!isFusionMode() && !isAutoPlayMode()) || autoPlayed) return; autoPlayed = true; binding.playerPanel.post(this::onPlay); } @@ -7293,16 +6897,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. ensureInlineDanmakuController(); binding.playerPanel.setVisibility(View.VISIBLE); binding.playerPanelSpacer.setVisibility(View.VISIBLE); // spacer 作为焦点桥梁需要可见 - detailPlayerFullscreenPending = false; enterInlineFullscreen(); if (!current) playInline(); } - private void revealDetailPlayerFullscreen() { - detailPlayerFullscreenPending = false; - if (!inlineFullscreen) enterInlineFullscreen(); - } - private void playInline() { inlineHttpRefreshAttempted = false; playInline(C.TIME_UNSET, "", ""); @@ -7316,22 +6914,24 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void playInline(long resumePosition, String failedUrl, String failureMessage) { if (selectedFlag == null || selectedEpisode == null) return; - inlinePlaybackPending = true; - updateInlineLoading(); + inlinePlaybackLoading = true; int generation = ++inlinePlaybackGeneration; + inlinePlayerSwitchLoading = false; String key = getKeyText(); String flag = selectedFlag.getFlag(); String episodeUrl = selectedEpisode.getUrl(); int playerKernel = inlineHistoryPlayerKernel(); + stopInlinePlayerForReload(); + showInlineLoading(); updateInlineDisplayPanel(); detailTasks.submit(() -> { try { Result result = SiteApi.playerContent(key, flag, episodeUrl, playerKernel); runOnAliveUi(() -> { if (!isInlinePlaybackRequestCurrent(generation, key, flag, episodeUrl)) return; - inlinePlaybackPending = false; String resolvedUrl = result.getUrl() == null ? "" : result.getUrl().v(); if (!TextUtils.isEmpty(failedUrl) && TextUtils.equals(failedUrl, resolvedUrl)) { + inlinePlaybackLoading = false; SpiderDebug.log("webhome-inline", "http refresh rejected unchanged url key=%s id=%s", key, episodeUrl); showInlineError(failureMessage); return; @@ -7342,7 +6942,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. String message = e.getMessage(); runOnAliveUi(() -> { if (!isInlinePlaybackRequestCurrent(generation, key, flag, episodeUrl)) return; - inlinePlaybackPending = false; String fallback = TextUtils.isEmpty(failureMessage) ? getString(R.string.error_play_url) : failureMessage; showInlineError(TextUtils.isEmpty(message) ? fallback : message); }); @@ -7351,7 +6950,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void stopInlinePlayerForReload() { - mAdFeedbackGeneration++; + // 换源换集:作废在途归因,避免用上一集的证据弹窗并写错规则 + resetAdFeedback(); subtitlePlaybackSession.stop(this); inlineStartPosition = C.TIME_UNSET; inlineStartPositionApplied = false; @@ -7375,7 +6975,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void startInlinePlayer(Result result, long resumePosition) { - inlinePlaybackPending = false; // 决定性拦截:play_url 协议为 novel:// / pics:// / manga:// → 直启阅读器,不再喂给内联 player // (选集 / quality 切换 / 重连 / resume 全走此汇聚点。) if (NovelRouter.guardInlinePlay(this, result, @@ -7400,10 +6999,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. pendingInlineResult = result; return; } + inlinePlaybackLoading = false; inlineStarted = true; - inlinePlayHealthKey = getKeyText(); - SiteHealthStore.recordPlayAttempt(inlinePlayHealthKey); - inlinePlayHealthRecorded = false; inlineFirstReady = false; // 重置标志,允许新播放首次 READY 时显示控制栏 inlineButtonsReordered = false; // 重置标志,允许新播放重新排序按钮 inlinePlaybackEpisode = selectedEpisode; @@ -7423,11 +7020,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. updateInlineButtons(false); Site site = getCurrentSite(); ensureInlineDanmakuController(); - if (SubtitleRestoreCoordinator.restore(history, player(), result)) persistHistorySubtitleSource(); startPlayer(getHistoryKey(), result, useParse, site == null ? 0 : site.getTimeout(), buildMetadata()); updateNavigationKey(); subtitlePlaybackSession.onPlaybackStarted(this, result); - modeController.onPlaybackStarted(); searchInlineDanmaku(result); binding.playerPanel.requestFocus(); } @@ -7440,8 +7035,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. .rawTitle(playbackHistoryName()) .rawRemarks(history.getVodRemarks()) .episodeName(historyEpisodeTitle(selectedEpisode)) - .tmdbId(danmakuTmdbId()) - .tmdbSeasonNumber(danmakuTmdbSeasonNumber()) .source(MediaTitleLearningExample.SOURCE_DANMAKU_AUTO) .allowAi(true) .build(), danmaku -> applyInlineDanmaku(result, danmaku)); @@ -7454,34 +7047,44 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. refreshInlineDanmakuButtons(); } - private int danmakuTmdbId() { - TmdbItem item = matchedTmdbItem; - return item == null ? 0 : item.getTmdbId(); - } - - private int danmakuTmdbSeasonNumber() { - TmdbItem item = matchedTmdbItem; - if (item == null || !item.isTv()) return 0; - TmdbEpisode tmdbEpisode = selectedEpisode == null ? null : selectedEpisode.getTmdbEpisode(); - return tmdbEpisode == null ? 0 : tmdbEpisode.getSeasonNumber(); - } - private void refreshInlineDanmakuButtons() { if (!isInlinePlayerMode() || service() == null || player() == null || player().isEmpty()) return; updateInlineButtons(player().isPlaying()); } private void showInlineError(String text) { + inlinePlaybackLoading = false; + hideInlineLoading(); binding.playerError.setText(text); binding.playerError.setVisibility(View.VISIBLE); updateInlineDisplayPanel(); } + private void showInlineLoading() { + binding.playerError.setVisibility(View.GONE); + binding.playerProgress.setVisibility(View.VISIBLE); + tintInlineLoading(); + updateInlineLoadingTraffic(); + hideInlineControls(); + } + + private void hideInlineLoading() { + binding.playerProgress.setVisibility(View.GONE); + binding.playerProgressTraffic.setVisibility(View.GONE); + Traffic.reset(binding.playerProgressTraffic); + } + + private void updateInlineLoadingTraffic() { + if (binding == null || binding.playerProgress.getVisibility() != View.VISIBLE) return; + Traffic.setSpeed(binding.playerProgressTraffic, service() == null ? null : player()); + tintInlineLoading(); + } + private void toggleInlinePlayback() { if (!isInlinePlayerMode()) return; if (inlinePlaybackReconnectPending) return; - if (isSamePendingInlinePlayback(selectedEpisode)) return; if (controller() == null || service() == null || player().isEmpty()) { + if (isSamePendingInlinePlayback(selectedEpisode)) return; onPlay(); return; } @@ -7639,11 +7242,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void updateInlineButtons(boolean playing) { if (!isInlinePlayerMode() || inlineControlController == null) { setInlineDecodeText(getString(R.string.play_decode_idle)); - updateInlineCustomButtonVisibility(); return; } boolean hasPlayer = service() != null && !player().isEmpty(); - updateInlineCustomButtonVisibility(); setInlineSpeedText(service() == null || player().isEmpty() ? getString(R.string.play_speed) : player().getSpeedText()); setInlineDecodeText(inlineDecodeText(hasPlayer)); binding.playerExternal.setText(service() == null ? getString(R.string.play_exo) : player().getPlayerText()); @@ -7702,7 +7303,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. setInlineFullscreenIcon(); updateMobileInlineButtons(playing, hasPlayer, episodeCount, hasTitle); applyInlinePlayerButtonSettings(); - setupInlineCustomButtonFocus(); updateInlineDisplayPanel(); // 更新按钮颜色 updateInlineButtonColors(); @@ -8044,27 +7644,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. // 已废弃:统一使用 PlayerOsdController(OSD)系统 // 原逻辑已迁移到 PlayerOsdController.render() // 保留此方法避免删除所有调用点时遗漏 - updateInlineLoading(); - } - - private void updateInlineLoading() { - boolean loading = inlinePlaybackPending || (player() != null && !isPaused() && !player().isPlaying() && player().isLoading()); - binding.playerLoading.removeCallbacks(inlineLoadingSpeedRefresh); - binding.playerLoading.setVisibility(loading ? View.VISIBLE : View.GONE); - if (loading && inlinePauseInfo) hideInlinePauseInfo(); - if (!loading) { - binding.playerLoadingTraffic.setText(""); - binding.playerLoadingTraffic.setVisibility(View.GONE); - return; - } - updateInlineLoadingSpeed(); - binding.playerLoading.postDelayed(inlineLoadingSpeedRefresh, 1000L); - } - - private void updateInlineLoadingSpeed() { - String traffic = inlineOsd == null ? "" : inlineOsd.sampleSpeedText(); - binding.playerLoadingTraffic.setText(traffic); - binding.playerLoadingTraffic.setVisibility(TextUtils.isEmpty(traffic) ? View.GONE : View.VISIBLE); } private void setButtonEnabled(View button, boolean enabled) { @@ -8263,6 +7842,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } saveInlineHistory(); + // 换画质同集换 URL:切片结构与在途归因都属于上一画质。 + // 放在这里而不是 startInlinePlayer,因为后者也承担断连恢复, + // 那条路径不该关掉用户正在看的反馈对话框。 + resetAdFeedback(); currentInlineResult.getUrl().set(position); updateInlineButtons(service() != null && player() != null && !player().isEmpty() && player().isPlaying()); startInlinePlayer(currentInlineResult); @@ -8321,11 +7904,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void changeInlineSpeed() { if (service() == null || player().isEmpty()) return; - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || player().isEmpty()) return; - setInlineSpeed(speed); - if (history != null) history.setUserSpeed(player().getSpeed()); - }); + setInlineSpeedText(player().addSpeed()); + if (history != null) history.setUserSpeed(player().getSpeed()); } private void setInlineSpeed(float speed) { @@ -8693,6 +8273,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. String episodeUrl = selectedEpisode.getUrl(); if (TextUtils.isEmpty(flag) || TextUtils.isEmpty(episodeUrl)) return false; int generation = ++inlinePlaybackGeneration; + inlinePlayerSwitchLoading = true; + showInlineLoading(); long position = player().getPosition(); float speed = player().getSpeed(); boolean repeat = player().isRepeatOne(); @@ -8704,13 +8286,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. runOnAliveUi(() -> { if (!isInlinePlayerSwitchRequestCurrent(generation, key, flag, episodeUrl)) return; if (result == null || result.hasMsg() || result.getRealUrl().isEmpty()) { - updateInlineDisplayPanel(); + finishInlinePlayerSwitchRequest(); Notify.show(result != null && result.hasMsg() ? result.getMsg() : getString(R.string.error_play_url)); } else { + // 换内核重新解析出可能不同的 URL,与换画质同理 + resetAdFeedback(); currentInlineResult = result; inlineHttpRefreshAttempted = false; useParse = result.shouldUseParse(); - player().switchPlayer(playerType, result, activePlaybackKey(), metadata, useParse, position, speed, repeat); + inlinePlayerSwitchLoading = false; + player().switchPlayer(playerType, result, activePlaybackKey(), metadata, useParse, position, speed, repeat); rememberInlinePlayerKernel(playerType); } finishInlinePlayerSwitch(); @@ -8718,7 +8303,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } catch (Throwable e) { runOnAliveUi(() -> { if (!isInlinePlayerSwitchRequestCurrent(generation, key, flag, episodeUrl)) return; - updateInlineDisplayPanel(); + finishInlinePlayerSwitchRequest(); finishInlinePlayerSwitch(); Notify.show(TextUtils.isEmpty(e.getMessage()) ? getString(R.string.error_play_url) : e.getMessage()); }); @@ -8728,12 +8313,20 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void cancelPendingInlinePlayerSwitch() { + if (!inlinePlayerSwitchLoading) return; inlinePlaybackGeneration++; + finishInlinePlayerSwitchRequest(); + } + + private void finishInlinePlayerSwitchRequest() { + inlinePlayerSwitchLoading = false; + hideInlineLoading(); updateInlineDisplayPanel(); } private boolean isInlinePlayerSwitchRequestCurrent(int generation, String key, String flag, String episodeUrl) { - return inlineStarted + return inlinePlayerSwitchLoading + && inlineStarted && isInlinePlayerMode() && isOwner() && service() != null @@ -8766,7 +8359,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } if (!isInlineAdFeedbackEnabled()) { - Notify.show(R.string.ad_feedback_ai_disabled); + Notify.show(R.string.ad_interval_invalid); return; } hideInlineControls(); @@ -8774,82 +8367,182 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean isInlineAdFeedbackEnabled() { - return Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection() - && isInlineAdFeedbackSupportedFormat(); + // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。 + // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。 + if (player() == null || TextUtils.isEmpty(player().getUrl())) return false; + return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0; } - private boolean isInlineAdFeedbackSupportedFormat() { - return player() != null && !TextUtils.isEmpty(player().getUrl()) - && PlaybackResourceClassifier.isHlsUrl(player().getUrl()); + private AdFeedbackController adFeedback() { + if (mAdFeedback == null) { + mAdFeedbackHost = new AdFeedbackHostAdapter( + new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi()); + mAdFeedback = new AdFeedbackController(mAdFeedbackHost); + } + return mAdFeedback; } + /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */ + private void resetAdFeedback() { + mAdMarkStartMs = -1; + if (mAdFeedback != null) mAdFeedback.invalidate(); + if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence(); + // 作废后归因回调不再刷新界面,对话框会永久停在「分析中」 + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); + mAdFeedbackDialog = null; + } + + /** + * 起播后记录本站域名,供域名信誉通道建立基线。 + * + *

必须强制初始化适配器:它原本只在用户首次按下「有广告」时懒创建, + * 若这里跳过未初始化的情况,首次反馈时基线仍为空,域名通道会因 + * 「无基线不断言」而弃权 —— 整条通道等于没接。 + */ + private void recordAdFeedbackHost() { + // 关掉去广告或播直播时不建基线:这些域名会挤掉 LRU 里真正有用的条目 + if (!isInlineAdFeedbackEnabled()) return; + adFeedback(); + mAdFeedbackHost.recordPlaybackHost(); + } + + /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */ private void submitInlineAdFeedback() { - AdDetectionRequest request = buildInlineAdDetectionRequest(); - if (request == null) { - Notify.show(R.string.ad_feedback_no_url); + AdFeedbackController controller = adFeedback(); + AdFeedbackSession session; + if (mAdMarkStartMs >= 0) { + // 先取位置再清标记:播放器已释放时保留标记,用户可重试而不必重新打点 + long end = mAdFeedbackHost.safePositionMs(); + if (end < 0) { + Notify.show(R.string.ad_interval_invalid); + return; + } + long start = mAdMarkStartMs; + mAdMarkStartMs = -1; + session = controller.onMarkedInterval(start, end); + } else { + session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence()); + } + if (session == null) { + Notify.show(R.string.ad_interval_invalid); return; } - AdBlockStatsStore.recordFeedback(request.getSiteKey()); - Notify.show(R.string.ad_feedback_analyzing); - int generation = ++mAdFeedbackGeneration; - AiConfig config = AiConfig.objectFrom(Setting.getAiConfig()); - detailTasks.submit(Task.recommendationExecutor(), () -> { - enrichInlineAdDetectionRequest(request); - AdDetectionResult result = new AiAdDetectionService(config).analyze(request); - runOnAliveUi(() -> { - if (generation != mAdFeedbackGeneration) return; - onInlineAdDetectionResult(request, result); - }); - }); + // 统计放在成交之后,未成立的反馈不计入 + AdBlockStatsStore.recordFeedback(getKeyText()); } - private AdDetectionRequest buildInlineAdDetectionRequest() { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return null; - Uri uri = Uri.parse(player().getUrl()); - AdDetectionRequest request = new AdDetectionRequest(); + /** 长按:打起点进入标记模式,再次长按取消。 */ + private boolean onInlineAdFeedbackLongPress() { + if (!isInlineAdFeedbackEnabled()) return false; + if (mAdMarkStartMs >= 0) { + mAdMarkStartMs = -1; + Notify.show(R.string.ad_interval_mark_cancelled); + } else { + mAdMarkStartMs = player().getPosition(); + Notify.show(R.string.ad_interval_mark_start); + } + return true; + } + + private void showAdFeedbackSession(AdFeedbackSession session) { + if (isFinishing() || isDestroyed()) return; + if (mAdFeedbackDialog == null) mAdFeedbackDialog = new AdFeedbackDialog(this, this::applyAdRulePlan); + mAdFeedbackDialog.show(session); + } + + private void applyAdRulePlan(com.fongmi.android.tv.ad.feedback.AdAttribution plan) { Site site = getCurrentSite(); - History currentHistory = getHistory(); - request.setSiteKey(site == null ? getKeyText() : site.getKey()); - request.setSiteName(site == null ? "" : site.getName()); - request.setVodName(currentHistory == null ? getNameText() : currentHistory.getVodName()); - request.setFlagName(selectedFlag == null ? "" : selectedFlag.getFlag()); - request.setEpisodeName(selectedEpisode == null ? "" : selectedEpisode.getName()); - request.setUrlHost(uri.getHost()); - request.setUrlPath(uri.getPath()); - return request; - } - - private void enrichInlineAdDetectionRequest(AdDetectionRequest request) { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return; - String url = player().getUrl(); - if (!url.contains(".m3u8")) return; - try { - request.setEvidence(com.fongmi.android.tv.utils.M3u8Parser.parse(url, player().getHeaders())); - } catch (Exception ignored) { - // Ignore parsing failures. - } - } - - private void onInlineAdDetectionResult(AdDetectionRequest request, AdDetectionResult result) { - AdBlockStatsStore.recordAiAnalysis(result != null && !result.isError()); - if (result == null || result.isError()) { - Notify.show(result == null ? getString(R.string.ad_feedback_failed) : result.getErrorMessage()); - return; - } - if (result.isEmpty()) { - Notify.show(R.string.ad_feedback_no_ad); - return; - } - AdRulePreviewDialog.create(result).show(this, confirmedResult -> { - UserAdRule rule = UserAdRule.fromAiResult(confirmedResult, request.getSiteKey()); - UserAdRuleStore.add(rule); - Notify.show(R.string.ad_feedback_saved); + AdRulePlanApplier.Outcome outcome = AdRulePlanApplier.apply( + plan, site == null ? getKeyText() : site.getKey()); + Notify.show(switch (outcome) { + case APPLIED -> R.string.ad_interval_rule_saved; + case SKIPPED -> R.string.ad_interval_rule_skipped; + case FAILED -> R.string.ad_interval_rule_failed; }); } + private final class AdFeedbackPlayback implements AdFeedbackHostAdapter.Playback { + @Override + public long positionMs() { + return player() == null ? 0 : player().getPosition(); + } + + @Override + public long durationMs() { + return player() == null ? 0 : player().getDuration(); + } + + @Override + public String playUrl() { + return player() == null ? "" : player().getUrl(); + } + + @Override + public java.util.Map headers() { + return player() == null ? java.util.Map.of() : player().getHeaders(); + } + + @Override + public boolean hls() { + return player() != null && MediaSourceFactory.isHlsUrl(player().getUrl()); + } + + @Override + public boolean skipInterval(long startMs, long endMs, String feedbackId) { + return player() != null && player().skipUserAdInterval(startMs, endMs, feedbackId); + } + } + + private final class AdFeedbackMetadata implements AdFeedbackHostAdapter.Metadata { + @Override + public String siteKey() { + Site site = getCurrentSite(); + return site == null ? getKeyText() : site.getKey(); + } + + @Override + public String siteName() { + Site site = getCurrentSite(); + return site == null ? "" : site.getName(); + } + + @Override + public String vodName() { + History currentHistory = getHistory(); + return currentHistory == null ? getNameText() : currentHistory.getVodName(); + } + + @Override + public String flagName() { + return selectedFlag == null ? "" : selectedFlag.getFlag(); + } + + @Override + public String episodeName() { + return selectedEpisode == null ? "" : selectedEpisode.getName(); + } + } + + private final class AdFeedbackUi implements AdFeedbackHostAdapter.Ui { + @Override + public void runBackground(Runnable task) { + detailTasks.submit(Task.recommendationExecutor(), task); + } + + @Override + public void runOnUi(Runnable task) { + runOnAliveUi(task); + } + + @Override + public void showSession(AdFeedbackSession session) { + showAdFeedbackSession(session); + } + } + private void showInlineDanmaku() { if (service() == null || player().isEmpty()) return; - DanmakuDialog.create().player(player()).identity(getKeyText(), getIdText(), playbackHistoryName(), selectedEpisode == null ? "" : historyEpisodeTitle(selectedEpisode)).tmdb(danmakuTmdbId(), danmakuTmdbSeasonNumber()).show(this); + DanmakuDialog.create().player(player()).identity(getKeyText(), getIdText(), playbackHistoryName(), selectedEpisode == null ? "" : historyEpisodeTitle(selectedEpisode)).show(this); } private void showInlineTitle() { @@ -8955,18 +8648,19 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (inlineFullscreen) backFromInlineFullscreen(); else { prepareInlinePlayerTransition(); - saveInlineHistory(); - stopInlinePlaybackSync(); - if (inlineStarted && isOwner()) stopPlayback(); - inlineStarted = false; finish(); } } private void backFromInlineFullscreen() { + // 详情直放模式(含手机版)返回时应关闭内嵌播放器回到纯详情页, + // 否则手机版只退出全屏、播放器面板仍可见,看起来跟沉浸融合模式一样 + if (isPlayerMode()) { + exitInlineFullscreen(); + closeDetailFullscreenPlayer(); + return; + } exitInlineFullscreen(); - modeController.onExitFullscreen(); - return; } private void finishPlaybackToHome() { @@ -9185,9 +8879,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } @Override - public void onItemLongClick(View anchor, Episode episode, int episodeNumber, TmdbEpisode tmdbEpisode) { + public void onItemLongClick(View anchor, Episode episode, int episodeNumber) { anchor.setPressed(false); - showTmdbEpisodeDetail(episode, episodeNumber, tmdbEpisode, recycler); + showTmdbEpisodeDetail(episode, episodeNumber, recycler); } }); adapter.setLight(lightTheme); @@ -9682,16 +9376,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean isSamePendingInlinePlayback(Episode episode) { - return inlinePlaybackPending && selectedEpisode != null && selectedEpisode.equals(episode); + return inlinePlaybackLoading && selectedEpisode != null && selectedEpisode.equals(episode); } private void selectInlineEpisode(Episode episode) { - if (isSamePendingInlinePlayback(episode)) return; if (NovelRouter.route(this, getKeyText(), episode, vod, selectedFlag, () -> inlinePlay(episode))) return; inlinePlay(episode); } private void inlinePlay(Episode episode) { + if (isSamePendingInlinePlayback(episode)) return; cancelPendingInlinePlayback(); playbackSelectionTouched = true; selectedEpisode = episode; @@ -10125,6 +9819,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. saveInlineHistory(); stopInlinePlaybackSync(); inlinePlaybackGeneration++; + inlinePlaybackLoading = false; + inlinePlayerSwitchLoading = false; introSkipPlayback.reset(); subtitlePlaybackSession.stop(this); hideInlineControls(); @@ -10132,13 +9828,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. player().stop(); player().clear(); } + hideInlineLoading(); binding.playerError.setVisibility(View.GONE); updateInlineDisplayPanel(); binding.playerPanel.setVisibility(View.GONE); binding.playerPanelSpacer.setVisibility(View.GONE); // 同步隐藏 spacer inlineStarted = false; - inlinePlaybackPending = false; - detailPlayerFullscreenPending = false; detailPlayerActive = false; pendingInlineResult = null; currentInlineResult = null; @@ -10403,32 +10098,17 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. RecyclerView.Adapter adapter = recycler.getAdapter(); if (adapter == null || adapter.getItemCount() == 0) return false; int target = Math.max(0, Math.min(position, adapter.getItemCount() - 1)); - return focusTmdbRecyclerItem(recycler, target, 0); - } - - private boolean focusTmdbRecyclerItem(RecyclerView recycler, int target, int attempt) { - if (binding == null || recycler == null || recycler.getVisibility() != View.VISIBLE) return false; - RecyclerView.Adapter adapter = recycler.getAdapter(); - if (adapter == null || adapter.getItemCount() == 0) return false; - int boundedTarget = Math.max(0, Math.min(target, adapter.getItemCount() - 1)); - if (recycler.isComputingLayout()) { - if (attempt >= 6) return true; - recycler.postOnAnimation(() -> focusTmdbRecyclerItem(recycler, boundedTarget, attempt + 1)); - return true; - } recycler.stopScroll(); - RecyclerView.ViewHolder visibleHolder = recycler.findViewHolderForAdapterPosition(boundedTarget); + RecyclerView.ViewHolder visibleHolder = recycler.findViewHolderForAdapterPosition(target); if (visibleHolder != null) { - boolean requested = visibleHolder.itemView.requestFocus(); - if (!requested) requested = visibleHolder.itemView.requestFocusFromTouch(); - if (requested && getCurrentFocus() == visibleHolder.itemView) return true; - if (attempt >= 6) return true; - recycler.postOnAnimation(() -> focusTmdbRecyclerItem(recycler, boundedTarget, attempt + 1)); + visibleHolder.itemView.requestFocus(); return true; } - if (attempt == 0) recycler.scrollToPosition(boundedTarget); - if (attempt >= 6) return true; - recycler.postOnAnimation(() -> focusTmdbRecyclerItem(recycler, boundedTarget, attempt + 1)); + recycler.scrollToPosition(target); + recycler.post(() -> { + RecyclerView.ViewHolder holder = recycler.findViewHolderForAdapterPosition(target); + if (holder != null) holder.itemView.requestFocus(); + }); return true; } @@ -10692,19 +10372,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. inlineControlController.applyDanmakuSetting(); } - private void recordInlinePlayHealth(boolean success, String error) { - if (inlinePlayHealthRecorded) return; - inlinePlayHealthRecorded = true; - SiteHealthStore.recordPlay(TextUtils.isEmpty(inlinePlayHealthKey) ? getKeyText() : inlinePlayHealthKey, success, error); - } - - @Override - protected void onFirstFrameRendered() { - recordInlinePlayHealth(true, ""); - if (!detailPlayerFullscreenPending || !isPlayerMode() || !inlineStarted || !isOwner()) return; - revealDetailPlayerFullscreen(); - } - @Override protected void onPlayingChanged(boolean isPlaying) { if (!isInlinePlayerMode() || !inlineStarted || !isOwner()) return; @@ -10719,7 +10386,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. super.onPlayWhenReadyChanged(playWhenReady, reason); if (!isInlinePlayerMode() || !inlineStarted || !isOwner()) return; syncInlinePauseInfo(playWhenReady); - updateInlineDisplayPanel(); } @Override @@ -10734,11 +10400,14 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected void onStateChanged(int state) { if (!isInlinePlayerMode()) return; + if (state == Player.STATE_BUFFERING) showInlineLoading(); if (state == Player.STATE_READY) { + hideInlineLoading(); hideInlineControls(); player().reset(); boolean pendingResumeSeekApplied = applyInlineStartPosition(); updateInlineButtons(player().isPlaying()); + recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线 applyInlineShortDramaMode(); requestIntroSkipPlan(); if (!pendingResumeSeekApplied) applyAutoIntroSkip(); @@ -10823,7 +10492,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected void onError(String msg) { - recordInlinePlayHealth(false, msg); if (!isInlinePlayerMode() || !inlineStarted || !isOwner()) return; showInlineError(msg); } @@ -10868,14 +10536,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } } - @Subscribe(threadMode = ThreadMode.MAIN) - public void onConfigEvent(ConfigEvent event) { - if (!event.isVod() || isFinishing() || isDestroyed()) return; - tmdbConfig = TmdbConfig.effectiveCurrent(); - tmdbCredentialScope = SubscriptionTmdbCredentialStore.currentScope(); - loadContent(null); - } - @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); @@ -10885,27 +10545,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected void onResume() { super.onResume(); - refreshSelectionAfterExternalPlayback(); if (vod != null && binding.loading.getVisibility() != View.VISIBLE) revealDefaultPlaybackLoadingPage(); scheduleBackdropSlide(BACKDROP_SLIDE_DELAY_MS); } - private void refreshSelectionAfterExternalPlayback() { - if (vod == null || vod.getFlags() == null || vod.getFlags().isEmpty()) return; - try { - history = History.findPlayback(getHistoryKey(), List.of(vod.getName(), getNameText()), - vod.getFlags(), null, sourceTitleSeasonNumber()); - if (history == null) return; - selectedFlag = TmdbUIAdapter.selectPlaybackFlag(vod.getFlags(), - history.getSourceBindingKey(), history.getEpisodeUrl(), history.getVodFlag()); - if (selectedFlag == null) return; - selectedEpisode = findEpisodeByUrl(history.getEpisodeUrl(), selectedFlag.getEpisodes()); - renderFlagSelection(); - renderEpisodes(); - } catch (Throwable ignored) { - } - } - @Override protected void onPause() { cancelBackdropSlideRequest(); @@ -10925,7 +10568,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected void onDestroy() { - introSkipPlayback.reset(); + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); loadGeneration++; cancelAiSeasonAnalysis(false); detailTasks.close(); @@ -11048,25 +10691,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (history != null && !Setting.isIncognito()) Task.execute(() -> history.save()); } - @Override - protected void onSubtitleSelected(Sub sub) { - if (SubtitleRestoreCoordinator.remember(history, sub)) persistHistorySubtitleSource(); - } - - /** - * 落盘字幕来源,不重新采样播放器进度。 - * - *

不能用 {@link #syncInlineHistory()}:那个方法会先跑 - * {@code updateInlineHistoryProgress()} 去读播放器当前位置,而恢复发生在起播之前—— - * 那时播放器还停在上一集,读到的进度会被记到新集头上。这里只写 history 对象上 - * 已有的字段快照(进度已由 {@code updateInlineHistory} 按新集重置好)。 - */ - private void persistHistorySubtitleSource() { - if (history == null || Setting.isIncognito()) return; - History snapshot = history.copy(); - Task.execute(snapshot::save); - } - private void updateInlineHistoryProgress() { if (history == null || service() == null || player() == null || player().isReleased() || !isOwner()) return; updateInlineHistoryProgress(System.currentTimeMillis(), player().getPosition(), player().getDuration()); @@ -11110,8 +10734,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void onIntroSkipPlanLoaded() { - if (isFinishing() || isDestroyed() || !isInlinePlayerMode() || !inlineStarted - || service() == null || player() == null || player().isReleased() || !isOwner()) return; updateInlineOpeningEndingText(); applyAutoIntroSkip(); preloadAdjacentIntroSkipPlans(); @@ -11143,8 +10765,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean applyAutoIntroSkip() { - if (!Setting.isIntroSkipEnabled() || isFinishing() || isDestroyed() || !isInlinePlayerMode() - || !inlineStarted || service() == null || player() == null || player().isReleased() || !isOwner()) return false; + if (!Setting.isIntroSkipEnabled() || player() == null) return false; // notify=true:片尾无处可跳(末集/电影)时至少要有提示,不能静默无反应 return introSkipPlayback.apply(player(), () -> advanceInlineEpisode(true)); } @@ -11162,12 +10783,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. .setTitle(R.string.intro_skip_confirm_title) .setMessage(IntroSkipKinds.confirmMessage(segment)) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> introSkipPlayback.declineConfirmation(segment)) + .setNegativeButton(android.R.string.cancel, null) .show(); - introSkipConfirmDialog.setOnDismissListener(dialog -> { - introSkipPlayback.cancelConfirmation(segment); - if (introSkipConfirmDialog == dialog) introSkipConfirmDialog = null; - }); return true; }); introSkipPlayback.setSkipNoticeListener(IntroSkipKinds::notifySkipped); @@ -11206,7 +10823,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override public void onTimeChanged(long time) { if (!isInlinePlayerMode()) return; - if (!isOwner() || history == null || service() == null || player() == null || player().isEmpty()) return; + updateInlineLoadingTraffic(); + if (inlinePlaybackLoading || !isOwner() || history == null || service() == null || player() == null || player().isEmpty()) return; long position = player().getPosition(); long duration = player().getDuration(); boolean canUpdateProgress = inlineStartPositionApplied || getInlineStartPosition() <= 0; @@ -11307,181 +10925,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. coalesce(castNames(), vod == null ? "" : vod.getActor()), coalesce(firstCrew("Director"), vod == null ? "" : vod.getDirector()), yearLabel()); - if (modeController.shouldPublishPlaybackHistory()) PlaybackEventCollector.get().updateHistory(history); + if (isFusionMode() || isPlayerMode()) PlaybackEventCollector.get().updateHistory(history); syncDanmakuCompatHistory(); } - private void onFollowing() { - if (!FollowingSettings.isEnabled()) { - Notify.show(R.string.following_enabled_hint); - return; - } - if (followingActionPending) return; - TmdbItem tmdb = followingTmdbItem(); - String siteKey = getKeyText(); - String vodId = getIdText(); - if (TextUtils.isEmpty(siteKey) || TextUtils.isEmpty(vodId)) return; - int season = Math.max(0, currentSeasonSourceScope()); - String identityKey = tmdb == null - ? FollowingIdentity.identityKey(VodConfig.getCid(), siteKey, vodId, season) - : FollowingIdentity.identityKey(tmdb, season); - followingActionPending = true; - setFollowingButtonsEnabled(false); - resolveFollowing(tmdb, identityKey, siteKey, vodId, season, existing -> { - if (isFinishing() || isDestroyed()) return; - if (existing != null) { - followingActionPending = false; - setFollowingButtonsEnabled(true); - FollowingActivity.start(this, existing.identityKey); - return; - } - addFollowing(tmdb, siteKey, vodId, season, identityKey); - }); - } - - private void addFollowing(TmdbItem tmdb, String siteKey, String vodId, int season, String identityKey) { - long now = System.currentTimeMillis(); - Following item = new Following(); - item.identityKey = identityKey; - item.seriesKey = tmdb == null - ? FollowingIdentity.seriesKey(VodConfig.getCid(), siteKey, vodId) - : FollowingIdentity.seriesKey(tmdb); - item.cid = VodConfig.getCid(); - item.siteKey = FollowingIdentity.normalize(siteKey); - item.vodId = FollowingIdentity.normalize(vodId); - item.vodName = playbackHistoryName(); - item.vodPic = playbackHistoryPic(); - item.mediaType = tmdb == null ? "tv" : FollowingIdentity.normalizeMediaType(tmdb.getMediaType()); - item.tmdbId = tmdb == null ? 0 : tmdb.getTmdbId(); - item.trackedSeason = season; - item.trackedEpisode = history == null ? 0 : Math.max(0, history.getTmdbEpisodeNumber()); - item.watchedSeason = history == null ? 0 : Math.max(0, history.getTmdbSeasonNumber()); - item.watchedEpisode = history == null ? 0 : Math.max(0, history.getTmdbEpisodeNumber()); - item.position = history == null ? 0 : Math.max(0, history.getPosition()); - item.duration = history == null ? 0 : Math.max(0, history.getDuration()); - item.notifyEnabled = FollowingSettings.isNotificationsEnabled(); - item.enabled = true; - item.createdAt = now; - item.updatedAt = now; - item.nextCheckAt = now; - applyInitialFollowingMetadata(item); - FollowingUpdatePolicy.initializeNew(item, FollowingUpdatePolicy.releasedEpisode(item), now); - item.nextCheckAt = now; - FollowingSource source = new FollowingSource(); - source.followingKey = identityKey; - source.cid = item.cid; - source.siteKey = item.siteKey; - source.vodId = item.vodId; - source.vodName = item.vodName; - source.vodPic = item.vodPic; - source.vodFlag = selectedFlag == null ? "" : selectedFlag.getFlag(); - source.playableSeason = Math.max(0, season); - source.preferred = true; - FollowingPlaybackBridge.addAsync(item, source, (saved, error) -> { - followingActionPending = false; - if (isFinishing() || isDestroyed()) return; - if (error != null) { - setFollowingButtonsEnabled(true); - Notify.show(error.getMessage()); - return; - } - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); - updateFollowingState(); - Notify.show(R.string.following_added); - }); - } - - private TmdbItem followingTmdbItem() { - TmdbItem item = matchedTmdbItem != null ? matchedTmdbItem : initialTmdbItem; - return item != null && item.isTv() && item.getTmdbId() > 0 ? item : null; - } - - private void applyInitialFollowingMetadata(Following item) { - TmdbEpisodeInfo info = tmdbEpisodeInfo(); - if (info == null || info.isEmpty()) return; - item.officialStatus = followingStatus(info); - item.seriesTotalEpisodes = info.getTotalEpisodes(); - item.seasonTotalEpisodes = info.getScopedTotalEpisodes(); - item.seasonReleasedEpisodes = info.getScopedAiredEpisodes(); - if (info.getLastSeason() == item.trackedSeason) item.latestReleasedEpisode = info.getLastEpisode(); - else item.latestReleasedEpisode = info.getScopedAiredEpisodes(); - item.latestReleasedSeason = info.getLastSeason(); - item.metadataUpdatedAt = System.currentTimeMillis(); - } - - private String followingStatus(TmdbEpisodeInfo info) { - if (info == null) return FollowingMetadataSnapshot.UNKNOWN; - return switch (info.getState()) { - case ONGOING -> FollowingMetadataSnapshot.RETURNING; - case PLANNED -> FollowingMetadataSnapshot.PLANNED; - case COMPLETE -> FollowingMetadataSnapshot.ENDED; - case CANCELED -> FollowingMetadataSnapshot.CANCELED; - default -> FollowingMetadataSnapshot.UNKNOWN; - }; - } - - private FollowingMetadataSnapshot followingSnapshot() { - TmdbEpisodeInfo info = tmdbEpisodeInfo(); - if (info == null || info.isEmpty()) return null; - FollowingMetadataSnapshot snapshot = new FollowingMetadataSnapshot(); - snapshot.source = "tmdb"; - snapshot.status = followingStatus(info); - snapshot.latestReleasedSeason = Math.max(0, info.getLastSeason()); - snapshot.latestReleasedEpisode = Math.max(0, info.getLastEpisode()); - snapshot.seasonTotalEpisodes = Math.max(0, info.getScopedTotalEpisodes()); - snapshot.seasonReleasedEpisodes = Math.max(0, info.getScopedAiredEpisodes()); - snapshot.seriesTotalEpisodes = Math.max(0, info.getTotalEpisodes()); - snapshot.fetchedAt = System.currentTimeMillis(); - return snapshot; - } - - private void resolveFollowing(TmdbItem tmdb, String identityKey, String siteKey, String vodId, int season, - java.util.function.Consumer callback) { - if (tmdb == null) FollowingPlaybackBridge.findAsync(identityKey, callback); - else FollowingPlaybackBridge.resolveTmdbAsync(tmdb, season, VodConfig.getCid(), siteKey, vodId, followingSnapshot(), callback); - } - - private void updateFollowingState() { - boolean enabled = FollowingSettings.isEnabled(); - boolean eligible = enabled && (followingTmdbItem() != null || (vod != null && !vod.getFlags().isEmpty())); - int generation = ++followingUiGeneration; - if (!eligible) { - applyFollowingButtonState(false, false); - return; - } - TmdbItem tmdb = followingTmdbItem(); - int season = Math.max(0, currentSeasonSourceScope()); - String identityKey = tmdb == null - ? FollowingIdentity.identityKey(VodConfig.getCid(), getKeyText(), getIdText(), season) - : FollowingIdentity.identityKey(tmdb, season); - applyFollowingButtonState(true, false); - resolveFollowing(tmdb, identityKey, getKeyText(), getIdText(), season, item -> { - if (generation != followingUiGeneration || isFinishing() || isDestroyed()) return; - applyFollowingButtonState(true, item != null); - }); - } - - private void applyFollowingButtonState(boolean eligible, boolean followed) { - String text = getString(followed ? R.string.following_added : R.string.following_add); - binding.following.setVisibility(eligible ? View.VISIBLE : View.GONE); - binding.followingTop.setVisibility(View.GONE); - binding.followingFusion.setVisibility(eligible ? View.VISIBLE : View.GONE); - binding.following.setText(text); - binding.followingTop.setText(text); - binding.followingFusion.setText(text); - binding.following.setSelected(followed); - binding.followingTop.setSelected(followed); - binding.followingFusion.setSelected(followed); - setFollowingButtonsEnabled(true); - } - - private void setFollowingButtonsEnabled(boolean enabled) { - binding.following.setEnabled(enabled); - binding.followingTop.setEnabled(enabled); - binding.followingFusion.setEnabled(enabled); - } - private void onKeep() { Keep keep = Keep.find(getHistoryKey()); if (keep != null) keep.delete(); @@ -13164,6 +12611,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. Objects.toString(getIntent().getStringExtra("tmdb_credit"), "")); } + private record TmdbBundle(TmdbItem item, JsonObject detail, List cast, List creators, List photos, List related, List seasons, Map seasonCounts, Map> seasonEpisodes, Map> seasonCast, Map> seasonPhotos) { + } + private record TmdbLoadResult(TmdbBundle bundle, List searchItems) { } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbPersonActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbPersonActivity.java index d62b98e443..647c4513a4 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbPersonActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbPersonActivity.java @@ -28,7 +28,6 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; import com.bumptech.glide.Glide; @@ -115,7 +114,7 @@ public class TmdbPersonActivity extends BaseActivity { @Override protected void initView(Bundle savedInstanceState) { - tmdbConfig = TmdbConfig.effectiveCurrent(); + tmdbConfig = TmdbConfig.objectFrom(Setting.getTmdbConfig()); siteKey = getIntent().getStringExtra("site_key"); detailMode = normalizeDetailMode(getIntent().getIntExtra("detail_mode", Setting.getDetailOpenMode())); light = resolveLightTheme(); @@ -135,93 +134,6 @@ public class TmdbPersonActivity extends BaseActivity { binding.filterTv.setOnClickListener(view -> setFilter("tv")); } - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - if (Util.isLeanback() && binding != null && event != null && event.getAction() == KeyEvent.ACTION_DOWN) { - View focus = getCurrentFocus(); - if (focus != null && isFocusInside(binding.filterGroup, focus)) { - if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) return moveFilterFocus(focus, true); - if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) return moveFilterFocus(focus, false); - if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_DOWN && focusFirstWork()) return true; - } else if (focus != null && isFirstWorkFocused(focus) - && event.getKeyCode() == KeyEvent.KEYCODE_DPAD_UP && focusSelectedFilter()) { - return true; - } - } - return super.dispatchKeyEvent(event); - } - - private boolean moveFilterFocus(View focus, boolean left) { - int index = binding.filterGroup.indexOfChild(focus); - if (index < 0) return false; - int targetIndex = index + (left ? -1 : 1); - if (targetIndex < 0 || targetIndex >= binding.filterGroup.getChildCount()) return true; - View target = binding.filterGroup.getChildAt(targetIndex); - if (target == null || !target.isFocusable()) return true; - target.requestFocus(left ? View.FOCUS_LEFT : View.FOCUS_RIGHT); - scrollFilterIntoView(target); - return true; - } - - private boolean focusFirstWork() { - if (workAdapter.getItemCount() == 0) return false; - RecyclerView.LayoutManager layoutManager = binding.works.getLayoutManager(); - View target = layoutManager == null ? null : layoutManager.findViewByPosition(0); - if (target != null) return target.requestFocus(); - binding.works.scrollToPosition(0); - return binding.works.post(() -> { - RecyclerView.LayoutManager manager = binding.works.getLayoutManager(); - View first = manager == null ? null : manager.findViewByPosition(0); - if (first != null) first.requestFocus(); - }); - } - - private boolean isFirstWorkFocused(View focus) { - View item = focus; - while (item != null && item.getParent() instanceof View && item.getParent() != binding.works) { - item = (View) item.getParent(); - } - return item != null && item.getParent() == binding.works && binding.works.getChildAdapterPosition(item) == 0; - } - - private boolean focusSelectedFilter() { - View target = null; - for (int i = 0; i < binding.filterGroup.getChildCount(); i++) { - View child = binding.filterGroup.getChildAt(i); - if (child.getVisibility() == View.VISIBLE && filter.equals(filterKey(child))) { - target = child; - break; - } - } - if (target == null && binding.filterGroup.getChildCount() > 0) { - target = binding.filterGroup.getChildAt(0); - } - if (target == null || !target.isFocusable() || !target.requestFocus()) return false; - scrollFilterIntoView(target); - return true; - } - - private boolean isFocusInside(ViewGroup parent, View focus) { - for (View current = focus; current != null; current = current.getParent() instanceof View ? (View) current.getParent() : null) { - if (current == parent) return true; - } - return false; - } - - private String filterKey(View button) { - if (button == binding.filterAll) return "all"; - if (button == binding.filterCast) return "cast"; - if (button == binding.filterCrew) return "crew"; - if (button == binding.filterDirector) return "director"; - if (button == binding.filterMovie) return "movie"; - if (button == binding.filterTv) return "tv"; - return ""; - } - - private void scrollFilterIntoView(View child) { - binding.filterScroll.post(() -> binding.filterScroll.smoothScrollTo(Math.max(0, child.getLeft() - dp(12)), 0)); - } - private void setInitialPerson() { binding.name.setText(textExtra("person_name")); binding.subtitle.setVisibility(View.GONE); @@ -428,14 +340,6 @@ public class TmdbPersonActivity extends BaseActivity { for (int i = 0; i < count; i++) { View child = binding.filterGroup.getChildAt(i); if (child instanceof MaterialButton button) { - button.setFocusable(true); - button.setFocusableInTouchMode(false); - button.setNextFocusLeftId(i == 0 ? button.getId() : binding.filterGroup.getChildAt(i - 1).getId()); - button.setNextFocusRightId(i == count - 1 ? button.getId() : binding.filterGroup.getChildAt(i + 1).getId()); - button.setOnFocusChangeListener((view, focused) -> { - updateFilters(); - if (focused) scrollFilterIntoView(view); - }); button.setMinWidth(0); button.setMinimumWidth(0); button.setInsetLeft(0); @@ -592,14 +496,13 @@ public class TmdbPersonActivity extends BaseActivity { private void updateFilter(MaterialButton button, String value) { boolean selected = filter.equals(value); - boolean focused = button.hasFocus(); int bg = selected ? (light ? 0xFFDBEAFE : 0xFF2F4F6F) : (light ? 0xFFF5F8FB : 0xFF1A2530); int fg = light ? 0xFF12202D : 0xFFFFFFFF; - int stroke = focused ? FOCUS_STROKE : selected ? 0xFF6DA8E8 : (light ? 0x33424B57 : 0x33FFFFFF); + int stroke = selected ? 0xFF6DA8E8 : (light ? 0x33424B57 : 0x33FFFFFF); button.setTextColor(fg); button.setBackgroundTintList(ColorStateList.valueOf(bg)); button.setStrokeColor(ColorStateList.valueOf(stroke)); - button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : selected ? 2 : 1)); + button.setStrokeWidth(selected ? 2 : 1); } private void setThemeColors() { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java deleted file mode 100644 index 869d5b6266..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.fongmi.android.tv.ui.adapter; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.databinding.ItemFollowingBinding; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingSource; -import com.fongmi.android.tv.following.FollowingUpdatePolicy; -import com.fongmi.android.tv.utils.ImgUtil; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -public class FollowingAdapter extends RecyclerView.Adapter { - - public interface Listener { - void onOpenDetail(Following item, FollowingSource source); - - void onContinue(Following item, FollowingSource source); - - void onFollowNextSeason(Following item); - - void onCheck(Following item); - - void onRead(Following item); - - void onToggleNotify(Following item); - - void onChangeSource(Following item); - - void onDelete(Following item); - } - - public static final class Row { - public final Following following; - public final FollowingSource source; - - public Row(Following following, FollowingSource source) { - this.following = following; - this.source = source; - } - } - - private final Listener listener; - private final List items = new ArrayList<>(); - - public FollowingAdapter(Listener listener) { - this.listener = listener; - setHasStableIds(true); - } - - public void setItems(List rows) { - items.clear(); - if (rows != null) items.addAll(rows); - notifyDataSetChanged(); - } - - public int indexOf(String identityKey) { - for (int i = 0; i < items.size(); i++) { - if (items.get(i).following.identityKey.equals(identityKey)) return i; - } - return -1; - } - - @Override - public long getItemId(int position) { - return items.get(position).following.identityKey.hashCode(); - } - - @NonNull - @Override - public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new Holder(ItemFollowingBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull Holder holder, int position) { - Row row = items.get(position); - Following item = row.following; - FollowingSource source = row.source; - holder.binding.title.setText(item.vodName); - ImgUtil.load(item.vodName, item.vodPic, holder.binding.image); - int released = FollowingUpdatePolicy.releasedEpisode(item); - if (released > 0) holder.binding.official.setText(holder.itemView.getContext().getString(R.string.following_official, released)); - else holder.binding.official.setText(R.string.following_official_unknown); - if (source != null && source.playableEpisode > 0) { - holder.binding.source.setText(holder.itemView.getContext().getString(R.string.following_source, source.playableEpisode)); - } else { - holder.binding.source.setText(R.string.following_source_unknown); - } - if (item.watchedEpisode > 0) holder.binding.progress.setText(holder.itemView.getContext().getString(R.string.following_watched, item.watchedEpisode)); - else holder.binding.progress.setText(R.string.following_source_unknown); - if (item.unwatchedCount > 0) holder.binding.progress.append("\n" + holder.itemView.getContext().getString(R.string.following_unwatched, item.unwatchedCount)); - else if (released > 0) holder.binding.progress.append("\n" + holder.itemView.getContext().getString(R.string.following_unwatched_none)); - if (item.hasUpdate && item.unwatchedCount > 0) { - holder.binding.badge.setVisibility(View.VISIBLE); - holder.binding.badge.setText(holder.itemView.getContext().getString(R.string.following_new_count, item.unwatchedCount)); - } else { - holder.binding.badge.setVisibility(View.GONE); - } - if (item.lastError == null || item.lastError.isBlank()) { - holder.binding.error.setVisibility(View.GONE); - } else { - holder.binding.error.setVisibility(View.VISIBLE); - holder.binding.error.setText(item.lastError); - } - holder.binding.notify.setText(item.notifyEnabled ? R.string.following_notify_on : R.string.following_notify_off); - holder.itemView.setOnClickListener(view -> listener.onOpenDetail(item, source)); - if (item.latestReleasedSeason > item.trackedSeason) { - holder.binding.nextSeason.setVisibility(View.VISIBLE); - holder.binding.nextSeason.setText(holder.itemView.getContext().getString(R.string.following_next_season, item.latestReleasedSeason + 1)); - } else { - holder.binding.nextSeason.setVisibility(View.GONE); - } - holder.binding.nextSeason.setOnClickListener(view -> listener.onFollowNextSeason(item)); - holder.binding.continuePlay.setOnClickListener(view -> listener.onContinue(item, source)); - holder.binding.check.setOnClickListener(view -> listener.onCheck(item)); - holder.binding.read.setVisibility(item.hasUpdate ? View.VISIBLE : View.GONE); - holder.binding.read.setOnClickListener(view -> listener.onRead(item)); - holder.binding.notify.setOnClickListener(view -> listener.onToggleNotify(item)); - holder.binding.sourceChange.setOnClickListener(view -> listener.onChangeSource(item)); - holder.binding.delete.setOnClickListener(view -> listener.onDelete(item)); - } - - @Override - public int getItemCount() { - return items.size(); - } - - public Row rowAt(int position) { - return position >= 0 && position < items.size() ? items.get(position) : null; - } - - public void markReadLocally(Collection identityKeys) { - if (identityKeys == null || identityKeys.isEmpty()) return; - for (int i = 0; i < items.size(); i++) { - Following item = items.get(i).following; - if (!identityKeys.contains(item.identityKey) || !item.hasUpdate) continue; - FollowingUpdatePolicy.markRead(item, 0); - notifyItemChanged(i); - } - } - - static final class Holder extends RecyclerView.ViewHolder { - final ItemFollowingBinding binding; - - Holder(ItemFollowingBinding binding) { - super(binding.getRoot()); - this.binding = binding; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/GithubProxyAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/GithubProxyAdapter.java index f121d93a4f..1712b33211 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/GithubProxyAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/adapter/GithubProxyAdapter.java @@ -1,27 +1,20 @@ package com.fongmi.android.tv.ui.adapter; import android.view.LayoutInflater; -import android.widget.TextView; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterGithubProxyBinding; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; public class GithubProxyAdapter extends RecyclerView.Adapter { private final OnClickListener listener; private final List mItems; - private final Map mLatency = new ConcurrentHashMap<>(); - private final Set mProbing = ConcurrentHashMap.newKeySet(); private int mSelected; public GithubProxyAdapter(OnClickListener listener) { @@ -39,32 +32,10 @@ public class GithubProxyAdapter extends RecyclerView.Adapter items, String active) { mItems.clear(); mItems.addAll(items); - mLatency.keySet().retainAll(items); mSelected = Math.max(0, mItems.indexOf(active)); notifyDataSetChanged(); } - public void setLatency(String item, String value) { - int position = mItems.indexOf(item); - mLatency.put(item, value); - mProbing.remove(item); - if (position >= 0) notifyItemChanged(position); - } - - public void startProbe(List items) { - mProbing.clear(); - mProbing.addAll(items); - for (String item : items) { - mLatency.remove(item); - int position = mItems.indexOf(item); - if (position >= 0) notifyItemChanged(position); - } - } - - public boolean isProbing() { - return !mProbing.isEmpty(); - } - public int getSelected() { return mSelected; } @@ -87,11 +58,7 @@ public class GithubProxyAdapter extends RecyclerView.Adapter listener.onActive(item)); holder.binding.remove.setOnClickListener(v -> listener.onRemove(item)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/MpvConfigProfileAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/MpvConfigProfileAdapter.java index 3c0fd022e5..95fb5216fd 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/MpvConfigProfileAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/adapter/MpvConfigProfileAdapter.java @@ -1,7 +1,5 @@ package com.fongmi.android.tv.ui.adapter; -import android.content.res.ColorStateList; -import android.graphics.Color; import android.view.LayoutInflater; import android.view.KeyEvent; import android.view.View; @@ -11,7 +9,6 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.databinding.AdapterMpvConfigProfileBinding; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.player.mpv.MpvConfigStore; import com.fongmi.android.tv.utils.Util; @@ -59,19 +56,9 @@ public class MpvConfigProfileAdapter extends RecyclerView.Adapter listener.onItemClick(episode)); holder.binding.getRoot().setOnLongClickListener(view -> { - listener.onItemLongClick(view, episode, episodeNumber, boundTmdbEpisode); + listener.onItemLongClick(view, episode, episodeNumber); return true; }); } - private void applyCardSize(ViewHolder holder, int position, boolean compact, boolean hasTmdbEpisodeData) { + private void applyCardSize(ViewHolder holder, boolean compact, boolean hasTmdbEpisodeData) { View root = holder.binding.getRoot(); ViewGroup.LayoutParams params = root.getLayoutParams(); int width; @@ -332,14 +330,8 @@ public class TmdbEpisodeAdapter extends RecyclerView.Adapter= 0 ? position % gridSpanCount : 0; - int marginStart = mode == Mode.GRID ? gridSpacing * gridColumn / gridSpanCount : 0; - int marginEnd = mode == Mode.GRID - ? gridSpacing - gridSpacing * (gridColumn + 1) / gridSpanCount - : dp(holder.itemView, 12); + int marginStart = mode == Mode.GRID ? gridSpacing / 2 : 0; + int marginEnd = mode == Mode.GRID ? gridSpacing - marginStart : dp(holder.itemView, 12); int bottomMargin = dp(holder.itemView, isNativeEnhanced() && mode == Mode.GRID && hasTmdbEpisodeData ? 16 : mode == Mode.GRID ? 10 : 0); layoutChanged |= marginParams.getMarginStart() != marginStart || marginParams.getMarginEnd() != marginEnd @@ -656,12 +648,6 @@ public class TmdbEpisodeAdapter extends RecyclerView.Adapter public void onBindViewHolder(@NonNull ViewHolder holder, int position) { Track item = mItems.get(position); holder.binding.text.setText(item.getName()); - holder.binding.text.setActivated(item.isSelected()); + holder.binding.text.setSelected(item.isSelected()); } public class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerOsdController.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerOsdController.java index e082445ba1..c870a24943 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerOsdController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerOsdController.java @@ -26,7 +26,6 @@ import androidx.media3.common.Tracks; import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.player.DolbyVisionFormatLabel; -import com.fongmi.android.tv.player.AudioPlaybackDiagnostics; import com.fongmi.android.tv.player.GpuLoadMonitor; import com.fongmi.android.tv.player.ijk.IjkDecodePressurePolicy; import com.fongmi.android.tv.player.PlayerManager; @@ -170,10 +169,6 @@ public class PlayerOsdController { return diagnosticsVisible; } - public String sampleSpeedText() { - return lastSpeedText; - } - public void setDiagnosticsVisible(boolean visible) { boolean next = visible && PlayerSetting.isOsdDiagnostics(); if (diagnosticsVisible == next) return; @@ -408,10 +403,7 @@ public class PlayerOsdController { Format audio = snapshot.audioFormat(); String state = stateText(player.getPlaybackState()) + (player.isLoading() ? " / 正在加载" : ""); String buffer = join(" / ", formatDuration(player.getBufferedDuration()), player.getBufferedPercentage() > 0 ? player.getBufferedPercentage() + "%" : ""); - // MPV has no Exo analytics snapshot; its buffering tracker belongs to PlayerManager. - int rebufferCount = player.isMpv() ? player.getRebufferCount() : snapshot.rebufferCount(); - long rebufferTotalMs = player.isMpv() ? player.getRebufferTotalMs() : snapshot.rebufferTotalMs(); - String rebuffer = rebufferCount <= 0 ? "0 次" : rebufferCount + " 次 / " + formatDuration(rebufferTotalMs); + String rebuffer = snapshot.rebufferCount() <= 0 ? "0 次" : snapshot.rebufferCount() + " 次 / " + formatDuration(snapshot.rebufferTotalMs()); long stableThroughput = player.getNetworkProtectionStableThroughput(); long consumption = player.getNetworkProtectionConsumption(); String networkProtection = player.getNetworkProtectionText(); @@ -443,11 +435,7 @@ public class PlayerOsdController { snapshot.videoDecoderName(), getVideoTrackState(player), videoDetails); AudioTrackState audioTrack = getAudioTrackState(player); - AudioPlaybackDiagnostics.Snapshot audioDetails = player.getAudioPlaybackDiagnostics(); - String runtimeAudio = AudioPlaybackDiagnostics.format(audioDetails); - String audioText = TextUtils.isEmpty(runtimeAudio) - ? summarizeAudio(audio, audioTrack, snapshot.audioDecoderName()) - : runtimeAudio; + String audioText = summarizeAudio(audio, audioTrack, snapshot.audioDecoderName()); String render = PlayerSetting.getRender() == PlayerSetting.RENDER_SURFACE ? "Surface" : "Texture"; String tunnel = switchText(PlayerSetting.isTunnelingEnabled()); String performance = PlaybackPerformanceSetting.getProfileName(player.getPlayerType()); @@ -455,7 +443,7 @@ public class PlayerOsdController { String preload = "预载" + switchText(PreloadSetting.isPreload(player.getPlayerType())); String frameRateMatch = player.isExo() ? "帧率匹配 开" : ""; String softTune = getSoftDecodeTuneText(player); - String playerText = join(" / ", player.getPlayerText(), player.getDecodeText(), render, "隧道" + tunnel, "性能" + performance, frameRateMatch, preload, softTune, player.isExo() ? "兜底开" : ""); + String playerText = join(" / ", player.getPlayerText(), player.getDecodeText(), render, "隧道" + tunnel, "性能" + performance, frameRateMatch, preload, "直通" + passThrough, softTune, player.isExo() ? "兜底开" : ""); String playback = join(" / ", state, buffer, "重缓冲 " + rebuffer, "掉帧 " + player.getDroppedFrames()); String startup = getStartupText(player); String error = getErrorText(player, snapshot); @@ -474,7 +462,7 @@ public class PlayerOsdController { row("播放", playback), row("配置", playerText), TextUtils.isEmpty(startup) ? "" : row("起播", startup), - row("结论", getDiagnosis(player, snapshot, video, audioTrack, audioDetails, localSource))); + row("结论", getDiagnosis(player, snapshot, video, audioTrack, localSource))); String extra = join("\n", row("设备", getDeviceText()), row("系统", getSystemText()), @@ -496,12 +484,12 @@ public class PlayerOsdController { return TextUtils.isEmpty(slowest) ? summary : summary + " 最慢 " + slowest; } - private String getDiagnosis(PlayerManager player, PlaybackAnalyticsListener.Snapshot snapshot, Format video, AudioTrackState audioTrack, AudioPlaybackDiagnostics.Snapshot audioDetails, boolean localSource) { - if (audioDetails.runtimeState() == AudioPlaybackDiagnostics.RuntimeState.FAILED) return "音频链路失败,先看音频和错误行"; + private String getDiagnosis(PlayerManager player, PlaybackAnalyticsListener.Snapshot snapshot, Format video, AudioTrackState audioTrack, boolean localSource) { if (isDecodeError(snapshot) && player.isHardDecode()) return "硬件解码失败:设备可能不支持该视频编码、分辨率、帧率或规格"; if (!TextUtils.isEmpty(snapshot.errorCode())) return "播放器报错,先看错误行"; if (audioTrack.hasTracks() && audioTrack.isUnsupported()) return "音频轨不支持:" + summarizeAudioFormat(audioTrack.format()) + " / " + supportText(audioTrack.support()); if (player.isExo() && audioTrack.hasTracks() && !audioTrack.selected() && snapshot.audioFormat() == null && player.getPlaybackState() == androidx.media3.common.Player.STATE_READY) return "已发现音轨但未选中,可能无声"; + if (player.isExo() && audioTrack.hasTracks() && TextUtils.isEmpty(snapshot.audioDecoderName()) && player.getPlaybackState() == androidx.media3.common.Player.STATE_READY) return "已发现音轨但 decoder 未初始化,可能无声"; if (!localSource) { long mediaBitrate = getMediaBitrate(video, snapshot.audioFormat() != null ? snapshot.audioFormat() : audioTrack.format()); long availableBitrate = snapshot.bandwidthEstimate() > 0 ? snapshot.bandwidthEstimate() : lastSpeedKBps * 1024 * 8; @@ -512,20 +500,7 @@ public class PlayerOsdController { } if (player.getDroppedFrames() >= 60) return "掉帧较多,可能是解码或渲染压力"; if (!localSource && formatBitrateValue(video) >= 30_000_000) return "资源码率较高,对网络和解码要求高"; - // Use the same observed output as the audio row. A missing decoder name - // is not a failure signal, and encoded output does not need an app decoder. - if (audioDetails.runtimeState() == AudioPlaybackDiagnostics.RuntimeState.ACTIVE) { - return switch (audioDetails.outputMode()) { - case PASSTHROUGH -> "音频直通,无需 App 解码器"; - case COMPRESSED_DIRECT -> "音频压缩直出,由音频设备解码"; - case OFFLOAD -> "音频卸载,由音频设备解码"; - case PCM -> TextUtils.isEmpty(audioDetails.decoderName()) - ? "PCM 输出已建立,解码器名称未上报" : "正常"; - case UNKNOWN -> "音频输出状态待确认"; - }; - } if (player.isExo() && audioTrack.hasTracks() && snapshot.audioFormat() == null) return "正在等待音频轨信息"; - if (audioTrack.hasTracks() || audioDetails.runtimeState() == AudioPlaybackDiagnostics.RuntimeState.PENDING) return "音频输出状态待确认"; return "正常"; } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/TmdbHeaderView.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/TmdbHeaderView.java index e1856ed514..9416ff914f 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/TmdbHeaderView.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/TmdbHeaderView.java @@ -110,8 +110,6 @@ public class TmdbHeaderView { default void onRematchLongClick() { onRematch(); } void onKeep(); - - void onFollowing(); } private final Activity activity; @@ -232,20 +230,6 @@ public class TmdbHeaderView { keep.setText(TmdbDetailLabels.keepLabel(selected)); } - public void setFollowingSelected(boolean selected) { - if (headerRoot == null) return; - MaterialButton following = headerRoot.findViewById(R.id.tmdbFollowing); - if (following == null) return; - following.setSelected(selected); - following.setText(ResUtil.getString(selected ? R.string.following_added : R.string.following_add)); - } - - public void setFollowingVisible(boolean visible) { - if (headerRoot == null) return; - View following = headerRoot.findViewById(R.id.tmdbFollowing); - if (following != null) following.setVisibility(visible ? View.VISIBLE : View.GONE); - } - /** * 绑定 TMDB 数据并填充视图。 */ @@ -668,9 +652,6 @@ public class TmdbHeaderView { headerRoot.findViewById(R.id.tmdbKeep).setOnClickListener(view -> { if (actionListener != null) actionListener.onKeep(); }); - headerRoot.findViewById(R.id.tmdbFollowing).setOnClickListener(view -> { - if (actionListener != null) actionListener.onFollowing(); - }); } private void updateOriginalEnhancedActionVisibility() { @@ -2038,7 +2019,6 @@ public class TmdbHeaderView { private void tintActions(int style) { tintAction(R.id.tmdbChangeSource, style); tintAction(R.id.tmdbKeep, style); - tintAction(R.id.tmdbFollowing, style); tintAction(R.id.tmdbRematch, style); tintAction(R.id.tmdbThemeToggle, style); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/BaseTmdbDetailModeController.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/BaseTmdbDetailModeController.java index 138e4678d9..6ffe1f3775 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/BaseTmdbDetailModeController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/BaseTmdbDetailModeController.java @@ -35,11 +35,6 @@ public abstract class BaseTmdbDetailModeController implements TmdbDetailModeCont // 默认空实现,子类按需覆盖 } - @Override - public void play() { - host.playDefaultPlayback(); - } - @Override public boolean shouldShowInlinePlayer() { return showInlinePlayer(); @@ -50,26 +45,11 @@ public abstract class BaseTmdbDetailModeController implements TmdbDetailModeCont return autoPlay(); } - @Override - public boolean shouldBindPlaybackService() { - return shouldShowInlinePlayer() || shouldAutoPlay(); - } - - @Override - public boolean shouldPublishPlaybackHistory() { - return shouldBindPlaybackService(); - } - @Override public boolean handleBack() { return false; // 默认不处理,交给 Activity } - @Override - public void onExitFullscreen() { - // Most modes keep the embedded player after leaving fullscreen. - } - @Override public void release() { // 默认空实现,子类按需覆盖 diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/DetailModeHost.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/DetailModeHost.java index 551a4e1518..6273cfc222 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/DetailModeHost.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/DetailModeHost.java @@ -22,12 +22,4 @@ public interface DetailModeHost { * 返回 ActivityTmdbDetailBinding */ ViewBinding binding(); - - void closeDetailFullscreenPlayer(); - - void playInline(); - - void playDetailFullscreen(); - - void playDefaultPlayback(); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/FusionDetailController.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/FusionDetailController.java index 329ad0da7e..cf2d5a15e1 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/FusionDetailController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/FusionDetailController.java @@ -3,14 +3,13 @@ package com.fongmi.android.tv.ui.detail; import android.view.View; import com.fongmi.android.tv.databinding.ActivityTmdbDetailBinding; -import com.fongmi.android.tv.setting.PlayerSetting; /** * 沉浸融合模式 Controller。 *

* 特点: * - 显示内联播放器 - * - 按“点播自动播放”设置决定是否自动播放 + * - 不自动播放 * - 特殊按钮布局(fusionActions 可见,detailActions 隐藏) * - heroSpacer 隐藏 */ @@ -27,7 +26,7 @@ public class FusionDetailController extends BaseTmdbDetailModeController { @Override protected boolean autoPlay() { - return PlayerSetting.isAutoPlay(); + return false; // 融合模式不自动播放 } @Override @@ -54,9 +53,4 @@ public class FusionDetailController extends BaseTmdbDetailModeController { // TODO: 融合模式特有的播放开始逻辑 // 目前留空,等 Phase 2 迁移具体逻辑 } - @Override - public void play() { - host.playInline(); - } - } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/PlayerDetailController.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/PlayerDetailController.java index 3056c02c7d..d20edd80bb 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/PlayerDetailController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/PlayerDetailController.java @@ -9,7 +9,7 @@ import com.fongmi.android.tv.databinding.ActivityTmdbDetailBinding; *

* 特点: * - 不显示内联播放器(初始) - * - 由用户点击播放后进入全屏 + * - 自动播放(进入后立即全屏播放) * - detailActions 可见,fusionActions 隐藏 * - heroSpacer 可见 */ @@ -26,7 +26,7 @@ public class PlayerDetailController extends BaseTmdbDetailModeController { @Override protected boolean autoPlay() { - return false; // 详情直放必须由用户点击播放 + return true; // 详情直放模式自动播放 } @Override @@ -34,30 +34,14 @@ public class PlayerDetailController extends BaseTmdbDetailModeController { return View.VISIBLE; } - @Override - public boolean shouldBindPlaybackService() { - return true; - } - - - @Override - public void onExitFullscreen() { - host.closeDetailFullscreenPlayer(); - } - @Override public void applyInitialLayout() { ActivityTmdbDetailBinding binding = (ActivityTmdbDetailBinding) host.binding(); - // 详情直放模式布局设置,等待用户点击播放 + // 详情直放模式布局设置(与炫彩详情类似,但后续会自动触发播放) binding.playerPanel.setVisibility(View.GONE); binding.heroSpacer.setVisibility(View.VISIBLE); binding.fusionActions.setVisibility(View.GONE); binding.detailActions.setVisibility(View.VISIBLE); } - @Override - public void play() { - host.playDetailFullscreen(); - } - } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeController.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeController.java index c8c88868a8..408aa29095 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeController.java @@ -46,9 +46,6 @@ public interface TmdbDetailModeController { */ void onPlaybackStarted(); - /** Launch playback using this mode's presentation. */ - void play(); - /** * 是否显示内联播放器。 *

@@ -63,12 +60,6 @@ public interface TmdbDetailModeController { */ boolean shouldAutoPlay(); - /** Whether this mode owns an in-process playback service connection. */ - boolean shouldBindPlaybackService(); - - /** Whether this mode publishes selected playback history to the in-process collector. */ - boolean shouldPublishPlaybackHistory(); - /** * 处理返回键。 *

@@ -76,9 +67,6 @@ public interface TmdbDetailModeController { */ boolean handleBack(); - /** Called after the shared fullscreen layout has been exited. */ - void onExitFullscreen(); - /** * 释放资源。 *

diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AboutDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AboutDialog.java index a8971d2ac7..219a89b5ec 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AboutDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AboutDialog.java @@ -15,7 +15,6 @@ import android.view.WindowManager; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; -import com.fongmi.android.tv.App; import com.fongmi.android.tv.BuildConfig; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogAboutBinding; @@ -26,20 +25,13 @@ import com.fongmi.android.tv.utils.AppVersion; import com.fongmi.android.tv.utils.GithubProxy; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.github.catvod.net.OkHttp; - -import java.util.List; -import java.util.Locale; -import java.util.concurrent.TimeUnit; public final class AboutDialog { private static final int DIALOG_VERTICAL_MARGIN_DP = 96; private static final int FULLSCREEN_INSET_DP = 32; - private static final long PROBE_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(5); private AboutDialog() { } @@ -85,15 +77,7 @@ public final class AboutDialog { } }); binding.list.setAdapter(adapter); - binding.list.setHasFixedSize(false); - binding.modeGroup.addOnButtonCheckedListener((group, checkedId, isChecked) -> { - if (isChecked) Setting.putGithubProxyMode(checkedId == R.id.modeStrip - ? GithubProxy.MODE_STRIP_SCHEME - : GithubProxy.MODE_FULL_URL); - }); - binding.modeGroup.check(GithubProxy.MODE_STRIP_SCHEME.equals(Setting.getGithubProxyMode()) - ? R.id.modeStrip - : R.id.modeFull); + binding.list.setHasFixedSize(true); binding.enabled.setChecked(Setting.isGithubProxyEnabled()); binding.enabled.setOnCheckedChangeListener((buttonView, isChecked) -> Setting.putGithubProxyEnabled(isChecked)); refreshGithubProxy(binding); @@ -112,27 +96,20 @@ public final class AboutDialog { Setting.putGithubProxy(GithubProxy.defaultSources()); refreshGithubProxy(binding); }); - binding.probe.setOnClickListener(v -> probeLatency(binding)); View.OnKeyListener dpadNav = (v, keyCode, event) -> { if (event.getAction() != KeyEvent.ACTION_DOWN) return false; if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && v == binding.enabled) { - return binding.modeGroup.requestFocus(); + return binding.input.requestFocus(); } if (keyCode == KeyEvent.KEYCODE_DPAD_UP && v == binding.enabled) { return binding.list.requestFocus(); } - if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && v == binding.modeGroup) { - return binding.input.requestFocus(); - } - if (keyCode == KeyEvent.KEYCODE_DPAD_UP && v == binding.modeGroup) { - return binding.enabled.requestFocus(); - } if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && v == binding.input) { return binding.reset.requestFocus(); } if (keyCode == KeyEvent.KEYCODE_DPAD_UP && v == binding.input) { - return binding.modeGroup.requestFocus(); + return binding.enabled.requestFocus(); } if (keyCode == KeyEvent.KEYCODE_DPAD_UP && v == binding.reset) { return binding.input.requestFocus(); @@ -140,7 +117,6 @@ public final class AboutDialog { return false; }; binding.enabled.setOnKeyListener(dpadNav); - binding.probe.setOnKeyListener(dpadNav); binding.input.setOnKeyListener(dpadNav); binding.reset.setOnKeyListener(dpadNav); @@ -163,47 +139,28 @@ public final class AboutDialog { configureGithubProxyWindow(activity, githubDialog, binding); } - private static void probeLatency(DialogGithubProxyBinding binding) { - GithubProxyAdapter adapter = (GithubProxyAdapter) binding.list.getAdapter(); - if (adapter == null || adapter.isProbing()) return; - List sources = GithubProxy.getSources(); - adapter.startProbe(sources); - for (String source : sources) { - Task.submitLarge(() -> { - long start = System.nanoTime(); - try (var response = OkHttp.newCall(OkHttp.client(PROBE_TIMEOUT_MS), GithubProxy.probeUrl(source), source).execute()) { - long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); - String value = App.get().getString(R.string.setting_github_proxy_latency, elapsed); - App.post(() -> adapter.setLatency(source, value)); - } catch (Exception e) { - App.post(() -> adapter.setLatency(source, App.get().getString(R.string.setting_github_proxy_latency_failed))); - } - }); - } - } - private static void configureGithubProxyWindow(FragmentActivity activity, AlertDialog dialog, DialogGithubProxyBinding binding) { - boolean leanback = Util.isLeanback(); + if (!Util.isLeanback()) return; Window window = dialog.getWindow(); if (window == null) return; - // 手机与电视都铺满全屏:不再按屏幕比例手算宽高,列表改为吃掉剩余空间。 + // 电视端铺满全屏:不再按屏幕比例手算宽高,列表改为吃掉剩余空间。 // 这个弹窗经 MaterialAlertDialogBuilder.setView 装载,root 会被 AlertController // 以 MATCH_PARENT 塞进 @id/custom,窗口给满高度后列表即可自由伸展。 WindowManager.LayoutParams params = window.getAttributes(); params.width = WindowManager.LayoutParams.MATCH_PARENT; - params.height = WindowManager.LayoutParams.WRAP_CONTENT; + params.height = WindowManager.LayoutParams.MATCH_PARENT; window.setAttributes(params); - // 弹窗有 URL 输入框。小窗居中时系统还能上推窗口避让键盘,铺满全屏后没有余量, + // 这个弹窗有 URL 输入框。小窗居中时系统还能上推窗口避让键盘,铺满全屏后没有余量, // 必须显式 ADJUST_RESIZE 让窗口自身缩小,否则输入框会被屏幕键盘盖住。 window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); - if (leanback) binding.list.requestFocus(); - else binding.list.post(() -> { - for (int i = 0; i < binding.list.getChildCount(); i++) { - View child = binding.list.getChildAt(i); - child.findViewById(R.id.text).setFocusable(false); - child.findViewById(R.id.remove).setFocusable(false); - } - }); + ViewGroup.LayoutParams listParams = binding.list.getLayoutParams(); + listParams.height = 0; + if (listParams instanceof androidx.appcompat.widget.LinearLayoutCompat.LayoutParams) { + ((androidx.appcompat.widget.LinearLayoutCompat.LayoutParams) listParams).weight = 1; + } else if (listParams instanceof android.widget.LinearLayout.LayoutParams) { + ((android.widget.LinearLayout.LayoutParams) listParams).weight = 1; + } + binding.list.setLayoutParams(listParams); } private static void refreshGithubProxy(DialogGithubProxyBinding binding) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdBlockLogFilterDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdBlockLogFilterDialog.java deleted file mode 100644 index 0fdcf82b73..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdBlockLogFilterDialog.java +++ /dev/null @@ -1,304 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Activity; -import android.content.res.Configuration; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.widget.CheckBox; -import android.widget.EditText; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.bean.AdBlockLog; -import com.fongmi.android.tv.bean.AdBlockLogFilter; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; - -/** - * 日志列的去重多选筛选器。 - * 候选项只出现一次,弹窗内的输入框只过滤候选项,不会破坏已勾选状态。 - */ -public final class AdBlockLogFilterDialog { - - public interface ValueProvider { - String value(AdBlockLog log); - } - - public interface OnApplyListener { - void onApply(Set values); - } - - private AdBlockLogFilterDialog() { - } - - public static void show(Activity activity, String title, List logs, - Set selectedValues, - ValueProvider valueProvider, - OnApplyListener listener) { - if (activity == null || activity.isFinishing()) return; - List

视图用代码搭建而非 layout:三端共用一份,且内容是不定长的证据列表, + * 用 layout 反而要为每端各维护一份文件。见设计文档第 4.3 节。 + */ +public final class AdFeedbackDialog implements AutoCloseable { + + /** 用户确认保存规则。 */ + public interface Callback { + void onSave(AdAttribution plan); + } + + private final WeakReference activity; + private final Handler main = new Handler(Looper.getMainLooper()); + private final Callback callback; + private AlertDialog dialog; + private TextView body; + private String feedbackId; + private boolean closed; + + public AdFeedbackDialog(FragmentActivity activity, Callback callback) { + this.activity = new WeakReference<>(activity); + this.callback = callback; + } + + /** 展示或原地刷新。同一 feedbackId 的后续调用只替换正文。 */ + public void show(AdFeedbackSession session) { + if (session == null) return; + runOnMain(() -> showInternal(session)); + } + + @Override + public void close() { + runOnMain(() -> { + if (closed) return; + closed = true; + dismissInternal(); + activity.clear(); + }); + } + + private void showInternal(AdFeedbackSession session) { + FragmentActivity owner = usableActivity(); + if (owner == null || closed) return; + + // 同一次反馈的第二屏:只换正文,避免对话框闪一下丢焦点 + if (dialog != null && dialog.isShowing() && session.feedbackId().equals(feedbackId)) { + if (body != null) body.setText(describe(owner, session)); + applyButtons(session); + return; + } + + dismissInternal(); + feedbackId = session.feedbackId(); + + ScrollView scroll = new ScrollView(owner); + LinearLayout content = new LinearLayout(owner); + content.setOrientation(LinearLayout.VERTICAL); + int padding = ResUtil.dp2px(20); + content.setPadding(padding, padding, padding, padding); + body = new TextView(owner); + body.setTextSize(14f); + body.setLineSpacing(0f, 1.2f); + body.setText(describe(owner, session)); + content.addView(body, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + scroll.addView(content); + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(owner) + .setTitle(title(owner, session)) + .setView(scroll) + .setNegativeButton(R.string.ad_interval_dismiss, (d, which) -> d.dismiss()); + if (session.hasActionablePlan()) { + builder.setPositiveButton(R.string.ad_interval_save, (d, which) -> onSave(session)); + } + dialog = builder.create(); + dialog.setOnDismissListener(d -> { + dialog = null; + body = null; + }); + dialog.show(); + centerButtons(); + } + + /** + * 归因完成后可能才出现「保存规则」按钮。AlertDialog 不支持事后加按钮, + * 因此重建一次;此时用户尚未与内容交互,重建代价可接受。 + */ + private void applyButtons(AdFeedbackSession session) { + if (dialog == null) return; + boolean hasSave = dialog.getButton(AlertDialog.BUTTON_POSITIVE) != null + && dialog.getButton(AlertDialog.BUTTON_POSITIVE).getVisibility() == View.VISIBLE; + if (session.hasActionablePlan() == hasSave) return; + feedbackId = null; + showInternal(session); + } + + private void onSave(AdFeedbackSession session) { + if (callback == null || session.verdict() == null) return; + AdAttribution plan = session.verdict().preferred(); + if (plan != null) callback.onSave(plan); + } + + private String title(FragmentActivity owner, AdFeedbackSession session) { + return owner.getString(R.string.ad_interval_title, + clock(session.startMs()), clock(session.endMs())); + } + + private String describe(FragmentActivity owner, AdFeedbackSession session) { + StringBuilder text = new StringBuilder(); + text.append(owner.getString(R.string.ad_interval_duration, session.durationMs() / 1000f)); + text.append('\n').append(originText(owner, session)); + text.append('\n').append(owner.getString(session.skipApplied() + ? R.string.ad_interval_skipped : R.string.ad_interval_recorded)); + + if (!session.analysisComplete()) { + text.append("\n\n").append(owner.getString(R.string.ad_interval_analyzing)); + return text.toString(); + } + + AdAttribution preferred = session.verdict().preferred(); + text.append("\n\n").append(planText(owner, preferred)); + if (preferred != null) { + text.append(" ").append(riskText(owner, preferred.risk())); + text.append(String.format(Locale.US, " %d%%", + Math.round(preferred.confidence() * 100))); + } + + appendLines(text, owner.getString(R.string.ad_interval_evidence), + session.preferredEvidence()); + if (session.verdict() != null && !session.verdict().diagnostics().isEmpty()) { + StringBuilder diagnostics = new StringBuilder(); + for (AdAttribution diagnostic : session.verdict().diagnostics()) { + for (String line : diagnostic.evidence()) { + if (diagnostics.length() > 0) diagnostics.append('\n'); + diagnostics.append("· ").append(line); + } + } + if (diagnostics.length() > 0) { + text.append("\n\n").append(owner.getString(R.string.ad_interval_diagnostics)) + .append('\n').append(diagnostics); + } + } + return text.toString(); + } + + private static void appendLines(StringBuilder text, String header, List lines) { + if (lines == null || lines.isEmpty()) return; + text.append("\n\n").append(header); + for (String line : lines) text.append("\n· ").append(line); + } + + private static String originText(FragmentActivity owner, AdFeedbackSession session) { + StartOrigin origin = session.startOrigin(); + if (origin == StartOrigin.USER_MARKED) return owner.getString(R.string.ad_interval_origin_marked); + if (origin == StartOrigin.DISCONTINUITY) return owner.getString(R.string.ad_interval_origin_discontinuity); + if (origin == StartOrigin.CROSS_DOMAIN) return owner.getString(R.string.ad_interval_origin_cross_domain); + if (origin == StartOrigin.AUDIO_CANDIDATE) return owner.getString(R.string.ad_interval_origin_audio); + return owner.getString(R.string.ad_interval_origin_window, + (int) TimeUnit.MILLISECONDS.toSeconds(session.durationMs())); + } + + private static String planText(FragmentActivity owner, AdAttribution plan) { + if (plan == null) return owner.getString(R.string.ad_interval_plan_none); + RemediationKind kind = plan.remediation(); + if (kind == RemediationKind.ENABLE_EXISTING_RULE) return owner.getString(R.string.ad_interval_plan_enable); + if (kind == RemediationKind.HOST_BLACKLIST) return owner.getString(R.string.ad_interval_plan_host); + if (kind == RemediationKind.HLS_STRUCTURED_RULE) return owner.getString(R.string.ad_interval_plan_hls); + if (kind == RemediationKind.URL_REGEX_RULE) return owner.getString(R.string.ad_interval_plan_regex); + return owner.getString(R.string.ad_interval_plan_none); + } + + private static String riskText(FragmentActivity owner, RiskLevel risk) { + if (risk == RiskLevel.LOW) return owner.getString(R.string.ad_interval_risk_low); + if (risk == RiskLevel.HIGH) return owner.getString(R.string.ad_interval_risk_high); + return owner.getString(R.string.ad_interval_risk_medium); + } + + /** mm:ss,超过一小时用 h:mm:ss。 */ + static String clock(long millis) { + long total = Math.max(0L, millis) / 1000L; + long hours = total / 3600L; + long minutes = (total % 3600L) / 60L; + long seconds = total % 60L; + return hours > 0 + ? String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds) + : String.format(Locale.US, "%02d:%02d", minutes, seconds); + } + + /** TV 上按钮默认贴右,遥控器操作时居中更容易命中。 */ + private void centerButtons() { + if (dialog == null || dialog.getWindow() == null) return; + View parent = dialog.getWindow().findViewById(android.R.id.button1); + if (parent != null && parent.getParent() instanceof LinearLayout buttons) { + buttons.setGravity(Gravity.CENTER_HORIZONTAL); + } + } + + private void dismissInternal() { + if (dialog != null) { + dialog.setOnDismissListener(null); + dialog.dismiss(); + } + dialog = null; + body = null; + } + + private FragmentActivity usableActivity() { + FragmentActivity owner = activity.get(); + if (owner == null || owner.isFinishing() || owner.isDestroyed()) return null; + return owner; + } + + private void runOnMain(Runnable task) { + if (Looper.myLooper() == Looper.getMainLooper()) task.run(); + else main.post(task); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialog.java index 676e6f9816..b60901c0ee 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialog.java @@ -114,12 +114,11 @@ public class AiRecommendationInfoDialog { dialog.dismiss(); }); confirm.setOnClickListener(v -> dialog.dismiss()); + detailScroll.setFocusableInTouchMode(Util.isLeanback()); + notInterested.setFocusableInTouchMode(Util.isLeanback()); + confirm.setFocusableInTouchMode(Util.isLeanback()); dialog.show(); if (Util.isLeanback()) { - detailScroll.setFocusable(true); - detailScroll.setFocusableInTouchMode(true); - notInterested.setFocusableInTouchMode(true); - confirm.setFocusableInTouchMode(true); installDetailScrollKeys(activity, detailScroll, notInterested); confirm.post(confirm::requestFocus); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java index 47ebe0b9aa..c43dd2d0cb 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java @@ -9,7 +9,6 @@ import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.viewbinding.ViewBinding; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -29,7 +28,7 @@ public abstract class BaseAlertDialog extends DialogFragment { } protected MaterialAlertDialogBuilder builder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.MaterialAlertDialog_WebHTV_Rounded); + return new MaterialAlertDialogBuilder(requireActivity()); } protected void initView() { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java index 5885bbcb63..e6453f43ce 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java @@ -13,7 +13,6 @@ import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.view.ViewParent; import android.view.Window; import android.view.WindowManager; import android.widget.LinearLayout; @@ -273,7 +272,7 @@ public final class ChoiceDialog extends DialogFragment { for (int offset = 0; offset < list.getChildCount(); offset++) { View child = list.getChildAt((start + offset) % list.getChildCount()); if (child.isEnabled() && child.isFocusable()) { - requestItemFocus(list, child); + child.requestFocus(); return; } } @@ -330,46 +329,11 @@ public final class ChoiceDialog extends DialogFragment { if (!(listView instanceof ViewGroup list)) return false; for (int index = position + direction; index >= 0 && index < list.getChildCount(); index += direction) { View child = list.getChildAt(index); - if (child.isEnabled() && child.isFocusable() && requestItemFocus(list, child)) return true; + if (child.isEnabled() && child.isFocusable() && child.requestFocus()) return true; } return direction > 0 ? focusFirstAction(root) : true; } - private boolean requestItemFocus(ViewGroup list, View child) { - if (!child.requestFocus()) return false; - ensureItemVisible(list, child); - return true; - } - - private void ensureItemVisible(View child) { - ViewParent parent = child.getParent(); - while (parent instanceof View) { - if (parent instanceof ViewGroup list && "choice_list".equals(list.getTag())) { - ensureItemVisible(list, child); - return; - } - parent = parent.getParent(); - } - } - - private void ensureItemVisible(ViewGroup list, View child) { - if (!(list.getParent() instanceof ScrollView scroll)) return; - int target = calculateItemScrollY(scroll.getScrollY(), scroll.getHeight(), list.getHeight(), child.getTop(), child.getBottom()); - if (target != scroll.getScrollY()) scroll.scrollTo(0, target); - } - - static int calculateItemScrollY(int currentScrollY, int viewportHeight, int contentHeight, int childTop, int childBottom) { - int current = Math.max(0, currentScrollY); - int viewport = Math.max(0, viewportHeight); - int content = Math.max(0, contentHeight); - int maxScroll = Math.max(0, content - viewport); - if (viewport == 0) return Math.min(current, maxScroll); - int target = current; - if (childTop < current) target = childTop; - else if (childBottom > current + viewport) target = childBottom - viewport; - return Math.max(0, Math.min(target, maxScroll)); - } - private boolean focusFirstAction(View root) { View actionView = root.findViewWithTag("choice_actions"); if (!(actionView instanceof ViewGroup actions)) return true; @@ -443,10 +407,7 @@ public final class ChoiceDialog extends DialogFragment { setItemEnabled(button, position); button.setText(itemText(position)); styleItem(button, position); - button.setOnFocusChangeListener((view, hasFocus) -> { - styleItem(button, position); - if (hasFocus && Util.isLeanback()) button.post(() -> ensureItemVisible(button)); - }); + button.setOnFocusChangeListener((view, hasFocus) -> styleItem(button, position)); button.setOnClickListener(view -> onItemClick(position)); button.setOnKeyListener((view, keyCode, event) -> { if (!Util.isLeanback() || event == null || event.getAction() != KeyEvent.ACTION_DOWN) return false; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuDialog.java index cebae240ba..1a1a1a3ac8 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuDialog.java @@ -32,8 +32,6 @@ public final class DanmakuDialog extends BaseBottomSheetDialog implements Danmak private String vodId; private String rawTitle; private String episodeName; - private int tmdbId; - private int tmdbSeasonNumber; public interface Host { @@ -61,12 +59,6 @@ public final class DanmakuDialog extends BaseBottomSheetDialog implements Danmak return this; } - public DanmakuDialog tmdb(int tmdbId, int tmdbSeasonNumber) { - this.tmdbId = tmdbId; - this.tmdbSeasonNumber = tmdbSeasonNumber; - return this; - } - public void show(FragmentActivity activity) { for (Fragment f : activity.getSupportFragmentManager().getFragments()) if (f instanceof DanmakuDialog) return; show(activity.getSupportFragmentManager(), null); @@ -113,10 +105,8 @@ public final class DanmakuDialog extends BaseBottomSheetDialog implements Danmak FragmentActivity activity = getActivity(); if (activity == null) return; dismissAllowingStateLoss(); - if (shouldUseInputDialog(activity)) - DanmakuSearchInputDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).tmdb(tmdbId, tmdbSeasonNumber).restoreParent(true).show(activity); - else - DanmakuSearchDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).tmdb(tmdbId, tmdbSeasonNumber).restoreParent(true).show(activity); + if (shouldUseInputDialog(activity)) DanmakuSearchInputDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).restoreParent(true).show(activity); + else DanmakuSearchDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).restoreParent(true).show(activity); } private boolean shouldUseInputDialog(FragmentActivity activity) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchDialog.java index 94833d41fc..f9f2f6d1fd 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchDialog.java @@ -89,9 +89,6 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac private String vodId; private String rawTitle; private String episodeName; - private int tmdbId; - private int tmdbSeasonNumber; - private final DanmakuSearchIntent searchIntent = new DanmakuSearchIntent(); public static DanmakuSearchDialog create() { return new DanmakuSearchDialog(); @@ -136,12 +133,6 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac return this; } - public DanmakuSearchDialog tmdb(int tmdbId, int tmdbSeasonNumber) { - this.tmdbId = tmdbId; - this.tmdbSeasonNumber = tmdbSeasonNumber; - return this; - } - public void show(FragmentActivity activity) { for (Fragment f : activity.getSupportFragmentManager().getFragments()) if (f instanceof DanmakuSearchDialog) return; show(activity.getSupportFragmentManager(), null); @@ -187,7 +178,6 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac public void onDestroyView() { super.onDestroyView(); activeCall = null; - searchIntent.cancel(); DanmakuApi.cancel(); } @@ -196,7 +186,7 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac super.onDismiss(dialog); FragmentActivity activity = getActivity(); if (!restoreParent || activity == null || activity.isFinishing()) return; - DanmakuDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).tmdb(tmdbId, tmdbSeasonNumber).show(activity); + DanmakuDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).show(activity); } private LinearLayout createContentView() { @@ -361,10 +351,7 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac private void rememberManualDanmaku(Danmaku item) { if (TextUtils.isEmpty(siteKey) || TextUtils.isEmpty(vodId) || item == null || item.isEmpty()) return; DanmakuMatchCache cache = Setting.getDanmakuMatchCache(); - String keyword = searchIntent.getResultKeyword(); - MediaTitleLearningExample example = cache.put(siteKey, vodId, first(episodeName, getEpisode().toString().trim()), keyword, first(rawTitle, getTitle().toString()), item); - cache.putSeries(siteKey, vodId, keyword, first(rawTitle, getTitle().toString()), item); - cache.putTmdbSeason(tmdbId, tmdbSeasonNumber, keyword, first(rawTitle, getTitle().toString()), item); + MediaTitleLearningExample example = cache.put(siteKey, vodId, first(episodeName, getEpisode().toString().trim()), getKeywordText(), first(rawTitle, getTitle().toString()), item); Setting.putDanmakuMatchCache(cache); if (example != null) MediaTitleLearningStore.load().put(example); } @@ -414,26 +401,19 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac } private void search() { - String keyword = getKeywordText(); showProgress(); activeCall = null; - searchIntent.cancel(); Util.hideKeyboard(keywordView); - Call call = DanmakuApi.newCall(keyword, getEpisode().toString().trim()); + Call call = DanmakuApi.newCall(getKeywordText(), getEpisode().toString().trim()); if (call == null) { hideProgress(true); Notify.show(R.string.danmaku_api_invalid); return; } - searchIntent.begin(call, keyword); activeCall = call; call.enqueue(this); } - private boolean isCurrentResult(Call call) { - return call == activeCall && searchIntent.isCurrent(call); - } - private void onSuccess(List items) { syncCurrentState(items); buildGroups(items); @@ -608,27 +588,21 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - if (call != activeCall || !searchIntent.complete(call)) return; + if (call != activeCall) return; try { String body = response.body() == null ? "" : response.body().string(); List items = DanmakuApi.arrayFrom(body); if (items.isEmpty()) throw new Exception(ResUtil.getString(R.string.error_empty)); - App.post(() -> { - if (isCurrentResult(call)) onSuccess(items); - }); + App.post(() -> onSuccess(items)); } catch (Exception e) { - App.post(() -> { - if (isCurrentResult(call)) onError(e); - }); + App.post(() -> onError(e)); } } @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { - if (call != activeCall || !searchIntent.complete(call)) return; - App.post(() -> { - if (isCurrentResult(call)) onError(e); - }); + if (call != activeCall) return; + App.post(() -> onError(e)); } private int dp(int value) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchInputDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchInputDialog.java index 99ebcbc219..2f3109f219 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchInputDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchInputDialog.java @@ -88,9 +88,6 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca private String vodId; private String rawTitle; private String episodeName; - private int tmdbId; - private int tmdbSeasonNumber; - private final DanmakuSearchIntent searchIntent = new DanmakuSearchIntent(); public static DanmakuSearchInputDialog create() { return new DanmakuSearchInputDialog(); @@ -119,12 +116,6 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca return this; } - public DanmakuSearchInputDialog tmdb(int tmdbId, int tmdbSeasonNumber) { - this.tmdbId = tmdbId; - this.tmdbSeasonNumber = tmdbSeasonNumber; - return this; - } - public void show(FragmentActivity activity) { for (Fragment f : activity.getSupportFragmentManager().getFragments()) if (f instanceof DanmakuSearchInputDialog) return; show(activity.getSupportFragmentManager(), null); @@ -167,14 +158,13 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca super.onDismiss(dialog); FragmentActivity activity = getActivity(); if (selected || !restoreParent || activity == null || activity.isFinishing()) return; - DanmakuDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).tmdb(tmdbId, tmdbSeasonNumber).show(activity); + DanmakuDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).show(activity); } @Override public void onDestroyView() { super.onDestroyView(); activeCall = null; - searchIntent.cancel(); DanmakuApi.cancel(); } @@ -189,10 +179,7 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca private void rememberManualDanmaku(Danmaku item) { if (TextUtils.isEmpty(siteKey) || TextUtils.isEmpty(vodId) || item == null || item.isEmpty()) return; DanmakuMatchCache cache = Setting.getDanmakuMatchCache(); - String keyword = searchIntent.getResultKeyword(); - MediaTitleLearningExample example = cache.put(siteKey, vodId, first(episodeName, getEpisode()), keyword, first(rawTitle, getTitle()), item); - cache.putSeries(siteKey, vodId, keyword, first(rawTitle, getTitle()), item); - cache.putTmdbSeason(tmdbId, tmdbSeasonNumber, keyword, first(rawTitle, getTitle()), item); + MediaTitleLearningExample example = cache.put(siteKey, vodId, first(episodeName, getEpisode()), getKeyword(), first(rawTitle, getTitle()), item); Setting.putDanmakuMatchCache(cache); if (example != null) MediaTitleLearningStore.load().put(example); } @@ -366,22 +353,16 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca input.setError(null); showProgress(); activeCall = null; - searchIntent.cancel(); Util.hideKeyboard(input); Call call = DanmakuApi.newCall(keyword, getEpisode()); if (call == null) { showError(getString(R.string.danmaku_api_invalid)); return; } - searchIntent.begin(call, keyword); activeCall = call; call.enqueue(this); } - private boolean isCurrentResult(Call call) { - return call == activeCall && searchIntent.isCurrent(call); - } - private String getEpisode() { CharSequence episode = player == null || player.getMetadata() == null ? "" : player.getMetadata().artist; return episode == null ? "" : episode.toString().trim(); @@ -578,27 +559,21 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - if (call != activeCall || !searchIntent.complete(call)) return; + if (call != activeCall) return; try { String body = response.body() == null ? "" : response.body().string(); List items = DanmakuApi.arrayFrom(body); if (items.isEmpty()) throw new Exception(ResUtil.getString(R.string.error_empty)); - App.post(() -> { - if (isCurrentResult(call)) onSuccess(items); - }); + App.post(() -> onSuccess(items)); } catch (Exception e) { - App.post(() -> { - if (isCurrentResult(call)) showError(e.getMessage()); - }); + App.post(() -> showError(e.getMessage())); } } @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { - if (call != activeCall || !searchIntent.complete(call)) return; - App.post(() -> { - if (isCurrentResult(call)) showError(e.getMessage()); - }); + if (call != activeCall) return; + App.post(() -> showError(e.getMessage())); } private int dp(int value) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchIntent.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchIntent.java deleted file mode 100644 index ccb7838805..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchIntent.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -final class DanmakuSearchIntent { - - private Object currentRequest; - private String submittedKeyword = ""; - private String resultKeyword = ""; - - synchronized void begin(Object request, String keyword) { - currentRequest = request; - submittedKeyword = clean(keyword); - resultKeyword = ""; - } - - synchronized boolean complete(Object request) { - if (request == null || request != currentRequest) return false; - resultKeyword = submittedKeyword; - return true; - } - - synchronized boolean isCurrent(Object request) { - return request != null && request == currentRequest; - } - - synchronized void cancel() { - currentRequest = null; - submittedKeyword = ""; - resultKeyword = ""; - } - - synchronized String getResultKeyword() { - return resultKeyword; - } - - private static String clean(String value) { - return value == null ? "" : value.trim(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DebugLogDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DebugLogDialog.java index 25d2b689a5..bb453b4617 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DebugLogDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DebugLogDialog.java @@ -38,38 +38,7 @@ public final class DebugLogDialog { content.setTextColor(Color.parseColor("#5F6368")); content.setTextSize(14); content.setLineSpacing(ResUtil.dp2px(2), 1f); - android.widget.LinearLayout panel = new android.widget.LinearLayout(activity); panel.setOrientation(android.widget.LinearLayout.VERTICAL); - panel.addView(content); - for (com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category category : com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category.values()) { - androidx.appcompat.widget.SwitchCompat toggle = new androidx.appcompat.widget.SwitchCompat(activity); - toggle.setText(category.title); - toggle.setTextColor(Color.parseColor("#202124")); - toggle.setPadding(0, ResUtil.dp2px(8), 0, ResUtil.dp2px(8)); - toggle.setFocusable(true); - toggle.setChecked(com.github.catvod.crawler.diagnostics.DiagnosticCategories.accepts(com.github.catvod.crawler.DebugLogStore.categories(), category)); - toggle.setOnCheckedChangeListener((button, checked) -> com.github.catvod.crawler.DebugLogStore.setCategory(category, checked)); - panel.addView(toggle); - } - MaterialTextView captureNote = new MaterialTextView(activity); - captureNote.setText("标准日志按容量轮转;深度统计只保留数值,不保存画面或声音。"); - captureNote.setTextSize(14); panel.addView(captureNote); - android.widget.Button mark = new android.widget.Button(activity); mark.setText("标记此刻故障"); mark.setFocusable(true); panel.addView(mark); - mark.setOnClickListener(v -> new androidx.appcompat.app.AlertDialog.Builder(activity).setTitle("选择当前现象") - .setItems(com.fongmi.android.tv.player.DiagnosticControls.SYMPTOMS, (d, which) -> { - try { com.fongmi.android.tv.player.DiagnosticControls.mark(com.fongmi.android.tv.player.DiagnosticControls.SYMPTOMS[which]); Notify.show("已标记,继续记录后 15 秒"); } - catch (RuntimeException error) { Notify.show(error.getMessage()); } - }).show()); - android.widget.Button depth = new android.widget.Button(activity); depth.setText("深度统计 60 秒"); depth.setFocusable(true); panel.addView(depth); - depth.setOnClickListener(v -> new androidx.appcompat.app.AlertDialog.Builder(activity).setTitle("限时深度统计") - .setMessage("对当前播放做少量低分辨率画面和 PCM 数值统计,不保存图像或声音。到期、切换播放或关闭诊断自动停止。") - .setNegativeButton("取消", null).setPositiveButton("开启 60 秒", (d, which) -> { - try { com.fongmi.android.tv.player.DiagnosticControls.startDepth(60); Notify.show("限时统计已开启"); } - catch (RuntimeException error) { Notify.show(error.getMessage()); } - }).show()); - android.widget.Button stop = new android.widget.Button(activity); stop.setText("停止深度统计"); stop.setFocusable(true); panel.addView(stop); - stop.setOnClickListener(v -> { com.github.catvod.crawler.diagnostics.DiagnosticCapture.stop("user-stopped"); Notify.show("深度统计已停止"); }); - android.widget.ScrollView scroll = new android.widget.ScrollView(activity); scroll.addView(panel); - android.app.Dialog dialog = LightDialog.create(activity, activity.getString(R.string.setting_debug_log), scroll, activity.getString(R.string.debug_log_open_browser), v -> open(activity, localUrl), activity.getString(R.string.dialog_negative), null, activity.getString(R.string.debug_log_copy_url), v -> copy(activity, lanUrl)); + android.app.Dialog dialog = LightDialog.create(activity, activity.getString(R.string.setting_debug_log), content, activity.getString(R.string.debug_log_open_browser), v -> open(activity, localUrl), activity.getString(R.string.dialog_negative), null, activity.getString(R.string.debug_log_copy_url), v -> copy(activity, lanUrl)); dialog.show(); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DiscMenuDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DiscMenuDialog.java deleted file mode 100644 index 3d9192b304..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DiscMenuDialog.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Dialog; -import android.os.Bundle; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; -import android.widget.FrameLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentActivity; -import androidx.media3.mpvplayer.MpvPlayer; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.databinding.DialogDiscMenuBinding; -import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Util; -import com.google.android.material.bottomsheet.BottomSheetDialog; - -public final class DiscMenuDialog extends BaseBottomSheetDialog { - - private static final String TAG = "disc-menu-controls"; - - private DialogDiscMenuBinding binding; - private MpvPlayer player; - - public static void show(FragmentActivity activity, MpvPlayer player) { - if (activity.getSupportFragmentManager().isStateSaved() - || activity.getSupportFragmentManager().findFragmentByTag(TAG) != null) return; - DiscMenuDialog dialog = new DiscMenuDialog(); - dialog.player = player; - dialog.show(activity.getSupportFragmentManager(), TAG); - } - - @Override - protected ViewBinding getBinding(@NonNull LayoutInflater inflater, @Nullable ViewGroup container) { - return binding = DialogDiscMenuBinding.inflate(inflater, container, false); - } - - @Override - protected void initView() { - if (player == null || !player.isDiscMenuAvailable()) { - dismiss(); - return; - } - binding.up.setOnClickListener(view -> player.sendDiscNav("up")); - binding.down.setOnClickListener(view -> player.sendDiscNav("down")); - binding.left.setOnClickListener(view -> player.sendDiscNav("left")); - binding.right.setOnClickListener(view -> player.sendDiscNav("right")); - binding.select.setOnClickListener(view -> activate("select")); - binding.rootMenu.setOnClickListener(view -> activate("menu")); - binding.popup.setOnClickListener(view -> activate("popup")); - binding.previous.setOnClickListener(view -> activate("prev")); - binding.close.setOnClickListener(view -> dismiss()); - } - - private void activate(String action) { - if (player.sendDiscNav(action)) dismiss(); - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - Dialog dialog = super.onCreateDialog(savedInstanceState); - Window window = dialog.getWindow(); - if (window != null) { - window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - window.setDimAmount(0f); - } - return dialog; - } - - @Override - protected void setBehavior(BottomSheetDialog dialog) { - super.setBehavior(dialog); - FrameLayout sheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); - if (sheet != null) { - sheet.setBackgroundColor(android.graphics.Color.TRANSPARENT); - ViewGroup.LayoutParams params = sheet.getLayoutParams(); - params.width = Math.min(ResUtil.dp2px(384), - getResources().getDisplayMetrics().widthPixels - ResUtil.dp2px(24)); - if (params instanceof CoordinatorLayout.LayoutParams layout) { - layout.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; - layout.bottomMargin = ResUtil.dp2px(8); - } - sheet.setLayoutParams(params); - } - Window window = dialog.getWindow(); - if (window != null && getActivity() != null && Util.isFullscreen(getActivity())) - Util.hideSystemUI(window); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java index be0b5c43ce..5695bd6121 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java @@ -21,7 +21,6 @@ import androidx.core.content.ContextCompat; import androidx.core.widget.TextViewCompat; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.button.MaterialButton; @@ -36,7 +35,6 @@ public final class LightDialog { if (dialog == null) return; Window window = dialog.getWindow(); if (window == null) return; - TouchOptimizationHelper.sync(dialog); Drawable background = ContextCompat.getDrawable(dialog.getContext(), R.drawable.shape_shell_proxy_dialog); if (background == null) return; int verticalInset = (int) (dialog.getContext().getResources().getDisplayMetrics().density * 24); @@ -126,10 +124,7 @@ public final class LightDialog { dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); dialog.setContentView(root(context, title, content, positive, listener(dialog, onPositive), negative, listener(dialog, onNegative), neutral, listener(dialog, onNeutral), heightPx > 0)); dialog.setCanceledOnTouchOutside(true); - dialog.setOnShowListener(d -> { - applyWindow(dialog, context, landFactor, portFactor, maxDp, heightPx); - TouchOptimizationHelper.sync(dialog); - }); + dialog.setOnShowListener(d -> applyWindow(dialog, context, landFactor, portFactor, maxDp, heightPx)); return dialog; } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigCreateDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigCreateDialog.java index 92ec9af1ff..e07961b4f8 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigCreateDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigCreateDialog.java @@ -6,8 +6,6 @@ import android.content.Intent; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.text.TextUtils; -import android.text.Editable; -import android.text.TextWatcher; import android.view.View; import android.view.Window; import android.view.WindowManager; @@ -27,31 +25,18 @@ import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.button.MaterialButtonToggleGroup; -import com.google.android.material.textfield.TextInputEditText; -import com.google.android.material.switchmaterial.SwitchMaterial; public class MpvConfigCreateDialog extends BaseAlertDialog { public interface Listener { - void onText(String name, boolean buttonEnabled, String trigger); + void onText(String name); - void onImport(String name, String path, boolean buttonEnabled, String trigger); + void onImport(String name, String path); } private DialogMpvConfigCreateBinding binding; private Listener listener; private String target; - private boolean scriptButtonMode; - private boolean scriptSettingsMode; - private String scriptId; - private String scriptContent; - private MpvConfigStore.CustomButton sourceButton; - private Runnable buttonCallback; - private String shortCode; - private String longCode; - private String startupCode; - private int triggerId; public static void show(FragmentManager manager, String target, Listener listener) { MpvConfigCreateDialog dialog = new MpvConfigCreateDialog(); @@ -60,28 +45,6 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { dialog.show(manager, "mpv-config-create"); } - public static void showScriptButton(FragmentManager manager, @Nullable MpvConfigStore.CustomButton source, Runnable callback) { - MpvConfigCreateDialog dialog = new MpvConfigCreateDialog(); - dialog.target = MpvConfigStore.TARGET_SCRIPTS; - dialog.scriptButtonMode = true; - dialog.sourceButton = source; - dialog.buttonCallback = callback; - dialog.show(manager, "mpv-script-button"); - } - - public static void showScriptSettings(FragmentManager manager, String scriptId, String title, - String content, @Nullable MpvConfigStore.CustomButton source, - Runnable callback) { - MpvConfigCreateDialog dialog = new MpvConfigCreateDialog(); - dialog.target = MpvConfigStore.TARGET_SCRIPTS; - dialog.scriptSettingsMode = true; - dialog.scriptId = scriptId; - dialog.scriptContent = content == null ? "" : content; - dialog.sourceButton = source; - dialog.buttonCallback = callback; - dialog.show(manager, "mpv-script-settings"); - } - @Override protected ViewBinding getBinding() { return binding = DialogMpvConfigCreateBinding.inflate(getLayoutInflater()); @@ -94,41 +57,13 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { @Override protected void initView() { - if (scriptSettingsMode) setupScriptSettings(); - else if (scriptButtonMode) setupScriptButton(); - else if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) setupScriptCreation(); - else setupTvFocus(); + setupTvFocus(); } @Override protected void initEvent() { binding.close.setOnClickListener(view -> dismiss()); - binding.triggerGroup.addOnButtonCheckedListener(this::onTriggerChecked); - if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) { - updateScriptTrigger(binding.buttonEnabled.isChecked(), triggerId); - binding.buttonEnabled.setOnCheckedChangeListener((button, enabled) -> - updateScriptTrigger(enabled, enabled ? R.id.triggerClick : R.id.triggerStartup)); - } - binding.scriptCode.addTextChangedListener(new TextWatcher() { - @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } - @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - updateScriptStats(s == null ? "" : s.toString()); - } - @Override public void afterTextChanged(Editable s) { } - }); - binding.buttonCancel.setOnClickListener(view -> { - if (scriptButtonMode && sourceButton == null) showScriptCreation(); - else dismissAllowingStateLoss(); - }); - binding.buttonSave.setOnClickListener(view -> { - if (scriptSettingsMode) saveScriptSettings(); - else saveScriptButton(); - }); - binding.scriptEdit.setOnClickListener(view -> openScriptEditor()); binding.textOption.setOnClickListener(view -> createText()); - if (scriptButtonMode) { - return; - } binding.urlOption.setOnClickListener(view -> showUrlInput()); binding.importOption.setOnClickListener(view -> chooseFile()); binding.urlBack.setOnClickListener(view -> showOptions()); @@ -156,22 +91,8 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { tvFocusable(binding.urlImport); binding.close.setNextFocusDownId(R.id.name); binding.name.setNextFocusUpId(R.id.close); - if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) { - tvFocusable(binding.buttonEnabled); - tvFocusable(binding.triggerClick); - tvFocusable(binding.triggerLong); - tvFocusable(binding.triggerStartup); - binding.name.setNextFocusDownId(R.id.buttonEnabled); - binding.buttonEnabled.setNextFocusUpId(R.id.name); - binding.buttonEnabled.setNextFocusDownId(R.id.triggerClick); - binding.triggerClick.setNextFocusUpId(R.id.buttonEnabled); - binding.triggerClick.setNextFocusDownId(R.id.textOption); - binding.triggerLong.setNextFocusDownId(R.id.textOption); - binding.triggerStartup.setNextFocusDownId(R.id.textOption); - } else { - binding.name.setNextFocusDownId(R.id.textOption); - } - binding.textOption.setNextFocusUpId(MpvConfigStore.TARGET_SCRIPTS.equals(target) ? R.id.triggerClick : R.id.name); + binding.name.setNextFocusDownId(R.id.textOption); + binding.textOption.setNextFocusUpId(R.id.name); binding.textOption.setNextFocusDownId(R.id.urlOption); binding.urlOption.setNextFocusUpId(R.id.textOption); binding.urlOption.setNextFocusDownId(R.id.importOption); @@ -184,263 +105,6 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { binding.urlImport.setNextFocusLeftId(R.id.urlBack); } - private void setupScriptButton() { - binding.createTitle.setText(getString(sourceButton == null ? R.string.mpv_config_custom_button_new : R.string.mpv_config_custom_button_edit)); - binding.chooseAction.setText(R.string.mpv_config_custom_button_new_desc); - binding.chooseAction.setVisibility(View.VISIBLE); - binding.nameLabel.setText(R.string.mpv_config_custom_button_name); - binding.name.setHint(R.string.mpv_config_custom_button_name_hint); - binding.name.setText(sourceButton == null ? "" : sourceButton.title); - binding.methodLabel.setVisibility(View.GONE); - binding.textOption.setVisibility(View.GONE); - binding.urlOption.setVisibility(View.GONE); - binding.importOption.setVisibility(View.GONE); - binding.urlPanel.setVisibility(View.GONE); - binding.scriptSettingsPanel.setVisibility(View.VISIBLE); - binding.scriptButtonPanel.setVisibility(View.VISIBLE); - binding.buttonEnabled.setChecked(sourceButton == null || sourceButton.enabled); - shortCode = sourceButton == null ? "" : value(sourceButton.content); - longCode = sourceButton == null ? "" : value(sourceButton.longPressContent); - startupCode = sourceButton == null ? "" : value(sourceButton.onStartup); - if (triggerId == 0) triggerId = R.id.triggerClick; - binding.triggerGroup.check(triggerId); - binding.scriptCode.setText(shortCode); - binding.scriptCode.setSelection(binding.scriptCode.length()); - updateScriptStats(shortCode); - if (Util.isLeanback()) { - tvFocusable(binding.close); - tvFocusable(binding.name); - tvFocusable(binding.buttonEnabled); - tvFocusable(binding.triggerClick); - tvFocusable(binding.triggerLong); - tvFocusable(binding.triggerStartup); - tvFocusable(binding.scriptCode); - tvFocusable(binding.buttonCancel); - tvFocusable(binding.buttonSave); - binding.close.setNextFocusDownId(R.id.name); - binding.name.setNextFocusUpId(R.id.close); - binding.name.setNextFocusDownId(R.id.buttonEnabled); - binding.buttonEnabled.setNextFocusUpId(R.id.name); - binding.buttonEnabled.setNextFocusDownId(R.id.triggerClick); - binding.triggerClick.setNextFocusUpId(R.id.buttonEnabled); - binding.triggerClick.setNextFocusDownId(R.id.scriptCode); - binding.triggerLong.setNextFocusDownId(R.id.scriptCode); - binding.triggerStartup.setNextFocusDownId(R.id.scriptCode); - binding.scriptCode.setNextFocusUpId(R.id.triggerClick); - binding.scriptCode.setNextFocusDownId(R.id.buttonCancel); - binding.buttonCancel.setNextFocusUpId(R.id.scriptCode); - binding.buttonCancel.setNextFocusRightId(R.id.buttonSave); - binding.buttonSave.setNextFocusLeftId(R.id.buttonCancel); - } - } - - private void setupScriptSettings() { - binding.createTitle.setText(R.string.mpv_config_script_settings_title); - binding.chooseAction.setText(R.string.mpv_config_script_settings_desc); - binding.chooseAction.setVisibility(View.VISIBLE); - binding.nameLabel.setText(R.string.mpv_config_custom_button_name); - binding.name.setHint(R.string.mpv_config_custom_button_name_hint); - binding.name.setText(sourceButton == null ? scriptButtonDefaultName(scriptId) : sourceButton.title); - binding.methodLabel.setVisibility(View.GONE); - binding.textOption.setVisibility(View.GONE); - binding.urlOption.setVisibility(View.GONE); - binding.importOption.setVisibility(View.GONE); - binding.urlPanel.setVisibility(View.GONE); - binding.scriptSettingsPanel.setVisibility(View.VISIBLE); - binding.scriptButtonPanel.setVisibility(View.VISIBLE); - binding.scriptCode.setVisibility(View.GONE); - binding.scriptStats.setVisibility(View.GONE); - binding.scriptEdit.setVisibility(View.VISIBLE); - binding.scriptEnabledRow.setVisibility(View.VISIBLE); - binding.scriptEnabled.setChecked(sourceButton == null || sourceButton.scriptEnabled); - binding.buttonEnabled.setChecked(sourceButton != null && sourceButton.enabled); - triggerId = triggerIdFor(MpvConfigStore.normalizeScriptTrigger(binding.buttonEnabled.isChecked(), - sourceButton == null ? null : sourceButton.trigger)); - binding.triggerGroup.check(triggerId); - if (Util.isLeanback()) { - tvFocusable(binding.close); - tvFocusable(binding.name); - tvFocusable(binding.scriptEnabled); - tvFocusable(binding.buttonEnabled); - tvFocusable(binding.triggerClick); - tvFocusable(binding.triggerLong); - tvFocusable(binding.triggerStartup); - tvFocusable(binding.scriptEdit); - tvFocusable(binding.buttonCancel); - tvFocusable(binding.buttonSave); - binding.close.setNextFocusDownId(R.id.name); - binding.name.setNextFocusUpId(R.id.close); - binding.name.setNextFocusDownId(R.id.scriptEnabled); - binding.scriptEnabled.setNextFocusUpId(R.id.name); - binding.scriptEnabled.setNextFocusDownId(R.id.buttonEnabled); - binding.buttonEnabled.setNextFocusUpId(R.id.scriptEnabled); - binding.buttonEnabled.setNextFocusDownId(R.id.triggerClick); - binding.triggerClick.setNextFocusUpId(R.id.buttonEnabled); - binding.triggerClick.setNextFocusDownId(R.id.scriptEdit); - binding.triggerLong.setNextFocusDownId(R.id.scriptEdit); - binding.triggerStartup.setNextFocusDownId(R.id.scriptEdit); - binding.scriptEdit.setNextFocusUpId(R.id.triggerClick); - binding.scriptEdit.setNextFocusDownId(R.id.buttonCancel); - binding.buttonCancel.setNextFocusUpId(R.id.scriptEdit); - binding.buttonCancel.setNextFocusRightId(R.id.buttonSave); - binding.buttonSave.setNextFocusLeftId(R.id.buttonCancel); - } - } - - private void setupScriptCreation() { - binding.createTitle.setText(R.string.mpv_config_script_new); - binding.chooseAction.setText(R.string.mpv_config_script_create_desc); - binding.nameLabel.setText(R.string.mpv_config_name_optional); - binding.name.setHint(R.string.mpv_config_custom_button_name_hint); - binding.textOptionTitle.setText(R.string.mpv_config_script_text_edit); - binding.textOptionDesc.setText(R.string.mpv_config_script_text_edit_desc); - binding.urlOptionTitle.setText(R.string.mpv_config_script_import_url); - binding.urlOptionDesc.setText(R.string.mpv_config_script_import_url_desc); - binding.importOptionTitle.setText(R.string.mpv_config_script_import_file); - binding.importOptionDesc.setText(R.string.mpv_config_script_import_file_desc); - shortCode = ""; - longCode = ""; - startupCode = ""; - triggerId = R.id.triggerClick; - binding.buttonEnabled.setChecked(true); - binding.triggerGroup.check(triggerId); - showScriptCreation(); - setupTvFocus(); - } - - private static String scriptButtonDefaultName(String scriptId) { - if (TextUtils.isEmpty(scriptId)) return ""; - if (scriptId.regionMatches(true, scriptId.length() - 4, ".lua", 0, 4) - || scriptId.regionMatches(true, scriptId.length() - 3, ".js", 0, 3)) { - return scriptId.substring(0, scriptId.lastIndexOf('.')); - } - return scriptId; - } - - private void showScriptCreation() { - scriptButtonMode = false; - binding.createTitle.setText(R.string.mpv_config_script_new); - binding.chooseAction.setVisibility(View.VISIBLE); - binding.nameLabel.setVisibility(View.VISIBLE); - binding.nameLayout.setVisibility(View.VISIBLE); - binding.scriptSettingsPanel.setVisibility(View.VISIBLE); - binding.buttonEnabled.setVisibility(View.VISIBLE); - binding.methodLabel.setVisibility(View.VISIBLE); - binding.textOption.setVisibility(View.VISIBLE); - binding.urlOption.setVisibility(View.VISIBLE); - binding.importOption.setVisibility(View.VISIBLE); - binding.urlPanel.setVisibility(View.GONE); - binding.scriptButtonPanel.setVisibility(View.GONE); - } - - private void enterScriptButtonEditor() { - scriptButtonMode = true; - setupScriptButton(); - binding.name.requestFocus(); - } - - private void onTriggerChecked(MaterialButtonToggleGroup group, int checkedId, boolean isChecked) { - if (!MpvConfigStore.TARGET_SCRIPTS.equals(target) || !isChecked || checkedId == triggerId) return; - if (!binding.buttonEnabled.isChecked() && checkedId != R.id.triggerStartup) { - group.check(R.id.triggerStartup); - return; - } - if (scriptButtonMode) saveCurrentCode(); - triggerId = checkedId; - if (scriptButtonMode) { - binding.scriptCode.setText(codeForTrigger(checkedId)); - binding.scriptCode.setSelection(binding.scriptCode.length()); - } - updateScriptTriggerFocus(); - } - - private void updateScriptTrigger(boolean enabled, int selectedId) { - // Some Material versions ignore setChecked on disabled buttons. - binding.triggerClick.setEnabled(true); - binding.triggerLong.setEnabled(true); - binding.triggerGroup.check(enabled ? selectedId : R.id.triggerStartup); - binding.triggerClick.setEnabled(enabled); - binding.triggerLong.setEnabled(enabled); - updateScriptTriggerFocus(); - } - - private void updateScriptTriggerFocus() { - if (!Util.isLeanback()) return; - boolean enabled = binding.buttonEnabled.isChecked(); - binding.triggerClick.setFocusable(enabled); - binding.triggerLong.setFocusable(enabled); - binding.buttonEnabled.setNextFocusDownId(triggerId); - binding.triggerClick.setNextFocusUpId(R.id.buttonEnabled); - binding.triggerLong.setNextFocusUpId(R.id.buttonEnabled); - binding.triggerStartup.setNextFocusUpId(R.id.buttonEnabled); - binding.textOption.setNextFocusUpId(triggerId); - binding.scriptCode.setNextFocusUpId(triggerId); - binding.scriptEdit.setNextFocusUpId(triggerId); - } - - private void saveCurrentCode() { - if (binding == null || binding.scriptCode == null) return; - String code = value(binding.scriptCode); - if (triggerId == R.id.triggerLong) longCode = code; - else if (triggerId == R.id.triggerStartup) startupCode = code; - else shortCode = code; - } - - private void updateScriptStats(String text) { - if (binding == null || binding.scriptStats == null) return; - int lines = text.isEmpty() ? 1 : text.split("\\r?\\n", -1).length; - binding.scriptStats.setText(getString(R.string.mpv_config_stats, lines, text.length())); - } - - private String codeForTrigger(int id) { - if (id == R.id.triggerLong) return longCode; - if (id == R.id.triggerStartup) return startupCode; - return shortCode; - } - - private void saveScriptButton() { - saveCurrentCode(); - try { - MpvConfigStore.saveCustomButton(sourceButton == null ? "" : sourceButton.id, name(), shortCode, longCode, startupCode, binding.buttonEnabled.isChecked()); - Notify.show(R.string.mpv_config_custom_button_saved); - if (buttonCallback != null) buttonCallback.run(); - dismissAllowingStateLoss(); - } catch (Throwable error) { - Notify.show(message(error)); - } - } - - private void openScriptEditor() { - MpvConfigEditorDialog.show(getChildFragmentManager(), name(), scriptContent, false, text -> { - scriptContent = text == null ? "" : text; - return true; - }); - } - - private void saveScriptSettings() { - try { - String savedId = MpvConfigStore.saveScriptSettings(scriptId, name(), scriptContent, - binding.buttonEnabled.isChecked(), triggerName(triggerId), binding.scriptEnabled.isChecked()); - if (buttonCallback != null) buttonCallback.run(); - Notify.show(R.string.mpv_config_profile_saved); - dismissAllowingStateLoss(); - } catch (Throwable error) { - Notify.show(message(error)); - } - } - - private static int triggerIdFor(String trigger) { - if ("long".equals(trigger)) return R.id.triggerLong; - if ("startup".equals(trigger)) return R.id.triggerStartup; - return R.id.triggerClick; - } - - private static String triggerName(int id) { - if (id == R.id.triggerLong) return "long"; - if (id == R.id.triggerStartup) return "startup"; - return "click"; - } - private static void tvFocusable(View view) { view.setFocusable(true); view.setFocusableInTouchMode(true); @@ -452,11 +116,9 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { private void createText() { String name = name(); - boolean enabled = binding.buttonEnabled.isChecked(); - String trigger = triggerName(triggerId); dismissAllowingStateLoss(); App.post(() -> { - if (listener != null) listener.onText(name, enabled, trigger); + if (listener != null) listener.onText(name); }); } @@ -466,7 +128,6 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { } private void showUrlInput() { - binding.scriptSettingsPanel.setVisibility(View.GONE); binding.chooseAction.setVisibility(View.GONE); binding.textOption.setVisibility(View.GONE); binding.urlOption.setVisibility(View.GONE); @@ -479,7 +140,6 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { binding.urlLayout.setError(null); binding.urlPanel.setVisibility(View.GONE); binding.chooseAction.setVisibility(View.VISIBLE); - if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) binding.scriptSettingsPanel.setVisibility(View.VISIBLE); binding.textOption.setVisibility(View.VISIBLE); binding.urlOption.setVisibility(View.VISIBLE); binding.importOption.setVisibility(View.VISIBLE); @@ -495,11 +155,9 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { } binding.urlLayout.setError(null); String name = name(); - boolean enabled = binding.buttonEnabled.isChecked(); - String trigger = triggerName(triggerId); dismissAllowingStateLoss(); App.post(() -> { - if (listener != null) listener.onImport(name, url, enabled, trigger); + if (listener != null) listener.onImport(name, url); }); } @@ -516,11 +174,9 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { return; } String name = binding == null ? "" : name(); - boolean enabled = binding != null && binding.buttonEnabled.isChecked(); - String trigger = triggerName(triggerId); dismissAllowingStateLoss(); App.post(() -> { - if (listener != null) listener.onImport(name, path, enabled, trigger); + if (listener != null) listener.onImport(name, path); }); }); @@ -541,21 +197,6 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); window.setAttributes(params); window.setLayout(params.width, WindowManager.LayoutParams.WRAP_CONTENT); - if (Util.isLeanback()) { - if (scriptButtonMode || scriptSettingsMode) binding.name.post(() -> binding.name.requestFocus()); - else binding.textOption.post(() -> binding.textOption.requestFocus()); - } - } - - private static String value(String value) { - return value == null ? "" : value; - } - - private static String value(TextInputEditText edit) { - return edit.getText() == null ? "" : edit.getText().toString(); - } - - private static String message(Throwable error) { - return TextUtils.isEmpty(error.getMessage()) ? error.getClass().getSimpleName() : error.getMessage(); + if (Util.isLeanback()) binding.textOption.post(() -> binding.textOption.requestFocus()); } } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigDialog.java index 41df01e1b3..b741301dbb 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigDialog.java @@ -200,7 +200,7 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile popup.dismiss(); openEditor(profile); })); - if (!profile.isDefault() && !profile.isCustomButton() && !MpvConfigStore.TARGET_SCRIPTS.equals(target)) content.addView(actionItem(R.string.mpv_config_rename, R.drawable.ic_mpv_rename, false, () -> { + if (!profile.isDefault()) content.addView(actionItem(R.string.mpv_config_rename, R.drawable.ic_mpv_rename, false, () -> { popup.dismiss(); showRename(profile); })); @@ -277,27 +277,21 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile } @Override - public void onText(String name, boolean buttonEnabled, String trigger) { + public void onText(String name) { String template; if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) template = "-- WebHTV MPV script\n\n"; else if (MpvConfigStore.TARGET_INPUT_CONF.equals(target)) template = "# WebHTV input.conf\n\n"; else template = "# WebHTV mpv.conf\n\n"; String displayName = TextUtils.isEmpty(name) ? getString(R.string.mpv_config_untitled) : name; - showEditor(null, displayName, template, true, buttonEnabled, trigger); - } - - private void onScriptButtonSaved() { - reload(); - notifyChanged(); + showEditor(null, displayName, template, true); } @Override - public void onImport(String name, String path, boolean buttonEnabled, String trigger) { + public void onImport(String name, String path) { String selectedTarget = target; runAsync(() -> { String id = MpvConfigStore.importProfile(selectedTarget, path, name); - if (MpvConfigStore.TARGET_SCRIPTS.equals(selectedTarget)) MpvConfigStore.saveScriptTrigger(id, buttonEnabled, trigger); - else MpvConfigStore.selectProfile(selectedTarget, id); + if (!MpvConfigStore.TARGET_SCRIPTS.equals(selectedTarget)) MpvConfigStore.selectProfile(selectedTarget, id); return id; }, id -> { if (TextUtils.equals(target, selectedTarget)) reload(); @@ -313,13 +307,8 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile String content = MpvConfigStore.profileContent(target, profile.id); App.post(() -> { Notify.dismiss(); - if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) { - MpvConfigCreateDialog.showScriptSettings(getChildFragmentManager(), profile.id, - profile.name, content, MpvConfigStore.scriptButton(profile.id), this::onScriptButtonSaved); - } else { - String name = profile.isDefault() ? getString(R.string.mpv_config_default_copy) : profile.name; - showEditor(profile.id, name, content, profile.isDefault(), false, "startup"); - } + String name = profile.isDefault() ? getString(R.string.mpv_config_default_copy) : profile.name; + showEditor(profile.id, name, content, profile.isDefault()); }); } catch (Throwable e) { App.post(() -> { @@ -330,15 +319,12 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile }); } - private void showEditor(String id, String name, String content, boolean creating, boolean buttonEnabled, String trigger) { + private void showEditor(String id, String name, String content, boolean creating) { MpvConfigEditorDialog.show(getChildFragmentManager(), name, content, creating, text -> { try { String savedId = MpvConfigStore.saveTextProfile(target, id, name, text); - if (creating) { - if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) MpvConfigStore.saveScriptTrigger(savedId, buttonEnabled, trigger); - else MpvConfigStore.selectProfile(target, savedId); - } - if (binding != null) binding.getRoot().post(this::reload); + if (creating && !MpvConfigStore.TARGET_SCRIPTS.equals(target)) MpvConfigStore.selectProfile(target, savedId); + reload(); Notify.show(R.string.mpv_config_profile_saved); notifyChanged(); return true; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvCustomButtonDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvCustomButtonDialog.java deleted file mode 100644 index d6581da9b7..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvCustomButtonDialog.java +++ /dev/null @@ -1,452 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Dialog; -import android.content.res.ColorStateList; -import android.graphics.Color; -import android.graphics.Typeface; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.GradientDrawable; -import android.net.Uri; -import android.text.InputType; -import android.text.TextUtils; -import android.util.Xml; -import android.view.Gravity; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; -import android.widget.CheckBox; -import android.widget.EditText; -import android.widget.LinearLayout; -import android.widget.ScrollView; -import android.widget.TextView; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.AppCompatImageButton; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.FragmentManager; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.player.mpv.MpvConfigStore; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Util; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.textfield.TextInputEditText; - -import org.xmlpull.v1.XmlPullParser; - -import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; - -public class MpvCustomButtonDialog extends DialogFragment { - - private static final int INITIAL_SLOT_COUNT = 8; - private Runnable callback; - private LinearLayout list; - - private final ActivityResultLauncher importLauncher = registerForActivityResult(new ActivityResultContracts.OpenDocument(), this::importButtons); - private final ActivityResultLauncher exportLauncher = registerForActivityResult(new ActivityResultContracts.CreateDocument("text/xml"), this::exportButtons); - - public static void show(FragmentManager manager, Runnable callback) { - MpvCustomButtonDialog dialog = new MpvCustomButtonDialog(); - dialog.callback = callback; - dialog.show(manager, "mpv-custom-buttons"); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable android.os.Bundle savedInstanceState) { - LinearLayout root = column(requireContext(), 16); - root.setBackgroundResource(R.drawable.shape_shell_proxy_dialog); - - LinearLayout header = row(requireContext()); - LinearLayout heading = column(requireContext(), 0); - TextView title = text(requireContext(), getString(R.string.mpv_config_custom_button_title), 21, true); - TextView subtitle = text(requireContext(), getString(R.string.mpv_config_custom_button_subtitle), 12, false); - subtitle.setTextColor(Color.rgb(95, 99, 104)); - heading.addView(title); - heading.addView(subtitle); - header.addView(heading, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); - AppCompatImageButton close = iconButton(R.drawable.ic_dialog_close, R.string.mpv_config_close); - close.setOnClickListener(view -> dismissAllowingStateLoss()); - header.addView(close, new LinearLayout.LayoutParams(ResUtil.dp2px(44), ResUtil.dp2px(40))); - root.addView(header, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ResUtil.dp2px(58))); - - ScrollView scroll = new ScrollView(requireContext()); - list = column(requireContext(), 0); - scroll.addView(list, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - root.addView(scroll, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); - refresh(); - return new MaterialAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(root).create(); - } - - private void refresh() { - if (list == null) return; - list.removeAllViews(); - List buttons = MpvConfigStore.customButtons(); - // Keep the familiar initial layout, then append one empty slot so the - // list remains extensible instead of imposing a button-count cap. - int slotCount = Math.max(INITIAL_SLOT_COUNT, buttons.size() + 1); - for (int index = 0; index < slotCount; index++) addSlot(index, index < buttons.size() ? buttons.get(index) : null); - addImportExportCard(); - } - - private void addSlot(int index, @Nullable MpvConfigStore.CustomButton item) { - String slot = index < 4 ? "L" + (index + 1) : "R" + (index - 3); - LinearLayout row = row(requireContext()); - row.setMinimumHeight(ResUtil.dp2px(72)); - row.setPadding(ResUtil.dp2px(6), ResUtil.dp2px(6), ResUtil.dp2px(6), ResUtil.dp2px(6)); - row.setFocusable(true); - row.setFocusableInTouchMode(Util.isLeanback()); - if (item != null) row.setBackgroundResource(R.drawable.selector_mpv_profile_card); - - TextView badge = text(requireContext(), slot, 12, false); - badge.setGravity(Gravity.CENTER); - badge.setTextColor(Color.rgb(83, 101, 132)); - badge.setBackground(badgeBackground(index < 4 ? 0xFFE7EEF9 : 0xFFF0EAF4)); - row.addView(badge, new LinearLayout.LayoutParams(ResUtil.dp2px(48), ResUtil.dp2px(48))); - - TextView name = text(requireContext(), item == null ? getString(R.string.mpv_config_custom_button_empty_slot) : item.title, 15, item != null); - name.setTextColor(item == null ? Color.rgb(128, 134, 139) : Color.rgb(32, 33, 36)); - row.addView(name, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); - - if (item != null) { - AppCompatImageButton delete = iconButton(R.drawable.ic_action_delete, R.string.mpv_config_delete); - delete.setColorFilter(Color.rgb(197, 34, 31)); - delete.setBackgroundTintList(ColorStateList.valueOf(0xFFFFE3E0)); - delete.setOnClickListener(view -> confirmDelete(item)); - row.addView(delete, new LinearLayout.LayoutParams(ResUtil.dp2px(44), ResUtil.dp2px(44))); - } - AppCompatImageButton drag = iconButton(R.drawable.ic_action_drag, R.string.mpv_config_custom_button_drag); - drag.setAlpha(item == null ? 0.35f : 0.75f); - row.addView(drag, new LinearLayout.LayoutParams(ResUtil.dp2px(44), ResUtil.dp2px(44))); - row.setOnClickListener(view -> openEditor(item)); - list.addView(row, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ResUtil.dp2px(72))); - } - - private void addImportExportCard() { - LinearLayout card = column(requireContext(), 16); - card.setBackgroundResource(R.drawable.selector_mpv_profile_card); - TextView title = text(requireContext(), getString(R.string.mpv_config_custom_button_import_export), 16, true); - TextView desc = text(requireContext(), getString(R.string.mpv_config_custom_button_import_export_desc), 12, false); - desc.setTextColor(Color.rgb(95, 99, 104)); - card.addView(title); - card.addView(desc); - LinearLayout actions = row(requireContext()); - MaterialButton importButton = actionButton(R.string.mpv_config_custom_button_import, R.drawable.ic_git_cloud_download, false); - importButton.setOnClickListener(view -> importLauncher.launch(new String[]{"text/xml", "application/xml", "text/*"})); - MaterialButton exportButton = actionButton(R.string.mpv_config_custom_button_export, R.drawable.ic_git_cloud_upload, true); - exportButton.setOnClickListener(view -> exportLauncher.launch("custom_buttons_" + System.currentTimeMillis() + ".xml")); - actions.addView(importButton, new LinearLayout.LayoutParams(0, ResUtil.dp2px(44), 1)); - actions.addView(exportButton, new LinearLayout.LayoutParams(0, ResUtil.dp2px(44), 1)); - card.addView(actions, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ResUtil.dp2px(52))); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.topMargin = ResUtil.dp2px(12); - params.bottomMargin = ResUtil.dp2px(8); - list.addView(card, params); - } - - private void openEditor(@Nullable MpvConfigStore.CustomButton button) { - Editor.show(getChildFragmentManager(), button, this::refresh); - } - - private void confirmDelete(MpvConfigStore.CustomButton button) { - new MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.mpv_config_custom_button_delete) - .setMessage(getString(R.string.mpv_config_custom_button_delete_message, button.title)) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.mpv_config_delete, (dialog, which) -> { - try { - MpvConfigStore.deleteCustomButton(button.id); - refresh(); - if (callback != null) callback.run(); - } catch (Throwable error) { - Notify.show(message(error)); - } - }).show(); - } - - private void importButtons(@Nullable Uri uri) { - if (uri == null) return; - try (InputStream input = requireContext().getContentResolver().openInputStream(uri)) { - if (input == null) throw new IllegalStateException(getString(R.string.mpv_config_custom_button_file_invalid)); - List buttons = parseXml(input); - for (MpvConfigStore.CustomButton button : buttons) { - MpvConfigStore.saveCustomButton(button.id, button.title, button.content, button.longPressContent, button.onStartup, button.enabled); - } - refresh(); - if (callback != null) callback.run(); - Notify.show(getString(R.string.mpv_config_custom_button_imported, buttons.size())); - } catch (Throwable error) { - Notify.show(message(error)); - } - } - - private void exportButtons(@Nullable Uri uri) { - if (uri == null) return; - try (OutputStream output = requireContext().getContentResolver().openOutputStream(uri)) { - if (output == null) throw new IllegalStateException(getString(R.string.mpv_config_custom_button_file_invalid)); - output.write(buildXml(MpvConfigStore.customButtons()).getBytes(java.nio.charset.StandardCharsets.UTF_8)); - Notify.show(R.string.mpv_config_custom_button_exported); - } catch (Throwable error) { - Notify.show(message(error)); - } - } - - private static String buildXml(List buttons) { - StringBuilder xml = new StringBuilder("\n\n"); - for (MpvConfigStore.CustomButton button : buttons) { - xml.append(" \n"); - } - return xml.append("\n").toString(); - } - - private static List parseXml(InputStream input) throws Exception { - List result = new ArrayList<>(); - XmlPullParser parser = Xml.newPullParser(); - parser.setInput(input, "UTF-8"); - MpvConfigStore.CustomButton current = null; - String field = null; - StringBuilder value = null; - int event; - while ((event = parser.next()) != XmlPullParser.END_DOCUMENT) { - if (event == XmlPullParser.START_TAG) { - if ("button".equals(parser.getName())) { - current = new MpvConfigStore.CustomButton(); - current.id = parser.getAttributeValue(null, "id"); - current.title = parser.getAttributeValue(null, "title"); - current.enabled = !"false".equalsIgnoreCase(parser.getAttributeValue(null, "enabled")); - } else if (current != null && ("short".equals(parser.getName()) || "longPress".equals(parser.getName()) || "startup".equals(parser.getName()))) { - field = parser.getName(); - value = new StringBuilder(); - } - } else if (event == XmlPullParser.TEXT && value != null) { - value.append(parser.getText()); - } else if (event == XmlPullParser.END_TAG) { - if (current != null && value != null && parser.getName().equals(field)) { - if ("short".equals(field)) current.content = value.toString(); - else if ("longPress".equals(field)) current.longPressContent = value.toString(); - else current.onStartup = value.toString(); - field = null; - value = null; - } else if ("button".equals(parser.getName()) && current != null) { - if (!TextUtils.isEmpty(current.title)) result.add(current); - current = null; - } - } - } - return result; - } - - private static String cdata(String tag, String value) { - String text = value == null ? "" : value.replace("]]>", "]]]]>"); - return " <" + tag + ">\n"; - } - - private static String attribute(String value) { - if (value == null) return ""; - return value.replace("&", "&").replace("\"", """).replace("<", "<").replace(">", ">"); - } - - @Override - public void onStart() { - super.onStart(); - Dialog dialog = getDialog(); - Window window = dialog == null ? null : dialog.getWindow(); - if (window == null) return; - WindowManager.LayoutParams params = window.getAttributes(); - params.width = Math.min((int) (ResUtil.getScreenWidth(requireContext()) * (Util.isLeanback() ? 0.68f : 0.94f)), ResUtil.dp2px(760)); - params.height = (int) (ResUtil.getScreenHeight(requireContext()) * (Util.isLeanback() ? 0.88f : 0.9f)); - params.dimAmount = 0.58f; - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.getDecorView().setPadding(0, 0, 0, 0); - window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - window.setAttributes(params); - window.setLayout(params.width, params.height); - } - - private AppCompatImageButton iconButton(int icon, int description) { - AppCompatImageButton view = new AppCompatImageButton(requireContext()); - view.setBackgroundResource(R.drawable.selector_mpv_icon_button); - view.setContentDescription(getString(description)); - view.setPadding(ResUtil.dp2px(10), ResUtil.dp2px(10), ResUtil.dp2px(10), ResUtil.dp2px(10)); - view.setImageResource(icon); - view.setColorFilter(Color.rgb(95, 99, 104)); - return view; - } - - private MaterialButton actionButton(int text, int icon, boolean primary) { - MaterialButton view = button(requireContext(), getString(text)); - view.setIconResource(icon); - view.setIconPadding(ResUtil.dp2px(6)); - view.setMinHeight(0); - view.setMinWidth(0); - if (!primary) { - view.setBackgroundTintList(ColorStateList.valueOf(Color.TRANSPARENT)); - view.setTextColor(Color.rgb(60, 64, 67)); - view.setStrokeWidth(ResUtil.dp2px(1)); - view.setStrokeColor(ColorStateList.valueOf(0xFFB7B9BE)); - } - return view; - } - - private static GradientDrawable badgeBackground(int color) { - GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(color); - drawable.setShape(GradientDrawable.OVAL); - return drawable; - } - - private static LinearLayout row(android.content.Context context) { - LinearLayout view = new LinearLayout(context); - view.setOrientation(LinearLayout.HORIZONTAL); - view.setGravity(Gravity.CENTER_VERTICAL); - return view; - } - - private static LinearLayout column(android.content.Context context, int padding) { - LinearLayout view = new LinearLayout(context); - view.setOrientation(LinearLayout.VERTICAL); - if (padding > 0) view.setPadding(ResUtil.dp2px(padding), ResUtil.dp2px(padding), ResUtil.dp2px(padding), ResUtil.dp2px(padding)); - return view; - } - - private static TextView text(android.content.Context context, String value, int size, boolean bold) { - TextView view = new TextView(context); - view.setText(value); - view.setTextColor(Color.rgb(32, 33, 36)); - view.setTextSize(size); - view.setTypeface(view.getTypeface(), bold ? Typeface.BOLD : Typeface.NORMAL); - return view; - } - - private static MaterialButton button(android.content.Context context, String value) { - MaterialButton view = new MaterialButton(context); - view.setText(value); - view.setMinHeight(0); - view.setMinWidth(0); - return view; - } - - private static String message(Throwable error) { - return TextUtils.isEmpty(error.getMessage()) ? error.getClass().getSimpleName() : error.getMessage(); - } - - public static final class Editor extends DialogFragment { - - private MpvConfigStore.CustomButton source; - private Runnable callback; - private TextInputEditText title; - private TextInputEditText shortCode; - private TextInputEditText longCode; - private TextInputEditText startupCode; - private CheckBox enabled; - - static void show(FragmentManager manager, MpvConfigStore.CustomButton source, Runnable callback) { - Editor dialog = new Editor(); - dialog.source = source; - dialog.callback = callback; - dialog.show(manager, "mpv-custom-button-editor"); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable android.os.Bundle savedInstanceState) { - LinearLayout root = column(requireContext(), 16); - root.setBackgroundResource(R.drawable.shape_shell_proxy_dialog); - TextView heading = text(requireContext(), getString(source == null ? R.string.mpv_config_custom_button_new : R.string.mpv_config_custom_button_edit), 19, true); - root.addView(heading, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ResUtil.dp2px(42))); - title = input(requireContext(), getString(R.string.mpv_config_custom_button_name), source == null ? "" : source.title, false); - root.addView(title, inputParams()); - enabled = new CheckBox(requireContext()); - enabled.setText(R.string.mpv_config_custom_button_enabled); - enabled.setChecked(source == null || source.enabled); - root.addView(enabled, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - TextView lua = text(requireContext(), getString(R.string.mpv_config_custom_button_lua_section), 14, true); - lua.setTextColor(Color.rgb(60, 64, 67)); - root.addView(lua, inputParams()); - shortCode = input(requireContext(), getString(R.string.mpv_config_custom_button_short), source == null ? "" : source.content, true); - longCode = input(requireContext(), getString(R.string.mpv_config_custom_button_long), source == null ? "" : source.longPressContent, true); - startupCode = input(requireContext(), getString(R.string.mpv_config_custom_button_startup), source == null ? "" : source.onStartup, true); - root.addView(shortCode, inputParams()); - root.addView(longCode, inputParams()); - root.addView(startupCode, inputParams()); - LinearLayout actions = row(requireContext()); - MaterialButton cancel = button(requireContext(), getString(R.string.mpv_config_close)); - cancel.setOnClickListener(view -> dismissAllowingStateLoss()); - MaterialButton save = button(requireContext(), getString(R.string.mpv_config_save)); - save.setOnClickListener(view -> save()); - actions.addView(cancel, new LinearLayout.LayoutParams(0, ResUtil.dp2px(46), 1)); - actions.addView(save, new LinearLayout.LayoutParams(0, ResUtil.dp2px(46), 1)); - root.addView(actions, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - ScrollView scroll = new ScrollView(requireContext()); - scroll.addView(root, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - return new MaterialAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(scroll).create(); - } - - private void save() { - try { - String id = source == null ? "" : source.id; - MpvConfigStore.saveCustomButton(id, value(title), value(shortCode), value(longCode), value(startupCode), enabled.isChecked()); - Notify.show(R.string.mpv_config_custom_button_saved); - if (callback != null) callback.run(); - dismissAllowingStateLoss(); - } catch (Throwable error) { - Notify.show(message(error)); - } - } - - private static TextInputEditText input(android.content.Context context, String hint, String value, boolean multiline) { - TextInputEditText edit = new TextInputEditText(context); - edit.setHint(hint); - edit.setText(value); - edit.setTextSize(14); - edit.setInputType(multiline ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE : InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); - edit.setSingleLine(!multiline); - if (multiline) { - edit.setMinLines(3); - edit.setGravity(Gravity.TOP | Gravity.START); - edit.setBackgroundResource(R.drawable.shape_mpv_editor); - edit.setPadding(ResUtil.dp2px(12), ResUtil.dp2px(10), ResUtil.dp2px(12), ResUtil.dp2px(10)); - } - return edit; - } - - private static LinearLayout.LayoutParams inputParams() { - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.topMargin = ResUtil.dp2px(8); - return params; - } - - private static String value(EditText edit) { - return edit.getText() == null ? "" : edit.getText().toString(); - } - - @Override - public void onStart() { - super.onStart(); - Dialog dialog = getDialog(); - Window window = dialog == null ? null : dialog.getWindow(); - if (window == null) return; - WindowManager.LayoutParams params = window.getAttributes(); - params.width = Math.min((int) (ResUtil.getScreenWidth(requireContext()) * (Util.isLeanback() ? 0.68f : 0.94f)), ResUtil.dp2px(760)); - params.height = (int) (ResUtil.getScreenHeight(requireContext()) * (Util.isLeanback() ? 0.9f : 0.92f)); - params.dimAmount = 0.58f; - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.getDecorView().setPadding(0, 0, 0, 0); - window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - window.setAttributes(params); - window.setLayout(params.width, params.height); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/OneKeySyncDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/OneKeySyncDialog.java index 35a8bf124a..c4408318cd 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/OneKeySyncDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/OneKeySyncDialog.java @@ -29,7 +29,6 @@ import com.fongmi.android.tv.utils.NsdDeviceDiscovery; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.LoginStateSync; -import com.fongmi.android.tv.utils.MpvConfigSync; import com.fongmi.android.tv.utils.ProgressRequestBody; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.ScanTask; @@ -187,7 +186,7 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic } private MaterialCheckBox[] boxes() { - return new MaterialCheckBox[]{binding.config, binding.spider, binding.mpvConfig, binding.loginState, binding.webHome, binding.search, binding.history, binding.keep, binding.follow, binding.settings, binding.remoteRelay}; + return new MaterialCheckBox[]{binding.config, binding.spider, binding.loginState, binding.webHome, binding.search, binding.history, binding.keep, binding.settings, binding.remoteRelay}; } private void updateSyncPathSummary() { @@ -202,13 +201,11 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic return SyncOptions.defaults() .config(binding.config.isChecked()) .spider(binding.spider.isChecked()) - .mpvConfig(binding.mpvConfig.isChecked()) .loginState(binding.loginState.isChecked()) .webHome(binding.webHome.isChecked()) .search(binding.search.isChecked()) .history(binding.history.isChecked()) .keep(binding.keep.isChecked()) - .follow(binding.follow.isChecked()) .settings(binding.settings.isChecked()) .remoteRelay(binding.remoteRelay.isChecked()) .paths(Setting.getSyncPaths()); @@ -338,17 +335,15 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic Task.execute(() -> { try { SyncFiles.Archive archive = toRemote && SyncFiles.hasPaths(options) ? SyncFiles.createArchive(SyncFiles.getPaths(options), () -> syncing, this::onPrepareProgress) : null; - MpvConfigSync.Archive mpvArchive = toRemote && options.isMpvConfig() ? MpvConfigSync.createArchive() : null; LoginStateSync.Archive loginArchive = toRemote && options.isLoginState() ? LoginStateSync.createArchive() : null; - RequestBody body = buildBody(options, archive, mpvArchive, loginArchive); + RequestBody body = buildBody(options, archive, loginArchive); if (!syncing) { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); return; } App.post(() -> binding.start.setEnabled(true)); - request(url, body, archive, mpvArchive, loginArchive, 0); + request(url, body, archive, loginArchive, 0); } catch (Exception e) { if (!syncing) return; App.post(() -> { @@ -359,8 +354,8 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic }); } - private RequestBody buildBody(SyncOptions options, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive) { - if (archive == null && mpvArchive == null && loginArchive == null) { + private RequestBody buildBody(SyncOptions options, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive) { + if (archive == null && loginArchive == null) { FormBody.Builder body = new FormBody.Builder(); body.add("options", options.toString()); body.add("force", "false"); @@ -376,7 +371,6 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic body.addFormDataPart("backup", Backup.create(options).toString()); if (options.isRemoteRelay()) body.addFormDataPart("remoteRelay", RemoteStore.exportRelayConfig()); if (archive != null) body.addFormDataPart(SyncFiles.PART_NAME, archive.getFile().getName(), new ProgressRequestBody(archive.getFile(), ZIP, (written, total) -> App.post(() -> updateUpload(archive, written, total)))); - if (mpvArchive != null) body.addFormDataPart(MpvConfigSync.PART_NAME, mpvArchive.getFile().getName(), new ProgressRequestBody(mpvArchive.getFile(), ZIP, null)); if (loginArchive != null) body.addFormDataPart(LoginStateSync.PART_NAME, loginArchive.getFile().getName(), new ProgressRequestBody(loginArchive.getFile(), ZIP, null)); return body.build(); } @@ -385,38 +379,35 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic if (options.isRemoteRelay() && includeLocal) body.add("remoteRelay", RemoteStore.exportRelayConfig()); } - private void request(String url, RequestBody body, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, int retry) { + private void request(String url, RequestBody body, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive, int retry) { syncCall = OkHttp.newCall(client, url, body); - syncCall.enqueue(callback(url, body, archive, mpvArchive, loginArchive, retry)); + syncCall.enqueue(callback(url, body, archive, loginArchive, retry)); } - private void retry(String url, RequestBody body, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, int retry, String msg) { + private void retry(String url, RequestBody body, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive, int retry, String msg) { if (!syncing) { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); return; } if (retry >= MAX_RETRY) { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); App.post(() -> { setSyncing(false); Notify.show(getString(R.string.sync_failed_with_reason, msg)); }); } else { - Task.schedule(() -> request(url, body, archive, mpvArchive, loginArchive, retry + 1), RETRY_DELAY, TimeUnit.MILLISECONDS); + Task.schedule(() -> request(url, body, archive, loginArchive, retry + 1), RETRY_DELAY, TimeUnit.MILLISECONDS); } } - private Callback callback(String url, RequestBody body, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, int retry) { + private Callback callback(String url, RequestBody body, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive, int retry) { return new Callback() { @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { if (call.isCanceled() || !syncing) { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); App.post(() -> { setSyncing(false); @@ -424,7 +415,7 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic }); return; } - retry(url, body, archive, mpvArchive, loginArchive, retry, e.getMessage()); + retry(url, body, archive, loginArchive, retry, e.getMessage()); } @Override @@ -432,13 +423,12 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic try (Response res = response) { if (res.isSuccessful()) App.post(() -> { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); setSyncing(false); Notify.show(R.string.sync_success); dismiss(); }); - else retry(url, body, archive, mpvArchive, loginArchive, retry, res.message()); + else retry(url, body, archive, loginArchive, retry, res.message()); } } }; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceDialog.java index 24207553df..ed90b66ef2 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceDialog.java @@ -19,7 +19,6 @@ import android.widget.ScrollView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; -import androidx.core.view.OneShotPreDrawListener; import androidx.core.widget.TextViewCompat; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; @@ -471,10 +470,6 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void refreshRows() { if (list == null) return; - View focused = list.findFocus(); - Object focusedOption = focused == null ? null : focused.getTag(); - ScrollView scroll = (ScrollView) list.getParent(); - int scrollY = scroll.getScrollY(); list.removeAllViews(); PlaybackPerformanceUiPolicy.Split split = optionSplit(); addHeader(getString(R.string.player_performance_common_section)); @@ -491,13 +486,6 @@ public final class PlaybackPerformanceDialog extends DialogFragment { addRow(option.id(), option.title(), optionValue(option.id()), optionAction(option.id())); } - // Row indices can change when a setting reveals another option. - if (focusedOption != null) { - View row = list.findViewWithTag(focusedOption); - if (row != null) row.requestFocus(); - } - // Restore after layout so ScrollView's focus handling cannot move us. - OneShotPreDrawListener.add(scroll, () -> scroll.scrollTo(0, scrollY)); } private void showConfirmDialog( @@ -569,7 +557,6 @@ public final class PlaybackPerformanceDialog extends DialogFragment { case PlaybackPerformanceCatalog.DEFERRED_CUES -> onOff(PlaybackPerformanceSetting.isDeferredCuesEnabled()); case PlaybackPerformanceCatalog.SOFT_VIDEO_TUNE -> onOff(PlaybackPerformanceSetting.isSoftVideoTuneEnabled()); case PlaybackPerformanceCatalog.AUDIO_PASSTHROUGH -> onOff(PlayerSetting.isAudioPassThrough()); - case PlaybackPerformanceCatalog.MPV_MULTICHANNEL_AUDIO -> MpvPerformanceSetting.getMultichannelAudioText(); case PlaybackPerformanceCatalog.PREFER_AAC -> onOff(PlayerSetting.isPreferAAC()); case PlaybackPerformanceCatalog.AUDIO_SOFT_PREFER -> onOff(PlayerSetting.isAudioPrefer()); case PlaybackPerformanceCatalog.VIDEO_SOFT_PREFER -> onOff(PlayerSetting.isVideoPrefer()); @@ -585,6 +572,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { case PlaybackPerformanceCatalog.MPV_FRAME_DROP -> MpvPerformanceSetting.getFrameDropText(); case PlaybackPerformanceCatalog.MPV_INTERPOLATION -> onOff(MpvPerformanceSetting.isInterpolation()); case PlaybackPerformanceCatalog.MPV_SOFT_TUNE -> MpvPerformanceSetting.getSoftTuneText(); + case PlaybackPerformanceCatalog.MPV_VERBOSE_LOG -> MpvPerformanceSetting.isVerboseLog() ? "详细" : "正常"; case PlaybackPerformanceCatalog.IJK_SCENE -> IjkPerformanceSetting.getSceneText(); case PlaybackPerformanceCatalog.IJK_BUFFER -> ijkBufferText(); case PlaybackPerformanceCatalog.IJK_PACKET_BUFFERING -> onOff(IjkPerformanceSetting.isPacketBuffering()); @@ -648,7 +636,9 @@ public final class PlaybackPerformanceDialog extends DialogFragment { if (PlayerSetting.getPlayer() == PlayerSetting.MPV) { int mode = PlaybackPerformanceSetting.getMpvDv7HandlingMode(); PlaybackPerformanceSetting.putMpvDv7HandlingMode( - PlaybackPerformanceSetting.nextMpvDv7HandlingMode(mode)); + mode == PlaybackPerformanceSetting.DV7_HANDLING_P81 + ? PlaybackPerformanceSetting.DV7_HANDLING_HDR10 + : PlaybackPerformanceSetting.DV7_HANDLING_P81); refresh(); return; } @@ -662,13 +652,6 @@ public final class PlaybackPerformanceDialog extends DialogFragment { case PlaybackPerformanceCatalog.DEFERRED_CUES -> () -> toggle(PlaybackPerformanceSetting::isDeferredCuesEnabled, PlaybackPerformanceSetting::putDeferredCuesEnabled); case PlaybackPerformanceCatalog.SOFT_VIDEO_TUNE -> () -> toggle(PlaybackPerformanceSetting::isSoftVideoTuneEnabled, PlaybackPerformanceSetting::putSoftVideoTuneEnabled); case PlaybackPerformanceCatalog.AUDIO_PASSTHROUGH -> () -> togglePlayer(id, PlayerSetting::isAudioPassThrough, PlayerSetting::putAudioPassThrough); - case PlaybackPerformanceCatalog.MPV_MULTICHANNEL_AUDIO -> () -> { - MpvPerformanceSetting.putMultichannelAudioMode( - MpvPerformanceSetting.isMultichannelPcm() - ? MpvPerformanceSetting.MULTICHANNEL_STEREO_COMPAT - : MpvPerformanceSetting.MULTICHANNEL_PCM); - refresh(); - }; case PlaybackPerformanceCatalog.PREFER_AAC -> () -> togglePlayer(id, PlayerSetting::isPreferAAC, PlayerSetting::putPreferAAC); case PlaybackPerformanceCatalog.AUDIO_SOFT_PREFER -> () -> togglePlayer(id, PlayerSetting::isAudioPrefer, PlayerSetting::putAudioPrefer); case PlaybackPerformanceCatalog.VIDEO_SOFT_PREFER -> () -> togglePlayer(id, PlayerSetting::isVideoPrefer, PlayerSetting::putVideoPrefer); @@ -722,6 +705,10 @@ public final class PlaybackPerformanceDialog extends DialogFragment { MpvPerformanceSetting.putSoftTuneMode((MpvPerformanceSetting.getSoftTuneMode() + 1) % 3); refresh(); }; + case PlaybackPerformanceCatalog.MPV_VERBOSE_LOG -> () -> { + MpvPerformanceSetting.putVerboseLog(!MpvPerformanceSetting.isVerboseLog()); + refresh(); + }; case PlaybackPerformanceCatalog.IJK_SCENE -> () -> { IjkPerformanceSetting.putScene((IjkPerformanceSetting.getScene() + 1) % 4); refresh(); @@ -816,7 +803,6 @@ public final class PlaybackPerformanceDialog extends DialogFragment { boolean overridden = PlaybackPerformanceSetting.isOverridden( PlayerSetting.getPlayer(), id); MaterialButton button = new MaterialButton(requireContext()); - button.setTag(id); button.setAllCaps(false); button.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); button.setSingleLine(false); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackSpeedDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackSpeedDialog.java deleted file mode 100644 index a49d2eedf0..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackSpeedDialog.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import androidx.fragment.app.FragmentActivity; - -import com.fongmi.android.tv.R; - -import java.util.Arrays; -import java.util.Locale; - -/** Playback shortcut speeds, presented using the same chooser as player kernels. */ -public final class PlaybackSpeedDialog { - - public interface Listener { - void onSelected(float speed); - } - - private PlaybackSpeedDialog() { - } - - public static void show(FragmentActivity activity, float current, Listener listener) { - if (activity.isFinishing() || activity.isDestroyed() - || activity.getSupportFragmentManager().isStateSaved()) return; - Choices choices = choices(current); - ChoiceDialog.showSingleNoCancel(activity, R.string.setting_play_speed, - choices.labels(), choices.selected(), which -> listener.onSelected(choices.speeds()[which])); - } - - static Choices choices(float current) { - float[] speeds = {0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, 2.5f, 3f, 5f}; - // Keep a speed selected through the slider or remote fine adjustment visible. - if (current >= 0.25f && current <= 5f && selectedIndex(speeds, current) < 0) { - speeds = Arrays.copyOf(speeds, speeds.length + 1); - speeds[speeds.length - 1] = current; - Arrays.sort(speeds); - } - String[] labels = new String[speeds.length]; - for (int i = 0; i < speeds.length; i++) labels[i] = String.format(Locale.US, "%.2fx", speeds[i]); - return new Choices(speeds, labels, selectedIndex(speeds, current)); - } - - private static int selectedIndex(float[] speeds, float current) { - for (int i = 0; i < speeds.length; i++) if (Math.abs(speeds[i] - current) < 0.001f) return i; - return -1; - } - - record Choices(float[] speeds, String[] labels, int selected) { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialog.java index f26de12d7d..2e92db18f2 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialog.java @@ -3,16 +3,13 @@ package com.fongmi.android.tv.ui.dialog; import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.ColorDrawable; -import android.text.Editable; import android.text.TextUtils; -import android.text.TextWatcher; import android.text.format.DateFormat; import android.view.View; import android.view.Window; import android.view.WindowManager; import android.widget.ProgressBar; -import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.LinearLayoutCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; @@ -29,7 +26,6 @@ import com.google.android.material.textview.MaterialTextView; import java.util.ArrayList; import java.util.List; -import java.util.Locale; public class SiteHealthReportDialog extends BaseAlertDialog { @@ -37,7 +33,6 @@ public class SiteHealthReportDialog extends BaseAlertDialog { private SiteHealthStore.Report report; private Filter filter = Filter.ALL; private Sort sort = Sort.RECENT; - private String query = ""; public static void show(Fragment fragment) { new SiteHealthReportDialog().show(fragment.getChildFragmentManager(), null); @@ -66,9 +61,8 @@ public class SiteHealthReportDialog extends BaseAlertDialog { WindowManager.LayoutParams params = window.getAttributes(); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); - int margin = ResUtil.dp2px(ResUtil.isLand(requireContext()) ? 24 : 16); - params.width = Math.max(1, ResUtil.getScreenWidth(requireContext()) - margin * 2); - params.height = Math.max(1, ResUtil.getScreenHeight(requireContext()) - margin * 2); + params.width = (int) (ResUtil.getScreenWidth(requireContext()) * (ResUtil.isLand(requireContext()) ? 0.62f : 0.94f)); + params.height = (int) (ResUtil.getScreenHeight(requireContext()) * (ResUtil.isLand(requireContext()) ? 0.78f : 0.82f)); window.setAttributes(params); window.setLayout(params.width, params.height); binding.close.requestFocus(); @@ -91,15 +85,6 @@ public class SiteHealthReportDialog extends BaseAlertDialog { binding.sortRecent.setOnClickListener(view -> setSort(Sort.RECENT)); binding.sortRate.setOnClickListener(view -> setSort(Sort.RATE)); binding.sortSamples.setOnClickListener(view -> setSort(Sort.SAMPLES)); - binding.clearAll.setOnClickListener(view -> confirmClearAll()); - binding.search.addTextChangedListener(new TextWatcher() { - @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} - @Override public void afterTextChanged(Editable s) {} - @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - query = s == null ? "" : s.toString().trim().toLowerCase(Locale.ROOT); - render(); - } - }); binding.close.setOnClickListener(view -> dismiss()); } @@ -135,16 +120,9 @@ public class SiteHealthReportDialog extends BaseAlertDialog { button.setTypeface(Typeface.DEFAULT, selected ? Typeface.BOLD : Typeface.NORMAL); } - private void refreshReport() { - report = SiteHealthStore.report(); - render(); - } - private void render() { while (binding.rows.getChildCount() > 1) binding.rows.removeViewAt(1); - binding.summary.setText(summaryText(report.summary) + "\n" + adDimensionSummary()); - binding.clearAll.setEnabled(!report.isEmpty()); - binding.clearAll.setAlpha(report.isEmpty() ? 0.5f : 1.0f); + binding.summary.setText(summaryText(report.summary)); int visible = 0; for (SiteHealthStore.Row row : sortedRows()) { if (!matches(row)) continue; @@ -181,8 +159,7 @@ public class SiteHealthReportDialog extends BaseAlertDialog { } private long lastFailAt(SiteHealthStore.Row row) { - return Math.max(Math.max(row.home.lastFailAt, row.category.lastFailAt), - Math.max(Math.max(row.search.lastFailAt, row.detail.lastFailAt), Math.max(row.parse.lastFailAt, row.play.lastFailAt))); + return Math.max(Math.max(row.search.lastFailAt, row.detail.lastFailAt), Math.max(row.parse.lastFailAt, row.play.lastFailAt)); } private float successRate(SiteHealthStore.Row row) { @@ -191,7 +168,6 @@ public class SiteHealthReportDialog extends BaseAlertDialog { } private boolean matches(SiteHealthStore.Row row) { - if (!query.isEmpty() && !row.siteName.toLowerCase(Locale.ROOT).contains(query)) return false; return switch (filter) { case ALL -> true; case BAD -> row.status == SiteHealthStore.Status.BAD; @@ -199,39 +175,6 @@ public class SiteHealthReportDialog extends BaseAlertDialog { }; } - private String adDimensionSummary() { - return getString(R.string.site_health_report_ad_summary, report.adBlockedTotal, pipelineSummary(report.adBlockedByPipeline)) - + "\n" + getString(R.string.ad_site_rank) + ": " + dimensionSummary(report.adBlockedBySite) - + "\n" + getString(R.string.ad_rule_rank) + ": " + ruleDimensionSummary(report.adBlockedByRule) - + "\n" + getString(R.string.ad_pipeline_rank) + ": " + dimensionSummary(report.adBlockedByPipeline); - } - - private String dimensionSummary(java.util.Map values) { - if (values.isEmpty()) return getString(R.string.ad_stats_empty); - return values.entrySet().stream() - .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) - .limit(5) - .map(entry -> entry.getKey() + " " + entry.getValue()) - .collect(java.util.stream.Collectors.joining(" · ")); - } - - private String ruleDimensionSummary(java.util.Map values) { - if (values.isEmpty()) return getString(R.string.ad_stats_empty); - return values.entrySet().stream() - .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) - .limit(5) - .map(entry -> AdBlockStatsStore.getRuleDisplayName(entry.getKey()) + " " + entry.getValue()) - .collect(java.util.stream.Collectors.joining(" · ")); - } - - private String pipelineSummary(java.util.Map pipelines) { - if (pipelines.isEmpty()) return getString(R.string.ad_stats_empty); - return pipelines.entrySet().stream() - .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) - .map(entry -> entry.getKey() + " " + entry.getValue()) - .collect(java.util.stream.Collectors.joining(" · ")); - } - private String summaryText(SiteHealthStore.Summary summary) { if (TextUtils.isEmpty(summary.topFailureReason)) { return getString(R.string.site_health_report_summary, summary.siteCount, summary.sampleCount, summary.failureCount, summary.degradedCount); @@ -267,13 +210,11 @@ public class SiteHealthReportDialog extends BaseAlertDialog { header.addView(toggle, new LinearLayoutCompat.LayoutParams(dp(20), LinearLayoutCompat.LayoutParams.WRAP_CONTENT)); root.addView(metaText(getString(R.string.site_health_report_row_meta, row.sampleCount(), row.failureCount()))); - addStage(root, R.string.site_health_stage_home, row.home); - addStage(root, R.string.site_health_stage_category, row.category); addStage(root, R.string.site_health_stage_search, row.search); addStage(root, R.string.site_health_stage_detail, row.detail); addStage(root, R.string.site_health_stage_parse, row.parse); addStage(root, R.string.site_health_stage_play, row.play); - addAdBlockStats(root, row); + addAdBlockStats(root, row.siteKey); String reason = row.topFailureReason(); if (!TextUtils.isEmpty(reason)) root.addView(metaText(getString(R.string.site_health_report_reason, reasonLabel(reason), row.topFailureCount()))); @@ -312,12 +253,11 @@ public class SiteHealthReportDialog extends BaseAlertDialog { block.addView(progress, progressParams); } - private void addAdBlockStats(LinearLayoutCompat root, SiteHealthStore.Row row) { - long blocked = AdBlockStatsStore.getSiteBlockedCount(row.siteKey); - int playCount = row.playAttempts; - // 始终显示播放与广告拦截比值(即使为 0,让用户知道该功能在工作) + private void addAdBlockStats(LinearLayoutCompat root, String siteKey) { + long blocked = AdBlockStatsStore.getSiteBlockedCount(siteKey); + // 始终显示广告拦截统计(即使为 0,让用户知道该功能在工作) int colorRes = blocked > 0 ? R.color.site_health_warn : R.color.black_80; - MaterialTextView view = text(getString(R.string.site_health_report_ad_blocked, playCount, blocked), 13, colorRes, Typeface.NORMAL); + MaterialTextView view = text(getString(R.string.site_health_report_ad_blocked, blocked), 13, colorRes, Typeface.NORMAL); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(LinearLayoutCompat.LayoutParams.MATCH_PARENT, LinearLayoutCompat.LayoutParams.WRAP_CONTENT); params.setMargins(0, dp(8), 0, 0); view.setLayoutParams(params); @@ -340,8 +280,6 @@ public class SiteHealthReportDialog extends BaseAlertDialog { LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(LinearLayoutCompat.LayoutParams.MATCH_PARENT, LinearLayoutCompat.LayoutParams.WRAP_CONTENT); params.setMargins(0, dp(8), 0, 0); block.setLayoutParams(params); - addRecentError(block, R.string.site_health_stage_home, row.home); - addRecentError(block, R.string.site_health_stage_category, row.category); addRecentError(block, R.string.site_health_stage_search, row.search); addRecentError(block, R.string.site_health_stage_detail, row.detail); addRecentError(block, R.string.site_health_stage_parse, row.parse); @@ -367,35 +305,16 @@ public class SiteHealthReportDialog extends BaseAlertDialog { } private void confirmClearSite(SiteHealthStore.Row row) { - showClearConfirmation( - R.string.site_health_clear_site_title, - getString(R.string.site_health_clear_site_message, row.siteName), - () -> { - SiteHealthStore.clear(row.siteKey); - binding.root.post(this::refreshReport); - }); - } - - private void confirmClearAll() { - if (report.isEmpty()) return; - showClearConfirmation( - R.string.site_health_clear_all_title, - getString(R.string.site_health_clear_all_message), - () -> { - SiteHealthStore.clear(); - binding.root.post(this::refreshReport); - }); - } - - private void showClearConfirmation(int titleRes, CharSequence message, Runnable action) { - AlertDialog dialog = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(titleRes) - .setMessage(message) + new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog) + .setTitle(R.string.site_health_clear_site_title) + .setMessage(getString(R.string.site_health_clear_site_message, row.siteName)) .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, (confirmation, which) -> action.run()) - .create(); - dialog.show(); - LightDialog.apply(dialog); + .setPositiveButton(R.string.dialog_positive, (dialog, which) -> { + SiteHealthStore.clear(row.siteKey); + report = SiteHealthStore.report(); + render(); + }) + .show(); } private void addRecentError(LinearLayoutCompat block, int labelRes, SiteHealthStore.Stage stage) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleSettingsDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleSettingsDialog.java index ee3e7e9406..e06a94ac7b 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleSettingsDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleSettingsDialog.java @@ -9,7 +9,6 @@ import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.setting.Setting; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; @@ -41,59 +40,33 @@ public final class SubtitleSettingsDialog { LightDialog.apply(dialog); } - public static void showSourceSummary(FragmentActivity activity, java.util.List providers, Runnable onSaved) { + public static void showAssrtToken(FragmentActivity activity, String currentToken, ValueCallback callback) { if (activity == null) return; - if (providers == null || providers.isEmpty()) { - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.player_subtitle_source) - .setMessage(R.string.player_subtitle_source_empty) - .setPositiveButton(R.string.dialog_positive, null) - .show(); - LightDialog.apply(dialog); - return; - } - String[] names = new String[providers.size()]; - for (int i = 0; i < providers.size(); i++) names[i] = providers.get(i).getName(); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.player_subtitle_source) - .setNegativeButton(R.string.dialog_negative, null) - .setItems(names, (d, which) -> showSourceEnvironment(activity, providers.get(which), onSaved)) - .show(); - LightDialog.apply(dialog); - } - - private static void showSourceEnvironment(FragmentActivity activity, com.fongmi.android.tv.subtitle.provider.SubtitleProvider provider, Runnable onSaved) { TextInputEditText input = new TextInputEditText(activity); - input.setSingleLine(false); - input.setMinLines(4); - input.setText(Setting.getSubtitleSourceEnvironment(provider.getKey())); + input.setSingleLine(true); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + input.setText(TextUtils.isEmpty(currentToken) ? "" : currentToken); + if (input.getText() != null) input.setSelection(input.length()); + TextInputLayout layout = new TextInputLayout(activity); - layout.setHint(activity.getString(R.string.player_subtitle_source_environment)); + layout.setHint(activity.getString(R.string.player_subtitle_assrt_token)); layout.setBoxBackgroundMode(TextInputLayout.BOX_BACKGROUND_OUTLINE); layout.addView(input, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + LinearLayout container = new LinearLayout(activity); container.setOrientation(LinearLayout.VERTICAL); int horizontal = dp(activity, 20); container.setPadding(horizontal, dp(activity, 8), horizontal, 0); - container.addView(layout); + container.addView(layout, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) - .setTitle(provider.getName()) + .setTitle(R.string.player_subtitle_assrt_token) .setView(container) .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, null) + .setPositiveButton(R.string.dialog_positive, (d, which) -> { + if (callback != null) callback.onValue(input.getText() == null ? "" : input.getText().toString().trim()); + }) .show(); - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(view -> { - String value = input.getText() == null ? "" : input.getText().toString().trim(); - try { - if (!value.isEmpty() && !com.google.gson.JsonParser.parseString(value).isJsonObject()) throw new IllegalArgumentException(); - } catch (RuntimeException e) { - layout.setError(activity.getString(R.string.player_subtitle_source_environment_invalid)); - return; - } - Setting.putSubtitleSourceEnvironment(provider.getKey(), value); - if (onSaved != null) onSaved.run(); - dialog.dismiss(); - }); LightDialog.apply(dialog); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbPersonDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbPersonDialog.java index 7ec9118abd..0e94f0c8c5 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbPersonDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbPersonDialog.java @@ -104,7 +104,7 @@ public class TmdbPersonDialog { this.person = person; this.site = site; this.tmdbService = new TmdbService(); - this.tmdbConfig = TmdbConfig.effectiveCurrent(); + this.tmdbConfig = TmdbConfig.objectFrom(Setting.getTmdbConfig()); this.light = resolveLightTheme(activity); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSourceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSourceDialog.java index 52119f9335..492ab6633c 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSourceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSourceDialog.java @@ -17,9 +17,7 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.service.TmdbConfigTestService; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.utils.Task; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -82,21 +80,10 @@ public class TmdbSourceDialog { omdbApiKeyInput = view.findViewById(R.id.omdbApiKeyInput); EditText ruleInput = view.findViewById(R.id.ruleInput); EditText disabledRuleInput = view.findViewById(R.id.disabledRuleInput); - TextView addBtn = view.findViewById(R.id.add); - TextView addDisabledBtn = view.findViewById(R.id.addDisabled); - TextView manageBtn = view.findViewById(R.id.manage); - TextView testBtn = view.findViewById(R.id.testConfig); - TextView resetBtn = view.findViewById(R.id.resetDefault); - addBtn.setText(R.string.dialog_tmdb_add); - addDisabledBtn.setText(R.string.dialog_tmdb_add); - manageBtn.setText(R.string.dialog_tmdb_site_manage); - testBtn.setText(R.string.dialog_tmdb_test_config); - resetBtn.setText(R.string.dialog_tmdb_reset_default); - addBtn.setAllCaps(false); - addDisabledBtn.setAllCaps(false); - manageBtn.setAllCaps(false); - testBtn.setAllCaps(false); - resetBtn.setAllCaps(false); + View addBtn = view.findViewById(R.id.add); + View addDisabledBtn = view.findViewById(R.id.addDisabled); + View manageBtn = view.findViewById(R.id.manage); + View resetBtn = view.findViewById(R.id.resetDefault); TmdbConfig config = TmdbConfig.objectFrom(Setting.getTmdbConfig()); tempEnabledRules = new ArrayList<>(config.getEnabledSites()); @@ -126,7 +113,6 @@ public class TmdbSourceDialog { return false; }); manageBtn.setOnClickListener(v -> showSiteManage()); - testBtn.setOnClickListener(v -> testConfig(testBtn)); resetBtn.setOnClickListener(v -> resetToDefault()); dialog = builder @@ -141,50 +127,11 @@ public class TmdbSourceDialog { LightDialog.apply(dialog); } - private void testConfig(View testButton) { - String credential = inputText(apiKeyInput); - String apiHost = inputText(apiHostInput); - String imageHost = inputText(imageHostInput); - String omdbApiKey = inputText(omdbApiKeyInput); - AlertDialog sourceDialog = dialog; - testButton.setEnabled(false); - Task.execute(() -> { - TmdbConfigTestService.Result result = TmdbConfigTestService.test(credential, apiHost, imageHost, omdbApiKey); - activity.runOnUiThread(() -> { - testButton.setEnabled(true); - if (activity.isFinishing() || activity.isDestroyed() || sourceDialog == null - || dialog != sourceDialog || !sourceDialog.isShowing()) return; - String apiResult = resultText(result.api, R.string.dialog_tmdb_test_api_success, R.string.dialog_tmdb_test_api_failed); - String imageResult = resultText(result.image, R.string.dialog_tmdb_test_image_success, R.string.dialog_tmdb_test_image_failed); - String omdbResult = resultText(result.omdb, R.string.dialog_tmdb_test_omdb_success, R.string.dialog_tmdb_test_omdb_failed); - new MaterialAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.dialog_tmdb_test_result_title) - .setMessage(apiResult + "\n" + imageResult + "\n" + omdbResult) - .setPositiveButton(R.string.dialog_positive, null) - .show(); - }); - }); - } - - private String resultText(TmdbConfigTestService.Check check, int successText, int failureText) { - return check.success - ? activity.getString(successText, check.latencyMillis) - : activity.getString(failureText, check.message, check.latencyMillis); - } - - private static String inputText(EditText input) { - return input.getText() == null ? "" : input.getText().toString().trim(); - } - private MaterialAlertDialogBuilder builder() { return new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog); } private void wireConfigDialogFocus(AlertDialog dialog, EditText ruleInput, View addBtn, EditText disabledRuleInput, View addDisabledBtn, View manageBtn, View resetBtn) { - wireConfigDialogFocus(dialog, ruleInput, addBtn, disabledRuleInput, addDisabledBtn, manageBtn, dialog.findViewById(R.id.testConfig), resetBtn); - } - - private void wireConfigDialogFocus(AlertDialog dialog, EditText ruleInput, View addBtn, EditText disabledRuleInput, View addDisabledBtn, View manageBtn, View testBtn, View resetBtn) { View positive = dialog.getButton(AlertDialog.BUTTON_POSITIVE); View negative = dialog.getButton(AlertDialog.BUTTON_NEGATIVE); wireTextDpadFocus(apiKeyInput, null, languageInput, null, null); @@ -195,12 +142,11 @@ public class TmdbSourceDialog { wireTextDpadFocus(ruleInput, omdbApiKeyInput, disabledRuleInput, null, addBtn); wireDpadFocus(addBtn, omdbApiKeyInput, addDisabledBtn, ruleInput, null); wireTextDpadFocus(disabledRuleInput, ruleInput, manageBtn, null, addDisabledBtn); - wireDpadFocus(addDisabledBtn, addBtn, testBtn, disabledRuleInput, null); - wireDpadFocus(manageBtn, disabledRuleInput, positive, null, testBtn); - wireDpadFocus(testBtn, addDisabledBtn, positive, manageBtn, resetBtn); - wireDpadFocus(resetBtn, addDisabledBtn, positive, testBtn, null); + wireDpadFocus(addDisabledBtn, addBtn, resetBtn, disabledRuleInput, null); + wireDpadFocus(manageBtn, disabledRuleInput, positive, null, resetBtn); + wireDpadFocus(resetBtn, addDisabledBtn, positive, manageBtn, null); wireDpadFocus(negative, manageBtn, null, null, positive); - wireDpadFocus(positive, testBtn, null, negative, null); + wireDpadFocus(positive, resetBtn, null, negative, null); } private static void wireDpadFocus(View view, View up, View down, View left, View right) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TrackDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TrackDialog.java index 10cd7a8610..58a6f4b98d 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TrackDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TrackDialog.java @@ -31,6 +31,7 @@ import com.fongmi.android.tv.bean.Track; import com.fongmi.android.tv.databinding.DialogTrackBinding; import com.fongmi.android.tv.player.PlayerHelper; import com.fongmi.android.tv.player.PlayerManager; +import com.fongmi.android.tv.player.exo.TrackUtil; import com.fongmi.android.tv.service.AiSubtitleTranslationService; import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; import com.fongmi.android.tv.setting.PlayerSetting; @@ -590,9 +591,6 @@ public final class TrackDialog extends BaseBottomSheetDialog implements TrackAda if (trackGroup.getType() != type) continue; for (int j = 0; j < trackGroup.length; j++) { Format format = trackGroup.getTrackFormat(j); - // One subtitle cannot occupy both roles (the same rule is enforced by MPV). - if (type == C.TRACK_TYPE_TEXT && (secondarySubtitle - ? player.isPrimarySubtitleSelected(format) : player.isSecondarySubtitleSelected(format))) continue; String name = provider.getTrackName(format); Log.d("TrackDialog", "track type=" + type + " id=" + format.id + " label=" + format.label + " lang=" + format.language + " codec=" + format.codecs + " mime=" + format.sampleMimeType + " name=" + name); // Keep the player's native track id with the visible item. Runtime track @@ -617,7 +615,8 @@ public final class TrackDialog extends BaseBottomSheetDialog implements TrackAda } private Format activeVideoFormat(Tracks tracks) { - return null; + if (secondarySubtitle || type != C.TRACK_TYPE_VIDEO) return null; + return TrackUtil.uniqueActiveFormat(tracks, type, player.getVideoFormat()); } private void addPendingSubtitleTrack(List items) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/UpdateSettingsDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/UpdateSettingsDialog.java index 5713452523..009ee6f949 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/UpdateSettingsDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/UpdateSettingsDialog.java @@ -16,6 +16,7 @@ import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogUpdateSettingsBinding; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.update.GithubProxy; import com.fongmi.android.tv.update.OciMirror; import com.fongmi.android.tv.update.UpdateSource; import com.fongmi.android.tv.update.UpdateUrl; @@ -33,21 +34,24 @@ public final class UpdateSettingsDialog { } public static void show(FragmentActivity activity) { - Dialog dialog = new Dialog(activity, R.style.Theme_WebHTV_LightDialog); - DialogUpdateSettingsBinding binding = DialogUpdateSettingsBinding.inflate(LayoutInflater.from(dialog.getContext())); + DialogUpdateSettingsBinding binding = DialogUpdateSettingsBinding.inflate(LayoutInflater.from(activity)); State state = State.load(); - dialog.setContentView(binding.getRoot()); + Dialog dialog = LightDialog.create(activity, null, binding.getRoot()); setupTabs(activity, binding, state); bind(activity, dialog, binding, state); render(activity, binding, state); dialog.setCanceledOnTouchOutside(false); dialog.show(); - configureWindow(activity, dialog, binding); + configureWindow(activity, dialog); configureTvFocus(binding, state); } private static void bind(FragmentActivity activity, Dialog dialog, DialogUpdateSettingsBinding binding, State state) { binding.close.setOnClickListener(view -> dialog.dismiss()); + binding.githubModeGroup.addOnButtonCheckedListener((group, id, checked) -> { + if (checked) state.githubMode = id == R.id.githubModeStrip ? GithubProxy.MODE_STRIP_SCHEME : GithubProxy.MODE_FULL_URL; + }); + binding.githubProxy.setOnClickListener(view -> chooseGithub(activity, binding, state)); binding.ociMirror.setOnClickListener(view -> chooseOci(activity, binding, state)); binding.save.setOnClickListener(view -> save(activity, dialog, binding, state)); } @@ -71,6 +75,20 @@ public final class UpdateSettingsDialog { binding.sourceTabs.selectTab(binding.sourceTabs.getTabAt(position)); } + private static void chooseGithub(FragmentActivity activity, DialogUpdateSettingsBinding binding, State state) { + GithubProxy.Preset[] presets = GithubProxy.presets(); + CharSequence[] labels = new CharSequence[presets.length]; + int selected = 0; + for (int i = 0; i < presets.length; i++) { + labels[i] = label(activity, presets[i].label, presets[i].id); + if (presets[i].id.equals(state.githubProxy)) selected = i; + } + ChoiceDialog.showSingle(activity, R.string.update_github_proxy, labels, selected, which -> { + state.githubProxy = presets[which].id; + renderGithub(activity, binding, state); + }); + } + private static void chooseOci(FragmentActivity activity, DialogUpdateSettingsBinding binding, State state) { OciMirror.Preset[] presets = OciMirror.presets(); CharSequence[] labels = new CharSequence[presets.length]; @@ -86,14 +104,22 @@ public final class UpdateSettingsDialog { } private static String label(FragmentActivity activity, String label, String id) { - if (OciMirror.DIRECT.equals(id)) return activity.getString(R.string.update_proxy_direct); - if (OciMirror.CUSTOM.equals(id)) return activity.getString(R.string.update_proxy_custom); + if (GithubProxy.DIRECT.equals(id) || OciMirror.DIRECT.equals(id)) return activity.getString(R.string.update_proxy_direct); + if (GithubProxy.CUSTOM.equals(id) || OciMirror.CUSTOM.equals(id)) return activity.getString(R.string.update_proxy_custom); return label; } private static void save(FragmentActivity activity, Dialog dialog, DialogUpdateSettingsBinding binding, State state) { + state.githubCustom = text(binding.githubCustom.getText()); state.ociCustom = text(binding.ociCustom.getText()); + binding.githubCustomLayout.setError(null); binding.ociCustomLayout.setError(null); + try { + if (UpdateSource.GITHUB.equals(state.source) && GithubProxy.CUSTOM.equals(state.githubProxy)) UpdateUrl.requireHttpsOrigin(state.githubCustom); + } catch (Exception e) { + binding.githubCustomLayout.setError(activity.getString(R.string.update_proxy_invalid)); + return; + } try { if (UpdateSource.OCI.equals(state.source) && OciMirror.CUSTOM.equals(state.ociMirror)) UpdateUrl.requireHttpsOrigin(state.ociCustom); } catch (Exception e) { @@ -101,6 +127,9 @@ public final class UpdateSettingsDialog { return; } Setting.putUpdateSource(state.source); + Setting.putUpdateGithubProxy(state.githubProxy); + Setting.putUpdateGithubProxyUrl(state.githubCustom); + Setting.putUpdateGithubProxyMode(state.githubMode); Setting.putUpdateOciMirror(state.ociMirror); Setting.putUpdateOciMirrorUrl(state.ociCustom); dialog.dismiss(); @@ -108,17 +137,29 @@ public final class UpdateSettingsDialog { } private static void render(FragmentActivity activity, DialogUpdateSettingsBinding binding, State state) { + binding.githubCustom.setText(state.githubCustom); binding.ociCustom.setText(state.ociCustom); + binding.githubModeGroup.check(GithubProxy.MODE_STRIP_SCHEME.equals(state.githubMode) ? R.id.githubModeStrip : R.id.githubModeFull); + renderGithub(activity, binding, state); renderOci(activity, binding, state); renderSource(binding, state); } private static void renderSource(DialogUpdateSettingsBinding binding, State state) { boolean github = UpdateSource.GITHUB.equals(state.source); + binding.githubPanel.setVisibility(github ? View.VISIBLE : View.GONE); binding.ociPanel.setVisibility(github ? View.GONE : View.VISIBLE); if (Util.isLeanback()) binding.sourceTabs.post(() -> configureTvFocus(binding, state)); } + private static void renderGithub(FragmentActivity activity, DialogUpdateSettingsBinding binding, State state) { + GithubProxy.Preset preset = GithubProxy.find(state.githubProxy); + binding.githubProxy.setText(activity.getString(R.string.update_github_proxy_value, label(activity, preset.label, preset.id))); + boolean custom = GithubProxy.CUSTOM.equals(preset.id); + binding.githubCustomLayout.setVisibility(custom ? View.VISIBLE : View.GONE); + binding.githubModeGroup.setVisibility(custom ? View.VISIBLE : View.GONE); + } + private static void renderOci(FragmentActivity activity, DialogUpdateSettingsBinding binding, State state) { OciMirror.Preset preset = OciMirror.find(state.ociMirror); binding.ociMirror.setText(activity.getString(R.string.update_oci_mirror_value, label(activity, preset.label, preset.id))); @@ -129,20 +170,14 @@ public final class UpdateSettingsDialog { return value == null ? "" : value.toString().trim(); } - private static void configureWindow(FragmentActivity activity, Dialog dialog, DialogUpdateSettingsBinding binding) { + private static void configureWindow(FragmentActivity activity, Dialog dialog) { Window window = dialog.getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); params.width = (int) (ResUtil.getScreenWidth(activity) * (ResUtil.isLand(activity) ? 0.62f : 0.92f)); params.height = WindowManager.LayoutParams.WRAP_CONTENT; params.gravity = Gravity.CENTER; - params.dimAmount = 0.58f; - // Reserve the title, save button, margins and padding outside the scroll area. - binding.contentScroll.setMaxHeight(Math.max(1, - (int) (ResUtil.getScreenHeight(activity) * 0.90f) - ResUtil.dp2px(150))); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.getDecorView().setPadding(0, 0, 0, 0); - window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); window.setAttributes(params); window.setLayout(params.width, WindowManager.LayoutParams.WRAP_CONTENT); } @@ -153,6 +188,7 @@ public final class UpdateSettingsDialog { tvFocusable(binding.save); binding.close.setOnKeyListener((view, keyCode, event) -> event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_DOWN && focusSelectedTab(binding)); binding.save.setOnKeyListener((view, keyCode, event) -> event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_UP && focusLastControl(binding, state)); + binding.githubProxy.setOnKeyListener((view, keyCode, event) -> focusFromPrimary(binding, keyCode, event)); binding.ociMirror.setOnKeyListener((view, keyCode, event) -> focusFromPrimary(binding, keyCode, event)); configureTabFocus(binding, state); focusSelectedTab(binding); @@ -189,14 +225,18 @@ public final class UpdateSettingsDialog { } private static boolean focusPrimary(DialogUpdateSettingsBinding binding, State state) { - // GitHub 标签下 ociPanel 是 GONE,对隐藏视图 requestFocus 恒返回 false, - // 遥控从标签条往下就走不动,保存按钮再也聚焦不到。此时直接跳到保存。 - if (UpdateSource.GITHUB.equals(state.source)) return binding.save.requestFocus(); - return binding.ociMirror.requestFocus(); + return (UpdateSource.GITHUB.equals(state.source) ? binding.githubProxy : binding.ociMirror).requestFocus(); } private static boolean focusLastControl(DialogUpdateSettingsBinding binding, State state) { - if (UpdateSource.GITHUB.equals(state.source)) return focusSelectedTab(binding); + if (UpdateSource.GITHUB.equals(state.source)) { + if (GithubProxy.CUSTOM.equals(state.githubProxy)) { + int checked = binding.githubModeGroup.getCheckedButtonId(); + View mode = checked == View.NO_ID ? binding.githubModeFull : binding.githubModeGroup.findViewById(checked); + if (mode != null) return mode.requestFocus(); + } + return binding.githubProxy.requestFocus(); + } if (OciMirror.CUSTOM.equals(state.ociMirror)) return binding.ociCustom.requestFocus(); return binding.ociMirror.requestFocus(); } @@ -209,12 +249,18 @@ public final class UpdateSettingsDialog { private static final class State { private String source; + private String githubProxy; + private String githubCustom; + private String githubMode; private String ociMirror; private String ociCustom; private static State load() { State state = new State(); state.source = Setting.getUpdateSource(); + state.githubProxy = Setting.getUpdateGithubProxy(); + state.githubCustom = Setting.getUpdateGithubProxyUrl(); + state.githubMode = Setting.getUpdateGithubProxyMode(); state.ociMirror = Setting.getUpdateOciMirror(); state.ociCustom = Setting.getUpdateOciMirrorUrl(); return state; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbBundle.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbBundle.java deleted file mode 100644 index 3b8c5969de..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbBundle.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.TmdbPerson; -import com.google.gson.JsonObject; - -import java.util.List; -import java.util.Map; - -/** UI-facing TMDB data assembled from source payload or the existing network path. */ -public record TmdbBundle( - TmdbItem item, - JsonObject detail, - List cast, - List creators, - List photos, - List related, - List seasons, - Map seasonCounts, - Map> seasonEpisodes, - Map> seasonCast, - Map> seasonPhotos -) { - - public boolean identityMatches(TmdbItem other) { - return item != null && other != null && item.getTmdbId() > 0 - && item.getTmdbId() == other.getTmdbId() - && item.getMediaType().equalsIgnoreCase(other.getMediaType()); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAdapter.java deleted file mode 100644 index 80cbfd0714..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAdapter.java +++ /dev/null @@ -1,491 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.TmdbPerson; -import com.fongmi.android.tv.bean.TmdbSourceDetail; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.bean.TmdbVideo; -import com.fongmi.android.tv.bean.Vod; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; - -/** Converts a validated C16 payload into the existing UI bundle without network access. */ -public final class TmdbSourceAdapter { - - private static final int MAX_CAST = 18; - private static final int MAX_CREATORS = 12; - private static final int MAX_PHOTOS = 24; - private static final int MAX_RELATED = 40; - - @Nullable - public static TmdbBundle toBundle(@Nullable TmdbSourcePayload payload, @Nullable Vod vod, @Nullable TmdbConfig config) { - TmdbSourcePayload valid = TmdbSourcePayloadParser.parse(payload); - if (valid == null) return null; - TmdbConfig effectiveConfig = config == null ? new TmdbConfig().sanitize() : config.sanitize(); - JsonObject detail = new TmdbSourceDetail(valid.getDetailJson()).object(); - if (!detail.has("media_type") && !valid.getMediaType().isEmpty()) detail.addProperty("media_type", valid.getMediaType()); - TmdbItem item = item(valid.getTmdbId(), valid.getMediaType(), detail, vod, effectiveConfig, valid.hasCapability(TmdbSourceCapabilityPlanner.CORE)); - List cast = cast(detail, effectiveConfig); - List creators = creators(detail, effectiveConfig); - List photos = photos(detail, effectiveConfig); - List related = related(valid.getMediaType(), detail, effectiveConfig); - SeasonData seasons = seasons(valid.getTmdbId(), valid.getSeasonNumber(), detail, effectiveConfig); - return new TmdbBundle(item, detail, cast, creators, photos, related, seasons.numbers, seasons.counts, seasons.episodes, seasons.cast, seasons.photos); - } - - public static TmdbBundle fromNetwork(@Nullable TmdbItem sourceItem, @Nullable JsonObject sourceDetail, @Nullable TmdbConfig config) { - if (sourceItem == null) return null; - TmdbConfig effectiveConfig = config == null ? new TmdbConfig().sanitize() : config.sanitize(); - JsonObject detail = sourceDetail == null ? new JsonObject() : sourceDetail.deepCopy(); - String mediaType = normalizeMediaType(sourceItem.getMediaType()); - if (mediaType.isEmpty()) mediaType = sourceItem.getMediaType(); - TmdbItem item = item(sourceItem.getTmdbId(), mediaType, detail, null, effectiveConfig, true); - item = fallbackItem(item, sourceItem); - List cast = cast(detail, effectiveConfig); - List creators = creators(detail, effectiveConfig); - List photos = photos(detail, effectiveConfig); - List related = related(mediaType, detail, effectiveConfig); - SeasonData seasons = seasons(sourceItem.getTmdbId(), 0, detail, effectiveConfig); - return new TmdbBundle(item, detail, cast, creators, photos, related, seasons.numbers, seasons.counts, seasons.episodes, seasons.cast, seasons.photos); - } - - public static List videos(@Nullable JsonObject detail, @Nullable String mediaType, int seasonNumber, int episodeNumber, @Nullable String language) { - List result = new ArrayList<>(); - if (detail == null) return result; - String normalized = normalizeMediaType(mediaType); - JsonObject season = findSeason(detail, seasonNumber); - JsonObject episode = findEpisode(season, episodeNumber); - if ("movie".equals(normalized) || seasonNumber < 0) { - addVideos(result, object(detail, "videos"), TmdbVideo.Scope.TITLE, -1, -1); - } else { - addVideos(result, object(season, "videos"), TmdbVideo.Scope.SEASON, seasonNumber, -1); - addVideos(result, object(episode, "videos"), TmdbVideo.Scope.EPISODE, seasonNumber, episodeNumber); - addVideos(result, object(detail, "videos"), TmdbVideo.Scope.TITLE, -1, -1); - } - return TmdbVideo.mergeAndRank(result, language == null ? "" : language, 12); - } - - private static TmdbItem item(int tmdbId, String mediaType, JsonObject detail, @Nullable Vod vod, TmdbConfig config, boolean coreComplete) { - boolean tv = "tv".equals(mediaType); - String title = firstString(detail, tv ? new String[]{"name", "title"} : new String[]{"title", "name"}); - if (title.isEmpty() && vod != null && coreComplete) title = vod.getName(); - String date = firstString(detail, tv ? new String[]{"first_air_date", "release_date"} : new String[]{"release_date", "first_air_date"}); - double vote = number(detail, "vote_average", 0d); - String subtitle = subtitle(date, vote); - String poster = imageUrl(config.getImageBase(), string(detail, "poster_path")); - String backdrop = imageUrl(config.getBackdropBase(), string(detail, "backdrop_path")); - return new TmdbItem( - tmdbId, - mediaType, - title, - subtitle, - string(detail, "overview"), - poster, - backdrop, - "", - vote, - string(detail, "original_language"), - firstString(detail, new String[]{"origin_country"}), - genreIds(detail), - "", - vote, - 0d - ); - } - - private static TmdbItem fallbackItem(TmdbItem primary, TmdbItem fallback) { - return new TmdbItem( - primary.getTmdbId(), - primary.getMediaType(), - firstNonEmpty(primary.getTitle(), fallback.getTitle()), - firstNonEmpty(primary.getSubtitle(), fallback.getSubtitle()), - firstNonEmpty(primary.getOverview(), fallback.getOverview()), - firstNonEmpty(primary.getPosterUrl(), fallback.getPosterUrl()), - firstNonEmpty(primary.getBackdropUrl(), fallback.getBackdropUrl()), - firstNonEmpty(primary.getCredit(), fallback.getCredit()), - primary.getRating() > 0 ? primary.getRating() : fallback.getRating(), - firstNonEmpty(primary.getOriginalLanguage(), fallback.getOriginalLanguage()), - firstNonEmpty(primary.getOriginCountry(), fallback.getOriginCountry()), - primary.getGenreIds().isEmpty() ? fallback.getGenreIds() : primary.getGenreIds(), - firstNonEmpty(primary.getDepartment(), fallback.getDepartment()), - primary.getTmdbRating() > 0 ? primary.getTmdbRating() : fallback.getTmdbRating(), - primary.getDoubanRating() > 0 ? primary.getDoubanRating() : fallback.getDoubanRating(), - firstNonEmpty(primary.getRecommendationReason(), fallback.getRecommendationReason()) - ); - } - - private static List cast(JsonObject detail, TmdbConfig config) { - JsonArray aggregate = array(object(detail, "aggregate_credits"), "cast"); - if (!aggregate.isEmpty()) return castItems(aggregate, config, true); - return castItems(array(object(detail, "credits"), "cast"), config, false); - } - - private static List castItems(JsonArray array, TmdbConfig config, boolean aggregate) { - List result = new ArrayList<>(); - Set seen = new LinkedHashSet<>(); - for (JsonElement element : array) { - if (!element.isJsonObject()) continue; - JsonObject object = element.getAsJsonObject(); - int id = integer(object, "id", 0); - if (id <= 0 || !seen.add(id)) continue; - String role = aggregate ? firstRole(object) : firstString(object, new String[]{"character", "known_for_department"}); - result.add(new TmdbPerson( - id, - string(object, "name"), - role, - imageUrl(config.getImageBase(), string(object, "profile_path")), - string(object, "known_for_department"), - "" - )); - if (result.size() >= MAX_CAST) break; - } - return result; - } - - private static List creators(JsonObject detail, TmdbConfig config) { - Map crews = new LinkedHashMap<>(); - addCrew(crews, array(object(detail, "credits"), "crew"), config, false); - addCrew(crews, array(object(detail, "aggregate_credits"), "crew"), config, true); - List values = new ArrayList<>(crews.values()); - values.sort(Comparator.comparingInt(crew -> jobOrder(crew.jobs.isEmpty() ? "" : crew.jobs.get(0)))); - List result = new ArrayList<>(); - for (Crew crew : values) { - if (crew.jobs.isEmpty()) continue; - result.add(new TmdbPerson(crew.id, crew.name, String.join(" / ", crew.jobs), crew.profileUrl, crew.department, "")); - if (result.size() >= MAX_CREATORS) break; - } - return result; - } - - private static void addCrew(Map crews, JsonArray array, TmdbConfig config, boolean aggregate) { - for (JsonElement element : array) { - if (!element.isJsonObject()) continue; - JsonObject object = element.getAsJsonObject(); - int id = integer(object, "id", 0); - if (id <= 0) continue; - Crew crew = crews.get(id); - if (crew == null) { - crew = new Crew(id, string(object, "name"), imageUrl(config.getImageBase(), string(object, "profile_path")), string(object, "known_for_department")); - crews.put(id, crew); - } - if (aggregate) { - boolean added = false; - for (JsonElement job : array(object, "jobs")) { - if (job.isJsonObject()) { - addJob(crew.jobs, string(job.getAsJsonObject(), "job")); - added = true; - } - } - if (!added) addJob(crew.jobs, string(object, "job")); - } else { - addJob(crew.jobs, string(object, "job")); - } - } - } - - private static void addJob(List jobs, String job) { - if (!job.isEmpty() && !jobs.contains(job)) jobs.add(job); - } - - private static int jobOrder(String job) { - if ("Director".equalsIgnoreCase(job) || "导演".equals(job)) return 0; - if ("Writer".equalsIgnoreCase(job) || "Screenplay".equalsIgnoreCase(job) || "编剧".equals(job)) return 1; - if ("Producer".equalsIgnoreCase(job) || "制片".equals(job)) return 2; - return 3; - } - - private static List photos(JsonObject detail, TmdbConfig config) { - JsonObject images = object(detail, "images"); - List result = imageArray(array(images, "backdrops"), config.getBackdropBase()); - for (String url : imageArray(array(images, "posters"), config.getImageBase())) if (!result.contains(url)) result.add(url); - addImage(result, config.getBackdropBase(), string(detail, "backdrop_path")); - addImage(result, config.getImageBase(), string(detail, "poster_path")); - return limit(result, MAX_PHOTOS); - } - - private static List related(String mediaType, JsonObject detail, TmdbConfig config) { - Map result = new LinkedHashMap<>(); - addRelated(result, array(object(detail, "recommendations"), "results"), mediaType, config); - addRelated(result, array(object(detail, "similar"), "results"), mediaType, config); - return result.values().stream().limit(MAX_RELATED).toList(); - } - - private static void addRelated(Map result, JsonArray array, String defaultMediaType, TmdbConfig config) { - for (JsonElement element : array) { - if (!element.isJsonObject()) continue; - JsonObject object = element.getAsJsonObject(); - int id = integer(object, "id", 0); - String mediaType = normalizeMediaType(string(object, "media_type")); - if (mediaType.isEmpty()) mediaType = defaultMediaType; - if (id <= 0 || mediaType.isEmpty()) continue; - String title = firstString(object, mediaType.equals("movie") ? new String[]{"title", "name"} : new String[]{"name", "title"}); - String date = firstString(object, mediaType.equals("movie") ? new String[]{"release_date"} : new String[]{"first_air_date"}); - double vote = number(object, "vote_average", 0d); - result.putIfAbsent(mediaType + ':' + id, new TmdbItem( - id, - mediaType, - title, - subtitle(date, vote), - string(object, "overview"), - imageUrl(config.getImageBase(), string(object, "poster_path")), - imageUrl(config.getBackdropBase(), string(object, "backdrop_path")), - "", - vote, - string(object, "original_language"), - firstString(object, new String[]{"origin_country"}), - integers(array(object, "genre_ids")), - string(object, "department"), - vote, - 0d - )); - } - } - - private static SeasonData seasons(int tmdbId, int selectedSeasonNumber, JsonObject detail, TmdbConfig config) { - LinkedHashSet numbers = new LinkedHashSet<>(); - Map counts = new HashMap<>(); - Map> episodes = new HashMap<>(); - Map> casts = new HashMap<>(); - Map> photos = new HashMap<>(); - for (JsonElement element : array(detail, "seasons")) { - if (!element.isJsonObject()) continue; - JsonObject season = element.getAsJsonObject(); - int number = integer(season, "season_number", -1); - if (number < 0) continue; - numbers.add(number); - List seasonEpisodes = episodes(season, tmdbId, number, config); - if (!seasonEpisodes.isEmpty() || season.has("episodes")) episodes.put(number, seasonEpisodes); - counts.put(number, integer(season, "episode_count", seasonEpisodes.size())); - List seasonCast = cast(season, config); - if (!seasonCast.isEmpty() || hasCreditObject(season)) casts.put(number, seasonCast); - List seasonPhotos = photos(season, config); - if (!seasonPhotos.isEmpty() || season.has("images")) photos.put(number, seasonPhotos); - } - if (selectedSeasonNumber >= 0 && detail.has("episodes")) { - int seasonNumber = selectedSeasonNumber; - List selected = episodes(detail, tmdbId, seasonNumber, config); - if (selectedSeasonNumber > 0 || !selected.isEmpty()) { - numbers.add(seasonNumber); - episodes.put(seasonNumber, selected); - counts.putIfAbsent(seasonNumber, selected.size()); - } - } - List sorted = numbers.stream().sorted().toList(); - return new SeasonData(sorted, counts, episodes, casts, photos); - } - - private static List episodes(JsonObject season, int tmdbId, int seasonNumber, TmdbConfig config) { - List result = new ArrayList<>(); - for (JsonElement element : array(season, "episodes")) { - if (!element.isJsonObject()) continue; - JsonObject episode = element.getAsJsonObject(); - int number = integer(episode, "episode_number", result.size() + 1); - result.add(new TmdbEpisode( - number, - string(episode, "name"), - string(episode, "air_date"), - string(episode, "overview"), - imageUrl(config.getBackdropBase(), string(episode, "still_path")), - number(episode, "vote_average", 0d), - integer(episode, "runtime", 0), - tmdbId, - seasonNumber - )); - } - return result; - } - - private static boolean hasCreditObject(JsonObject object) { - return object.has("credits") || object.has("aggregate_credits"); - } - - private static List genreIds(JsonObject detail) { - List result = new ArrayList<>(); - for (JsonElement element : array(detail, "genres")) { - if (!element.isJsonObject()) continue; - int id = integer(element.getAsJsonObject(), "id", 0); - if (id > 0) result.add(id); - } - return result; - } - - private static List integers(JsonArray array) { - List result = new ArrayList<>(); - for (JsonElement element : array) { - int value = integerElement(element, 0); - if (value > 0) result.add(value); - } - return result; - } - - private static List imageArray(JsonArray array, String base) { - List result = new ArrayList<>(); - for (JsonElement element : array) { - if (element.isJsonObject()) addImage(result, base, string(element.getAsJsonObject(), "file_path")); - } - return result; - } - - private static void addImage(List result, String base, String path) { - String url = imageUrl(base, path); - if (!url.isEmpty() && !result.contains(url)) result.add(url); - } - - private static List limit(List values, int max) { - return values.size() <= max ? values : new ArrayList<>(values.subList(0, max)); - } - - private static void addVideos(List target, JsonObject videos, TmdbVideo.Scope scope, int seasonNumber, int episodeNumber) { - for (JsonElement element : array(videos, "results")) { - if (!element.isJsonObject()) continue; - TmdbVideo video = TmdbVideo.from(element.getAsJsonObject(), scope, seasonNumber, episodeNumber); - if (video != null) target.add(video); - } - } - - private static JsonObject findSeason(JsonObject detail, int seasonNumber) { - if (seasonNumber < 0) return new JsonObject(); - for (JsonElement element : array(detail, "seasons")) { - if (!element.isJsonObject()) continue; - JsonObject season = element.getAsJsonObject(); - if (integer(season, "season_number", -1) == seasonNumber) return season; - } - return new JsonObject(); - } - - private static JsonObject findEpisode(JsonObject season, int episodeNumber) { - if (season == null || episodeNumber <= 0) return new JsonObject(); - for (JsonElement element : array(season, "episodes")) { - if (!element.isJsonObject()) continue; - JsonObject episode = element.getAsJsonObject(); - if (integer(episode, "episode_number", -1) == episodeNumber) return episode; - } - return new JsonObject(); - } - - private static String imageUrl(String base, String path) { - String normalized = TmdbSourcePayloadParser.normalizeImageUrl(path); - if (normalized.isEmpty()) return ""; - if (!normalized.startsWith("/")) return normalized; - String normalizedBase = base == null ? "" : base.trim(); - return normalizedBase.isEmpty() ? normalized : normalizedBase + normalized; - } - - private static String subtitle(String date, double vote) { - String rating = vote > 0 ? String.format(Locale.US, "%.1f", vote) : ""; - if (!date.isEmpty() && !rating.isEmpty()) return date + " · " + rating; - return date.isEmpty() ? rating : date; - } - - private static String firstNonEmpty(String first, String second) { - return first != null && !first.isEmpty() ? first : second == null ? "" : second; - } - - private static String firstRole(JsonObject object) { - for (JsonElement element : array(object, "roles")) { - if (!element.isJsonObject()) continue; - String role = string(element.getAsJsonObject(), "character"); - if (!role.isEmpty()) return role; - } - return firstString(object, new String[]{"character", "known_for_department"}); - } - - private static String normalizeMediaType(String value) { - String mediaType = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); - return "movie".equals(mediaType) || "tv".equals(mediaType) ? mediaType : ""; - } - - private static JsonObject object(@Nullable JsonObject object, String key) { - if (object == null || !object.has(key) || !object.get(key).isJsonObject()) return new JsonObject(); - return object.getAsJsonObject(key); - } - - private static JsonArray array(@Nullable JsonObject object, String key) { - if (object == null || !object.has(key) || !object.get(key).isJsonArray()) return new JsonArray(); - return object.getAsJsonArray(key); - } - - private static String string(JsonObject object, String key) { - if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive() || !object.getAsJsonPrimitive(key).isString()) return ""; - return object.get(key).getAsString(); - } - - private static long longValue(JsonObject object, String key, long fallback) { - if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive() || !object.getAsJsonPrimitive(key).isNumber()) return fallback; - try { - return object.get(key).getAsLong(); - } catch (RuntimeException ignored) { - return fallback; - } - } - - private static int integer(JsonObject object, String key, int fallback) { - long value = longValue(object, key, fallback); - return value > Integer.MAX_VALUE || value < Integer.MIN_VALUE ? fallback : (int) value; - } - - private static int integerElement(JsonElement element, int fallback) { - if (element == null || !element.isJsonPrimitive() || !element.getAsJsonPrimitive().isNumber()) return fallback; - try { - return element.getAsInt(); - } catch (RuntimeException ignored) { - return fallback; - } - } - - private static double number(JsonObject object, String key, double fallback) { - if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive() || !object.getAsJsonPrimitive(key).isNumber()) return fallback; - try { - double value = object.get(key).getAsDouble(); - return Double.isFinite(value) ? value : fallback; - } catch (RuntimeException ignored) { - return fallback; - } - } - - private static String firstString(JsonObject object, String[] keys) { - for (String key : keys) { - JsonElement value = object == null ? null : object.get(key); - if (value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { - String text = value.getAsString(); - if (text != null && !text.trim().isEmpty()) return text.trim(); - } - JsonArray array = array(object, key); - for (JsonElement item : array) { - if (item.isJsonPrimitive() && item.getAsJsonPrimitive().isString()) { - String text = item.getAsString(); - if (text != null && !text.trim().isEmpty()) return text.trim(); - } - } - } - return ""; - } - - private record Crew(int id, String name, String profileUrl, String department, List jobs) { - private Crew(int id, String name, String profileUrl, String department) { - this(id, name, profileUrl, department, new ArrayList<>()); - } - } - - private record SeasonData(List numbers, Map counts, Map> episodes, - Map> cast, Map> photos) { - } - - private TmdbSourceAdapter() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAvailability.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAvailability.java deleted file mode 100644 index dfb72992d3..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAvailability.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.bean.Vod; -import com.fongmi.android.tv.setting.TmdbSourceState; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.util.List; -import java.util.Map; - -/** Classifies whether a source payload is merely valid identity data or can render TMDB UI. */ -public final class TmdbSourceAvailability { - - public static TmdbSourceState classify( - @Nullable Vod vod, - @Nullable TmdbSourcePayload payload, - @Nullable TmdbBundle bundle - ) { - TmdbSourcePayload normalized = TmdbSourcePayloadParser.parse(payload); - if (normalized == null || bundle == null || bundle.item() == null) return TmdbSourceState.ABSENT_OR_INVALID; - if (!matches(normalized, bundle.item().getTmdbId(), bundle.item().getMediaType())) { - return TmdbSourceState.ABSENT_OR_INVALID; - } - if (vod != null && vod.getTmdb() != null) { - TmdbSourcePayload vodPayload = TmdbSourcePayloadParser.parse(vod.getTmdb()); - if (vodPayload == null || !sameIdentity(normalized, vodPayload)) { - return TmdbSourceState.ABSENT_OR_INVALID; - } - } - return isRenderable(bundle) ? TmdbSourceState.RENDERABLE : TmdbSourceState.IDENTITY_ONLY; - } - - public static boolean isRenderable(@Nullable TmdbBundle bundle) { - if (bundle == null || bundle.item() == null) return false; - if (hasItems(bundle.cast()) || hasItems(bundle.creators()) || hasItems(bundle.photos()) || hasItems(bundle.related())) { - return true; - } - if (hasItems(bundle.seasons()) || hasItems(bundle.seasonCounts()) || hasItems(bundle.seasonEpisodes()) - || hasItems(bundle.seasonCast()) || hasItems(bundle.seasonPhotos())) { - return true; - } - return hasVisibleFields(bundle.detail()); - } - - private static boolean hasVisibleFields(@Nullable JsonObject detail) { - if (detail == null || detail.isEmpty()) return false; - if (hasNonBlank(detail, "title", "name", "original_title", "original_name", "overview", "tagline", "status", - "release_date", "first_air_date", "original_language", "poster_path", "backdrop_path")) { - return true; - } - if (hasPositive(detail, "vote_average", "vote_count", "runtime", "number_of_seasons", "number_of_episodes")) { - return true; - } - if (hasArrayItems(detail, "genres", "origin_country", "episode_run_time", "seasons", "episodes")) return true; - if (hasArrayItems(object(detail, "images"), "backdrops", "posters", "logos")) return true; - if (hasArrayItems(object(detail, "credits"), "cast", "crew")) return true; - if (hasArrayItems(object(detail, "aggregate_credits"), "cast", "crew")) return true; - if (hasArrayItems(object(detail, "videos"), "results")) return true; - if (hasArrayItems(object(detail, "recommendations"), "results")) return true; - if (hasArrayItems(object(detail, "similar"), "results")) return true; - return hasExternalIds(detail.get("external_ids")); - } - - private static boolean hasNonBlank(JsonObject object, String... keys) { - if (object == null) return false; - for (String key : keys) { - JsonElement value = object.get(key); - if (value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() - && !value.getAsString().trim().isEmpty()) { - return true; - } - } - return false; - } - - private static boolean hasPositive(JsonObject object, String... keys) { - if (object == null) return false; - for (String key : keys) { - JsonElement value = object.get(key); - if (value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) { - try { - if (value.getAsDouble() > 0d) return true; - } catch (RuntimeException ignored) { - // Keep checking the remaining fields. - } - } - } - return false; - } - - private static boolean hasArrayItems(@Nullable JsonObject object, String... keys) { - if (object == null) return false; - for (String key : keys) { - JsonElement value = object.get(key); - if (value != null && value.isJsonArray() && !value.getAsJsonArray().isEmpty()) return true; - } - return false; - } - - private static boolean hasExternalIds(@Nullable JsonElement value) { - if (value == null || !value.isJsonObject()) return false; - JsonObject ids = value.getAsJsonObject(); - for (Map.Entry entry : ids.entrySet()) { - JsonElement id = entry.getValue(); - if (id == null || id.isJsonNull() || !id.isJsonPrimitive()) continue; - if (id.getAsJsonPrimitive().isString() && !id.getAsString().trim().isEmpty()) return true; - if (id.getAsJsonPrimitive().isNumber()) { - try { - if (id.getAsLong() > 0) return true; - } catch (RuntimeException ignored) { - // Keep checking the remaining IDs. - } - } - } - return false; - } - - private static JsonObject object(@Nullable JsonObject parent, String key) { - if (parent == null || !parent.has(key) || !parent.get(key).isJsonObject()) return null; - return parent.getAsJsonObject(key); - } - - private static boolean hasItems(@Nullable Iterable values) { - return values != null && values.iterator().hasNext(); - } - - private static boolean hasItems(@Nullable Map values) { - return values != null && !values.isEmpty(); - } - - private static boolean hasItems(@Nullable List values) { - return values != null && !values.isEmpty(); - } - - private static boolean matches(TmdbSourcePayload payload, int tmdbId, String mediaType) { - return tmdbId > 0 && payload.getTmdbId() == tmdbId - && payload.getMediaType().equalsIgnoreCase(mediaType == null ? "" : mediaType); - } - - private static boolean sameIdentity(TmdbSourcePayload first, TmdbSourcePayload second) { - return first.getTmdbId() == second.getTmdbId() - && first.getMediaType().equalsIgnoreCase(second.getMediaType()); - } - - private TmdbSourceAvailability() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceCapabilityPlanner.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceCapabilityPlanner.java deleted file mode 100644 index 2f54ab6c3b..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceCapabilityPlanner.java +++ /dev/null @@ -1,214 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; - -/** Pure capability planner for source-embedded TMDB data. */ -public final class TmdbSourceCapabilityPlanner { - - public static final String CORE = "core"; - public static final String CREDITS = "credits"; - public static final String IMAGES = "images"; - public static final String EXTERNAL_IDS = "external_ids"; - public static final String VIDEOS = "videos"; - public static final String RECOMMENDATIONS = "recommendations"; - public static final String SIMILAR = "similar"; - - public record UiState(boolean core, boolean credits, boolean images, boolean externalIds, boolean videos, - boolean recommendations, boolean similar, int seasonNumber, int episodeNumber, - boolean seasonVideos, boolean episodeVideos) { - - public static UiState initialScreen() { - return new UiState(true, true, true, false, false, false, false, -1, -1, false, false); - } - - public UiState withSeason(int seasonNumber) { - return new UiState(core, credits, images, externalIds, videos, recommendations, similar, seasonNumber, episodeNumber, seasonVideos, episodeVideos); - } - - public UiState withEpisode(int seasonNumber, int episodeNumber) { - return new UiState(core, credits, images, externalIds, videos, recommendations, similar, seasonNumber, episodeNumber, seasonVideos, episodeVideos); - } - - public UiState withVideos(boolean detailVideos, boolean seasonVideos, boolean episodeVideos) { - return new UiState(core, credits, images, externalIds, detailVideos, recommendations, similar, seasonNumber, episodeNumber, seasonVideos, episodeVideos); - } - - public UiState withRelated(boolean recommendations, boolean similar) { - return new UiState(core, credits, images, externalIds, videos, recommendations, similar, seasonNumber, episodeNumber, seasonVideos, episodeVideos); - } - } - - public record Plan(Set required, Set available, Set missing) { - - public Plan { - required = Set.copyOf(required); - available = Set.copyOf(available); - missing = Set.copyOf(missing); - } - - public boolean hasInitialNetworkGaps() { - return missing.contains(CORE) || missing.contains(CREDITS) || missing.contains(IMAGES) || missing.contains(EXTERNAL_IDS); - } - - public boolean needsNetwork() { - return !missing.isEmpty(); - } - } - - public static Plan plan(@Nullable TmdbBundle bundle, @Nullable TmdbSourcePayload payload, @Nullable UiState state) { - UiState ui = state == null ? UiState.initialScreen() : state; - Set required = new TreeSet<>(); - if (ui.core()) required.add(CORE); - if (ui.credits()) required.add(CREDITS); - if (ui.images()) required.add(IMAGES); - if (ui.externalIds()) required.add(EXTERNAL_IDS); - if (ui.videos()) required.add(VIDEOS); - if (ui.recommendations()) required.add(RECOMMENDATIONS); - if (ui.similar()) required.add(SIMILAR); - if (ui.seasonNumber() >= 0) required.add(season(ui.seasonNumber())); - if (ui.episodeNumber() > 0 && ui.seasonNumber() >= 0) required.add(episode(ui.seasonNumber(), ui.episodeNumber())); - if (ui.seasonVideos() && ui.seasonNumber() >= 0) required.add(seasonVideos(ui.seasonNumber())); - if (ui.episodeVideos() && ui.seasonNumber() >= 0 && ui.episodeNumber() > 0) required.add(episodeVideos(ui.seasonNumber(), ui.episodeNumber())); - - Set available = new LinkedHashSet<>(); - for (String group : required) if (isAvailable(bundle, payload, group, ui.seasonNumber(), ui.episodeNumber())) available.add(group); - Set missing = new TreeSet<>(required); - missing.removeAll(available); - return new Plan(required, available, missing); - } - - public static boolean isAvailable(@Nullable TmdbBundle bundle, @Nullable TmdbSourcePayload payload, String group, int seasonNumber, int episodeNumber) { - if (group == null || group.isEmpty()) return false; - if (payload != null && payload.hasCapability(group)) return true; - if (bundle == null || bundle.item() == null) return false; - JsonObject detail = bundle.detail() == null ? new JsonObject() : bundle.detail(); - return switch (group) { - case CORE -> coreAvailable(bundle.item(), detail); - case CREDITS -> !bundle.cast().isEmpty() || !bundle.creators().isEmpty(); - case IMAGES -> !bundle.item().getPosterUrl().isEmpty() && !bundle.item().getBackdropUrl().isEmpty(); - case EXTERNAL_IDS -> externalIdsAvailable(detail); - case VIDEOS -> array(object(detail, "videos"), "results").size() > 0; - case RECOMMENDATIONS -> pageOneAvailable(detail, "recommendations"); - case SIMILAR -> pageOneAvailable(detail, "similar"); - default -> seasonalAvailable(bundle, detail, group, seasonNumber, episodeNumber); - }; - } - - public static String season(int seasonNumber) { - return "season:" + Math.max(0, seasonNumber); - } - - public static String seasonVideos(int seasonNumber) { - return "season_videos:" + Math.max(0, seasonNumber); - } - - public static String episode(int seasonNumber, int episodeNumber) { - return "episode:" + Math.max(0, seasonNumber) + ':' + Math.max(1, episodeNumber); - } - - public static String episodeVideos(int seasonNumber, int episodeNumber) { - return "episode_videos:" + Math.max(0, seasonNumber) + ':' + Math.max(1, episodeNumber); - } - - private static boolean seasonalAvailable(TmdbBundle bundle, JsonObject detail, String group, int seasonNumber, int episodeNumber) { - if (group.startsWith("season_videos:") || group.startsWith("episode_videos:")) return false; - if (group.startsWith("episode:")) { - int[] parsed = parseEpisode(group); - if (parsed == null) return false; - for (TmdbEpisode item : bundle.seasonEpisodes().getOrDefault(parsed[0], List.of())) if (item.getNumber() == parsed[1]) return true; - return false; - } - if (group.startsWith("season:")) { - int[] parsed = parseSeason(group); - if (parsed == null) return false; - List episodes = bundle.seasonEpisodes().get(parsed[0]); - return episodes != null && !episodes.isEmpty(); - } - return false; - } - - private static boolean coreAvailable(TmdbItem item, JsonObject detail) { - if (item.getTmdbId() <= 0 || item.getMediaType().isEmpty() || item.getTitle().isEmpty()) return false; - if (string(detail, "overview").isEmpty() || date(detail).isEmpty() || !detail.has("vote_average") || !detail.has("genres")) return false; - return !item.isTv() || (detail.has("number_of_seasons") && detail.has("number_of_episodes")); - } - - private static boolean pageOneAvailable(JsonObject detail, String field) { - JsonObject value = object(detail, field); - if (value == null) return false; - JsonElement page = value.get("page"); - return page != null && page.isJsonPrimitive() && page.getAsJsonPrimitive().isNumber() && page.getAsInt() == 1 && value.has("results") && value.get("results").isJsonArray() && value.getAsJsonArray("results").size() > 0; - } - - private static boolean externalIdsAvailable(JsonObject detail) { - JsonObject externalIds = object(detail, "external_ids"); - if (externalIds == null) return false; - for (Map.Entry entry : externalIds.entrySet()) { - JsonElement value = entry.getValue(); - if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) continue; - if (value.getAsJsonPrimitive().isString() && !value.getAsString().trim().isEmpty()) return true; - if (value.getAsJsonPrimitive().isNumber()) { - try { - if (value.getAsLong() > 0) return true; - } catch (RuntimeException ignored) { - // Keep checking the remaining IDs. - } - } - } - return false; - } - - private static String date(JsonObject detail) { - String date = string(detail, "release_date"); - return date.isEmpty() ? string(detail, "first_air_date") : date; - } - - private static String string(JsonObject detail, String key) { - if (detail == null || !detail.has(key) || !detail.get(key).isJsonPrimitive() || !detail.getAsJsonPrimitive(key).isString()) return ""; - String value = detail.get(key).getAsString(); - return value == null ? "" : value.trim(); - } - - private static JsonObject object(JsonObject detail, String key) { - if (detail == null || !detail.has(key) || !detail.get(key).isJsonObject()) return null; - return detail.getAsJsonObject(key); - } - - private static JsonArray array(JsonObject detail, String key) { - if (detail == null || !detail.has(key) || !detail.get(key).isJsonArray()) return new JsonArray(); - return detail.getAsJsonArray(key); - } - - private static int[] parseSeason(String group) { - try { - return new int[]{Integer.parseInt(group.substring("season:".length()))}; - } catch (RuntimeException ignored) { - return null; - } - } - - private static int[] parseEpisode(String group) { - try { - String[] parts = group.substring("episode:".length()).split(":", -1); - return parts.length == 2 ? new int[]{Integer.parseInt(parts[0]), Integer.parseInt(parts[1])} : null; - } catch (RuntimeException ignored) { - return null; - } - } - - private TmdbSourceCapabilityPlanner() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceMerger.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceMerger.java deleted file mode 100644 index d71705859c..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceMerger.java +++ /dev/null @@ -1,253 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.TmdbPerson; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; - -/** Fill-only merge for source, local-cache and remote TMDB bundles. */ -public final class TmdbSourceMerger { - - public enum Origin { - SOURCE, - LOCAL_CACHE, - REMOTE_TMDB, - VOD_FALLBACK - } - - public record MergedBundle(TmdbBundle bundle, Map origins) { - - public MergedBundle { - origins = Collections.unmodifiableMap(new LinkedHashMap<>(origins)); - } - - public Origin origin(String field) { - return origins.getOrDefault(field, Origin.SOURCE); - } - } - - public static TmdbBundle fillOnly(@Nullable TmdbBundle source, @Nullable TmdbSourcePayload payload, @Nullable TmdbBundle network) { - return merge(source, payload, null, network).bundle(); - } - - public static MergedBundle merge(@Nullable TmdbBundle source, @Nullable TmdbSourcePayload payload, - @Nullable TmdbBundle cache, @Nullable TmdbBundle network) { - TmdbBundle base = source == null ? empty() : source; - Map origins = new LinkedHashMap<>(); - base = mergePair(base, payload, cache, Origin.LOCAL_CACHE, origins); - base = mergePair(base, payload, network, Origin.REMOTE_TMDB, origins); - return new MergedBundle(base, origins); - } - - private static TmdbBundle mergePair(TmdbBundle base, @Nullable TmdbSourcePayload payload, @Nullable TmdbBundle lower, - Origin lowerOrigin, Map origins) { - if (lower == null || lower.item() == null) return base; - if (base.item() != null && !identityMatches(base.item(), lower.item())) return base; - - TmdbItem item = mergeItem(base.item(), lower.item(), payload, origins, lowerOrigin); - JsonObject detail = mergeObject(base.detail(), lower.detail(), payload, "detail", origins, lowerOrigin); - List cast = mergeList(base.cast(), lower.cast(), "cast", cachedComplete(payload, "credits"), origins, lowerOrigin); - List creators = mergeList(base.creators(), lower.creators(), "creators", cachedComplete(payload, "credits"), origins, lowerOrigin); - List photos = mergeList(base.photos(), lower.photos(), "photos", cachedComplete(payload, "images"), origins, lowerOrigin); - List related = mergeList(base.related(), lower.related(), "related", cachedComplete(payload, "recommendations") && cachedComplete(payload, "similar"), origins, lowerOrigin); - List seasons = mergeSeasons(base.seasons(), lower.seasons()); - Map seasonCounts = mergeMap(base.seasonCounts(), lower.seasonCounts(), payload, "seasonCounts", origins, lowerOrigin); - Map> seasonEpisodes = mergeSeasonEpisodes(base.seasonEpisodes(), lower.seasonEpisodes(), payload, origins, lowerOrigin); - Map> seasonCast = mergeMap(base.seasonCast(), lower.seasonCast(), payload, "seasonCast", origins, lowerOrigin); - Map> seasonPhotos = mergeMap(base.seasonPhotos(), lower.seasonPhotos(), payload, "seasonPhotos", origins, lowerOrigin); - return new TmdbBundle(item, detail, cast, creators, photos, related, seasons, seasonCounts, seasonEpisodes, seasonCast, seasonPhotos); - } - - private static TmdbItem mergeItem(TmdbItem source, TmdbItem lower, @Nullable TmdbSourcePayload payload, Map origins, Origin lowerOrigin) { - if (source == null) return lower; - String title = choose(source.getTitle(), lower.getTitle(), "item.title", origins, lowerOrigin); - String subtitle = choose(source.getSubtitle(), lower.getSubtitle(), "item.subtitle", origins, lowerOrigin); - String overview = choose(source.getOverview(), lower.getOverview(), "item.overview", origins, lowerOrigin); - String poster = choose(source.getPosterUrl(), lower.getPosterUrl(), "item.poster", origins, lowerOrigin); - String backdrop = choose(source.getBackdropUrl(), lower.getBackdropUrl(), "item.backdrop", origins, lowerOrigin); - String credit = choose(source.getCredit(), lower.getCredit(), "item.credit", origins, lowerOrigin); - String language = choose(source.getOriginalLanguage(), lower.getOriginalLanguage(), "item.language", origins, lowerOrigin); - String country = choose(source.getOriginCountry(), lower.getOriginCountry(), "item.country", origins, lowerOrigin); - String department = choose(source.getDepartment(), lower.getDepartment(), "item.department", origins, lowerOrigin); - List genres = source.getGenreIds(); - if (genres.isEmpty() && !lower.getGenreIds().isEmpty()) { - genres = lower.getGenreIds(); - mark(origins, "item.genres", lowerOrigin); - } - boolean coreComplete = cachedComplete(payload, TmdbSourceCapabilityPlanner.CORE); - double rating = source.getRating() > 0 || coreComplete ? source.getRating() : lower.getRating(); - double tmdbRating = source.getTmdbRating() > 0 || coreComplete ? source.getTmdbRating() : lower.getTmdbRating(); - double doubanRating = source.getDoubanRating() > 0 ? source.getDoubanRating() : lower.getDoubanRating(); - return new TmdbItem(source.getTmdbId(), source.getMediaType(), title, subtitle, overview, poster, backdrop, credit, rating, language, country, genres, department, tmdbRating, doubanRating, source.getRecommendationReason()); - } - - private static JsonObject mergeObject(@Nullable JsonObject source, @Nullable JsonObject lower, @Nullable TmdbSourcePayload payload, String path, - Map origins, Origin lowerOrigin) { - JsonObject result = source == null ? new JsonObject() : source.deepCopy(); - if (lower == null) return result; - for (Map.Entry entry : lower.entrySet()) { - String key = entry.getKey(); - String childPath = path.isEmpty() ? key : path + '.' + key; - JsonElement current = result.get(key); - JsonElement replacement = entry.getValue(); - if (current == null || isEmptyValue(current, payload, childPath)) { - result.add(key, replacement.deepCopy()); - mark(origins, childPath, lowerOrigin); - } else if (current.isJsonObject() && replacement.isJsonObject()) { - result.add(key, mergeObject(current.getAsJsonObject(), replacement.getAsJsonObject(), payload, childPath, origins, lowerOrigin)); - } else if (current.isJsonArray() && replacement.isJsonArray()) { - JsonArray merged = mergeArray(current.getAsJsonArray(), replacement.getAsJsonArray(), payload, childPath, origins, lowerOrigin); - result.add(key, merged); - } - } - return result; - } - - private static JsonArray mergeArray(JsonArray source, JsonArray lower, @Nullable TmdbSourcePayload payload, String path, - Map origins, Origin lowerOrigin) { - JsonArray result = source.deepCopy(); - if (!source.isEmpty() || cachedComplete(payload, capabilityForField(path))) return result; - for (JsonElement element : lower) result.add(element.deepCopy()); - if (!lower.isEmpty()) mark(origins, path, lowerOrigin); - return result; - } - - private static boolean isEmptyValue(JsonElement value, @Nullable TmdbSourcePayload payload, String key) { - if (value == null || value.isJsonNull()) return true; - if (value.isJsonPrimitive()) { - if (value.getAsJsonPrimitive().isString()) return value.getAsString().trim().isEmpty(); - if (value.getAsJsonPrimitive().isNumber()) { - try { - return value.getAsDouble() == 0d && !cachedComplete(payload, capabilityForField(key)); - } catch (RuntimeException ignored) { - return true; - } - } - } - if (value.isJsonObject()) return value.getAsJsonObject().isEmpty() && !cachedComplete(payload, capabilityForField(key)); - if (value.isJsonArray()) return value.getAsJsonArray().isEmpty() && !cachedComplete(payload, capabilityForField(key)); - return false; - } - - private static List mergeList(@Nullable List source, @Nullable List lower, String field, boolean sourceComplete, - Map origins, Origin lowerOrigin) { - List base = source == null ? List.of() : source; - if (!base.isEmpty() || sourceComplete) return base; - List replacement = lower == null ? List.of() : lower; - if (!replacement.isEmpty()) mark(origins, field, lowerOrigin); - return replacement; - } - - private static List mergeSeasons(@Nullable List source, @Nullable List lower) { - Set merged = new TreeSet<>(); - if (source != null) merged.addAll(source); - if (lower != null) merged.addAll(lower); - return new ArrayList<>(merged); - } - - private static Map mergeMap(@Nullable Map source, @Nullable Map lower, - @Nullable TmdbSourcePayload payload, String field, Map origins, Origin lowerOrigin) { - Map result = source == null ? new LinkedHashMap<>() : new LinkedHashMap<>(source); - if (lower == null) return result; - for (Map.Entry entry : lower.entrySet()) { - T current = result.get(entry.getKey()); - if (current != null && !isEmpty(current)) continue; - result.put(entry.getKey(), entry.getValue()); - mark(origins, field + '.' + entry.getKey(), lowerOrigin); - } - return result; - } - - private static Map> mergeSeasonEpisodes(@Nullable Map> source, - @Nullable Map> lower, - @Nullable TmdbSourcePayload payload, - Map origins, Origin lowerOrigin) { - Map> result = source == null ? new LinkedHashMap<>() : new LinkedHashMap<>(source); - if (lower == null) return result; - for (Map.Entry> entry : lower.entrySet()) { - List current = result.get(entry.getKey()); - boolean complete = payload != null && payload.hasCapability(TmdbSourceCapabilityPlanner.season(entry.getKey())); - if (current != null && (!current.isEmpty() || complete)) continue; - result.put(entry.getKey(), entry.getValue()); - mark(origins, "seasonEpisodes." + entry.getKey(), lowerOrigin); - } - return result; - } - - private static boolean isEmpty(Object value) { - if (value instanceof List list) return list.isEmpty(); - if (value instanceof Map map) return map.isEmpty(); - return value == null; - } - - private static boolean cachedComplete(@Nullable TmdbSourcePayload payload, String group) { - return payload != null && payload.hasCapability(group); - } - - private static String capabilityForField(String path) { - String normalized = path == null ? "" : path; - if (normalized.contains("credits")) return TmdbSourceCapabilityPlanner.CREDITS; - if (normalized.contains("recommendations")) return TmdbSourceCapabilityPlanner.RECOMMENDATIONS; - if (normalized.contains("similar")) return TmdbSourceCapabilityPlanner.SIMILAR; - if (normalized.contains("videos")) return TmdbSourceCapabilityPlanner.VIDEOS; - if (normalized.contains("images")) return TmdbSourceCapabilityPlanner.IMAGES; - if (normalized.contains("external_ids")) return TmdbSourceCapabilityPlanner.EXTERNAL_IDS; - if (isCoreField(normalized)) return TmdbSourceCapabilityPlanner.CORE; - String field = path == null ? "" : path.substring(path.lastIndexOf('.') + 1); - return switch (field) { - case "credits", "aggregate_credits", "cast", "crew" -> TmdbSourceCapabilityPlanner.CREDITS; - case "images" -> TmdbSourceCapabilityPlanner.IMAGES; - case "external_ids" -> TmdbSourceCapabilityPlanner.EXTERNAL_IDS; - case "videos" -> TmdbSourceCapabilityPlanner.VIDEOS; - case "recommendations" -> TmdbSourceCapabilityPlanner.RECOMMENDATIONS; - case "similar" -> TmdbSourceCapabilityPlanner.SIMILAR; - default -> ""; - }; - } - - private static boolean isCoreField(String path) { - String field = path.substring(path.lastIndexOf('.') + 1); - return switch (field) { - case "id", "title", "name", "original_title", "original_name", "overview", "tagline", "status", - "release_date", "first_air_date", "vote_average", "vote_count", "genres", "origin_country", - "original_language", "runtime", "episode_run_time", "number_of_seasons", "number_of_episodes", - "poster_path", "backdrop_path" -> true; - default -> false; - }; - } - - private static String choose(String source, String lower, String field, Map origins, Origin lowerOrigin) { - if (source != null && !source.isEmpty()) return source; - if (lower != null && !lower.isEmpty()) mark(origins, field, lowerOrigin); - return lower == null ? "" : lower; - } - - private static void mark(Map origins, String field, Origin origin) { - origins.putIfAbsent(field, origin); - } - - private static boolean identityMatches(TmdbItem left, TmdbItem right) { - return left.getTmdbId() > 0 && left.getTmdbId() == right.getTmdbId() && left.getMediaType().equalsIgnoreCase(right.getMediaType()); - } - - private static TmdbBundle empty() { - return new TmdbBundle(null, new JsonObject(), List.of(), List.of(), List.of(), List.of(), List.of(), Map.of(), Map.of(), Map.of(), Map.of()); - } - - private TmdbSourceMerger() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourcePayloadParser.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourcePayloadParser.java deleted file mode 100644 index 2948189827..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourcePayloadParser.java +++ /dev/null @@ -1,323 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.JsonPrimitive; - -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashSet; -import java.util.Locale; -import java.util.Set; -import java.util.TreeSet; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** Validates and normalizes source-provided C16 TMDB payloads without throwing into the detail page. */ -public final class TmdbSourcePayloadParser { - - public static final int MAX_PROTOCOL_BYTES = 2 * 1024 * 1024; - public static final int MAX_ARRAY_ITEMS = 500; - public static final int MAX_STRING_BYTES = 64 * 1024; - - private static final Pattern FETCHED_AT = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$"); - private static final Pattern SEASON = Pattern.compile("^season:(\\d+)$"); - private static final Pattern SEASON_VIDEOS = Pattern.compile("^season_videos:(\\d+)$"); - private static final Pattern EPISODE = Pattern.compile("^episode:(\\d+):(\\d+)$"); - private static final Pattern EPISODE_VIDEOS = Pattern.compile("^episode_videos:(\\d+):(\\d+)$"); - - private static final Set STATIC_CAPABILITIES = Set.of( - "core", "credits", "images", "external_ids", "videos", "recommendations", "similar" - ); - private static final Set IMAGE_FIELDS = Set.of( - "poster_path", "backdrop_path", "still_path", "profile_path", "logo_path", "file_path" - ); - private static final Set STRING_FIELDS = Set.of( - "title", "name", "original_title", "original_name", "overview", "tagline", "status", - "release_date", "first_air_date", "original_language", "poster_path", "backdrop_path", "still_path", - "profile_path", "logo_path", "file_path", "imdb_id", "iso_639_1", "iso_3166_1" - ); - private static final Set NUMBER_FIELDS = Set.of( - "id", "vote_average", "vote_count", "runtime", "number_of_seasons", "number_of_episodes", - "season_number", "episode_number", "order", "popularity" - ); - private static final Set OBJECT_FIELDS = Set.of( - "images", "credits", "aggregate_credits", "external_ids", "videos", "recommendations", "similar", - "translations", "content_ratings", "release_dates" - ); - private static final Set ARRAY_FIELDS = Set.of( - "genres", "origin_country", "episode_run_time", "seasons", "episodes", "results", "cast", "crew", - "guest_stars", "translations", "content_ratings", "release_dates", "backdrops", "posters", "logos", "profiles" - ); - private static final String[] TRIM_ORDER = { - "release_dates", "content_ratings", "translations", "similar", "recommendations", "videos", - "images", "credits", "aggregate_credits", "seasons", "episodes" - }; - - private static final Gson GSON = new Gson(); - - @Nullable - public static TmdbSourcePayload parseJson(@Nullable String json) { - if (json == null || json.isBlank()) return null; - if (json.getBytes(StandardCharsets.UTF_8).length > MAX_PROTOCOL_BYTES) return null; - try { - return parse(GSON.fromJson(json, TmdbSourcePayload.class)); - } catch (RuntimeException ignored) { - return null; - } - } - - @Nullable - public static TmdbSourcePayload parse(@Nullable TmdbSourcePayload source) { - if (source == null || source.getSchema() != TmdbSourcePayload.SCHEMA_VERSION || source.getTmdbId() <= 0) return null; - String mediaType = normalizeMediaType(source.getMediaType()); - if (mediaType.isEmpty()) return null; - int seasonNumber = source.getSeasonNumber(); - if (seasonNumber < 0 || ("movie".equals(mediaType) && seasonNumber != 0)) return null; - - TmdbSourcePayload result = source.copy(); - result.setMediaType(mediaType); - result.setSeasonNumber(seasonNumber); - result.setLanguage(normalizeLanguage(result.getLanguage())); - result.setFetchedAt(normalizeFetchedAt(result.getFetchedAt())); - result.setSourceKind(normalizeSourceKind(result.getSourceKind())); - - JsonObject detail = parseDetail(result.getDetailJson()); - JsonElement sanitized = sanitize(detail, ""); - JsonObject normalized = sanitized != null && sanitized.isJsonObject() ? sanitized.getAsJsonObject() : new JsonObject(); - normalized = normalizeIdentity(normalized, result); - if (normalized == null) return null; - - LinkedHashSet complete = canonicalCapabilities(result.getComplete()); - removeUnsupportedClaims(normalized, complete); - normalized = trimToLimit(normalized, complete); - if (normalized == null) return null; - - result.setComplete(complete); - result.setDetailJson(normalized.toString()); - if (result.estimatedParcelBytes() > MAX_PROTOCOL_BYTES) return null; - return result; - } - - /** Returns an empty string for invalid image references and otherwise the protocol-safe value. */ - public static String normalizeImageUrl(@Nullable String value) { - if (value == null) return ""; - String result = value.trim(); - if (result.isEmpty() || result.startsWith("//")) return ""; - if (result.startsWith("/")) return result.indexOf('\\') >= 0 || result.indexOf(' ') >= 0 ? "" : result; - try { - URI uri = URI.create(result); - if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getHost().isEmpty()) return ""; - return result; - } catch (IllegalArgumentException ignored) { - return ""; - } - } - - private static JsonObject parseDetail(String json) { - if (json == null || json.isBlank()) return new JsonObject(); - try { - JsonElement parsed = JsonParser.parseString(json); - return parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject(); - } catch (RuntimeException ignored) { - return new JsonObject(); - } - } - - @Nullable - private static JsonObject normalizeIdentity(JsonObject detail, TmdbSourcePayload payload) { - JsonElement id = detail.get("id"); - if (id != null) { - if (!id.isJsonPrimitive() || !id.getAsJsonPrimitive().isNumber()) { - detail.remove("id"); - } else { - try { - if (id.getAsInt() != payload.getTmdbId()) return null; - } catch (RuntimeException ignored) { - detail.remove("id"); - } - } - } - if ("tv".equals(payload.getMediaType())) { - JsonElement season = detail.get("season_number"); - if (season != null) { - if (!season.isJsonPrimitive() || !season.getAsJsonPrimitive().isNumber()) { - detail.remove("season_number"); - } else { - try { - if (season.getAsInt() != payload.getSeasonNumber()) return null; - } catch (RuntimeException ignored) { - detail.remove("season_number"); - } - } - } - } else { - detail.remove("season_number"); - } - return detail; - } - - @Nullable - private static JsonElement sanitize(@Nullable JsonElement element, String field) { - if (element == null || element.isJsonNull()) return null; - if (element.isJsonPrimitive()) { - JsonPrimitive primitive = element.getAsJsonPrimitive(); - if (primitive.isString()) { - String value = primitive.getAsString(); - if (value == null || value.getBytes(StandardCharsets.UTF_8).length > MAX_STRING_BYTES) return null; - if (IMAGE_FIELDS.contains(field)) { - String image = normalizeImageUrl(value); - return image.isEmpty() ? null : new JsonPrimitive(image); - } - return new JsonPrimitive(value); - } - if (NUMBER_FIELDS.contains(field) && !primitive.isNumber()) return null; - if (STRING_FIELDS.contains(field)) return null; - return element.deepCopy(); - } - if (ARRAY_FIELDS.contains(field) && !element.isJsonArray()) return null; - if (OBJECT_FIELDS.contains(field) && !element.isJsonObject()) return null; - if (element.isJsonArray()) { - JsonArray array = element.getAsJsonArray(); - if (array.size() > MAX_ARRAY_ITEMS) return null; - JsonArray result = new JsonArray(); - for (JsonElement item : array) { - JsonElement sanitized = sanitize(item, ""); - if (sanitized != null) result.add(sanitized); - } - return result; - } - if (element.isJsonObject()) { - JsonObject result = new JsonObject(); - for (String key : element.getAsJsonObject().keySet()) { - JsonElement sanitized = sanitize(element.getAsJsonObject().get(key), key); - if (sanitized != null) result.add(key, sanitized); - } - if (result.isEmpty() && !element.getAsJsonObject().isEmpty() && OBJECT_FIELDS.contains(field) && !"external_ids".equals(field)) return null; - return result; - } - return null; - } - - private static LinkedHashSet canonicalCapabilities(Set values) { - TreeSet sorted = new TreeSet<>(); - if (values != null) for (String value : values) { - String canonical = canonicalCapability(value); - if (!canonical.isEmpty()) sorted.add(canonical); - } - return new LinkedHashSet<>(sorted); - } - - private static String canonicalCapability(String value) { - if (value == null) return ""; - String group = value.trim().toLowerCase(Locale.ROOT); - if (STATIC_CAPABILITIES.contains(group)) return group; - Matcher season = SEASON.matcher(group); - if (season.matches()) return canonicalSeason("season", season.group(1)); - Matcher seasonVideos = SEASON_VIDEOS.matcher(group); - if (seasonVideos.matches()) return canonicalSeason("season_videos", seasonVideos.group(1)); - Matcher episode = EPISODE.matcher(group); - if (episode.matches()) return canonicalEpisode("episode", episode.group(1), episode.group(2)); - Matcher episodeVideos = EPISODE_VIDEOS.matcher(group); - if (episodeVideos.matches()) return canonicalEpisode("episode_videos", episodeVideos.group(1), episodeVideos.group(2)); - return ""; - } - - private static String canonicalSeason(String prefix, String number) { - try { - int value = Integer.parseInt(number); - return value < 0 ? "" : prefix + ':' + value; - } catch (NumberFormatException ignored) { - return ""; - } - } - - private static String canonicalEpisode(String prefix, String season, String episode) { - try { - int seasonNumber = Integer.parseInt(season); - int episodeNumber = Integer.parseInt(episode); - return seasonNumber < 0 || episodeNumber <= 0 ? "" : prefix + ':' + seasonNumber + ':' + episodeNumber; - } catch (NumberFormatException ignored) { - return ""; - } - } - - private static void removeUnsupportedClaims(JsonObject detail, Set complete) { - if (!hasAny(detail, "credits", "aggregate_credits")) complete.remove("credits"); - if (!hasAny(detail, "images", "poster_path", "backdrop_path")) complete.remove("images"); - if (!hasObject(detail, "external_ids")) complete.remove("external_ids"); - if (!hasObject(detail, "videos")) complete.remove("videos"); - if (!hasObject(detail, "recommendations")) complete.remove("recommendations"); - if (!hasObject(detail, "similar")) complete.remove("similar"); - if (!hasArray(detail, "seasons")) { - complete.removeIf(group -> group.startsWith("season:")); - complete.removeIf(group -> group.startsWith("season_videos:")); - } - } - - @Nullable - private static JsonObject trimToLimit(JsonObject detail, Set complete) { - for (String field : TRIM_ORDER) { - if (detail.toString().getBytes(StandardCharsets.UTF_8).length <= MAX_PROTOCOL_BYTES) return detail; - detail.remove(field); - removeCapabilityForField(complete, field); - } - return detail.toString().getBytes(StandardCharsets.UTF_8).length <= MAX_PROTOCOL_BYTES ? detail : null; - } - - private static void removeCapabilityForField(Set complete, String field) { - switch (field) { - case "credits", "aggregate_credits" -> complete.remove("credits"); - case "images" -> complete.remove("images"); - case "videos" -> complete.remove("videos"); - case "recommendations" -> complete.remove("recommendations"); - case "similar" -> complete.remove("similar"); - case "seasons" -> complete.removeIf(group -> group.startsWith("season:") || group.startsWith("season_videos:")); - case "episodes" -> complete.removeIf(group -> group.startsWith("episode:") || group.startsWith("episode_videos:")); - default -> { - } - } - } - - private static boolean hasAny(JsonObject object, String... fields) { - for (String field : fields) if (object.has(field) && !object.get(field).isJsonNull()) return true; - return false; - } - - private static boolean hasObject(JsonObject object, String field) { - return object.has(field) && object.get(field).isJsonObject(); - } - - private static boolean hasArray(JsonObject object, String field) { - return object.has(field) && object.get(field).isJsonArray(); - } - - private static String normalizeMediaType(String value) { - String result = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); - return "movie".equals(result) || "tv".equals(result) ? result : ""; - } - - private static String normalizeLanguage(String value) { - String result = value == null ? "" : value.trim(); - return result.length() > 64 ? "" : result; - } - - private static String normalizeFetchedAt(String value) { - String result = value == null ? "" : value.trim(); - return FETCHED_AT.matcher(result).matches() ? result : ""; - } - - private static String normalizeSourceKind(String value) { - return TmdbSourcePayload.CACHE_TMDB.equals(value) || TmdbSourcePayload.REMOTE_TMDB.equals(value) - ? value : TmdbSourcePayload.SOURCE_TMDB; - } - - private TmdbSourcePayloadParser() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java index 07b122dfca..fe1d1750d1 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java @@ -5,7 +5,6 @@ import android.text.TextUtils; import com.fongmi.android.tv.App; import com.fongmi.android.tv.api.SiteApi; -import com.fongmi.android.tv.api.config.SubscriptionTmdbCredentialStore; import com.fongmi.android.tv.bean.Episode; import com.fongmi.android.tv.bean.Flag; import com.fongmi.android.tv.bean.TmdbConfig; @@ -17,7 +16,6 @@ import com.fongmi.android.tv.bean.TmdbSeasonMatchCache; import com.fongmi.android.tv.bean.TmdbSeasonSegment; import com.fongmi.android.tv.bean.TmdbSeasonScope; import com.fongmi.android.tv.bean.TmdbPerson; -import com.fongmi.android.tv.bean.TmdbSourcePayload; import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.service.AiRecommendationService; @@ -81,8 +79,8 @@ public class TmdbUIAdapter { private final Activity activity; private final TmdbService tmdbService; - private TmdbMatcher tmdbMatcher; - private TmdbConfig tmdbConfig; + private final TmdbMatcher tmdbMatcher; + private final TmdbConfig tmdbConfig; private final Runnable pendingVodRefresh = this::dispatchPendingVodRefresh; private final TmdbDetailPrefetch detailPrefetch; private final Task.Scope backgroundTasks; @@ -121,15 +119,11 @@ public class TmdbUIAdapter { private boolean relatedVideoLoading; private String relatedVideoContextKey = ""; private boolean loaded; - private boolean sourceOnly; - private TmdbBundle activeSourceBundle; - private TmdbSourcePayload activeSourcePayload; private volatile boolean episodeMetadataLoaded; private volatile int loadGeneration; private volatile int episodeMetadataGeneration; private volatile int relatedVideoGeneration; - private volatile SubscriptionTmdbCredentialStore.Scope subscriptionScope; private final Object episodeMetadataLock = new Object(); // 季级内存缓存:切季 / 切线路会反复要同一季的集数,磁盘缓存虽然命中但仍要读文件 + // 解析整季 JSON。缓存解析结果让重复访问零 IO。键为 tmdbId|mediaType|season。 @@ -152,7 +146,8 @@ public class TmdbUIAdapter { public TmdbUIAdapter(Activity activity) { this.activity = activity; this.tmdbService = new TmdbService(); - refreshRuntimeConfig(); + this.tmdbConfig = TmdbConfig.objectFrom(Setting.getTmdbConfig()); + this.tmdbMatcher = new TmdbMatcher(tmdbService, tmdbConfig); this.backgroundTasks = new Task.Scope(Task.recommendationExecutor()); // 选集元数据独立线程池:recommendationExecutor 只有 3 条线程,推荐 / 个性化 / AI 推荐 // 都挤在里面。原先 1200ms 延迟天然把选集和它们错开了,现在选集不再延迟,必须换到 @@ -162,17 +157,13 @@ public class TmdbUIAdapter { } public boolean isReady() { - return TmdbConfig.effectiveCurrent().isReady(); + return tmdbConfig.isReady(); } public boolean isLoaded() { return loaded; } - public boolean isSourceOnly() { - return sourceOnly; - } - public boolean isEpisodeMetadataLoaded() { return episodeMetadataLoaded; } @@ -389,21 +380,10 @@ public class TmdbUIAdapter { detailPrefetch.cancel(); } - public void invalidateSubscription() { - refreshRuntimeConfig(); - subscriptionScope = SubscriptionTmdbCredentialStore.currentScope(); - loadGeneration++; - backgroundTasks.cancelAll(); - episodeTasks.cancelAll(); - cancelActivePrefetch(); - detailPrefetch.cancel(); - } - /** * Prefetch core TMDB detail for a known item without mutating or publishing a Vod. */ public void prefetch(TmdbItem item) { - refreshRuntimeConfig(); if (item == null || !isReady()) return; long start = System.currentTimeMillis(); // Intent 在主线程读:本方法由 prefetchDirectTmdbDetail 在主线程调用,而 Intent 内部是 @@ -504,7 +484,6 @@ public class TmdbUIAdapter { */ public void load(TmdbItem item, Vod vod) { if (item == null) return; - refreshRuntimeConfig(); // resetLoadState 会清掉 sourceCacheTitle,而手动换条目时 vod.getName() 已被 // enrichVod 改写成上一个 TMDB 标题。先留住站源标题,季度绑定的键才能跨会话一致。 String sourceTitle = sourceCacheTitle; @@ -573,7 +552,6 @@ public class TmdbUIAdapter { load(item, vod); return; } - refreshRuntimeConfig(); String sourceTitle = sourceCacheTitle; int generation = resetLoadState(); captureSourceSeason(vod, sourceTitle); @@ -584,130 +562,6 @@ public class TmdbUIAdapter { backgroundTasks.submit(() -> loadDetailSync(vod, cached.getItem(), cached.getDetail(), cached.getCast(), generation)); } - /** - * Applies a validated C16 source bundle first. If an effective credential exists, only planner-reported - * initial gaps are filled from TMDB; complete source data stays offline and lazy capabilities remain on demand. - */ - public void loadSource(TmdbBundle bundle, Vod sourceVod, TmdbSourcePayload payload) { - if (bundle == null || bundle.item() == null || bundle.detail() == null || sourceVod == null) return; - refreshRuntimeConfig(); - String sourceTitle = sourceCacheTitle; - int generation = resetLoadState(); - sourceOnly = !tmdbConfig.isReady(); - captureSourceSeason(sourceVod, sourceTitle); - if (requestSeasonNumber < 0 && payload != null && payload.getSeasonNumber() >= 0) requestSeasonNumber = payload.getSeasonNumber(); - cancelActivePrefetch(); - detailPrefetch.cancel(); - applySourceBundle(bundle, sourceVod, payload); - TmdbSourceCapabilityPlanner.Plan plan = TmdbSourceCapabilityPlanner.plan(bundle, payload, TmdbSourceCapabilityPlanner.UiState.initialScreen()); - boolean fillInitial = !sourceOnly && plan.hasInitialNetworkGaps(); - SpiderDebug.log("tmdb", "source loaded title=%s media=%s id=%d seasons=%d sourceOnly=%s fill=%s missing=%s", - tmdbItem.getTitle(), tmdbItem.getMediaType(), tmdbItem.getTmdbId(), seasonOptions.size(), sourceOnly, fillInitial, plan.missing()); - notifyVodChanged(sourceVod, generation, RefreshEvent.Type.VOD_CORE); - notifyLoadComplete(sourceVod, generation); - if (fillInitial) backgroundTasks.submit(() -> fillInitialSourceGaps(bundle, sourceVod, payload, plan, generation)); - } - - private void applySourceBundle(TmdbBundle bundle, Vod sourceVod, TmdbSourcePayload payload) { - vod = sourceVod; - activeSourceBundle = bundle; - activeSourcePayload = payload; - tmdbItem = bundle.item(); - tmdbDetail = bundle.detail(); - tmdbCast = new ArrayList<>(bundle.cast()); - recommendations = new ArrayList<>(bundle.related()); - relatedVideos = new ArrayList<>(); - personalTmdbRecommendations = new ArrayList<>(); - personalDoubanRecommendations = new ArrayList<>(); - personalAiRecommendations = new ArrayList<>(); - personalTmdbPage = PersonalRecommendationService.RecommendationPage.empty(""); - personalDoubanPage = PersonalRecommendationService.RecommendationPage.empty(""); - personalAiPage = PersonalRecommendationService.RecommendationPage.empty(""); - activeFlag = activeFlagFor(sourceVod); - activeFlagKey = flagKey(activeFlag, flagIndex(sourceVod == null ? null : sourceVod.getFlags(), activeFlag)); - - seasonEpisodeCache.clear(); - for (Map.Entry> entry : bundle.seasonEpisodes().entrySet()) { - if (entry.getKey() == null || entry.getKey() < 0 || entry.getValue() == null || entry.getValue().isEmpty()) continue; - seasonEpisodeCache.put(seasonEpisodeKey(tmdbItem, entry.getKey()), List.copyOf(entry.getValue())); - } - seasonOptions = sourceSeasonOptions(bundle); - if (tmdbItem.isTv()) { - List seasons = new ArrayList<>(); - Map counts = new LinkedHashMap<>(); - for (SeasonOption option : seasonOptions) { - seasons.add(option.getSeasonNumber()); - counts.put(option.getSeasonNumber(), option.getEpisodeCount()); - } - Flag sourceFlag = activeFlagFor(sourceVod); - seasonResolution = TmdbSeasonResolver.resolve( - requestSeasonNumber, - null, - explicitSourceSeasons(sourceFlag), - titleSeasonNumber, - seasons, - counts, - sourceEpisodeCount(sourceFlag), - sourceEpisodeNumbers(sourceFlag), - null, - false); - if (seasonResolution.getStatus() == TmdbSeasonResolver.Status.RESOLVED - && seasonResolution.getSelectedSeason() != null) { - sourceSeasonNumber = seasonResolution.getSelectedSeason(); - } - } else { - sourceSeasonNumber = -1; - seasonResolution = null; - seasonOptions = List.of(); - } - episodeInfo = TmdbEpisodeInfo.from(tmdbItem.getMediaType(), tmdbDetail, sourceSeasonNumber); - loaded = true; - episodeMetadataLoaded = true; - enrichVod(sourceVod, tmdbItem, tmdbDetail); - } - - private void fillInitialSourceGaps(TmdbBundle sourceBundle, Vod sourceVod, TmdbSourcePayload payload, - TmdbSourceCapabilityPlanner.Plan plan, int generation) { - long start = System.currentTimeMillis(); - TmdbConfig requestConfig = tmdbConfig; - try { - JsonObject detail = tmdbService.detailForSource(sourceBundle.item(), payload == null ? 0 : payload.getSeasonNumber(), requestConfig, plan.missing()); - if (!isCurrentGeneration(generation)) return; - TmdbBundle networkBundle = TmdbSourceAdapter.fromNetwork(sourceBundle.item(), detail, requestConfig); - TmdbBundle merged = TmdbSourceMerger.fillOnly(sourceBundle, payload, networkBundle); - SpiderDebug.log("tmdb", "source fill finish cost=%dms missing=%s", System.currentTimeMillis() - start, plan.missing()); - if (activity == null) return; - activity.runOnUiThread(() -> { - if (!isCurrentGeneration(generation)) return; - applySourceBundle(merged, sourceVod, payload); - notifyVodChanged(sourceVod, generation, RefreshEvent.Type.VOD_CORE); - notifyLoadComplete(sourceVod, generation); - }); - } catch (Throwable e) { - SpiderDebug.log("tmdb", "source fill failed cost=%dms missing=%s error=%s", System.currentTimeMillis() - start, plan.missing(), e.getMessage()); - } - } - - private static String seasonEpisodeKey(TmdbItem item, int seasonNumber) { - return item.getTmdbId() + "|" + item.getMediaType() + "|" + seasonNumber; - } - - private static List sourceSeasonOptions(TmdbBundle bundle) { - List fromDetail = parseSeasonOptions(bundle.detail()); - if (!fromDetail.isEmpty()) return fromDetail; - List result = new ArrayList<>(); - for (Integer seasonNumber : bundle.seasons()) { - if (seasonNumber == null || seasonNumber < 0 || containsSeason(result, seasonNumber)) continue; - result.add(new SeasonOption(seasonNumber, "", "", bundle.seasonCounts().getOrDefault(seasonNumber, 0))); - } - return List.copyOf(result); - } - - private static boolean containsSeason(List options, int seasonNumber) { - for (SeasonOption option : options) if (option.getSeasonNumber() == seasonNumber) return true; - return false; - } - public void rememberManualMatch(Vod vod, TmdbItem item) { saveTitleLearning(vod, item); saveManualMatch(vod, item); @@ -724,7 +578,6 @@ public class TmdbUIAdapter { } public void autoMatch(String videoName, Vod vod, String searchKeyword) { - refreshRuntimeConfig(); int generation = resetLoadState(); captureSourceSeason(vod, videoName); cancelActivePrefetch(); @@ -847,7 +700,6 @@ public class TmdbUIAdapter { } public List search(String keyword, Vod vod) throws Exception { - refreshRuntimeConfig(); return tmdbMatcher.search(keyword, vod); } @@ -872,7 +724,6 @@ public class TmdbUIAdapter { private int resetLoadState() { int generation = ++loadGeneration; - subscriptionScope = SubscriptionTmdbCredentialStore.currentScope(); episodeMetadataGeneration++; tmdbItem = null; tmdbDetail = null; @@ -905,9 +756,6 @@ public class TmdbUIAdapter { relatedVideoContextKey = ""; relatedVideoGeneration++; loaded = false; - sourceOnly = false; - activeSourceBundle = null; - activeSourcePayload = null; episodeMetadataLoaded = false; pendingVodRefreshVod = null; pendingVodRefreshGeneration = generation; @@ -1338,12 +1186,7 @@ public class TmdbUIAdapter { return activity == null || activity.getIntent() == null ? "" : activity.getIntent().getStringExtra("name"); } private boolean isCurrentGeneration(int generation) { - return generation == loadGeneration && SubscriptionTmdbCredentialStore.isCurrent(subscriptionScope); - } - - private void refreshRuntimeConfig() { - tmdbConfig = TmdbConfig.effectiveCurrent(); - tmdbMatcher = new TmdbMatcher(tmdbService, tmdbConfig); + return generation == loadGeneration; } private void loadDetailSync(Vod vod, TmdbItem item, int generation) { @@ -1600,7 +1443,7 @@ public class TmdbUIAdapter { PersonalRecommendationService.RecommendationPages pages = PersonalRecommendationService.RecommendationPages.empty(); PersonalRecommendationService service = new PersonalRecommendationService(tmdbService, tmdbConfig); try { - pages = service.loadIndependentPages(vod, item, detail, 0, PersonalRecommendationService.DEFAULT_PAGE_SIZE); + pages = service.loadPage(vod, item, detail, 0, PersonalRecommendationService.DEFAULT_PAGE_SIZE); } catch (Throwable e) { SpiderDebug.log("tmdb", "initial personal recommendations failed error=%s", e.getMessage()); } @@ -1615,13 +1458,12 @@ public class TmdbUIAdapter { personalDoubanRecommendations = personalDoubanPage.getItems(); if (vod != null && (!personalTmdbRecommendations.isEmpty() || !personalDoubanRecommendations.isEmpty())) notifyVodChanged(vod, generation, RefreshEvent.Type.VOD_PERSONAL); }); - service.enrichTmdbPageRatingsAsync(loadedPages.getTmdb(), enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod, item, service)); + service.enrichTmdbPageRatingsAsync(loadedPages.getTmdb(), enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod)); }); loadPersonalAiRecommendationsAsync(vod, item, generation); } private void loadPersonalAiRecommendationsAsync(Vod vod, TmdbItem item, int generation) { - if (sourceOnly) return; if (vod == null || !Setting.isPersonalRecommendation() || personalAiLoading) return; personalAiLoading = true; backgroundTasks.submit(() -> { @@ -1709,7 +1551,6 @@ public class TmdbUIAdapter { SpiderDebug.log("tmdb", "season episodes source=memory season=%d count=%d", seasonNumber, cached.size()); return cached; } - if (sourceOnly) return List.of(); JsonObject season = tmdbService.season(item, seasonNumber, tmdbConfig); if (season == null) return List.of(); List episodes = tmdbService.episodes(season, tmdbConfig, item.getTmdbId(), seasonNumber); @@ -2366,29 +2207,12 @@ public class TmdbUIAdapter { public void loadRelatedVideosAsync(int seasonNumber, int episodeNumber) { TmdbItem item = tmdbItem; Vod currentVod = vod; - if (!loaded || item == null) { - relatedVideos = new ArrayList<>(); - return; - } - String contextKey = item.getMediaType() + ":" + item.getTmdbId() + ":" + seasonNumber + ":" + episodeNumber + ":" + tmdbConfig.getLanguage(); - if (applySourceVideosIfAvailable(item, seasonNumber, episodeNumber, contextKey)) return; - if (sourceOnly) { - relatedVideoGeneration++; - relatedVideoLoading = false; - relatedVideoContextKey = contextKey; - relatedVideos = new ArrayList<>(TmdbSourceAdapter.videos( - tmdbDetail, - item.getMediaType(), - seasonNumber, - episodeNumber, - tmdbConfig.getLanguage())); - return; - } - if (!isReady()) { + if (!loaded || item == null || !isReady()) { relatedVideos = new ArrayList<>(); return; } int generation = loadGeneration; + String contextKey = item.getMediaType() + ":" + item.getTmdbId() + ":" + seasonNumber + ":" + episodeNumber + ":" + tmdbConfig.getLanguage(); if (contextKey.equals(relatedVideoContextKey) && (relatedVideoLoading || relatedVideos != null)) return; int videoGeneration = ++relatedVideoGeneration; relatedVideoContextKey = contextKey; @@ -2413,30 +2237,6 @@ public class TmdbUIAdapter { }); } - private boolean applySourceVideosIfAvailable(TmdbItem item, int seasonNumber, int episodeNumber, String contextKey) { - String group; - if (item.isMovie()) { - group = TmdbSourceCapabilityPlanner.VIDEOS; - } else if (episodeNumber > 0 && seasonNumber >= 0) { - group = TmdbSourceCapabilityPlanner.episodeVideos(seasonNumber, episodeNumber); - } else if (seasonNumber >= 0) { - group = TmdbSourceCapabilityPlanner.seasonVideos(seasonNumber); - } else { - group = TmdbSourceCapabilityPlanner.VIDEOS; - } - if (!TmdbSourceCapabilityPlanner.isAvailable(activeSourceBundle, activeSourcePayload, group, seasonNumber, episodeNumber)) return false; - relatedVideoGeneration++; - relatedVideoLoading = false; - relatedVideoContextKey = contextKey; - relatedVideos = new ArrayList<>(TmdbSourceAdapter.videos( - tmdbDetail, - item.getMediaType(), - seasonNumber, - episodeNumber, - tmdbConfig.getLanguage())); - return true; - } - public boolean hasMoreRecommendations() { return recommendationHasMore; } @@ -2454,10 +2254,6 @@ public class TmdbUIAdapter { } public void loadMoreRecommendations(LoadMoreCallback callback) { - if (sourceOnly) { - if (callback != null) callback.onLoaded(false); - return; - } if (recommendationLoading || !recommendationHasMore || tmdbItem == null || tmdbDetail == null) { if (callback != null) callback.onLoaded(false); return; @@ -2504,10 +2300,6 @@ public class TmdbUIAdapter { } public void refreshPersonalRecommendations(LoadMoreCallback callback) { - if (sourceOnly) { - if (callback != null) callback.onLoaded(false); - return; - } if (personalRefreshLoading || tmdbDetail == null) { if (callback != null) callback.onLoaded(false); return; @@ -2549,15 +2341,11 @@ public class TmdbUIAdapter { if (callback != null) callback.onLoaded(hasChanged); if (hasAiChanged) loadPersonalAiRecommendationsAsync(vod, tmdbItem, generation); }); - if (hasChanged) service.enrichTmdbPageRatingsAsync(loadedPages.getTmdb(), enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod, tmdbItem, service)); + if (hasChanged) service.enrichTmdbPageRatingsAsync(loadedPages.getTmdb(), enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod)); }); } private void loadMorePersonalRecommendations(boolean tmdb, LoadMoreCallback callback) { - if (sourceOnly) { - if (callback != null) callback.onLoaded(false); - return; - } PersonalRecommendationService.RecommendationPage page = tmdb ? personalTmdbPage : personalDoubanPage; if (page == null || !page.hasMore() || (tmdb ? personalTmdbLoading : personalDoubanLoading)) { if (callback != null) callback.onLoaded(false); @@ -2592,7 +2380,7 @@ public class TmdbUIAdapter { if (callback != null) callback.onLoaded(changed); } }); - if (tmdb) service.enrichTmdbPageRatingsAsync(loadedPage, enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod, tmdbItem, service)); + if (tmdb) service.enrichTmdbPageRatingsAsync(loadedPage, enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod)); }); } @@ -2603,13 +2391,10 @@ public class TmdbUIAdapter { }); } - private void applyPersonalTmdbRatingEnrichment( - PersonalRecommendationService.RecommendationPage enriched, int generation, Vod eventVod, - TmdbItem currentItem, PersonalRecommendationService service) { + private void applyPersonalTmdbRatingEnrichment(PersonalRecommendationService.RecommendationPage enriched, int generation, Vod eventVod) { activity.runOnUiThread(() -> { if (!isCurrentGeneration(generation) || enriched == null || !mergeRecommendationRatings(personalTmdbRecommendations, enriched.getItems())) return; if (personalTmdbPage != null) personalTmdbPage = personalTmdbPage.withItems(personalTmdbRecommendations); - if (service != null) service.writeCachedTmdbPage(eventVod, currentItem, personalTmdbPage); if (eventVod != null) notifyVodChanged(eventVod, generation, RefreshEvent.Type.VOD_PERSONAL); }); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java deleted file mode 100644 index dd89838343..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java +++ /dev/null @@ -1,253 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import android.app.Dialog; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.webkit.WebView; -import android.widget.EditText; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.utils.Util; - -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.Method; -import java.lang.reflect.Proxy; -import java.lang.ref.WeakReference; -import java.util.Collections; -import java.util.Map; -import java.util.Set; -import java.util.WeakHashMap; - -public final class TouchOptimizationHelper { - - private static final String BASE_GRID_VIEW = "androidx.leanback.widget.BaseGridView"; - private static final int FOCUS_SCROLL_ALIGNED = 0; - private static final int FOCUS_SCROLL_ITEM = 1; - private static final Map ORIGINAL_TOUCH_MODES = new WeakHashMap<>(); - private static final Map GRID_STATES = new WeakHashMap<>(); - private static final Set RECYCLERS_WITH_LISTENER = Collections.newSetFromMap(new WeakHashMap<>()); - - private TouchOptimizationHelper() { - } - - public static void sync(View root) { - if (!Util.isLeanback()) return; - if (root == null) return; - if (Setting.isTouchOptimized()) optimize(root); - else restore(root); - } - - public static void sync(Dialog dialog) { - if (!Util.isLeanback()) return; - if (dialog == null || dialog.getWindow() == null) return; - View decor = dialog.getWindow().getDecorView(); - decor.post(() -> sync(decor)); - } - - public static void optimize(View root) { - if (root == null || !Setting.isTouchOptimized()) return; - traverse(root, true); - } - - public static void restore(View root) { - if (root == null) return; - traverse(root, false); - } - - /** Returns whether a leanback grid is receiving a touch gesture or settling from one. */ - public static boolean isTouchActive(View view) { - if (view == null) return false; - synchronized (GRID_STATES) { - for (Map.Entry entry : GRID_STATES.entrySet()) { - View grid = entry.getKey(); - GridState state = entry.getValue(); - if (state == null || (!state.touchActive && !state.touchSettling) || grid == null) continue; - if (view == grid || isDescendantOf(view, grid)) return true; - } - } - return false; - } - - private static void traverse(View view, boolean optimize) { - if (isInputView(view)) return; - if (view instanceof RecyclerView recycler) { - if (optimize) attachListener(recycler); - else restoreGrid(recycler); - } - if (isBaseGridView(view)) { - if (optimize) attachGrid(view); - else restoreGrid(view); - } - if (optimize) { - if (view.isFocusableInTouchMode()) { - if (!ORIGINAL_TOUCH_MODES.containsKey(view)) ORIGINAL_TOUCH_MODES.put(view, true); - view.setFocusableInTouchMode(false); - } - } else { - Boolean original = ORIGINAL_TOUCH_MODES.remove(view); - if (Boolean.TRUE.equals(original)) view.setFocusableInTouchMode(true); - } - if (view instanceof ViewGroup group) { - for (int i = 0; i < group.getChildCount(); i++) traverse(group.getChildAt(i), optimize); - } - } - - private static void attachListener(RecyclerView recycler) { - if (!RECYCLERS_WITH_LISTENER.add(recycler)) return; - recycler.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() { - @Override - public void onChildViewAttachedToWindow(@NonNull View view) { - sync(view); - } - - @Override - public void onChildViewDetachedFromWindow(@NonNull View view) { - } - }); - } - - private static void attachGrid(View grid) { - synchronized (GRID_STATES) { - if (GRID_STATES.containsKey(grid)) return; - GridState state = new GridState(); - state.grid = new WeakReference<>(grid); - state.focusStrategy = invokeInt(grid, "getFocusScrollStrategy", FOCUS_SCROLL_ALIGNED); - state.focusSearchDisabled = invokeBoolean(grid, "isFocusSearchDisabled", false); - GRID_STATES.put(grid, state); - try { - Class touchListener = Class.forName(BASE_GRID_VIEW + "$OnTouchInterceptListener"); - Method setter = grid.getClass().getMethod("setOnTouchInterceptListener", touchListener); - setter.invoke(grid, Proxy.newProxyInstance(touchListener.getClassLoader(), new Class[]{touchListener}, handler(state))); - ((RecyclerView) grid).addOnScrollListener(new RecyclerView.OnScrollListener() { - @Override - public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { - if (newState == RecyclerView.SCROLL_STATE_IDLE && !state.touchActive) state.touchSettling = false; - } - }); - } catch (ReflectiveOperationException | RuntimeException ignored) { - GRID_STATES.remove(grid); - } - } - } - - private static InvocationHandler handler(GridState state) { - return (proxy, method, args) -> { - if ("onInterceptTouchEvent".equals(method.getName()) && args != null && args.length > 0) { - return onTouch(state, (MotionEvent) args[0]); - } - if (method.getReturnType() == boolean.class) return false; - return null; - }; - } - - private static boolean onTouch(GridState state, MotionEvent event) { - if (event == null || !Setting.isTouchOptimized()) { - state.touchGeneration++; - restoreGrid(state.grid.get()); - return false; - } - View grid = state.grid.get(); - if (grid == null) return false; - if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { - state.touchGeneration++; - state.touchSettling = false; - state.touchActive = true; - invoke(grid, "setFocusScrollStrategy", int.class, FOCUS_SCROLL_ITEM); - invoke(grid, "setFocusSearchDisabled", boolean.class, true); - } else if (event.getActionMasked() == MotionEvent.ACTION_UP) { - int generation = ++state.touchGeneration; - state.touchActive = false; - restoreGrid(grid); - state.touchSettling = true; - grid.post(() -> { - if (state.touchGeneration == generation && !state.touchActive - && (!(grid instanceof RecyclerView recycler) || recycler.getScrollState() == RecyclerView.SCROLL_STATE_IDLE)) { - state.touchSettling = false; - } - }); - } else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { - state.touchGeneration++; - restoreGrid(grid); - } - return false; - } - - private static void restoreGrid(RecyclerView recycler) { - if (!isBaseGridView(recycler)) return; - synchronized (GRID_STATES) { - GridState state = GRID_STATES.get(recycler); - if (state == null) return; - View grid = state.grid.get(); - if (grid != null) restoreGrid(grid); - } - } - - private static void restoreGrid(View grid) { - GridState state = GRID_STATES.get(grid); - if (state == null) return; - invoke(grid, "setFocusScrollStrategy", int.class, state.focusStrategy); - invoke(grid, "setFocusSearchDisabled", boolean.class, state.focusSearchDisabled); - state.touchActive = false; - state.touchSettling = false; - } - - private static boolean isBaseGridView(View view) { - try { - return Class.forName(BASE_GRID_VIEW).isInstance(view); - } catch (ClassNotFoundException e) { - return false; - } - } - - private static boolean isDescendantOf(View view, View parent) { - View current = view; - while (current != null) { - if (current == parent) return true; - if (!(current.getParent() instanceof View next)) return false; - current = next; - } - return false; - } - - private static int invokeInt(View target, String name, int fallback) { - try { - Object value = target.getClass().getMethod(name).invoke(target); - return value instanceof Integer ? (Integer) value : fallback; - } catch (ReflectiveOperationException | RuntimeException e) { - return fallback; - } - } - - private static boolean invokeBoolean(View target, String name, boolean fallback) { - try { - Object value = target.getClass().getMethod(name).invoke(target); - return value instanceof Boolean ? (Boolean) value : fallback; - } catch (ReflectiveOperationException | RuntimeException e) { - return fallback; - } - } - - private static void invoke(View target, String name, Class parameter, Object value) { - try { - target.getClass().getMethod(name, parameter).invoke(target, value); - } catch (ReflectiveOperationException | RuntimeException ignored) { - } - } - - private static boolean isInputView(View view) { - return view instanceof WebView || view instanceof EditText || view.onCheckIsTextEditor(); - } - - private static final class GridState { - private WeakReference grid; - private int focusStrategy; - private boolean focusSearchDisabled; - private boolean touchActive; - private boolean touchSettling; - private int touchGeneration; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java b/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java index c9aa99ea04..5c8807bd0b 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java @@ -6,14 +6,11 @@ import android.content.Context; import android.content.Intent; import android.os.Handler; import android.os.Looper; -import android.text.TextUtils; import com.fongmi.android.tv.api.SiteApi; -import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.ComicSourceConfig; import com.fongmi.android.tv.bean.Episode; import com.fongmi.android.tv.bean.Flag; -import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.bean.NovelSourceConfig; import com.fongmi.android.tv.bean.Result; import com.fongmi.android.tv.bean.Vod; @@ -26,9 +23,6 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; /** * 集数点击 / 播放入口路由器:决定是「小说/漫画阅读」还是「普通播放」。 @@ -43,16 +37,7 @@ import java.util.concurrent.atomic.AtomicInteger; public final class NovelRouter { private static final ExecutorService executor = Executors.newSingleThreadExecutor(); - private static final AtomicInteger HISTORY_REQUESTS = new AtomicInteger(); - private static final AtomicInteger CONTENT_REQUESTS = new AtomicInteger(); - - private static Handler mainHandler() { - return MainHandler.INSTANCE; - } - - private static class MainHandler { - private static final Handler INSTANCE = new Handler(Looper.getMainLooper()); - } + private static final Handler main = new Handler(Looper.getMainLooper()); public interface Fallback { void run(); } @@ -76,13 +61,11 @@ public final class NovelRouter { pd.setMessage("正在识别内容…"); pd.setCancelable(true); pd.show(); - int request = CONTENT_REQUESTS.incrementAndGet(); - AtomicBoolean canceled = new AtomicBoolean(false); - Future future = executor.submit(() -> { + executor.execute(() -> { Runnable launch = null; try { Result r = SiteApi.playerContent(pure, flag == null ? "" : flag.getFlag(), episode.getUrl()); - String u = readerPayload(r); + String u = firstContent(r); if (isComic(u)) { final String p = u; launch = () -> openPics(ctx, siteKey, flag, vod, episode, p); @@ -103,18 +86,13 @@ public final class NovelRouter { } // startActivity 统一回主线程执行,与 dismiss 保持时序 final Runnable finalLaunch = launch; - mainHandler().post(() -> { + main.post(() -> { try { pd.dismiss(); } catch (Throwable ignore) {} - if (canceled.get() || request != CONTENT_REQUESTS.get() || isDead(ctx)) return; + if (isDead(ctx)) return; if (finalLaunch != null) finalLaunch.run(); else if (fallback != null) fallback.run(); }); }); - pd.setOnCancelListener(ignored -> { - canceled.set(true); - CONTENT_REQUESTS.compareAndSet(request, request + 1); - future.cancel(true); - }); return true; } @@ -147,9 +125,7 @@ public final class NovelRouter { if (!reader.hasPendingHostChapterRequest() && NovelRouter.consumeStaleChapterResult()) return false; // 不在这里清表:结清哪一条只有阅读器知道(它持有本次请求的令牌), // 在这里猜或整表清空都会抹掉另一次仍在途的请求,返回键会重新失效。 - String payload = readerPayload(result); - if (TextUtils.isEmpty(payload)) return false; - reader.onEpisodeResolved(kind, payload, extractTitle(payload)); + reader.onEpisodeResolved(kind, result.getRealUrl(), extractTitle(result.getRealUrl())); return true; } // 刚关闭 / 属于已关阅读器的在途切章:都是返回后残留的回调,不再拉起 @@ -157,8 +133,7 @@ public final class NovelRouter { if (ctx instanceof NovelReaderHost) setHost((NovelReaderHost) ctx); - String payload = readerPayload(result); - if (TextUtils.isEmpty(payload)) return false; + String payload = result.getRealUrl(); ArrayList ch = new ArrayList<>(); if (chapters != null) ch.addAll(chapters); int index = 0; @@ -194,7 +169,7 @@ public final class NovelRouter { } /** 从 Result 多字段中提取首个有效 play_url。优先返回带 novel:///pics:///manga:// 协议前缀的字段,避免内容藏在 msg/header 时漏判。 */ - public static String readerPayload(Result r) { + private static String firstContent(Result r) { if (r == null) return null; String playUrl = r.getPlayUrl(); String urlV = null; @@ -220,7 +195,7 @@ public final class NovelRouter { /** 判定 play_url 协议类型:0=非阅读/普通视频;1=novel:// 小说;2=pics:///manga:// 漫画。 */ public static int readerUrlKind(Result r) { - String u = readerPayload(r); + String u = firstContent(r); if (u == null) return 0; u = u.trim(); if (u.startsWith("novel://")) return 1; @@ -255,7 +230,7 @@ public final class NovelRouter { if (kind == 0) return false; Episode current = episodes == null || position < 0 || position >= episodes.size() ? null : episodes.get(position); - String payload = readerPayload(result); + String payload = result.getRealUrl(); String title = extractTitle(payload); if (title == null || title.isEmpty()) title = current == null ? vodName : current.getName(); @@ -306,232 +281,6 @@ public final class NovelRouter { return ComicSourceConfig.isEnabledByKey(pure) || NovelSourceConfig.isEnabledByKey(pure); } - /** Opens a readable history item directly, falling back when the source is not readable. */ - public static boolean openHistory(Activity activity, History history, int targetCid, Fallback fallback) { - return openHistory(activity, history, null, null, null, targetCid, fallback); - } - - /** Opens a resolved cross-source history item directly in the reader. */ - public static boolean openHistory(Activity activity, History history, Vod target, - Flag targetFlag, Episode targetEpisode, int targetCid, - Fallback fallback) { - if (activity == null || history == null) return false; - String siteKey = pureSiteKey(target == null ? history.getSiteKey() : target.getSiteKey()); - String vodId = target == null ? history.getVodId() : target.getId(); - if (TextUtils.isEmpty(vodId) || targetCid != VodConfig.getCid()) return false; - if (!isReadableHistoryCandidate(isReaderSite(siteKey), history, targetEpisode)) return false; - - ProgressDialog dialog = new ProgressDialog(activity); - dialog.setMessage("正在打开阅读器..."); - dialog.setCancelable(true); - dialog.setCanceledOnTouchOutside(true); - dialog.show(); - - int request = HISTORY_REQUESTS.incrementAndGet(); - AtomicBoolean canceled = new AtomicBoolean(false); - Future future = executor.submit(() -> { - ReaderData data = resolveHistory(history, target, targetFlag, targetEpisode, targetCid, canceled); - mainHandler().post(() -> { - try { - dialog.dismiss(); - } catch (Throwable ignore) { - } - if (canceled.get() || request != HISTORY_REQUESTS.get() - || targetCid != VodConfig.getCid() || isDead(activity)) return; - if (data != null) openReaderData(activity, data); - else if (fallback != null) fallback.run(); - }); - return data; - }); - dialog.setOnCancelListener(ignored -> { - synchronized (canceled) { - canceled.set(true); - } - future.cancel(true); - }); - return true; - } - - static boolean isReadableHistoryCandidate(boolean readerSite, History history, Episode targetEpisode) { - if (history == null) return false; - return isReaderUrl(history.getEpisodeUrl()) - || isReaderUrl(targetEpisode == null ? null : targetEpisode.getUrl()) - || readerSite - || ReaderHistory.isReaderRecord(history); - } - - private static ReaderData resolveHistory(History history, Vod target, - Flag targetFlag, Episode targetEpisode, int targetCid, - AtomicBoolean canceled) { - try { - if (canceled.get()) return null; - String siteKey = pureSiteKey(target == null ? history.getSiteKey() : target.getSiteKey()); - String vodId = target == null ? history.getVodId() : target.getId(); - Vod vod = target; - if (vod == null || vod.getFlags().isEmpty()) { - Result detail = SiteApi.detailContent(siteKey, vodId); - if (canceled.get()) return null; - vod = detail == null ? null : detail.getVod(); - if (vod == null || vod.getFlags().isEmpty()) return null; - vod.checkName(history.getVodName()); - vod.checkPic(history.getVodPic()); - } - if (TextUtils.isEmpty(vodId)) vodId = vod.getId(); - if (TextUtils.isEmpty(vodId) || vod.getFlags().isEmpty()) return null; - if (targetCid != VodConfig.getCid()) return null; - - Flag flag = targetFlag; - Episode episode = targetEpisode; - if (flag == null || episode == null) { - flag = com.fongmi.android.tv.ui.helper.TmdbUIAdapter.selectPlaybackFlag( - vod.getFlags(), history.getSourceBindingKey(), - history.getEpisodeUrl(), history.getVodFlag()); - if (flag != null) episode = findHistoryEpisode(flag, history); - } - if (flag == null || episode == null) { - for (Flag candidate : vod.getFlags()) { - episode = findHistoryEpisode(candidate, history); - if (episode != null) { - flag = candidate; - break; - } - } - } - if (flag == null || episode == null) { - if (!canDefaultToFirstChapter(vod, history)) return null; - flag = vod.getFlags().get(0); - episode = flag.getEpisodes().get(0); - } - if (TextUtils.isEmpty(episode.getUrl())) return null; - - String payload = episode.getUrl(); - int kind = isComic(payload) ? 2 : isNovel(payload) ? 1 : 0; - if (kind == 0) { - Result result = SiteApi.playerContent(siteKey, flag.getFlag(), payload); - if (canceled.get()) return null; - String content = readerPayload(result); - if (TextUtils.isEmpty(content)) return null; - kind = readerUrlKind(result); - if (kind == 0) kind = kindBySiteRule(siteKey, result); - if (kind == 0) return null; - payload = content; - } - - if (targetCid != VodConfig.getCid()) return null; - - String name = TextUtils.isEmpty(vod.getName()) ? history.getVodName() : vod.getName(); - String pic = TextUtils.isEmpty(vod.getPic()) ? history.getVodPic() : vod.getPic(); - seedCrossSourceHistory(history, siteKey, vodId, flag.getFlag(), episode, name, pic, targetCid, canceled); - ArrayList chapters = chaptersOf(flag, episode); - return new ReaderData(kind, payload, siteKey, flag.getFlag(), vodId, name, pic, - chapters, indexOf(chapters, episode)); - } catch (Throwable ignore) { - return null; - } - } - - static Episode findHistoryEpisode(Flag flag, History history) { - if (flag == null || history == null) return null; - String url = history.getEpisodeUrl(); - if (!TextUtils.isEmpty(url)) { - for (Episode episode : flag.getEpisodes()) { - if (episode != null && TextUtils.equals(url, episode.getUrl())) return episode; - } - } - String name = history.getVodRemarks(); - return TextUtils.isEmpty(name) ? null : flag.find(name, true); - } - - private static void seedCrossSourceHistory(History history, String siteKey, String vodId, - String flag, Episode episode, String name, String pic, - int targetCid) { - if (history == null || TextUtils.isEmpty(siteKey) || TextUtils.isEmpty(vodId)) return; - History target = ReaderHistory.find(targetCid, siteKey, vodId); - if (target != null) { - String expectedKey = ReaderHistory.buildKey(siteKey, vodId, targetCid); - target.replace(expectedKey); - alignResolvedHistoryProgress(target, history, episode.getUrl()); - target.setCid(targetCid); - target.setVodName(name); - target.setVodPic(pic); - target.setVodFlag(flag); - target.setVodRemarks(episode.getName()); - target.setEpisodeUrl(episode.getUrl()); - target.setMediaType(ReaderHistory.MEDIA_TYPE); - target.setCreateTime(System.currentTimeMillis()); - ReaderHistory.saveRow(target); - return; - } - - History seed = new History(); - seed.setKey(ReaderHistory.buildKey(siteKey, vodId, targetCid)); - seed.setCid(targetCid); - seed.setVodName(name); - seed.setVodPic(pic); - seed.setVodFlag(flag); - seed.setVodRemarks(episode.getName()); - alignResolvedHistoryProgress(seed, history, episode.getUrl()); - seed.setEpisodeUrl(episode.getUrl()); - seed.setTmdbId(0); - seed.setMediaType(ReaderHistory.MEDIA_TYPE); - seed.setTmdbSeasonNumber(0); - seed.setTmdbEpisodeNumber(0); - seed.setCreateTime(System.currentTimeMillis()); - ReaderHistory.saveRow(seed); - } - - private static void seedCrossSourceHistory(History history, String siteKey, String vodId, - String flag, Episode episode, String name, String pic, - int targetCid, AtomicBoolean canceled) { - synchronized (canceled) { - if (canceled.get()) return; - seedCrossSourceHistory(history, siteKey, vodId, flag, episode, name, pic, targetCid); - } - } - - /** - * A resolved chapter must not inherit the target row's progress when its URL changed. - * Keep progress only when the target row already represents the resolved chapter; otherwise - * use the source row's progress, or clear it when the source has no valid chapter position. - */ - static void alignResolvedHistoryProgress(History target, History source, String resolvedEpisodeUrl) { - if (target == null || source == null || TextUtils.isEmpty(resolvedEpisodeUrl)) return; - if (TextUtils.equals(target.getEpisodeUrl(), resolvedEpisodeUrl)) return; - if (ReaderHistory.isReaderRecord(source) && source.hasPlaybackTime()) { - target.setPosition(source.getPosition()); - target.setDuration(source.getDuration()); - } else { - target.resetPlaybackPosition(); - } - } - - private static boolean canDefaultToFirstChapter(Vod vod, History history) { - if (vod == null || vod.getFlags() == null || vod.getFlags().size() != 1) return false; - Flag flag = vod.getFlags().get(0); - if (flag == null || flag.getEpisodes() == null || flag.getEpisodes().size() != 1) return false; - return TextUtils.isEmpty(history.getEpisodeUrl()) - && TextUtils.isEmpty(history.getVodRemarks()); - } - - private static void openReaderData(Activity activity, ReaderData data) { - Intent intent = new Intent(activity, WebReaderActivity.class); - intent.putExtra(WebReaderActivity.EXTRA_KIND, data.kind); - intent.putExtra(WebReaderActivity.EXTRA_CACHE_KEY, - WebReaderActivity.cacheLargeData(data.payload, data.chapters)); - intent.putExtra(WebReaderActivity.EXTRA_SITE_KEY, data.siteKey); - intent.putExtra(WebReaderActivity.EXTRA_FLAG, data.flag); - intent.putExtra(WebReaderActivity.EXTRA_VOD_ID, data.vodId); - intent.putExtra(WebReaderActivity.EXTRA_VOD_NAME, data.name); - intent.putExtra(WebReaderActivity.EXTRA_VOD_PIC, data.pic); - intent.putExtra(WebReaderActivity.EXTRA_INDEX, data.index); - activity.startActivity(intent); - } - - private record ReaderData(int kind, String payload, String siteKey, String flag, - String vodId, String name, String pic, - ArrayList chapters, int index) { - } - /** * 站点级分流:命中小说 / 漫画源规则时,后台取详情 + 解析首章,直接进阅读器, * 不再打开播放页(解决「先进播放页再报错」)。 @@ -547,10 +296,8 @@ public final class NovelRouter { pd.setMessage("正在加载…"); pd.setCancelable(true); pd.show(); - int request = CONTENT_REQUESTS.incrementAndGet(); - AtomicBoolean canceled = new AtomicBoolean(false); - Future future = executor.submit(() -> { + executor.execute(() -> { String error = null; Runnable launch = null; try { @@ -567,7 +314,7 @@ public final class NovelRouter { if (kind == 0) { // 章节 url 不是阅读协议 → 解析首章内容 Result r = SiteApi.playerContent(pure, flag.getFlag(), ep.getUrl()); - String u = readerPayload(r); + String u = firstContent(r); if (u == null || u.isEmpty()) throw new IllegalStateException("章节内容为空"); kind = isComic(u) ? 2 : isNovel(u) ? 1 : 0; if (kind == 0) { @@ -599,19 +346,14 @@ public final class NovelRouter { final String finalError = error; final Runnable finalLaunch = launch; final boolean fallback = launch == null && error == null; - mainHandler().post(() -> { + main.post(() -> { try { pd.dismiss(); } catch (Throwable ignore) {} - if (canceled.get() || request != CONTENT_REQUESTS.get() || isDead(activity)) return; + if (isDead(activity)) return; if (finalLaunch != null) finalLaunch.run(); else if (fallback) openPlayerFallback(activity, key, id, name, pic, mark); else Notify.show(finalError); }); }); - pd.setOnCancelListener(ignored -> { - canceled.set(true); - CONTENT_REQUESTS.compareAndSet(request, request + 1); - future.cancel(true); - }); return true; } @@ -667,7 +409,7 @@ public final class NovelRouter { // 要求二次解析 → 内容还没解析出来,不是阅读数据 if (result.needParse()) return 0; - String u = readerPayload(result); + String u = firstContent(result); if (u == null || u.isEmpty()) return 0; u = u.trim(); @@ -818,7 +560,7 @@ public final class NovelRouter { int kind = readerUrlKind(result); if (kind == 0) return false; - String payload = readerPayload(result); + String payload = result.getRealUrl(); String flag = result.getFlag() == null ? "" : result.getFlag(); // 关键修复:startPlayer 传入的 key 是 getHistoryKey()(siteKey@@@vodId@@@1), diff --git a/app/src/main/java/com/fongmi/android/tv/ui/novel/ReaderHistory.java b/app/src/main/java/com/fongmi/android/tv/ui/novel/ReaderHistory.java index 882c0b3baa..328f6c7d63 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/novel/ReaderHistory.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/novel/ReaderHistory.java @@ -35,21 +35,13 @@ public final class ReaderHistory { /** 旧版小说进度的放大倍数(现仅用于兼容历史记录)。 */ public static final long SCALE = 10000L; - /** 历史表是阅读与影视共用表,阅读记录必须显式标注类型。 */ - public static final String MEDIA_TYPE = "reader"; - - private static final String KEY_NAMESPACE = AppDatabase.SYMBOL + MEDIA_TYPE; private ReaderHistory() { } /** 阅读记录 key,与音频一致带上 cid,避免换配置后串记录。 */ public static String buildKey(String siteKey, String vodId) { - return buildKey(siteKey, vodId, VodConfig.getCid()); - } - - public static String buildKey(String siteKey, String vodId, int cid) { - return siteKey + AppDatabase.SYMBOL + vodId + AppDatabase.SYMBOL + cid + KEY_NAMESPACE; + return siteKey + AppDatabase.SYMBOL + vodId + AppDatabase.SYMBOL + VodConfig.getCid(); } private static String buildLegacyKey(String siteKey, String vodId) { @@ -63,24 +55,10 @@ public final class ReaderHistory { /** 查询已保存的阅读记录;null 表示没读过。 */ @Nullable public static History find(String siteKey, String vodId) { - return find(VodConfig.getCid(), siteKey, vodId); - } - - @Nullable - public static History find(int cid, String siteKey, String vodId) { if (!canUse(siteKey, vodId)) return null; - History history = History.find(cid, buildKey(siteKey, vodId, cid)); - if (isReaderRecord(history)) return history; - // 老版本阅读记录没有类型标记,且旧 key 可能与普通视频完全相同;无法可靠区分时 - // 宁可让旧进度失效,也不能把同键的影视进度当作阅读进度或被其覆盖。 - History legacy = History.find(cid, buildLegacyKey(siteKey, vodId)); - return isReaderRecord(legacy) ? legacy : null; - } - - static boolean isReaderRecord(@Nullable History history) { - if (history == null) return false; - if (MEDIA_TYPE.equals(history.getMediaType())) return true; - return history.getKey().endsWith(KEY_NAMESPACE); + History history = History.find(buildKey(siteKey, vodId)); + if (history == null) history = History.find(buildLegacyKey(siteKey, vodId)); + return history; } /** @@ -94,8 +72,7 @@ public final class ReaderHistory { if (total <= 0) return; long duration = total; long position = toPosition(anchor, duration); - int cid = VodConfig.getCid(); - Task.execute(() -> saveSync(cid, record, chapterName, chapterUrl, position, duration)); + Task.execute(() -> saveSync(record, chapterName, chapterUrl, position, duration)); } /** @@ -126,32 +103,29 @@ public final class ReaderHistory { return (int) Math.max(0, position); } - private static void saveSync(int cid, Record record, String chapterName, String chapterUrl, - long position, long duration) { - String key = buildKey(record.siteKey, record.vodId, cid); - History history = find(cid, record.siteKey, record.vodId); - if (history == null) { + private static void saveSync(Record record, String chapterName, String chapterUrl, long position, long duration) { + String key = buildKey(record.siteKey, record.vodId); + History history = find(record.siteKey, record.vodId); + boolean created = history == null; + if (created) { history = new History(); history.setKey(key); } else if (!key.equals(history.getKey())) { history.replace(key); + created = true; } - history.setCid(cid); + boolean changedChapter = !TextUtils.equals(chapterUrl, history.getEpisodeUrl()); + history.setCid(VodConfig.getCid()); history.setVodName(record.vodName); history.setVodPic(record.vodPic); history.setVodFlag(record.vodFlag); history.setVodRemarks(chapterName); history.setEpisodeUrl(chapterUrl); - history.setMediaType(MEDIA_TYPE); history.setPosition(position); history.setDuration(duration); history.setCreateTime(System.currentTimeMillis()); - saveRow(history); - App.post(RefreshEvent::history); - } - - static void saveRow(History history) { - AppDatabase.get().getHistoryDao().insertOrUpdate(history); + history.save(); + if (created || changedChapter) App.post(RefreshEvent::history); } /** 阅读身份(一本书 / 一部漫画)。 */ diff --git a/app/src/main/java/com/fongmi/android/tv/ui/player/ShortDramaQueueCoordinator.java b/app/src/main/java/com/fongmi/android/tv/ui/player/ShortDramaQueueCoordinator.java deleted file mode 100644 index 1bdb8ef544..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/player/ShortDramaQueueCoordinator.java +++ /dev/null @@ -1,440 +0,0 @@ -package com.fongmi.android.tv.ui.player; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -/** - * Pure, bounded state machine for the short-drama Exo playlist bridge. - * - *

The coordinator owns identity and event ordering only. It does not know about Android, - * Media3, Activity state, or a player implementation. A session contains one current episode; - * at most one resolved next episode may be retained. A caller must carry the generation from - * {@link #begin(Session)} through parsing, queue insertion, and transition handling.

- */ -public final class ShortDramaQueueCoordinator { - - private static final long FIRST_GENERATION = 1L; - - private long generation; - private Session session; - private int currentIndex = -1; - private QueueItem queued; - - /** Starts a new session and invalidates all work belonging to the previous one. */ - public synchronized Snapshot begin(Session nextSession) { - if (nextSession == null) throw new IllegalArgumentException("session == null"); - generation = nextGeneration(generation); - session = nextSession; - currentIndex = nextSession.currentIndex(); - queued = null; - return snapshotLocked(); - } - - /** Invalidates parsing and queued media for the current session without touching the player. */ - public synchronized long invalidate() { - generation = nextGeneration(generation); - session = null; - currentIndex = -1; - queued = null; - return generation; - } - - public synchronized Snapshot snapshot() { - return snapshotLocked(); - } - - /** Returns the only episode that may be parsed/queued next, or {@code null} at the end. */ - public synchronized ResolveRequest nextRequest() { - return nextRequestLocked(); - } - - /** - * Accepts a resolved next item only when it still belongs to the current generation and - * exactly matches the expected adjacent episode. Duplicate or ineligible results are - * rejected and cannot replace an already queued item. - */ - public synchronized QueueItem acceptResolved( - long requestGeneration, Episode episode, ResolvedItem resolved) { - ResolveRequest expected = nextRequestLocked(); - if (expected == null || queued != null) return null; - if (requestGeneration != generation || !sameEpisode(expected.episode(), episode)) return null; - if (resolved == null || !resolved.isEligible()) return null; - queued = new QueueItem(generation, expected.session(), expected.episode(), resolved); - return queued; - } - - /** - * Commits one natural AUTO transition. Manual/seek/repeat transitions and stale timeline - * callbacks are intentionally ignored. The successful call consumes the queue item, so a - * repeated callback cannot submit the same business transition twice. - */ - public synchronized Transition onMediaItemTransition( - long eventGeneration, String mediaId, TransitionReason reason) { - if (reason != TransitionReason.AUTO || queued == null) return null; - if (eventGeneration != generation || !Objects.equals(mediaId, queued.mediaId())) return null; - Episode previous = currentEpisodeLocked(); - QueueItem consumed = queued; - currentIndex = consumed.episode().index(); - queued = null; - return new Transition(generation, previous, consumed.episode(), nextRequestLocked()); - } - - /** A queued item is present; legacy near-end auto-advance must stay suppressed. */ - public synchronized boolean shouldSuppressLegacyAutoAdvance() { - return session != null && queued != null; - } - - private Snapshot snapshotLocked() { - return new Snapshot(generation, session, currentEpisodeLocked(), queued, nextRequestLocked()); - } - - private ResolveRequest nextRequestLocked() { - if (session == null) return null; - int nextIndex = session.nextIndex(currentIndex); - if (nextIndex < 0 || nextIndex >= session.episodes().size()) return null; - return new ResolveRequest(generation, session, session.episodes().get(nextIndex)); - } - - private Episode currentEpisodeLocked() { - if (session == null || currentIndex < 0 || currentIndex >= session.episodes().size()) return null; - return session.episodes().get(currentIndex); - } - - private static boolean sameEpisode(Episode left, Episode right) { - return left != null && right != null - && left.index() == right.index() - && Objects.equals(left.identity(), right.identity()); - } - - private static long nextGeneration(long current) { - return current == Long.MAX_VALUE ? FIRST_GENERATION : current + 1L; - } - - public enum TransitionReason { - AUTO, - MANUAL, - SEEK, - REPEAT, - UNKNOWN - } - - public enum MediaKind { - HLS, - PROGRESSIVE_MP4 - } - - public static final class Session { - private final String workId; - private final String siteKey; - private final String lineId; - private final String playFlag; - private final boolean reverse; - private final List episodes; - private final int currentIndex; - - public Session( - String workId, - String siteKey, - String lineId, - String playFlag, - List episodes, - int currentIndex, - boolean reverse) { - if (episodes == null || episodes.isEmpty()) throw new IllegalArgumentException("episodes empty"); - if (currentIndex < 0 || currentIndex >= episodes.size()) { - throw new IllegalArgumentException("currentIndex out of range"); - } - List copy = new ArrayList<>(episodes.size()); - for (int i = 0; i < episodes.size(); i++) { - Episode episode = episodes.get(i); - if (episode == null || episode.index() != i) { - throw new IllegalArgumentException("episodes must have contiguous indexes"); - } - copy.add(episode); - } - this.workId = value(workId); - this.siteKey = value(siteKey); - this.lineId = value(lineId); - this.playFlag = value(playFlag); - this.reverse = reverse; - this.episodes = Collections.unmodifiableList(copy); - this.currentIndex = currentIndex; - } - - public String workId() { - return workId; - } - - public String siteKey() { - return siteKey; - } - - public String lineId() { - return lineId; - } - - public String playFlag() { - return playFlag; - } - - public boolean reverse() { - return reverse; - } - - public List episodes() { - return episodes; - } - - public int currentIndex() { - return currentIndex; - } - - private int nextIndex(int current) { - return current + (reverse ? -1 : 1); - } - } - - public static final class Episode { - private final String id; - private final String title; - private final int index; - - public Episode(String id, String title, int index) { - if (index < 0) throw new IllegalArgumentException("index < 0"); - this.id = value(id); - this.title = value(title); - this.index = index; - } - - public String id() { - return id; - } - - public String title() { - return title; - } - - public int index() { - return index; - } - - public String identity() { - if (!id.isEmpty()) return id; - return index + ":" + title; - } - } - - public static final class ResolveRequest { - private final long generation; - private final Session session; - private final Episode episode; - - private ResolveRequest(long generation, Session session, Episode episode) { - this.generation = generation; - this.session = session; - this.episode = episode; - } - - public long generation() { - return generation; - } - - public Session session() { - return session; - } - - public Episode episode() { - return episode; - } - } - - public static final class ResolvedItem { - private final String uri; - private final Map headers; - private final String format; - private final MediaKind kind; - private final boolean drm; - private final boolean requiresSecondParse; - private final boolean live; - private final boolean specialEncryption; - private final boolean concatenated; - private final boolean engineSwitch; - - public ResolvedItem(String uri, Map headers, String format, MediaKind kind) { - this(uri, headers, format, kind, false, false, false, false, false, false); - } - - public ResolvedItem( - String uri, - Map headers, - String format, - MediaKind kind, - boolean drm, - boolean requiresSecondParse, - boolean live, - boolean specialEncryption, - boolean concatenated, - boolean engineSwitch) { - this.uri = value(uri); - this.headers = immutableHeaders(headers); - this.format = value(format); - this.kind = kind; - this.drm = drm; - this.requiresSecondParse = requiresSecondParse; - this.live = live; - this.specialEncryption = specialEncryption; - this.concatenated = concatenated; - this.engineSwitch = engineSwitch; - } - - public String uri() { - return uri; - } - - public Map headers() { - return headers; - } - - public String format() { - return format; - } - - public MediaKind kind() { - return kind; - } - - public boolean isEligible() { - if (uri.isEmpty() || kind == null || drm || requiresSecondParse || live - || specialEncryption || concatenated || engineSwitch) return false; - if (uri.contains("***") || uri.contains("|||")) return false; - try { - URI parsed = new URI(uri); - String scheme = parsed.getScheme(); - return "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme); - } catch (URISyntaxException e) { - return false; - } - } - } - - public static final class QueueItem { - private final long generation; - private final Session session; - private final Episode episode; - private final ResolvedItem resolved; - private final String mediaId; - - private QueueItem(long generation, Session session, Episode episode, ResolvedItem resolved) { - this.generation = generation; - this.session = session; - this.episode = episode; - this.resolved = resolved; - this.mediaId = generation + ":" + episode.identity(); - } - - public long generation() { - return generation; - } - - public Session session() { - return session; - } - - public Episode episode() { - return episode; - } - - public ResolvedItem resolved() { - return resolved; - } - - public String mediaId() { - return mediaId; - } - } - - public static final class Transition { - private final long generation; - private final Episode previous; - private final Episode current; - private final ResolveRequest nextRequest; - - private Transition(long generation, Episode previous, Episode current, ResolveRequest nextRequest) { - this.generation = generation; - this.previous = previous; - this.current = current; - this.nextRequest = nextRequest; - } - - public long generation() { - return generation; - } - - public Episode previous() { - return previous; - } - - public Episode current() { - return current; - } - - public ResolveRequest nextRequest() { - return nextRequest; - } - } - - public static final class Snapshot { - private final long generation; - private final Session session; - private final Episode current; - private final QueueItem queued; - private final ResolveRequest nextRequest; - - private Snapshot(long generation, Session session, Episode current, QueueItem queued, ResolveRequest nextRequest) { - this.generation = generation; - this.session = session; - this.current = current; - this.queued = queued; - this.nextRequest = nextRequest; - } - - public long generation() { - return generation; - } - - public Session session() { - return session; - } - - public Episode current() { - return current; - } - - public QueueItem queued() { - return queued; - } - - public ResolveRequest nextRequest() { - return nextRequest; - } - } - - private static Map immutableHeaders(Map headers) { - if (headers == null || headers.isEmpty()) return Collections.emptyMap(); - Map copy = new LinkedHashMap<>(); - for (Map.Entry entry : headers.entrySet()) { - if (entry.getKey() != null && entry.getValue() != null) copy.put(entry.getKey(), entry.getValue()); - } - return copy.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(copy); - } - - private static String value(String value) { - return value == null ? "" : value.trim(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/web/GameWebActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/web/GameWebActivity.java deleted file mode 100755 index 2ede0b09e4..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/web/GameWebActivity.java +++ /dev/null @@ -1,200 +0,0 @@ -package com.fongmi.android.tv.ui.web; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.os.Build; -import android.os.Bundle; -import android.view.View; -import android.view.WindowManager; -import android.webkit.RenderProcessGoneDetail; -import android.webkit.WebChromeClient; -import android.webkit.WebResourceRequest; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.widget.ProgressBar; -import android.widget.Toast; - -import androidx.appcompat.app.AppCompatActivity; - -import com.fongmi.android.tv.R; - -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; - -/** - * 实验室:小游戏内置 WebView 容器。 - * - * 与 WebReaderActivity(加载本地 reader.html 模板渲染 novel:// 数据)不同, - * 本页直接 loadUrl 远程游戏地址(H5 / 模拟器站),全屏沉浸运行。 - * 卡片 vod_id 内嵌 JSON 的 url / header(含自定义 UA)在此消费。 - */ -public class GameWebActivity extends AppCompatActivity { - - public static final String EXTRA_URL = "url"; - public static final String EXTRA_TITLE = "title"; - public static final String EXTRA_UA = "ua"; - public static final String EXTRA_HEADERS = "headers"; // HashMap 序列化 - - private WebView webView; - private ProgressBar progress; - private View loading; - private String loadUrl = ""; - private String ua = ""; - private Map extraHeaders = new HashMap<>(); - private boolean renderedGone; - - public static void start(android.app.Activity activity, String url, String title, String ua, Map headers) { - Intent it = new Intent(activity, GameWebActivity.class); - it.putExtra(EXTRA_URL, url == null ? "" : url); - it.putExtra(EXTRA_TITLE, title == null ? "" : title); - it.putExtra(EXTRA_UA, ua == null ? "" : ua); - if (headers != null) it.putExtra(EXTRA_HEADERS, new HashMap<>(headers)); - activity.startActivity(it); - } - - @SuppressLint({"SetJavaScriptEnabled"}) - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_web_reader); - applyImmersive(); - - // 兼容 Android 13+ enableOnBackInvokedCallback:手势/系统返回直接关闭游戏页 - getOnBackPressedDispatcher().addCallback(this, new androidx.activity.OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - finish(); - } - }); - - Intent it = getIntent(); - loadUrl = it.getStringExtra(EXTRA_URL) == null ? "" : it.getStringExtra(EXTRA_URL); - ua = it.getStringExtra(EXTRA_UA) == null ? "" : it.getStringExtra(EXTRA_UA); - String title = it.getStringExtra(EXTRA_TITLE) == null ? "" : it.getStringExtra(EXTRA_TITLE); - if (!title.isEmpty()) setTitle(title); - //noinspection unchecked - Map headers = (Map) it.getSerializableExtra(EXTRA_HEADERS); - if (headers != null) extraHeaders.putAll(headers); - // UA 单独传递优先;否则从 headers 里取 - if (ua.isEmpty() && extraHeaders.containsKey("User-Agent")) ua = extraHeaders.get("User-Agent"); - - webView = findViewById(R.id.web_view); - progress = findViewById(R.id.progress); - loading = findViewById(R.id.loading); - webView.setBackgroundColor(0xFF101010); - - WebSettings s = webView.getSettings(); - s.setJavaScriptEnabled(true); - s.setDomStorageEnabled(true); - s.setDatabaseEnabled(true); - s.setUseWideViewPort(true); - s.setLoadWithOverviewMode(true); - s.setSupportZoom(false); - s.setAllowFileAccess(false); - s.setAllowContentAccess(false); - s.setMediaPlaybackRequiresUserGesture(false); // 游戏音频自动播放 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - s.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); // 禁用混合内容 - } - if (!ua.isEmpty()) s.setUserAgentString(ua); - - webView.setWebChromeClient(new WebChromeClient() { - @Override - public void onProgressChanged(WebView view, int newProgress) { - if (progress != null) { - if (newProgress >= 100) progress.setVisibility(View.GONE); - else { progress.setVisibility(View.VISIBLE); progress.setProgress(newProgress); } - } - } - }); - webView.setWebViewClient(new WebViewClient() { - @Override - public void onPageFinished(WebView view, String url) { - hideLoading(); - } - - @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - String target = request.getUrl() == null ? "" : request.getUrl().toString(); - return !isWebUrl(target); // 游戏内网页跳转留在容器,外部 scheme 不交给 WebView - } - - @Override - public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) { - // 游戏页渲染进程崩溃:避免整 app 闪退,直接关闭游戏页 - renderedGone = true; - Toast.makeText(GameWebActivity.this, "游戏页面已崩溃退出", Toast.LENGTH_SHORT).show(); - finish(); - return true; - } - }); - - if (!isWebUrl(loadUrl)) { - Toast.makeText(this, loadUrl.isEmpty() ? "游戏地址为空" : "网页地址无效", Toast.LENGTH_SHORT).show(); - finish(); - return; - } - if (extraHeaders.isEmpty()) webView.loadUrl(loadUrl); - else webView.loadUrl(loadUrl, extraHeaders); - } - - private void hideLoading() { - if (loading != null) loading.setVisibility(View.GONE); - } - - private static boolean isWebUrl(String url) { - if (url == null) return false; - String value = url.trim().toLowerCase(Locale.ROOT); - return value.startsWith("http://") || value.startsWith("https://"); - } - - private void applyImmersive() { - getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); // 玩游戏保持常亮 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Android 11+ / targetSdk 35+:FLAG_FULLSCREEN 已失效,必须用 InsetsController 隐藏系统栏 - try { - getWindow().setDecorFitsSystemWindows(false); - android.view.WindowInsetsController c = getWindow().getInsetsController(); - if (c != null) { - c.hide(android.view.WindowInsets.Type.statusBars() | android.view.WindowInsets.Type.navigationBars()); - c.setSystemBarsBehavior(android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); - } - } catch (Throwable ignore) {} - } else { - try { - int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_FULLSCREEN - | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; - getWindow().getDecorView().setSystemUiVisibility(flags); - } catch (Throwable ignore) {} - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - try { - WindowManager.LayoutParams lp = getWindow().getAttributes(); - lp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; - getWindow().setAttributes(lp); - } catch (Throwable ignore) {} - } - } - - @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - // WebView 加载/游戏交互后焦点变化会把系统栏重新拉出来,必须重新隐藏 - if (hasFocus) applyImmersive(); - } - - @Override - protected void onDestroy() { - if (webView != null && !renderedGone) { - webView.loadUrl("about:blank"); - webView.destroy(); - } - super.onDestroy(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/GithubProxy.java b/app/src/main/java/com/fongmi/android/tv/update/GithubProxy.java new file mode 100644 index 0000000000..a021f65ef9 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/update/GithubProxy.java @@ -0,0 +1,78 @@ +package com.fongmi.android.tv.update; + +import java.util.Arrays; + +public final class GithubProxy { + + public static final String DIRECT = "direct"; + public static final String CUSTOM = "custom"; + public static final String MODE_FULL_URL = "full_url"; + public static final String MODE_STRIP_SCHEME = "strip_scheme"; + + private static final Preset[] PRESETS = { + new Preset(DIRECT, "GitHub", "", MODE_FULL_URL), + new Preset("github_chenc", "github.chenc.dev", "https://github.chenc.dev", MODE_STRIP_SCHEME), + new Preset("gh_acmsz", "gh.acmsz.top", "https://gh.acmsz.top", MODE_FULL_URL), + new Preset("ghfast", "ghfast.top", "https://ghfast.top", MODE_FULL_URL), + new Preset("gh_monlor", "gh.monlor.com", "https://gh.monlor.com", MODE_FULL_URL), + new Preset(CUSTOM, "Custom", "", MODE_FULL_URL), + }; + + private GithubProxy() { + } + + public static Preset[] presets() { + return Arrays.copyOf(PRESETS, PRESETS.length); + } + + public static Preset find(String id) { + for (Preset preset : PRESETS) if (preset.id.equals(id)) return preset; + return PRESETS[0]; + } + + public static Config resolve(String id, String customUrl, String customMode) { + Preset preset = find(id); + if (DIRECT.equals(preset.id)) return new Config(DIRECT, "", MODE_FULL_URL); + if (CUSTOM.equals(preset.id)) return new Config(CUSTOM, UpdateUrl.requireHttpsOrigin(customUrl), normalizeMode(customMode)); + return new Config(preset.id, preset.baseUrl, preset.mode); + } + + public static String normalizeMode(String mode) { + return MODE_STRIP_SCHEME.equals(mode) ? MODE_STRIP_SCHEME : MODE_FULL_URL; + } + + public static final class Preset { + + public final String id; + public final String label; + public final String baseUrl; + public final String mode; + + private Preset(String id, String label, String baseUrl, String mode) { + this.id = id; + this.label = label; + this.baseUrl = baseUrl; + this.mode = mode; + } + } + + public static final class Config { + + public final String id; + public final String baseUrl; + public final String mode; + + private Config(String id, String baseUrl, String mode) { + this.id = id; + this.baseUrl = baseUrl; + this.mode = mode; + } + + public String rewrite(String url) { + String target = UpdateUrl.requireHttpsUrl(url); + if (DIRECT.equals(id)) return target; + if (MODE_STRIP_SCHEME.equals(mode)) return baseUrl + "/" + target.substring("https://".length()); + return baseUrl + "/" + target; + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/update/UpdateRoutePlanner.java b/app/src/main/java/com/fongmi/android/tv/update/UpdateRoutePlanner.java index 8e91ace826..a2dd3531fe 100644 --- a/app/src/main/java/com/fongmi/android/tv/update/UpdateRoutePlanner.java +++ b/app/src/main/java/com/fongmi/android/tv/update/UpdateRoutePlanner.java @@ -1,7 +1,5 @@ package com.fongmi.android.tv.update; -import com.fongmi.android.tv.utils.GithubProxy; - import java.util.ArrayList; import java.util.List; diff --git a/app/src/main/java/com/fongmi/android/tv/utils/AdBlockTimeFormatter.java b/app/src/main/java/com/fongmi/android/tv/utils/AdBlockTimeFormatter.java deleted file mode 100644 index 23e05f0e28..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/AdBlockTimeFormatter.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.fongmi.android.tv.utils; - -import java.util.Locale; - -/** Formats HLS segment positions using the same clock style as the player timeline. */ -public final class AdBlockTimeFormatter { - - private static final long TENTHS_PER_SECOND = 10L; - private static final long TENTHS_PER_MINUTE = 60L * TENTHS_PER_SECOND; - private static final long TENTHS_PER_HOUR = 60L * TENTHS_PER_MINUTE; - - private AdBlockTimeFormatter() { - } - - /** - * Formats a media position or duration as {@code HH:MM:SS.t}. - * - *

The stored HLS timings contain milliseconds, so retaining one decimal place avoids - * hiding the difference between adjacent short segments while still looking like a player - * timeline. Rounding is done before splitting the fields so values such as 59.96 seconds - * correctly carry into the next minute.

- */ - public static String formatSeconds(double seconds) { - double safeSeconds = Double.isFinite(seconds) ? Math.max(0d, seconds) : 0d; - long tenths = Math.round(safeSeconds * TENTHS_PER_SECOND); - long hours = tenths / TENTHS_PER_HOUR; - long minutes = (tenths % TENTHS_PER_HOUR) / TENTHS_PER_MINUTE; - long wholeSeconds = (tenths % TENTHS_PER_MINUTE) / TENTHS_PER_SECOND; - long fractionalSecond = tenths % TENTHS_PER_SECOND; - return String.format(Locale.US, "%02d:%02d:%02d.%d", - hours, minutes, wholeSeconds, fractionalSecond); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/AppBackup.java b/app/src/main/java/com/fongmi/android/tv/utils/AppBackup.java index 29a59fcdff..8c5099d6bb 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/AppBackup.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/AppBackup.java @@ -268,7 +268,7 @@ public final class AppBackup { private static void reload() { VodConfig.get().clear("app-backup").init().load(new Callback()); LiveConfig.get().clear().init().load(); - WallConfig.get().initPreservingSelection().load(); + WallConfig.get().init().load(); ConfigEvent.common(); RefreshEvent.keep(); RefreshEvent.history(); diff --git a/app/src/main/java/com/fongmi/android/tv/utils/CrashRestartMode.java b/app/src/main/java/com/fongmi/android/tv/utils/CrashRestartMode.java deleted file mode 100644 index e17836626c..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/CrashRestartMode.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.fongmi.android.tv.utils; - -import com.github.catvod.utils.Prefers; - -public final class CrashRestartMode { - - private static final String KEY = "crash_restart_skip_config_once"; - - private CrashRestartMode() { - } - - public static void arm() { - // The crash library kills this process immediately after starting the restart activity. - // Persist synchronously so the new process cannot miss the one-shot marker. - Prefers.getPrefers().edit().putBoolean(KEY, true).commit(); - } - - public static boolean consume() { - if (!Prefers.getBoolean(KEY)) return false; - Prefers.getPrefers().edit().remove(KEY).commit(); - return true; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/GithubProxy.java b/app/src/main/java/com/fongmi/android/tv/utils/GithubProxy.java index b113e03256..34955ba855 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/GithubProxy.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/GithubProxy.java @@ -5,15 +5,10 @@ import com.fongmi.android.tv.setting.Setting; import java.util.ArrayList; import java.util.List; import java.util.Locale; -import java.net.URI; public final class GithubProxy { public static final String DEFAULT = "https://gh-proxy.com/"; - public static final String DIRECT = "direct"; - public static final String MODE_FULL_URL = "full_url"; - public static final String MODE_STRIP_SCHEME = "strip_scheme"; - private static final String[] BUILT_IN = { DEFAULT, "https://ghfast.top/", @@ -39,19 +34,6 @@ public final class GithubProxy { return isEmpty(proxy) ? url : normalize(proxy) + url; } - public static Config config() { - return config(Setting.getGithubProxy(), Setting.getGithubProxyMode(), Setting.isGithubProxyEnabled()); - } - - public static Config config(String configured, String mode, boolean enabled) { - if (!enabled) return new Config(DIRECT, "", MODE_FULL_URL); - return new Config("proxy", normalize(first(configured)), normalizeMode(mode)); - } - - public static String normalizeMode(String mode) { - return MODE_STRIP_SCHEME.equals(mode) ? MODE_STRIP_SCHEME : MODE_FULL_URL; - } - public static String defaultSources() { return String.join("\n", BUILT_IN); } @@ -96,15 +78,6 @@ public final class GithubProxy { return String.join("\n", list); } - public static String addSources(String configured, String value) { - if (isEmpty(value)) return configured; - List list = sources(configured); - for (String source : sources(value)) { - if (!list.contains(source)) list.add(source); - } - return String.join("\n", list); - } - public static String removeSource(String source) { if (isEmpty(source)) return Setting.getGithubProxy(); String normalized = normalize(source); @@ -118,32 +91,6 @@ public final class GithubProxy { return sources.isEmpty() ? defaultSources() : String.join("\n", sources); } - public static String probeUrl(String source) { - String proxy = normalize(isEmpty(source) ? first(Setting.getGithubProxy()) : source); - return proxy + "https://github.com/Silent1566/webhtv/releases/download/update-channel/update.json"; - } - - public static final class Config { - - public final String id; - public final String baseUrl; - public final String mode; - - private Config(String id, String baseUrl, String mode) { - this.id = id; - this.baseUrl = baseUrl; - this.mode = mode; - } - - public String rewrite(String url) { - String target = requireHttpsUrl(url); - if (DIRECT.equals(id) || baseUrl.isEmpty()) return target; - String prefix = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; - if (MODE_STRIP_SCHEME.equals(mode)) return prefix + target.substring("https://".length()); - return prefix + target; - } - } - private static String first(String configured) { List sources = sources(configured); return sources.isEmpty() ? DEFAULT : sources.get(0); @@ -178,21 +125,6 @@ public final class GithubProxy { return lower.matches("https?://[^/]+/https?://.*"); } - private static String requireHttpsUrl(String value) { - try { - String text = value == null ? "" : value.trim(); - URI uri = URI.create(text); - if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException("HTTPS required"); - if (uri.getHost() == null || uri.getHost().isEmpty()) throw new IllegalArgumentException("Host required"); - if (uri.getUserInfo() != null) throw new IllegalArgumentException("Credentials are not allowed"); - return text; - } catch (IllegalArgumentException e) { - throw e; - } catch (Exception e) { - throw new IllegalArgumentException("Invalid HTTPS URL", e); - } - } - private static boolean isEmpty(String text) { return text == null || text.length() == 0; } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/HistoryProgressFormatter.java b/app/src/main/java/com/fongmi/android/tv/utils/HistoryProgressFormatter.java deleted file mode 100644 index 8b17e74367..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/HistoryProgressFormatter.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.fongmi.android.tv.utils; - -import java.util.Locale; - -public final class HistoryProgressFormatter { - - private HistoryProgressFormatter() { - } - - public static String format(long positionMs, long durationMs) { - if (positionMs <= 0) return ""; - long displayMs = durationMs > 0 ? Math.min(positionMs, durationMs) : positionMs; - long seconds = displayMs / 1000; - long hours = seconds / 3600; - long minutes = seconds / 60 % 60; - return hours > 0 - ? String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, seconds % 60) - : String.format(Locale.ROOT, "%02d:%02d", minutes, seconds % 60); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockNotice.java b/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockNotice.java deleted file mode 100644 index 53a2fe99eb..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockNotice.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.fongmi.android.tv.utils; - -import java.util.LinkedHashMap; -import java.util.Map; - -/** Playback-session debounce for repeated live-playlist refreshes. */ -public final class HlsAdblockNotice { - - private static final long WINDOW_MS = 30_000L; - private static final int MAX_ENTRIES = 64; - private static final Map RECENT = new LinkedHashMap<>(); - - private HlsAdblockNotice() {} - - public static synchronized boolean shouldNotify(String playlistUrl, long nowMs) { - String key = playlistUrl == null ? "" : playlistUrl; - Long previous = RECENT.get(key); - if (previous != null && nowMs - previous < WINDOW_MS) return false; - RECENT.put(key, nowMs); - while (RECENT.size() > MAX_ENTRIES) RECENT.remove(RECENT.keySet().iterator().next()); - return true; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java b/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java index 939fc227d0..1de773e521 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java @@ -2,92 +2,46 @@ package com.fongmi.android.tv.utils; import androidx.media3.exoplayer.hls.playlist.HlsAdsParser; +import com.github.catvod.crawler.SpiderDebug; + import java.net.URI; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; public final class HlsAdblockPipeline { private HlsAdblockPipeline() {} public static Outcome apply(String url, String manifest, List rules, boolean legacyFallback) { - if (!legacyFallback) { - HlsManifestCleaner.Result clean = HlsManifestCleaner.clean(url, manifest, rules); - return new Outcome(clean.manifest(), clean.changed(), false, - clean.removedSegments(), clean.removedDurationSec(), clean.ruleCounts(), - clean.removedSegmentDetails()); + // Encrypted playlists whose IV is implied by the media sequence number must not be + // rewritten by *either* engine, so the check lives here rather than inside one of + // them. Putting it in HlsManifestCleaner was actively harmful: the cleaner's only + // way to decline is Result.fallback, and fallback is precisely what invites the + // legacy heuristic in below — measured, that turned "25 ads removed" into "200 main + // segments deleted by the heuristic, and every retained segment decrypting with the + // wrong IV". Declining here stops both. + if (HlsEncryptionGuard.hasImpliedSegmentIv(manifest)) { + SpiderDebug.log("hls-adblock", "declined reason=implied-segment-iv url=%s", url); + return new Outcome(manifest, false, false, 0, 0); } HlsManifestCleaner.Result clean = HlsManifestCleaner.clean(url, manifest, rules); if (clean.changed()) { - return new Outcome(clean.manifest(), true, false, clean.removedSegments(), clean.removedDurationSec(), - clean.ruleCounts(), clean.removedSegmentDetails()); + return new Outcome(clean.manifest(), true, false, clean.removedSegments(), clean.removedDurationSec()); } - if (!legacyFallback || clean.fallback() || manifest == null || !manifest.contains("#EXT-X-ENDLIST")) { + // 结构化引擎回退时仍要让 legacy 兜底。fallback 的语义是「这份 manifest + // 我处理不了」,恰恰最该由启发式接手;此前把它并入短路条件,等于一条 + // 过宽的规则不只无效,还会连带停掉该站原有的去广能力 —— 比不加规则更差。 + // 触发源不止用户规则,内置与接口下发的规则同样会让 cleaner 回退。 + if (!legacyFallback || manifest == null || !manifest.contains("#EXT-X-ENDLIST")) { return new Outcome(manifest, false, false, 0, 0); } try { String filtered = HlsAdsParser.process(manifest); - if (filtered.equals(manifest)) return new Outcome(manifest, false, false, 0, 0); - List removed = legacyRemovedSegments(url, manifest, filtered); - double duration = removed.stream().mapToDouble(HlsManifestCleaner.RemovedSegment::durationSec).sum(); - return new Outcome(filtered, false, true, removed.size(), duration, - Map.of(), List.copyOf(removed)); + return new Outcome(filtered, false, !filtered.equals(manifest), 0, 0); } catch (Throwable ignored) { return new Outcome(manifest, false, false, 0, 0); } } - static List legacyRemovedSegments( - String baseUrl, String original, String filtered) { - List source = parseSegments(baseUrl, original); - Map kept = new HashMap<>(); - for (SegmentInfo segment : parseSegments(baseUrl, filtered)) kept.merge(segment.uri(), 1, Integer::sum); - List removed = new ArrayList<>(); - for (SegmentInfo segment : source) { - int count = kept.getOrDefault(segment.uri(), 0); - if (count > 0) { - kept.put(segment.uri(), count - 1); - continue; - } - removed.add(new HlsManifestCleaner.RemovedSegment( - segment.host(), "hls.legacy-fallback", segment.startSeconds(), segment.durationSeconds())); - } - return removed; - } - - private static List parseSegments(String baseUrl, String manifest) { - List result = new ArrayList<>(); - if (manifest == null) return result; - double start = 0; - double duration = -1; - for (String raw : manifest.replace("\r", "").split("\n")) { - String line = raw.trim(); - if (line.startsWith("#EXTINF:")) { - try { - String value = line.substring(8); - int comma = value.indexOf(','); - duration = Double.parseDouble(comma < 0 ? value : value.substring(0, comma)); - } catch (RuntimeException e) { - duration = -1; - } - } else if (!line.isEmpty() && !line.startsWith("#") && duration >= 0) { - try { - URI resolved = URI.create(baseUrl).resolve(line); - result.add(new SegmentInfo(line, resolved.getHost() == null ? "" : resolved.getHost(), start, duration)); - } catch (RuntimeException e) { - result.add(new SegmentInfo(line, "", start, duration)); - } - start += duration; - duration = -1; - } - } - return result; - } - - private record SegmentInfo(String uri, String host, double startSeconds, double durationSeconds) {} - public static boolean isCoreM3u8Proxy(String url) { try { URI uri = URI.create(url); @@ -99,11 +53,5 @@ public final class HlsAdblockPipeline { } } - public record Outcome(String manifest, boolean structured, boolean legacy, int removedSegments, - double removedDurationSec, Map ruleCounts, - List removedSegmentDetails) { - public Outcome(String manifest, boolean structured, boolean legacy, int removedSegments, double removedDurationSec) { - this(manifest, structured, legacy, removedSegments, removedDurationSec, Map.of(), List.of()); - } - } + public record Outcome(String manifest, boolean structured, boolean legacy, int removedSegments, double removedDurationSec) {} } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/HlsEncryptionGuard.java b/app/src/main/java/com/fongmi/android/tv/utils/HlsEncryptionGuard.java new file mode 100644 index 0000000000..4cb2ae5979 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/utils/HlsEncryptionGuard.java @@ -0,0 +1,59 @@ +package com.fongmi.android.tv.utils; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Detects HLS playlists whose segment decryption IV is implied by the media sequence + * number, which makes them unsafe to rewrite by any means that removes segments. + * + *

When {@code #EXT-X-KEY} enables encryption but omits {@code IV=}, RFC 8216 §5.2 says + * the IV is the segment's media sequence number. Removing a segment renumbers every + * segment after it, so each one is then decrypted with the wrong IV and the stream turns + * to garbage — not a mis-cut, a black or corrupt picture. + * + *

The predicates below mirror {@code media3}'s forked {@code HlsPlaylistParser} + * exactly, because the only thing that matters is what that parser will conclude + * from the same bytes. Two of its properties are easy to get wrong, and both were: + * + *

    + *
  • {@code TAG_KEY} is {@code "#EXT-X-KEY"} without the colon, so + * {@code "#EXT-X-KEY :METHOD=AES-128,…"} still parses as a key line.
  • + *
  • {@code REGEX_METHOD} and {@code REGEX_IV} are case-sensitive and anchored to the + * attribute name, so a lowercase {@code iv=} inside a quoted {@code URI} neither + * pins the IV for media3 nor should count as pinned here. Matching on an + * upper-cased substring got this backwards and let corrupting playlists through.
  • + *
+ */ +final class HlsEncryptionGuard { + + /** {@code media3 HlsPlaylistParser.TAG_KEY} — deliberately without the trailing colon. */ + private static final String TAG_KEY = "#EXT-X-KEY"; + /** Mirrors {@code media3 HlsPlaylistParser.REGEX_METHOD}. */ + private static final Pattern REGEX_METHOD = Pattern.compile( + "METHOD=(NONE|AES-128|SAMPLE-AES|SAMPLE-AES-CENC|SAMPLE-AES-CTR)\\s*(?:,|$)"); + /** Mirrors {@code media3 HlsPlaylistParser.REGEX_IV}. */ + private static final Pattern REGEX_IV = Pattern.compile("IV=([^,.*]+)"); + + private HlsEncryptionGuard() { + } + + /** + * @return whether any key line enables encryption without pinning an explicit IV + */ + static boolean hasImpliedSegmentIv(String manifest) { + if (manifest == null) return false; + // Split the way BufferedReader.readLine does — a lone CR ends a line too. Splitting + // on "\n" alone collapses a CR-only playlist into one giant line, hiding its keys. + for (String line : manifest.split("\r\n|\n|\r", -1)) { + if (!line.startsWith(TAG_KEY)) continue; + Matcher method = REGEX_METHOD.matcher(line); + // No recognisable METHOD: media3's parseStringAttr throws and the whole playlist + // fails to parse, so it is not something we could have corrupted. Ignore it. + if (!method.find()) continue; + if ("NONE".equals(method.group(1))) continue; + if (!REGEX_IV.matcher(line).find()) return true; + } + return false; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/HlsManifestCleaner.java b/app/src/main/java/com/fongmi/android/tv/utils/HlsManifestCleaner.java index c0fbd6cf37..333f53cbf7 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/HlsManifestCleaner.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/HlsManifestCleaner.java @@ -4,10 +4,8 @@ import java.net.URI; import java.math.BigInteger; import java.util.ArrayList; import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.regex.Pattern; /** Pure manifest-text cleaner. Network fetching and URI proxy rewriting stay outside this class. */ @@ -34,24 +32,15 @@ public final class HlsManifestCleaner { List nodes = parse(manifest); int segmentCount = 0; int removedCount = 0; - double mediaOffsetSec = 0; double removedDurationSec = 0; - Map ruleCounts = new LinkedHashMap<>(); - List removedSegments = new ArrayList<>(); for (Node node : nodes) { if (!(node instanceof Segment segment)) continue; segmentCount++; - Rule matchedRule = matchRule(baseUrl, segment, rules); - if (matchedRule != null) { + if (matches(baseUrl, segment, rules)) { segment.removed = true; removedCount++; removedDurationSec += segment.durationSec; - ruleCounts.merge(matchedRule.id, 1L, Long::sum); - URI resolved = URI.create(baseUrl).resolve(segment.uri); - String host = resolved.getHost() == null ? "" : resolved.getHost(); - removedSegments.add(new RemovedSegment(host, matchedRule.id, mediaOffsetSec, segment.durationSec)); } - mediaOffsetSec += segment.durationSec; } if (removedCount == 0) return Result.unchanged(manifest); if (segmentCount == 0 || removedCount == segmentCount || (double) removedCount / segmentCount > MAX_REMOVAL_RATIO @@ -76,8 +65,7 @@ public final class HlsManifestCleaner { if (discontinuitySequenceIncrement > 0 && !manifest.contains("#EXT-X-DISCONTINUITY-SEQUENCE:")) return Result.fallback(manifest); } return new Result(render(nodes, manifest.endsWith("\n"), mediaSequenceIncrement, discontinuitySequenceIncrement), - true, false, removedCount, removedDurationSec, Collections.unmodifiableMap(ruleCounts), - Collections.unmodifiableList(removedSegments)); + true, false, removedCount, removedDurationSec); } catch (RuntimeException e) { return Result.fallback(manifest); } @@ -142,7 +130,7 @@ public final class HlsManifestCleaner { pending.clear(); } - private static Rule matchRule(String baseUrl, Segment segment, List rules) { + private static boolean matches(String baseUrl, Segment segment, List rules) { URI base = URI.create(baseUrl); URI resolved = base.resolve(segment.uri); String url = resolved.toString(); @@ -158,9 +146,9 @@ public final class HlsManifestCleaner { if (rule.hasDurationRange() && segment.durationSec >= rule.minDuration && segment.durationSec <= rule.maxDuration) signals++; if (rule.requireDiscontinuity && segment.discontinuityBefore) signals++; if (rule.requireCrossDomain && !host.isEmpty() && !baseHost.isEmpty() && !host.equals(baseHost)) signals++; - if (signals >= rule.minimumSignals) return rule; + if (signals >= rule.minimumSignals) return true; } - return null; + return false; } private static boolean matchesHost(String host, List suffixes) { @@ -257,22 +245,12 @@ public final class HlsManifestCleaner { } } - public record RemovedSegment(String adDomain, String ruleId, double startSeconds, double durationSec) {} - - public record Result(String manifest, boolean changed, boolean fallback, int removedSegments, - double removedDurationSec, Map ruleCounts, - List removedSegmentDetails) { - private static Result unchanged(String manifest) { - return new Result(manifest, false, false, 0, 0, Map.of(), List.of()); - } - - private static Result fallback(String manifest) { - return new Result(manifest, false, true, 0, 0, Map.of(), List.of()); - } + public record Result(String manifest, boolean changed, boolean fallback, int removedSegments, double removedDurationSec) { + private static Result unchanged(String manifest) { return new Result(manifest, false, false, 0, 0); } + private static Result fallback(String manifest) { return new Result(manifest, false, true, 0, 0); } } public static final class Rule { - private final String id; private final List hostSuffixes; private final List playlistHostSuffixes; private final List playlistHostPatterns; @@ -284,7 +262,6 @@ public final class HlsManifestCleaner { private final boolean requireCrossDomain; private Rule(Builder builder) { - this.id = builder.id; this.hostSuffixes = List.copyOf(builder.hostSuffixes); this.playlistHostSuffixes = List.copyOf(builder.playlistHostSuffixes); this.playlistHostPatterns = compilePatterns(builder.playlistHostPatterns); @@ -317,7 +294,6 @@ public final class HlsManifestCleaner { public static Builder builder() { return new Builder(); } public static final class Builder { - private String id = "unnamed"; private List hostSuffixes = List.of(); private List playlistHostSuffixes = List.of(); private List playlistHostPatterns = List.of(); @@ -328,7 +304,6 @@ public final class HlsManifestCleaner { private boolean requireDiscontinuity; private boolean requireCrossDomain; - public Builder id(String value) { id = value == null || value.isBlank() ? "unnamed" : value; return this; } public Builder hostSuffixes(List values) { hostSuffixes = values == null ? List.of() : values; return this; } public Builder playlistHostSuffixes(List values) { playlistHostSuffixes = values == null ? List.of() : values; return this; } public Builder playlistHostPatterns(List values) { playlistHostPatterns = values == null ? List.of() : values; return this; } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/MpvConfigSync.java b/app/src/main/java/com/fongmi/android/tv/utils/MpvConfigSync.java deleted file mode 100644 index 55fd738fe0..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/MpvConfigSync.java +++ /dev/null @@ -1,329 +0,0 @@ -package com.fongmi.android.tv.utils; - -import com.fongmi.android.tv.player.mpv.MpvConfigStore; -import com.github.catvod.crawler.SpiderDebug; -import com.github.catvod.utils.Path; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.UUID; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; -import java.util.zip.ZipOutputStream; - -/** Archives only the files owned by MPV configuration management. */ -public final class MpvConfigSync { - - public static final String PART_NAME = "mpvConfigFiles"; - - private static final int BUFFER_SIZE = 64 * 1024; - private static final int MAX_ENTRIES = 512; - private static final long MAX_ENTRY_BYTES = 2L * 1024 * 1024; - private static final long MAX_ARCHIVE_BYTES = 32L * 1024 * 1024; - private static final String MPV_CONF = "mpv.conf"; - private static final String INPUT_CONF = "input.conf"; - private static final String SCRIPTS = "scripts/"; - private static final String PROFILES = "profiles/"; - - private MpvConfigSync() { - } - - public static Archive createArchive() throws IOException { - File root = MpvConfigStore.configDir().getCanonicalFile(); - File archive = File.createTempFile("webhtv-mpv-sync-", ".zip", Path.cache()); - int count = 0; - long size = 0; - byte[] buffer = new byte[BUFFER_SIZE]; - try (ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(archive), BUFFER_SIZE))) { - for (String name : new String[]{MPV_CONF, INPUT_CONF}) { - File file = new File(root, name); - if (file.isFile()) { - Stats stats = addFile(root, file, name, zos, buffer); - count += stats.count; - size += stats.size; - } - } - File scripts = new File(root, "scripts"); - addDirectoryEntry(scripts, SCRIPTS, zos); - Stats scriptsStats = addTree(root, scripts, SCRIPTS, zos, buffer); - count += scriptsStats.count; - size += scriptsStats.size; - checkLimits(count, size); - for (String target : new String[]{"mpv_conf", "input_conf"}) { - File profileDir = new File(root, "profiles" + File.separator + target); - String prefix = PROFILES + target + "/"; - addDirectoryEntry(profileDir, prefix, zos); - Stats profileStats = addTree(root, profileDir, prefix, zos, buffer); - count += profileStats.count; - size += profileStats.size; - checkLimits(count, size); - } - } catch (Throwable e) { - deleteRecursively(archive); - if (e instanceof IOException) throw (IOException) e; - throw new IOException(e); - } - if (archive.length() > MAX_ARCHIVE_BYTES) { - deleteRecursively(archive); - throw new IOException("MPV config archive too large"); - } - SpiderDebug.log("sync", "archive mpv config count=%d size=%d zip=%d file=%s", count, size, archive.length(), archive.getAbsolutePath()); - return new Archive(archive, count, size, archive.length()); - } - - public static int restoreArchive(File archive) throws IOException { - if (archive == null || !archive.isFile() || archive.length() <= 0) return 0; - if (archive.length() > MAX_ARCHIVE_BYTES) throw new IOException("MPV config archive too large"); - File staging = new File(Path.cache(), "webhtv-mpv-restore-" + UUID.randomUUID()); - if (!staging.mkdirs()) throw new IOException("Cannot create MPV config staging directory"); - int count = 0; - int entries = 0; - long total = 0; - byte[] buffer = new byte[BUFFER_SIZE]; - try { - try (ZipInputStream zis = new ZipInputStream(new BufferedInputStream(new FileInputStream(archive), BUFFER_SIZE))) { - ZipEntry entry; - while ((entry = zis.getNextEntry()) != null) { - if (++entries > MAX_ENTRIES) throw new IOException("Too many MPV config files"); - String path = normalize(entry.getName()); - if (!isAllowed(path)) throw new IOException("Invalid MPV config path"); - File out = new File(staging, path); - if (!inside(staging, out)) throw new IOException("Invalid MPV config path"); - if (entry.isDirectory()) { - if (!out.mkdirs() && !out.isDirectory()) throw new IOException("Cannot create MPV config directory"); - zis.closeEntry(); - continue; - } - count++; - File parent = out.getParentFile(); - if (parent != null && !parent.mkdirs() && !parent.isDirectory()) throw new IOException("Cannot create MPV config directory"); - long written = 0; - try (BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(out), BUFFER_SIZE)) { - int read; - while ((read = zis.read(buffer)) != -1) { - written += read; - total += read; - if (written > MAX_ENTRY_BYTES || total > MAX_ARCHIVE_BYTES) throw new IOException("MPV config archive too large"); - output.write(buffer, 0, read); - } - } - if (entry.getTime() > 0) out.setLastModified(entry.getTime()); - zis.closeEntry(); - } - } - apply(staging); - SpiderDebug.log("sync", "restore mpv config count=%d file=%s", count, archive.getAbsolutePath()); - return count; - } finally { - deleteRecursively(staging); - } - } - - static boolean isAllowed(String path) { - if (path == null || path.isEmpty() || path.startsWith("/") || path.contains("..")) return false; - if (MPV_CONF.equals(path) || INPUT_CONF.equals(path)) return true; - return path.startsWith(SCRIPTS) || path.startsWith(PROFILES + "mpv_conf/") || path.startsWith(PROFILES + "input_conf/") - || "scripts".equals(path) || "profiles".equals(path) - || "profiles/mpv_conf".equals(path) || "profiles/input_conf".equals(path); - } - - private static void apply(File staging) throws IOException { - File root = MpvConfigStore.configDir(); - replaceFile(new File(staging, MPV_CONF), new File(root, MPV_CONF)); - replaceFile(new File(staging, INPUT_CONF), new File(root, INPUT_CONF)); - replaceDirectory(new File(staging, "scripts"), new File(root, "scripts")); - replaceDirectory(new File(staging, PROFILES + "mpv_conf"), new File(root, "profiles/mpv_conf")); - replaceDirectory(new File(staging, PROFILES + "input_conf"), new File(root, "profiles/input_conf")); - MpvConfigStore.ensureCustomButtonScript(); - } - - private static void replaceFile(File source, File target) throws IOException { - if (target.exists() && !target.delete()) throw new IOException("Cannot replace MPV config file"); - if (!source.isFile()) return; - copy(source, target); - } - - private static void replaceDirectory(File source, File target) throws IOException { - deleteRecursively(target); - if (!source.exists()) { - if (!target.mkdirs() && !target.isDirectory()) throw new IOException("Cannot create MPV config directory"); - return; - } - if (!target.mkdirs() && !target.isDirectory()) throw new IOException("Cannot create MPV config directory"); - File[] files = source.listFiles(); - if (files == null) return; - for (File file : files) { - File output = new File(target, file.getName()); - if (file.isDirectory()) copyDirectory(file, output); - else copy(file, output); - } - } - - private static void copyDirectory(File source, File target) throws IOException { - if (!target.mkdirs() && !target.isDirectory()) throw new IOException("Cannot create MPV config directory"); - File[] files = source.listFiles(); - if (files == null) return; - for (File file : files) { - File output = new File(target, file.getName()); - if (file.isDirectory()) copyDirectory(file, output); - else copy(file, output); - } - } - - private static void copy(File source, File target) throws IOException { - File parent = target.getParentFile(); - if (parent != null && !parent.mkdirs() && !parent.isDirectory()) throw new IOException("Cannot create MPV config directory"); - byte[] buffer = new byte[BUFFER_SIZE]; - try (BufferedInputStream input = new BufferedInputStream(new FileInputStream(source), BUFFER_SIZE); - BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(target), BUFFER_SIZE)) { - int read; - while ((read = input.read(buffer)) != -1) output.write(buffer, 0, read); - } - target.setLastModified(source.lastModified()); - } - - private static Stats addTree(File root, File directory, String prefix, ZipOutputStream zos, byte[] buffer) throws IOException { - if (!directory.isDirectory() || !isRegularPath(directory) || !inside(root, directory)) return new Stats(); - Stats stats = new Stats(); - File[] files = directory.listFiles(); - if (files == null) return stats; - for (File file : files) { - if (!isRegularPath(file) || !inside(root, file)) continue; - String name = prefix + file.getName(); - if (file.isDirectory()) { - addDirectoryEntry(file, name + "/", zos); - stats.add(addTree(root, file, name + "/", zos, buffer)); - } else if (file.isFile()) { - stats.add(addFile(root, file, name, zos, buffer)); - } - } - return stats; - } - - private static Stats addFile(File root, File file, String name, ZipOutputStream zos, byte[] buffer) throws IOException { - if (!isRegularPath(file) || !inside(root, file) || file.length() > MAX_ENTRY_BYTES) throw new IOException("MPV config file too large"); - ZipEntry entry = new ZipEntry(name); - entry.setTime(file.lastModified()); - zos.putNextEntry(entry); - long size = 0; - try (BufferedInputStream input = new BufferedInputStream(new FileInputStream(file), BUFFER_SIZE)) { - int read; - while ((read = input.read(buffer)) != -1) { - size += read; - zos.write(buffer, 0, read); - } - } - zos.closeEntry(); - return new Stats(1, size); - } - - private static void checkLimits(int count, long size) throws IOException { - if (count > MAX_ENTRIES || size > MAX_ARCHIVE_BYTES) throw new IOException("MPV config archive too large"); - } - - private static void addDirectoryEntry(File directory, String name, ZipOutputStream zos) throws IOException { - if (!directory.isDirectory() || !isRegularPath(directory) || !inside(MpvConfigStore.configDir(), directory)) return; - ZipEntry entry = new ZipEntry(name.endsWith("/") ? name : name + "/"); - entry.setTime(directory.lastModified()); - zos.putNextEntry(entry); - zos.closeEntry(); - } - - private static String normalize(String value) { - if (value == null) return ""; - String path = value.replace('\\', '/'); - while (path.startsWith("./")) path = path.substring(2); - while (path.endsWith("/") && path.length() > 0) path = path.substring(0, path.length() - 1); - return path; - } - - private static boolean inside(File root, File file) { - try { - String base = root.getCanonicalPath(); - String path = file.getCanonicalPath(); - return path.equals(base) || path.startsWith(base + File.separator); - } catch (IOException e) { - return false; - } - } - - private static boolean isRegularPath(File file) { - try { - return file.getAbsoluteFile().equals(file.getCanonicalFile()); - } catch (IOException e) { - return false; - } - } - - private static void deleteRecursively(File file) { - if (file == null || !file.exists()) return; - try { - if (!file.getAbsoluteFile().equals(file.getCanonicalFile())) { - file.delete(); - return; - } - } catch (IOException e) { - file.delete(); - return; - } - File[] files = file.isDirectory() ? file.listFiles() : null; - if (files != null) for (File child : files) deleteRecursively(child); - file.delete(); - } - - public static final class Archive { - private final File file; - private final int count; - private final long rawSize; - private final long zipSize; - - private Archive(File file, int count, long rawSize, long zipSize) { - this.file = file; - this.count = count; - this.rawSize = rawSize; - this.zipSize = zipSize; - } - - public File getFile() { - return file; - } - - public int getCount() { - return count; - } - - public long getRawSize() { - return rawSize; - } - - public long getZipSize() { - return zipSize; - } - - public void delete() { - deleteRecursively(file); - } - } - - private static final class Stats { - private int count; - private long size; - - private Stats() { - } - - private Stats(int count, long size) { - this.count = count; - this.size = size; - } - - private void add(Stats other) { - count += other.count; - size += other.size; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/PreviousProcessExitLogger.java b/app/src/main/java/com/fongmi/android/tv/utils/PreviousProcessExitLogger.java index 929974746e..b99383ad23 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/PreviousProcessExitLogger.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/PreviousProcessExitLogger.java @@ -8,14 +8,9 @@ import android.os.Build; import androidx.annotation.RequiresApi; import com.github.catvod.crawler.SpiderDebug; -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; import com.github.catvod.utils.Prefers; import java.util.List; -import java.io.InputStream; -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; public final class PreviousProcessExitLogger { @@ -25,29 +20,20 @@ public final class PreviousProcessExitLogger { } public static void log(Context context) { - if (!SpiderDebug.isEnabled()) return; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - DebugLogStore.event(event().unknown("exitReason", DiagnosticEvent.Status.NOT_SUPPORTED)); - return; + if (!SpiderDebug.isEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return; + try { + logApi30(context.getApplicationContext()); + } catch (Throwable error) { + SpiderDebug.log("process-exit", "query failed error=%s", error.getClass().getSimpleName()); } - Context app = context.getApplicationContext(); - Task.execute(() -> { - if (!SpiderDebug.isEnabled()) return; - try { logApi30(app); } - catch (Throwable error) { - DebugLogStore.event(event().unknown("exitReason", DiagnosticEvent.Status.READ_ERROR) - .observed("javaClass", error.getClass().getSimpleName())); - SpiderDebug.log("process-exit", "query failed error=%s", error.getClass().getSimpleName()); - } - }); } @RequiresApi(Build.VERSION_CODES.R) private static void logApi30(Context context) { ActivityManager manager = context.getSystemService(ActivityManager.class); - if (manager == null) { DebugLogStore.event(event().unknown("exitReason", DiagnosticEvent.Status.UNAVAILABLE)); return; } + if (manager == null) return; List exits = manager.getHistoricalProcessExitReasons(context.getPackageName(), 0, 1); - if (exits.isEmpty()) { DebugLogStore.event(event().unknown("exitReason", DiagnosticEvent.Status.UNAVAILABLE)); return; } + if (exits.isEmpty()) return; ApplicationExitInfo exit = exits.get(0); long timestamp = exit.getTimestamp(); if (timestamp <= Prefers.getLong(KEY_LAST_EXIT_TIMESTAMP, 0)) return; @@ -56,43 +42,6 @@ public final class PreviousProcessExitLogger { "previous reason=%s(%d) status=%d importance=%d timestamp=%d pss=%d rss=%d description=%s", reasonName(exit.getReason()), exit.getReason(), exit.getStatus(), exit.getImportance(), timestamp, exit.getPss(), exit.getRss(), safe(exit.getDescription())); - DebugLogStore.event(event().observed("exitReason", reasonName(exit.getReason())).observed("exitStatus", exit.getStatus()) - .observed("firstSeenMs", timestamp).unknown("previousRun", DiagnosticEvent.Status.UNKNOWN) - .observed("reason", "previous-process-exit; correlate with journal, not current trace").pin("previous-process-exit")); - captureTrace(exit); - } - - private static DiagnosticEvent event() { - return new DiagnosticEvent("process.recovery", "none", "process", 0, 0) - .source("android", String.valueOf(Build.VERSION.SDK_INT), "app", "ApplicationExitInfo", "previous-process; trace-unresolved", - null, null, 0, android.os.SystemClock.elapsedRealtimeNanos()); - } - - @RequiresApi(Build.VERSION_CODES.R) - private static void captureTrace(ApplicationExitInfo exit) { - try (InputStream input = exit.getTraceInputStream()) { - if (input == null) { - DebugLogStore.event(event().unknown("traceAvailable", DiagnosticEvent.Status.UNAVAILABLE)); - return; - } - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - byte[] part = new byte[1024]; - int read; - while (bytes.size() < 8192 && (read = input.read(part, 0, Math.min(part.length, 8192 - bytes.size()))) > 0) bytes.write(part, 0, read); - boolean text = exit.getReason() == ApplicationExitInfo.REASON_ANR; - DebugLogStore.event(event().observed("traceAvailable", true).observed("bytes", bytes.size()) - .observed("format", text ? "anr-text-prefix" : "platform-trace; binary content not exported") - .observed("truncated", bytes.size() == 8192)); - if (text) { - String trace = new String(bytes.toByteArray(), StandardCharsets.UTF_8); - int chunks = (trace.length() + 999) / 1000; - for (int i = 0; i < chunks; i++) DebugLogStore.event(event().observed("chunk", i).observed("chunkCount", chunks) - .message(trace.substring(i * 1000, Math.min(trace.length(), (i + 1) * 1000)))); - } - } catch (Exception error) { - DebugLogStore.event(event().unknown("traceAvailable", DiagnosticEvent.Status.READ_ERROR) - .observed("javaClass", error.getClass().getSimpleName())); - } } @RequiresApi(Build.VERSION_CODES.R) diff --git a/app/src/main/java/com/fongmi/android/tv/utils/PushParser.java b/app/src/main/java/com/fongmi/android/tv/utils/PushParser.java index d4312e183e..540d082902 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/PushParser.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/PushParser.java @@ -8,8 +8,6 @@ public final class PushParser { private static final Pattern PUSH_URL = Pattern.compile("(https?|thunder|magnet|ed2k|video):\\S+"); private static final Pattern COMMON_HEADER = Pattern.compile("(?i)^(user-agent|referer|referrer|cookie|origin|authorization|range|accept|content-type)\\s*[:=].+"); - private static final Pattern NETWORK_LOCATION = Pattern.compile("(?i)^(https?|file|content|smb|nfs|ftp|rtsp|video)://\\S+$"); - private static final Pattern WINDOWS_PATH = Pattern.compile("(?i)^[a-z]:[\\\\/].+"); private PushParser() { } @@ -97,21 +95,5 @@ public final class PushParser { public String getName() { return title.isEmpty() ? url : title; } - - public boolean hasExplicitTitle() { - return !title.isEmpty(); - } - - public boolean shouldSkipAutoTmdbMatch() { - return !hasExplicitTitle() && isObviousMediaLocation(getName()); - } - - private static boolean isObviousMediaLocation(String value) { - String location = clean(value); - return NETWORK_LOCATION.matcher(location).matches() - || location.startsWith("/") - || location.startsWith("\\\\") - || WINDOWS_PATH.matcher(location).matches(); - } } } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/SearchSourceVisibility.java b/app/src/main/java/com/fongmi/android/tv/utils/SearchSourceVisibility.java index 014b8c5363..d44f859d42 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/SearchSourceVisibility.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/SearchSourceVisibility.java @@ -5,7 +5,11 @@ public final class SearchSourceVisibility { private SearchSourceVisibility() { } - public static boolean shouldShow(boolean hasVisibleResults) { - return hasVisibleResults; + /** + * Keeps configured empty source rows only when filtering is disabled. Once a similarity filter + * is active, a source appears dynamically as soon as it has at least one visible match. + */ + public static boolean shouldShow(int similarity, boolean fixedOrder, boolean hasVisibleResults) { + return hasVisibleResults || (similarity <= 0 && fixedOrder); } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/web/HomeWebBridge.java b/app/src/main/java/com/fongmi/android/tv/web/HomeWebBridge.java index 4adc93cdda..b757d5dc94 100644 --- a/app/src/main/java/com/fongmi/android/tv/web/HomeWebBridge.java +++ b/app/src/main/java/com/fongmi/android/tv/web/HomeWebBridge.java @@ -10,7 +10,6 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.api.SiteApi; import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.bean.Result; import com.fongmi.android.tv.bean.Site; @@ -280,32 +279,10 @@ public class HomeWebBridge { String wall = wallPic(payload); String content = content(payload); final String playSiteKey = siteKey; - App.post(() -> controller.prepareNativePlayback(() -> { - History resume = findResumeHistory(playSiteKey, vodId); - if (resume != null) { - VideoActivity.startDirect(activity, playSiteKey, vodId, title, pic, null, null, null, null, resume); - } else { - VideoActivity.start(activity, playSiteKey, vodId, title, pic, null, wall, content); - } - })); + App.post(() -> controller.prepareNativePlayback(() -> VideoActivity.start(activity, playSiteKey, vodId, title, pic, null, wall, content))); return "{}"; } - /** - * WebHome 打开有观看历史的影片时按组合键(siteKey@@@vodId@@@cid)直取 History,走与 - * 「最近观看」相同的续播直通(EXTRA_RESUME_FROM_HISTORY 携带完整历史,跳过重匹配)。 - * 否则详情页靠 History.findPlayback 重匹配,isSeasonEligible 的季资格检查会把多季 - * TMDB 剧的历史拒绝掉,「继续观看」回落到第 1 集。 - */ - private static History findResumeHistory(String siteKey, String vodId) { - if (TextUtils.isEmpty(siteKey) || TextUtils.isEmpty(vodId)) return null; - try { - return History.find(siteKey + AppDatabase.SYMBOL + vodId + AppDatabase.SYMBOL + VodConfig.getCid()); - } catch (Exception e) { - return null; - } - } - private String playVodInline(JsonObject payload) { Site originSite = VodConfig.get().getHome(); String vodId = WebHomeInlineVodStore.put(payload, this::resolveInlineEpisode, originSite); diff --git a/app/src/main/java/com/fongmi/android/tv/widget/AdBlockChartView.java b/app/src/main/java/com/fongmi/android/tv/widget/AdBlockChartView.java deleted file mode 100644 index 5926791f2e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/widget/AdBlockChartView.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.fongmi.android.tv.widget; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.util.AttributeSet; -import android.view.View; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** Lightweight horizontal bar chart used by the ad-block statistics dialog. */ -public class AdBlockChartView extends View { - - private static final int BAR_COLOR = Color.rgb(33, 150, 243); - private static final int TRACK_COLOR = Color.argb(28, 0, 0, 0); - private static final int TEXT_COLOR = Color.rgb(60, 64, 67); - - private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private List entries = Collections.emptyList(); - - public AdBlockChartView(Context context) { - this(context, null); - } - - public AdBlockChartView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public AdBlockChartView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - barPaint.setColor(BAR_COLOR); - trackPaint.setColor(TRACK_COLOR); - textPaint.setColor(TEXT_COLOR); - textPaint.setTextSize(dp(13)); - } - - public void setEntries(List entries) { - this.entries = entries == null ? Collections.emptyList() : new ArrayList<>(entries); - requestLayout(); - invalidate(); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - int rows = Math.max(1, entries.size()); - int desiredHeight = getPaddingTop() + getPaddingBottom() + rows * dp(48); - setMeasuredDimension(resolveSize(dp(280), widthMeasureSpec), resolveSize(desiredHeight, heightMeasureSpec)); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - if (entries.isEmpty()) return; - long maximum = 1; - for (Entry entry : entries) maximum = Math.max(maximum, entry.value); - float left = getPaddingLeft(); - float right = getWidth() - getPaddingRight(); - float rowHeight = dp(48); - float barTopOffset = dp(24); - float barHeight = dp(12); - for (int index = 0; index < entries.size(); index++) { - Entry entry = entries.get(index); - float top = getPaddingTop() + index * rowHeight; - String value = String.valueOf(entry.value); - canvas.drawText(entry.label, left, top + dp(15), textPaint); - float valueWidth = textPaint.measureText(value); - canvas.drawText(value, right - valueWidth, top + dp(15), textPaint); - float barTop = top + barTopOffset; - canvas.drawRoundRect(left, barTop, right, barTop + barHeight, barHeight / 2, barHeight / 2, trackPaint); - float fraction = entry.value / (float) maximum; - canvas.drawRoundRect(left, barTop, left + (right - left) * fraction, barTop + barHeight, - barHeight / 2, barHeight / 2, barPaint); - } - } - - private int dp(int value) { - return Math.round(value * getResources().getDisplayMetrics().density); - } - - public static final class Entry { - public final String label; - public final long value; - - public Entry(String label, long value) { - this.label = label == null ? "" : label; - this.value = Math.max(0, value); - } - } -} diff --git a/app/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegAudioRenderer.java b/app/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegAudioRenderer.java index 7e108ae16d..cca608b721 100644 --- a/app/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegAudioRenderer.java +++ b/app/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegAudioRenderer.java @@ -3,16 +3,12 @@ package io.github.anilbeesetti.nextlib.media3ext.ffdecoder; import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY; import android.content.Context; -import android.media.AudioFormat; -import android.media.AudioTrack; import android.os.Handler; -import android.util.Log; import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.MimeTypes; import androidx.media3.common.util.Assertions; -import androidx.media3.common.util.CodecSpecificDataUtil; import androidx.media3.common.util.TraceUtil; import androidx.media3.common.util.Util; import androidx.media3.decoder.CryptoConfig; @@ -25,9 +21,6 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecUtil; import com.github.catvod.crawler.SpiderDebug; -import java.util.Arrays; -import java.util.List; - public final class CompatFfmpegAudioRenderer extends DecoderAudioRenderer { private static final int NUM_BUFFERS = 16; @@ -74,13 +67,6 @@ public final class CompatFfmpegAudioRenderer extends DecoderAudioRenderer STEREO_CHANNEL_COUNT); } - if (MimeTypes.AUDIO_ALAC.equals(decodeFormat.sampleMimeType)) { - byte[] data = decodeFormat.initializationData.isEmpty() - ? null : decodeFormat.initializationData.get(0); - Log.d("ExoAlac", "init count=" + decodeFormat.initializationData.size() - + " length=" + (data == null ? 0 : data.length) - + " prefix=" + hexPrefix(data)); - } FfmpegAudioDecoder decoder = new FfmpegAudioDecoder(decodeFormat, NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, shouldOutputFloat(decodeFormat, outputChannelCount), outputChannelCount); TraceUtil.endSection(); return decoder; @@ -107,63 +93,9 @@ public final class CompatFfmpegAudioRenderer extends DecoderAudioRenderer 0) builder.setSampleRate(config[0]); - if (format.channelCount == Format.NO_VALUE && config[1] > 0) builder.setChannelCount(config[1]); - if (initializationData != format.initializationData.get(0)) { - builder.setInitializationData(List.of(initializationData)); - } - return builder.build(); - } catch (RuntimeException ignored) { - // Keep the original format; the decoder will report malformed ALAC data. - } - } return format; } - static byte[] normalizeAlacInitializationData(byte[] initializationData) { - if (initializationData.length >= 12 - && readInt(initializationData, 0) == initializationData.length - && initializationData[4] == 'a' - && initializationData[5] == 'l' - && initializationData[6] == 'a' - && initializationData[7] == 'c') { - return Arrays.copyOfRange(initializationData, 12, initializationData.length); - } - return initializationData; - } - - private static int readInt(byte[] data, int offset) { - return ((data[offset] & 0xFF) << 24) - | ((data[offset + 1] & 0xFF) << 16) - | ((data[offset + 2] & 0xFF) << 8) - | (data[offset + 3] & 0xFF); - } - - private static String hexPrefix(byte[] data) { - if (data == null || data.length == 0) return ""; - int length = Math.min(data.length, 36); - StringBuilder out = new StringBuilder(length * 2); - for (int i = 0; i < length; i++) { - int value = data[i] & 0xFF; - if (value < 0x10) out.append('0'); - out.append(Integer.toHexString(value)); - } - return out.toString(); - } - private boolean isSystemDecoderSupported(Format format) { if (context == null || format.sampleMimeType == null) return false; try { @@ -177,26 +109,7 @@ public final class CompatFfmpegAudioRenderer extends DecoderAudioRenderer - - - - - - diff --git a/app/src/main/res/color/about_primary_action_text.xml b/app/src/main/res/color/about_primary_action_text.xml deleted file mode 100644 index 6b513f97e6..0000000000 --- a/app/src/main/res/color/about_primary_action_text.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/color/about_primary_icon_tint.xml b/app/src/main/res/color/about_primary_icon_tint.xml deleted file mode 100644 index 94dcc376ed..0000000000 --- a/app/src/main/res/color/about_primary_icon_tint.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/color/following_button_danger_bg.xml b/app/src/main/res/color/following_button_danger_bg.xml deleted file mode 100644 index 90064eea24..0000000000 --- a/app/src/main/res/color/following_button_danger_bg.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/color/following_button_danger_text.xml b/app/src/main/res/color/following_button_danger_text.xml deleted file mode 100644 index e442d56264..0000000000 --- a/app/src/main/res/color/following_button_danger_text.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/color/following_button_primary_bg.xml b/app/src/main/res/color/following_button_primary_bg.xml deleted file mode 100644 index 2bc153cca3..0000000000 --- a/app/src/main/res/color/following_button_primary_bg.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/color/following_button_primary_text.xml b/app/src/main/res/color/following_button_primary_text.xml deleted file mode 100644 index 282713b34d..0000000000 --- a/app/src/main/res/color/following_button_primary_text.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/main/res/color/following_button_secondary_bg.xml b/app/src/main/res/color/following_button_secondary_bg.xml deleted file mode 100644 index ffeb229b61..0000000000 --- a/app/src/main/res/color/following_button_secondary_bg.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/color/following_button_secondary_text.xml b/app/src/main/res/color/following_button_secondary_text.xml deleted file mode 100644 index 60d424af23..0000000000 --- a/app/src/main/res/color/following_button_secondary_text.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable-hdpi/ic_notification.png b/app/src/main/res/drawable-hdpi/ic_notification.png index 4330156e96..0647619ad2 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_notification.png and b/app/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_notification.png b/app/src/main/res/drawable-mdpi/ic_notification.png index c89d81612f..d3c42424a3 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_notification.png and b/app/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_logo.png b/app/src/main/res/drawable-nodpi/ic_logo.png index 4fd0c792cd..7050386587 100644 Binary files a/app/src/main/res/drawable-nodpi/ic_logo.png and b/app/src/main/res/drawable-nodpi/ic_logo.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_notification.png b/app/src/main/res/drawable-xhdpi/ic_notification.png index faefd85e94..53ce10ebac 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_notification.png and b/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification.png b/app/src/main/res/drawable-xxhdpi/ic_notification.png index 84db735f41..e2d67cb552 100644 Binary files a/app/src/main/res/drawable-xxhdpi/ic_notification.png and b/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable/about_primary_icon_button.xml b/app/src/main/res/drawable/about_primary_icon_button.xml deleted file mode 100644 index 0311c8000f..0000000000 --- a/app/src/main/res/drawable/about_primary_icon_button.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_home_following.xml b/app/src/main/res/drawable/ic_home_following.xml deleted file mode 100644 index 442614d694..0000000000 --- a/app/src/main/res/drawable/ic_home_following.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_nav_following.xml b/app/src/main/res/drawable/ic_nav_following.xml deleted file mode 100644 index 2bc78dac76..0000000000 --- a/app/src/main/res/drawable/ic_nav_following.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/selector_dialog_step_button.xml b/app/src/main/res/drawable/selector_dialog_step_button.xml index 9b1fa2ea4e..6425b21815 100644 --- a/app/src/main/res/drawable/selector_dialog_step_button.xml +++ b/app/src/main/res/drawable/selector_dialog_step_button.xml @@ -3,7 +3,7 @@ - + diff --git a/app/src/main/res/drawable/selector_mpv_script_disabled_card.xml b/app/src/main/res/drawable/selector_mpv_script_disabled_card.xml deleted file mode 100644 index ade322a06f..0000000000 --- a/app/src/main/res/drawable/selector_mpv_script_disabled_card.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/shape_audio_playlist_panel.xml b/app/src/main/res/drawable/shape_audio_playlist_panel.xml index d5be9de17b..685eefc80f 100644 --- a/app/src/main/res/drawable/shape_audio_playlist_panel.xml +++ b/app/src/main/res/drawable/shape_audio_playlist_panel.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/drawable/shape_dialog_glass_toolbar.xml b/app/src/main/res/drawable/shape_dialog_glass_toolbar.xml index 8836f1e271..8dd8cbd46c 100644 --- a/app/src/main/res/drawable/shape_dialog_glass_toolbar.xml +++ b/app/src/main/res/drawable/shape_dialog_glass_toolbar.xml @@ -6,7 +6,7 @@ android:endColor="#CC303463" android:startColor="#E62F315E" /> - + - - - - - diff --git a/app/src/main/res/drawable/shape_display_dialog_panel.xml b/app/src/main/res/drawable/shape_display_dialog_panel.xml index 9d4cb2570d..2de60ebd7d 100644 --- a/app/src/main/res/drawable/shape_display_dialog_panel.xml +++ b/app/src/main/res/drawable/shape_display_dialog_panel.xml @@ -2,7 +2,7 @@ - + diff --git a/app/src/main/res/drawable/shape_one_key_sync_dialog.xml b/app/src/main/res/drawable/shape_one_key_sync_dialog.xml index 658271618c..76c72d6d86 100644 --- a/app/src/main/res/drawable/shape_one_key_sync_dialog.xml +++ b/app/src/main/res/drawable/shape_one_key_sync_dialog.xml @@ -4,6 +4,8 @@ - + diff --git a/app/src/main/res/drawable/shape_shell_proxy_dialog.xml b/app/src/main/res/drawable/shape_shell_proxy_dialog.xml index 658271618c..0ce7998154 100644 --- a/app/src/main/res/drawable/shape_shell_proxy_dialog.xml +++ b/app/src/main/res/drawable/shape_shell_proxy_dialog.xml @@ -4,6 +4,6 @@ - + diff --git a/app/src/main/res/drawable/shape_site_dialog.xml b/app/src/main/res/drawable/shape_site_dialog.xml deleted file mode 100644 index c96fc56c69..0000000000 --- a/app/src/main/res/drawable/shape_site_dialog.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/activity_following.xml b/app/src/main/res/layout/activity_following.xml deleted file mode 100644 index 5066390698..0000000000 --- a/app/src/main/res/layout/activity_following.xml +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_tmdb_detail.xml b/app/src/main/res/layout/activity_tmdb_detail.xml index e681a2d8a6..d2f909fe26 100644 --- a/app/src/main/res/layout/activity_tmdb_detail.xml +++ b/app/src/main/res/layout/activity_tmdb_detail.xml @@ -110,22 +110,6 @@ android:visibility="gone" app:backgroundTint="#3A4652" app:strokeColor="@android:color/transparent" /> - - - + android:orientation="vertical" + android:padding="6dp"> + + - + + + diff --git a/app/src/main/res/layout/dialog_about.xml b/app/src/main/res/layout/dialog_about.xml index 65c6aa4ee3..ac23e75451 100644 --- a/app/src/main/res/layout/dialog_about.xml +++ b/app/src/main/res/layout/dialog_about.xml @@ -103,9 +103,9 @@ android:layout_weight="1" android:minHeight="44dp" android:text="@string/about_check_update" - android:textColor="@color/about_primary_action_text" + android:textColor="@color/dialog_tonal_button_text" android:textSize="14sp" - app:backgroundTint="@color/about_primary_action_bg" + app:backgroundTint="@color/dialog_tonal_button_bg" app:cornerRadius="8dp" /> @@ -131,7 +130,6 @@ android:layout_height="44dp" android:layout_marginTop="10dp" android:minHeight="44dp" - android:nextFocusUp="@id/checkUpdate" android:text="@string/about_acknowledge" android:textColor="@color/dialog_primary_button_text" android:textSize="14sp" @@ -144,11 +142,11 @@ android:layout_height="44dp" android:layout_gravity="end" android:layout_marginTop="10dp" - android:background="@drawable/about_primary_icon_button" + android:background="@drawable/selector_dialog_step_button" android:contentDescription="@string/update_settings" android:padding="10dp" android:src="@drawable/ic_remote_settings" - app:tint="@color/about_primary_icon_tint" /> + app:tint="#5F6368" /> diff --git a/app/src/main/res/layout/dialog_action_input.xml b/app/src/main/res/layout/dialog_action_input.xml deleted file mode 100755 index cecc9a28ba..0000000000 --- a/app/src/main/res/layout/dialog_action_input.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_ai_recommendation_info.xml b/app/src/main/res/layout/dialog_ai_recommendation_info.xml index fe60430cfe..673f462f20 100644 --- a/app/src/main/res/layout/dialog_ai_recommendation_info.xml +++ b/app/src/main/res/layout/dialog_ai_recommendation_info.xml @@ -227,7 +227,6 @@ android:fadeScrollbars="false" android:fadingEdge="vertical" android:focusable="true" - android:focusableInTouchMode="true" android:nextFocusDown="@id/notInterested" android:overScrollMode="ifContentScrolls" android:requiresFadingEdge="vertical" @@ -351,7 +350,6 @@ android:layout_width="wrap_content" android:layout_height="36dp" android:focusable="true" - android:focusableInTouchMode="true" android:insetTop="0dp" android:insetBottom="0dp" android:minWidth="112dp" @@ -370,7 +368,6 @@ android:layout_height="36dp" android:layout_marginStart="10dp" android:focusable="true" - android:focusableInTouchMode="true" android:insetTop="0dp" android:insetBottom="0dp" android:minWidth="86dp" diff --git a/app/src/main/res/layout/dialog_disc_menu.xml b/app/src/main/res/layout/dialog_disc_menu.xml deleted file mode 100644 index 8bf5a3bd43..0000000000 --- a/app/src/main/res/layout/dialog_disc_menu.xml +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_episode_detail.xml b/app/src/main/res/layout/dialog_episode_detail.xml index 25088dc05e..e1bdfeccf1 100644 --- a/app/src/main/res/layout/dialog_episode_detail.xml +++ b/app/src/main/res/layout/dialog_episode_detail.xml @@ -29,12 +29,11 @@ android:layout_height="0dp" android:layout_weight="1" android:fillViewport="false" - android:overScrollMode="never"> + android:overScrollMode="ifContentScrolls"> @@ -133,6 +132,17 @@ + + diff --git a/app/src/main/res/layout/dialog_github_proxy.xml b/app/src/main/res/layout/dialog_github_proxy.xml index 1219e73a48..94b86e3e5b 100644 --- a/app/src/main/res/layout/dialog_github_proxy.xml +++ b/app/src/main/res/layout/dialog_github_proxy.xml @@ -1,18 +1,14 @@ - - - + android:background="@color/white" + android:orientation="vertical" + android:paddingStart="16dp" + android:paddingTop="8dp" + android:paddingEnd="16dp" + android:paddingBottom="4dp"> @@ -41,36 +36,8 @@ android:text="@string/setting_github_proxy_enabled" android:textColor="#202124" android:textSize="15sp" - app:thumbTint="@color/dialog_switch_thumb" - app:trackTint="@color/dialog_switch_track" /> - - - - - - - - + app:thumbTint="@color/dialog_switch_thumb" + app:trackTint="@color/dialog_switch_track" /> - - - - diff --git a/app/src/main/res/layout/dialog_mpv_config_create.xml b/app/src/main/res/layout/dialog_mpv_config_create.xml index c0cd86ff78..38623ec5c8 100644 --- a/app/src/main/res/layout/dialog_mpv_config_create.xml +++ b/app/src/main/res/layout/dialog_mpv_config_create.xml @@ -14,7 +14,6 @@ android:orientation="horizontal"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_site_health_report.xml b/app/src/main/res/layout/dialog_site_health_report.xml index ad4f7be7e4..c2ba8fa5f2 100644 --- a/app/src/main/res/layout/dialog_site_health_report.xml +++ b/app/src/main/res/layout/dialog_site_health_report.xml @@ -30,18 +30,6 @@ android:textSize="14sp" tools:text="3 sites · 42 samples · 8 failures" /> - - - - - - - - + android:text="@string/dialog_tmdb_add" + android:textAllCaps="false" /> @@ -312,7 +313,8 @@ android:layout_marginStart="12dp" android:insetTop="0dp" android:insetBottom="0dp" - /> + android:text="@string/dialog_tmdb_add" + android:textAllCaps="false" /> @@ -329,16 +331,8 @@ android:layout_width="0dp" android:layout_height="52dp" android:layout_weight="1" - /> - - + android:text="@string/dialog_tmdb_site_manage" + android:textAllCaps="false" /> + android:text="@string/dialog_tmdb_reset_default" + android:textAllCaps="false" /> diff --git a/app/src/main/res/layout/dialog_update_settings.xml b/app/src/main/res/layout/dialog_update_settings.xml index 784cd97c05..01ec9f933e 100644 --- a/app/src/main/res/layout/dialog_update_settings.xml +++ b/app/src/main/res/layout/dialog_update_settings.xml @@ -38,12 +38,12 @@ - + + + + + + + + + + + + + + + + + + + - + diff --git a/app/src/main/res/layout/item_following.xml b/app/src/main/res/layout/item_following.xml deleted file mode 100644 index eb96019f0a..0000000000 --- a/app/src/main/res/layout/item_following.xml +++ /dev/null @@ -1,229 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/view_tmdb_header.xml b/app/src/main/res/layout/view_tmdb_header.xml index 5578e38e6f..139adef111 100644 --- a/app/src/main/res/layout/view_tmdb_header.xml +++ b/app/src/main/res/layout/view_tmdb_header.xml @@ -327,23 +327,6 @@ app:strokeColor="@color/tmdb_action_button_stroke" app:strokeWidth="1dp" /> - - 马上回来,30 -中场休息*>广告时间,[2,30] diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4f1d699705..b606de9942 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -240,9 +240,8 @@ 自动备份 全屏菜单键 首页菜单键 - 播放返回详情 - 播放菜单遮盖层 - 播放倍速 + 播放返回详情 + 播放倍速 记录分集进度 历史记录跨源聚合 基于 TMDB 匹配统一不同源的历史记录 @@ -257,8 +256,6 @@ 历史记录已不存在 点播配置已切换,请重新操作 确定删除所有点播配置的历史记录吗? - 删除播放源? - 确定删除“%1$s”吗?相关播放历史也可能丢失,此操作无法撤销。 首页最近观看 个性推荐 不感兴趣管理 @@ -535,11 +532,6 @@ 清空本站 清空站点统计? 清空“%1$s”的健康样本? - 一键清空样本 - 清空全部站点健康样本? - 将删除全部播放链路健康样本,且无法撤销。 - 首页 - 分类 搜索 详情 解析 @@ -871,7 +863,6 @@ 代理测试失败:%1$s 一键同步 无痕模式 - 触屏优化 网盘检测 音频源配置 小说源配置 @@ -931,10 +922,6 @@ 移除 重置默认 请输入有效的 http/https 地址 - 探测延迟 - %1$d ms - 失败 - 测试中 选择备份 @@ -966,12 +953,10 @@ 取消全选 接口与站源列表 Jar/脚本保存数据 - MPV 配置管理 登录态 WebHome 主页数据 搜索记录 观看记录 - 已看 %1$s 收藏数据 应用设置 中转服务配置 @@ -1173,18 +1158,6 @@ MPV 配置管理 MPV 渲染后端 当前不可用 - 蓝光原盘菜单 - 主菜单 - 弹出菜单 - 菜单返回 - 上 - 下 - 左 - 右 - 原盘菜单 - 打开原盘主菜单;长按打开菜单导航 - 菜单导航 - 可直接点击画面中的菜单按钮,也可使用方向键选择 %1$s · %2$s 默认 本地 mpv.conf @@ -1245,60 +1218,13 @@ 当前使用:%1$s 应用所选 关闭 - 脚本按钮 - 新建脚本按钮 - 添加到播放器控制层 - 脚本按钮 - 在脚本列表中管理播放控制按钮 - 暂无自定义按钮 - 空白位 - 拖动调整顺序 - 导入/导出 - 将全部自定义按钮及其 Lua 脚本保存为 XML 文件备份或分享 - 导入 - 导出 - 已导入 %1$d 个按钮 - 按钮导出成功 - 无法读取或写入所选文件 - Lua 脚本 - 新建脚本按钮 - 编辑脚本按钮 - 按钮名称 - 例如:切换画质 - 短按 Lua - 长按 Lua - MPV 启动时执行 - 启用按钮 - 启用 - 停用 - 删除按钮 - 确定删除“%1$s”吗? - 自定义按钮已保存 - 最多添加 8 个自定义按钮 - 自定义按钮 ID 无效 - 执行时机 - 点击 - 长按 - 启动时 - 输入 Lua 脚本 - 新建脚本 - 选择一种方式添加脚本;保存后可在脚本设置中启用按钮。 - 通过文本创建 - 打开文本编辑器,输入并保存 Lua 脚本 - 脚本设置 - 启用脚本 - 设置名称、启用状态和执行时机 - 打开编辑窗口 - 通过 URL 导入脚本 - 下载远程 Lua/JS 脚本并保存 - 导入脚本文件 - 从本地选择 Lua/JS 脚本文件 字幕渲染 字幕样式 后台播放 音频软解 音频直通 视频软解 + FFmpeg模式 AAC优先 音乐后台通知 有声书后台通知 @@ -1320,8 +1246,7 @@ 暂停时继续预载 仅 WiFi 始终 - 点播自动播放 - 直播源失效自动切换 + 自动播放 自动换源 记住播放亮度 播放失败回退 @@ -1330,11 +1255,7 @@ 屏显设置 自动匹配字幕 字幕偏好语言 - 环境变量(JSON) - 请输入有效的 JSON 对象 - 字幕源 - 已加载 %1$d 个 - 暂无可用字幕源 + 射手网 Token AI字幕最大并发数 AI字幕分块数量 已自动匹配字幕:%1$s @@ -1605,9 +1526,8 @@ 此版本没有可用的更新来源。 - 发生意外错误\n即将自动重启,本次不重新加载接口 + 发生意外错误\n抱歉给您带来不便 重新启动 - 静默重启 错误信息 错误信息 应用版本:%1$s\n\n%2$s @@ -1764,19 +1684,6 @@ 搜索后选择播放源 - - 关闭 - 自动容灾 - 手动确认 - - - 点播接口容灾 - 接口加载失败 - 当前接口:%1$s - 正在尝试下一个接口:%1$s - 切换接口 - 已取消接口切换 - 关闭 自动跳过 @@ -1795,14 +1702,11 @@ 跟随系统 - 更小 - 紧凑 - 较紧凑 标准 - 稍大 - 较大 - 大 - 特大 + 较紧凑 + 紧凑 + 更紧凑 + 更小 @@ -2530,7 +2434,7 @@ 00:00 - 23:59 暂无节目信息 - 播放 %1$d 次 / 广告拦截 %2$d 次 + 广告拦截 · %1$d 次 搜索布局 启用规则 添加关键词或站点… @@ -2564,19 +2468,12 @@ 手动添加规则 待审核接口规则(%d) 导入接口去广规则 - 仅导入(保持停用) - 导入并启用已选 - 低风险全选 - 全选 - 反选 + 导入(默认停用) 忽略全部 暂无待审核接口规则 自定义规则 接口配置规则 暂无规则 - 全部启用接口导入规则 - 全部停用接口导入规则 - 确定停用全部接口导入规则吗?之后可以随时批量重新启用。 来自配置 · 暂无 来自配置 · 共 %d 条(只读) 编辑规则 @@ -2614,41 +2511,12 @@ 总拦截次数 AI 反馈次数 AI 分析成功率 - 站点 - 规则 - 播放链路 + 站点拦截排行 + 规则命中排行 重置统计 确定重置所有统计数据吗? 暂无统计数据 - 没有符合筛选条件的日志 - 概览 - 拦截日志 - 统计图表 - 站点名称 - 站点域名 - 拦截规则 - 广告域名 - 拦截时间 - 切片开始时间 - 切片结束时间 - 切片时长 - 剧名 - 线路名称 - 集数名称 - 播放链路 - 未知 - 筛选 - 输入关键词查找 - 全选 - 清除选择 - 应用 - 取消 - 暂无可选值 - 已选 %d 项 - 站点统计概览 - 涉及站点数 - 拦截最多站点 - 拦截占比 + 统计概览 次 %.1f%% 站点 @@ -2662,6 +2530,34 @@ 白名单(正则) (无) 应用并屏蔽 + 广告区间 %1$s – %2$s + 时长 %1$.1f 秒 + 本次播放已跳过 + 已记录。该区间已经播过,未执行跳过。 + 起点:由你框选 + 起点:根据切片断点推断 + 起点:根据切片跨域切换推断 + 起点:复用音频检测结果 + 起点:按回溯 %1$d 秒估算 + 正在分析… + 新增 HLS 切片规则 + 拉黑广告域名 + 新增 URL 正则规则 + 启用已有规则 + 未能归纳出可复用的规则 + 风险:低 + 风险:中 + 风险:高 + 证据 + 诊断 + 保存规则 + 关闭 + 已标记广告起点,到结束处再按一次 + 已取消标记 + 当前位置无法反馈广告 + 规则已保存并生效 + 该区间没有可保存的规则 + 规则保存失败 AI/自定义 %1$d 条 · 接口配置 %2$d 条 · 内置 HLS %3$d 条 内置 HLS · v%1$d · %2$s 已启用 @@ -2705,27 +2601,6 @@ 语音广告识别 广告关键词 %1$d 个关键词 - 语音规则 - 暂无语音规则 - %1$d 条规则 · %2$s - 规则设置错误:%1$s - 管理语音规则 - 编辑规则 - 导入文本规则 - 清除自定义规则 - 查看内置规则 - 确认清除当前自定义语音规则? - 已清除自定义语音规则 - 语音规则已保存 - 已导入 %1$d 条语音规则 - 语音规则导入失败:%1$s - 无 - 内置 - 编辑 - 导入 - 内置 + 自定义 - 内置语音规则 - %1$s · %2$d 条规则 跳过时长 %1$d 秒 跳过模式 @@ -2769,66 +2644,4 @@ revision %1$d · %2$d 条规则 尚未拉取 规则源必须以 https:// 开头 - 请先填写社区规则源地址 - 测试配置 - 配置测试结果 - TMDB API:连接成功并返回有效数据(%1$d 毫秒) - TMDB API:测试失败(%1$s,%2$d 毫秒) - 图片域名:连接成功并返回有效图片(%1$d 毫秒) - 图片域名:测试失败(%1$s,%2$d 毫秒) - OMDb API:连接成功并返回有效数据(%1$d 毫秒) - OMDb API:测试失败(%1$s,%2$d 毫秒) - 按站点名称搜索 - 追更 - 追更 - 追更 - 追更 - 加入追更 - 已追更 - 追更更新 - 已追剧集的新集更新提醒 - 《%1$s》有新集 - 官方已更新至第 %1$d 集,当前源可播至第 %2$d 集 - 检查更新 - 检查 - 正在检查… - 更新检查完成 - 更新检查失败,已保留上次数据 - 还没有追更内容。打开剧集详情页,点击“加入追更”。 - 追更 %1$d 部 · 未读 %2$d 部 - 继续看 - 标记已读 - 提醒开 - 提醒关 - 换源 - 取消追更 - 取消追更? - 观看历史和 TMDB 进度会继续保留。 - 官方已更新至第 %1$d 集 - 官方状态未知 - 当前源可播至第 %1$d 集 - 当前源尚未检查 - 看到第 %1$d 集 - 还有 %1$d 集未看 - 已看完当前更新 - 新 %1$d 集 - 追更功能当前已关闭。 - 未获得通知权限;站内角标和手动检查仍可使用。 - 导入订阅 - 服务端地址(http/https) - Token(不会保存) - 预览 - 导入 - 请输入有效的 http/https 服务端地址 - 没有可导入的订阅 - 待导入订阅 - 已导入 %1$d 条,更新 %2$d 条 - 只看更新 - 显示全部 - 当前没有未看更新的追更内容。 - 未看完 - 已完结 - 检查失败 - 追更更新 - 追新季 S%1$d - + 请先填写社区规则源地址 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4fcc3e92b4..89172eac88 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -507,11 +507,6 @@ 清空本站 清空站點統計? 清空「%1$s」的健康樣本? - 一鍵清空樣本 - 清空全部站點健康樣本? - 將刪除全部播放鏈路健康樣本,且無法撤銷。 - 首頁 - 分類 搜尋 詳情 解析 @@ -827,7 +822,6 @@ 代理測試失敗:%1$s 一鍵同步 無痕模式 - 觸屏優化 網盤檢測 音訊源配置 小說源配置 @@ -887,10 +881,6 @@ 移除 重設預設 請輸入有效的 http/https 地址 - 探測延遲 - %1$d ms - 失敗 - 測試中 選擇備份 @@ -922,12 +912,10 @@ 取消全選 接口與站源列表 Jar/腳本保存資料 - MPV 配置管理 登入狀態 WebHome 主頁資料 搜尋記錄 觀看記錄 - 已看 %1$s 收藏資料 應用設定 中轉服務配置 @@ -1129,18 +1117,6 @@ MPV 設定管理 MPV 渲染後端 目前不可用 - 藍光原盤選單 - 主選單 - 彈出選單 - 選單返回 - 上 - 下 - 左 - 右 - 原碟選單 - 開啟原碟主選單;長按開啟選單導覽 - 選單導覽 - 可直接點選畫面中的選單按鈕,也可使用方向鍵選擇 %1$s · %2$s 預設 本機 mpv.conf @@ -1201,60 +1177,13 @@ 目前使用:%1$s 套用所選 關閉 - 腳本按鈕 - 新增腳本按鈕 - 新增至播放器控制層 - 腳本按鈕 - 在腳本清單中管理播放控制按鈕 - 尚無自訂按鈕 - 空白位 - 拖動調整順序 - 匯入/匯出 - 將全部自訂按鈕及其 Lua 指令碼儲存為 XML 檔案備份或分享 - 匯入 - 匯出 - 已匯入 %1$d 個按鈕 - 按鈕匯出成功 - 無法讀取或寫入所選檔案 - Lua 指令碼 - 新增腳本按鈕 - 編輯腳本按鈕 - 按鈕名稱 - 例如:切換畫質 - 短按 Lua - 長按 Lua - MPV 啟動時執行 - 啟用按鈕 - 啟用 - 停用 - 刪除按鈕 - 確定刪除「%1$s」嗎? - 自訂按鈕已儲存 - 最多新增 8 個自訂按鈕 - 自訂按鈕 ID 無效 - 執行時機 - 點擊 - 長按 - 啟動時 - 輸入 Lua 指令碼 - 新增指令碼 - 選擇一種方式新增指令碼;儲存後可在指令碼設定中啟用按鈕。 - 透過文字建立 - 開啟文字編輯器,輸入並儲存 Lua 指令碼 - 指令碼設定 - 啟用指令碼 - 設定名稱、啟用狀態和執行時機 - 開啟編輯視窗 - 透過 URL 匯入指令碼 - 下載遠端 Lua/JS 指令碼並儲存 - 匯入指令碼檔案 - 從本機選擇 Lua/JS 指令碼檔案 字幕渲染 字幕樣式 背景播放 音訊軟解 音訊直通 視訊軟解 + FFmpeg模式 AAC優先 音樂背景通知 有聲書背景通知 @@ -1276,8 +1205,7 @@ 暫停時繼續預載 僅 WiFi 始終 - 點播自動播放 - 直播源失效自動切換 + 自動播放 自動換源 記住播放亮度 播放失敗回退 @@ -1286,8 +1214,7 @@ 屏顯設定 自動匹配字幕 字幕偏好語言 - 環境變數(JSON) - 請輸入有效的 JSON 物件 + 射手網 Token AI字幕最大並發數 AI字幕分塊數量 已自動匹配字幕:%1$s @@ -1558,9 +1485,8 @@ 此版本沒有可用的更新來源。 - 發生意外錯誤\n即將自動重新啟動,本次不重新載入介面 + 發生意外錯誤\n抱歉給您帶來不便 重新啟動 - 靜默重新啟動 錯誤訊息 錯誤訊息 應用版本:%1$s\n\n%2$s @@ -1659,14 +1585,11 @@ 跟隨系統 - 更小 - 緊湊 - 較緊湊 標準 - 稍大 - 較大 - 大 - 特大 + 較緊湊 + 緊湊 + 更緊湊 + 更小 @@ -2019,13 +1942,6 @@ 搜尋後選擇播放源 - - 關閉 - 自動容災 - 手動確認 - - - 關閉 自動跳過 @@ -2422,19 +2338,12 @@ 自動備份 全屏菜單鍵 首頁菜單鍵 - 播放返回詳情 - 播放選單遮罩 - 播放倍速 + 播放返回詳情 + 播放倍速 記錄分集進度 歷史記錄跨源聚合 基於 TMDB 匹配統一不同源的歷史記錄 全域歷史續播 - 點播介面容災 - 介面載入失敗 - 目前介面:%1$s - 正在嘗試下一個介面:%1$s - 切換介面 - 已取消介面切換 其他配置 介面已失效 正在配對可續播播放源… @@ -2445,10 +2354,8 @@ 歷史記錄已不存在 點播設定已切換,請重新操作 確定刪除所有點播設定的歷史記錄嗎? - 刪除播放源? - 確定刪除「%1$s」嗎?相關播放歷史也可能遺失,此操作無法復原。 首頁最近觀看 - 播放 %1$d 次 / 廣告攔截 %2$d 次 + 廣告攔截 · %1$d 次 排序 完成 拖拽排序 @@ -2514,19 +2421,12 @@ 手動添加規則 待審核接口規則(%d) 導入接口去廣規則 - 僅導入(保持停用) - 導入並啟用已選 - 低風險全選 - 全選 - 反選 + 導入(默認停用) 忽略全部 暫無待審核接口規則 自定義規則 接口配置規則 暫無規則 - 全部啟用介面匯入規則 - 全部停用介面匯入規則 - 確定停用全部介面匯入規則嗎?之後可以隨時批量重新啟用。 來自配置 · 暫無 來自配置 · 共 %d 條(只讀) 編輯規則 @@ -2564,17 +2464,12 @@ 總攔截次數 AI 反饋次數 AI 分析成功率 - 站點 - 規則 - 播放鏈路 + 站點攔截排行 + 規則命中排行 重置統計 確定重置所有統計數據嗎? 暫無統計數據 - 概覽 - 站點統計概覽 - 涉及站點數 - 攔截最多站點 - 攔截佔比 + 統計概覽 次 %.1f%% 站點 @@ -2588,6 +2483,34 @@ 白名單(正則) (無) 應用並屏蔽 + 廣告區間 %1$s – %2$s + 時長 %1$.1f 秒 + 本次播放已跳過 + 已記錄。該區間已經播過,未執行跳過。 + 起點:由你框選 + 起點:根據切片斷點推斷 + 起點:根據切片跨域切換推斷 + 起點:複用音訊檢測結果 + 起點:按回溯 %1$d 秒估算 + 正在分析… + 新增 HLS 切片規則 + 拉黑廣告域名 + 新增 URL 正則規則 + 啟用已有規則 + 未能歸納出可複用的規則 + 風險:低 + 風險:中 + 風險:高 + 證據 + 診斷 + 儲存規則 + 關閉 + 已標記廣告起點,到結束處再按一次 + 已取消標記 + 當前位置無法反饋廣告 + 規則已儲存並生效 + 該區間沒有可儲存的規則 + 規則儲存失敗 AI/自定義 %1$d 條 · 接口配置 %2$d 條 · 內置 HLS %3$d 條 內置 HLS · v%1$d · %2$s 已啓用 @@ -2647,27 +2570,6 @@ 語音廣告識別 廣告關鍵詞 %1$d 個關鍵詞 - 語音規則 - 暫無語音規則 - %1$d 條規則 · %2$s - 規則設定錯誤:%1$s - 管理語音規則 - 編輯規則 - 匯入文字規則 - 清除自訂規則 - 查看內建規則 - 確認清除目前的自訂語音規則? - 已清除自訂語音規則 - 語音規則已儲存 - 已匯入 %1$d 條語音規則 - 語音規則匯入失敗:%1$s - 無 - 內建 - 編輯 - 匯入 - 內建 + 自訂 - 內建語音規則 - %1$s · %2$d 條規則 跳過時長 %1$d 秒 跳過模式 @@ -2711,66 +2613,4 @@ revision %1$d · %2$d 條規則 尚未拉取 規則源必須以 https:// 開頭 - 請先填寫社群規則源位址 - 測試配置 - 配置測試結果 - TMDB API:連線成功並傳回有效資料(%1$d 毫秒) - TMDB API:測試失敗(%1$s,%2$d 毫秒) - 圖片網域:連線成功並傳回有效圖片(%1$d 毫秒) - 圖片網域:測試失敗(%1$s,%2$d 毫秒) - OMDb API:連線成功並傳回有效資料(%1$d 毫秒) - OMDb API:測試失敗(%1$s,%2$d 毫秒) - 按站點名稱搜尋 - 追更 - 追更 - 追更 - 追更 - 加入追更 - 已追更 - 追更更新 - 已追劇集的新集更新提醒 - 《%1$s》有新集 - 官方已更新至第 %1$d 集,目前來源可播至第 %2$d 集 - 檢查更新 - 檢查 - 正在檢查… - 更新檢查完成 - 更新檢查失敗,已保留上次資料 - 還沒有追更內容。打開劇集詳情頁,點擊「加入追更」。 - 追更 %1$d 部 · 未讀 %2$d 部 - 繼續看 - 標記已讀 - 提醒開 - 提醒關 - 換源 - 取消追更 - 取消追更? - 觀看歷史和 TMDB 進度會繼續保留。 - 官方已更新至第 %1$d 集 - 官方狀態未知 - 目前來源可播至第 %1$d 集 - 目前來源尚未檢查 - 看到第 %1$d 集 - 還有 %1$d 集未看 - 已看完目前更新 - 新 %1$d 集 - 追更功能目前已關閉。 - 未取得通知權限;站內角標和手動檢查仍可使用。 - 匯入訂閱 - 伺服器位址(http/https) - Token(不會儲存) - 預覽 - 匯入 - 請輸入有效的 http/https 伺服器位址 - 沒有可匯入的訂閱 - 待匯入訂閱 - 已匯入 %1$d 條,更新 %2$d 條 - 只看更新 - 顯示全部 - 目前沒有未看更新的追更內容。 - 未看完 - 已完結 - 檢查失敗 - 追更更新 - 追新季 S%1$d - + 請先填寫社群規則源位址 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 8da064aeae..c289614a73 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -39,17 +39,4 @@ #B7791F #C53030 - #F3F7FD - #FFFFFF - #D8E3F2 - #172033 - #65748B - #2563B9 - #174EA6 - #FF8A00 - #E56F00 - #EEF4FC - #24599C - #FDECEC - #B3261E diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml new file mode 100644 index 0000000000..96a75d0ef5 --- /dev/null +++ b/app/src/main/res/values/integers.xml @@ -0,0 +1,4 @@ + + + 0 + diff --git a/app/src/main/res/values/playback_recovery.xml b/app/src/main/res/values/playback_recovery.xml deleted file mode 100644 index 04e6f209b8..0000000000 --- a/app/src/main/res/values/playback_recovery.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - 播放器暂时无响应 - 您可以继续等待,或结束本次故障播放并返回首页。不会自动重播视频,未保存的播放进度可能丢失。 - 继续等待 - 正在等待播放恢复。您随时可以结束本次播放;恢复响应后将自动返回播放器。 - 结束播放并返回首页 - 返回首页 - 播放已恢复响应或原进程已结束,不需要强制退出。 - 无法安全结束故障进程。您仍可继续等待或使用系统的强制停止功能。 - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18a195901b..8bcee0c363 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -242,9 +242,8 @@ Automatic backup Fullscreen menu key Home menu key - Return playback to detail - Playback menu overlay - Playback speed + Return playback to detail + Playback speed Remember episode progress Aggregate history by TMDB Unify history across sources based on TMDB match @@ -259,8 +258,6 @@ The history record no longer exists VOD configuration changed. Please try again. Delete history from all VOD configurations? - Delete source? - Delete “%1$s”? Related playback history may also be lost. This cannot be undone. Home recent history Personal recommendations Not interested management @@ -524,7 +521,7 @@ %1$d sites · %2$d samples · %3$d failures · %4$d degraded · Top %5$s x%6$d %1$d sites · %2$d samples %1$d samples · %2$d failures - Playback %1$d / Ad blocks %2$d + Ads blocked · %1$d %1$s · %2$s · %3$d/%4$d %1$d%% No data @@ -538,11 +535,6 @@ Clear this site Clear site health? Clear health samples for %1$s? - Clear all samples - Clear all site health samples? - This will delete all playback-chain health samples. This action cannot be undone. - Home - Category Search Detail Parse @@ -874,7 +866,6 @@ Proxy test failed: %1$s One-key sync Incognito mode - Touch optimization Drive link check Audio source config Novel source config @@ -934,10 +925,6 @@ Remove Reset to default Please enter a valid http/https URL - Probe latency - %1$d ms - Failed - Testing Select backup @@ -969,12 +956,10 @@ Deselect all Interfaces and source lists Jar/script saved data - MPV configuration management Login state WebHome saved data Search records Watch history - Watched %1$s Favorites App settings Relay server config @@ -1176,18 +1161,6 @@ MPV config manager MPV renderer Unavailable - Blu-ray disc menu - Top menu - Pop-up menu - Back - Up - Down - Left - Right - Disc menu - Open the disc top menu. Long press for navigation controls. - Menu navigation - Tap the disc buttons directly, or select with these controls. %1$s · %2$s Default Local mpv.conf @@ -1197,54 +1170,6 @@ No MPV config history Script URL or choose local script %1$d scripts - Script buttons - New script button - Add a button to the player controls - Script buttons - Manage playback buttons in the script list - No custom buttons yet - Empty slot - Drag to reorder - Import / Export - Back up or share custom buttons and their Lua scripts as an XML file - Import - Export - Imported %1$d buttons - Buttons exported - Unable to read or write the selected file - Lua scripts - New script button - Edit script button - Button title - For example: Change quality - Short press Lua - Long press Lua - Run on MPV startup - Enable button - ON - OFF - Delete button - Delete “%1$s”? - Custom button saved - You can add up to 8 custom buttons - Invalid custom button id - Trigger - Click - Long press - On startup - Enter Lua script - New script - Choose how to add a script. Enable its player button later in script settings. - Create from text - Open the text editor to enter and save the Lua script - Script settings - Enable script - Set the name, enabled state, and trigger - Open editor - Import script from URL - Download and save a remote Lua/JS script - Import script file - Choose a Lua/JS script file from this device MPV config saved. Reopen playback to apply it. MPV config is empty Invalid mpv.conf file @@ -1302,6 +1227,7 @@ Audio software decode Audio passthrough Video software decode + FFmpeg mode Prefer aac track Music background notification Audiobook background notification @@ -1323,8 +1249,7 @@ Continue preload while paused Wi-Fi only Always - Auto play VOD - Switch live source on failure + Auto play Auto source change Remember playback brightness Failure fallback @@ -1333,11 +1258,7 @@ On-screen display Auto subtitle match Preferred subtitle language - Environment variables (JSON) - Enter a valid JSON object - Subtitle sources - %1$d loaded - No subtitle sources available + ASSRT token AI subtitle max concurrency AI subtitle chunks Subtitle matched: %1$s @@ -1636,9 +1557,8 @@ The selected update source is not available for this release. - An unexpected error occurred.\nRestarting automatically without reloading the interface. + An unexpected error occurred.\nSorry for the inconvenience. Restart app - Silent restart Error details Error details App version: %1$s\n\n%2$s @@ -1800,19 +1720,6 @@ Choose source from search - - Off - Auto - Confirm - - - VOD config failover - Interface load failed - Current: %1$s - Trying next interface: %1$s - Switch - Interface switch cancelled - Off Auto skip @@ -1831,14 +1738,11 @@ Follow system - Smallest - Compact - Slightly compact Standard - Slightly large - Large - Larger - Largest + Mild compact + Compact + More compact + Smaller @@ -2229,19 +2133,12 @@ Add rule manually Source rules pending review (%d) Import ad rules from source - Import only (keep disabled) - Import and enable selected - Select low risk - Select all - Invert selection + Import (disabled by default) Ignore all No source rules pending review Custom rules Rules from source config No rules - Enable all imported source rules - Disable all imported source rules - Temporarily disable all imported source rules? You can re-enable them together at any time. Delete rule Delete “%1$s”? Imported source rules can be re-imported from the pending review list. From config · none @@ -2281,42 +2178,12 @@ Total blocked AI reports AI success rate - Sites - Rules - Pipelines - Ad blocking: %1$d total · %2$s + Top blocked sites + Top matching rules Reset stats Reset all statistics? No statistics yet - No logs match the filters Overview - Block log - Charts - Site name - Site domain - Block rule - Ad domain - Blocked at - Segment start time - Segment end time - Segment duration - Title - Line name - Episode name - Pipeline - Unknown - Filter - Search filter values - Select all - Clear selection - Apply - Cancel - No filter values - %d selected - Site overview - Sites involved - Most blocked site - Share of blocks times %.1f%% Site @@ -2330,6 +2197,34 @@ Allowlist (regex) (none) Apply and block + Ad interval %1$s – %2$s + Length %1$.1fs + Skipped for this playback + Recorded. The interval already played, so nothing was skipped. + start: marked by you + start: inferred from segment discontinuity + start: inferred from a cross-domain segment switch + start: reused from an audio detection + start: estimated by rewinding %1$ds + Analyzing… + Add an HLS segment rule + Block the ad domain + Add a URL pattern rule + Turn on an existing rule + No reusable rule could be derived + risk: low + risk: medium + risk: high + Evidence + Diagnostics + Save rule + Close + Ad start marked. Press again at the end. + Marking cancelled + Cannot report an ad at this position + Rule saved and active + Nothing to save for this interval + Could not save the rule AI/custom %1$d · source config %2$d · built-in HLS %3$d Built-in HLS · v%1$d · %2$s Enabled @@ -2710,27 +2605,6 @@ Speech ad recognition Ad keywords %1$d keywords - Speech rules - No speech rules - %1$d rules · %2$s - Rule settings error: %1$s - Manage speech rules - Edit rules - Import text rules - Clear custom rules - View built-in rules - Clear the current custom speech rules? - Custom speech rules cleared - Speech rules saved - Imported %1$d speech rules - Speech rule import failed: %1$s - none - built-in - edited - imported - built-in + custom - Built-in speech rules - %1$s · %2$d rules Skip duration %1$d seconds Skip mode @@ -2774,66 +2648,4 @@ Revision %1$d · %2$d rules Never fetched The rule source must start with https:// - Set a community rule source first - Test configuration - Configuration test - TMDB API: connected and returned valid data (%1$d ms) - TMDB API: failed (%1$s, %2$d ms) - Image domain: connected and returned a valid image (%1$d ms) - Image domain: failed (%1$s, %2$d ms) - OMDb API: connected and returned valid data (%1$d ms) - OMDb API: failed (%1$s, %2$d ms) - Search by site name - Following - Following - Following - Follow - Add to following - Following - Following updates - New episode updates for followed shows - %1$s has a new episode - Official release: episode %1$d; current source: episode %2$d - Check updates - Check - Checking… - Update check complete - Update check failed. Previous data was kept. - No followed shows yet. Open a series detail page and tap “Add to following”. - %1$d followed · %2$d unread - Continue - Mark read - Notify on - Notify off - Source - Unfollow - Unfollow this show? - The viewing history and TMDB progress will be kept. - Official: episode %1$d - Official status unknown - Current source: episode %1$d - Current source not checked - Watched to episode %1$d - %1$d episodes remaining - Up to date - New %1$d - Following is currently disabled. - Notifications were not authorized; in-app badges still work. - Import subscriptions - Server address (http/https) - Token (not stored) - Preview - Import - Enter a valid http/https server address - No importable subscriptions found - Subscriptions to import - Imported %1$d, updated %2$d - Updates only - Show all - No followed shows have unwatched updates. - Unfinished - Ended - Check failed - Following updates - Follow new season S%1$d - + Set a community rule source first diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 748ef9e19a..43c6dc9530 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -55,15 +55,22 @@ - - - - - diff --git a/app/src/main/res/values/styles_disc_menu.xml b/app/src/main/res/values/styles_disc_menu.xml deleted file mode 100644 index ab90c144b7..0000000000 --- a/app/src/main/res/values/styles_disc_menu.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HomeActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HomeActivity.java index b1ce5cdd57..cae76e7567 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HomeActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HomeActivity.java @@ -29,9 +29,6 @@ import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.event.ServerEvent; import com.fongmi.android.tv.event.StateEvent; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingScheduler; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.lab.LabActivity; import com.fongmi.android.tv.lab.LabConfig; @@ -53,7 +50,6 @@ import com.fongmi.android.tv.ui.fragment.SettingPersonalFragment; import com.fongmi.android.tv.ui.fragment.SettingPlayerFragment; import com.fongmi.android.tv.ui.fragment.SettingSubtitleFragment; import com.fongmi.android.tv.ui.fragment.VodFragment; -import com.fongmi.android.tv.utils.CrashRestartMode; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.MobileWindow; import com.fongmi.android.tv.utils.Notify; @@ -110,8 +106,6 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt mBinding.getRoot().addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> checkWindowShape(right - left, bottom - top)); mBinding.navigation.setOnItemSelectedListener(this); PermissionUtil.requestFile(this, allGranted -> PermissionUtil.requestNotify(this)); - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); initFragment(savedInstanceState); initConfig(); } @@ -119,9 +113,7 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt @Override protected void onResume() { super.onResume(); - if (mBinding.navigation.getMenu().findItem(R.id.lab).isVisible() != LabConfig.get().getNavEntry() - || mBinding.navigation.getMenu().findItem(R.id.following).isVisible() != FollowingSettings.isEnabled()) setNavigation(); - updateFollowingBadge(); + if (mBinding.navigation.getMenu().findItem(R.id.lab).isVisible() != LabConfig.get().getNavEntry()) setNavigation(); } @Override @@ -184,11 +176,6 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt } private void initConfig() { - if (CrashRestartMode.consume()) { - checkAction(getIntent()); - StateEvent.empty(); - return; - } VodConfig.get().config(mStartupConfig == null ? Config.vod() : mStartupConfig).load(getCallback()); LiveConfig.get().init().load(); WallConfig.get().init(); @@ -225,8 +212,6 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt mBinding.navigation.getMenu().findItem(R.id.setting).setVisible(true); mBinding.navigation.getMenu().findItem(R.id.lab).setVisible(LabConfig.get().getNavEntry()); mBinding.navigation.getMenu().findItem(R.id.live).setVisible(LiveConfig.hasUrl()); - mBinding.navigation.getMenu().findItem(R.id.following).setVisible(FollowingSettings.isEnabled()); - updateFollowingBadge(); syncNavigationSelection(); } @@ -277,6 +262,10 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt } } + @Subscribe(threadMode = ThreadMode.MAIN) + public void onRefreshEvent(RefreshEvent event) { + if (event.getType() == RefreshEvent.Type.THEME) recreate(); + } @Subscribe(threadMode = ThreadMode.MAIN) public void onServerEvent(ServerEvent event) { @@ -295,35 +284,9 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt } if (item.getItemId() == R.id.vod) return changeFragment(0); if (item.getItemId() == R.id.live) return openLive(); - if (item.getItemId() == R.id.following) { - FollowingActivity.start(this, null); - return false; - } return false; } - private void updateFollowingBadge() { - if (mBinding == null || mBinding.navigation.getMenu().findItem(R.id.following) == null) return; - if (!FollowingSettings.isEnabled()) { - mBinding.navigation.removeBadge(R.id.following); - return; - } - applyFollowingBadge(FollowingPlaybackBridge.cachedUnreadCount()); - FollowingPlaybackBridge.refreshUnreadCountAsync(unread -> { - if (!isFinishing() && !isDestroyed()) applyFollowingBadge(unread); - }); - } - - private void applyFollowingBadge(int unread) { - if (mBinding == null || mBinding.navigation.getMenu().findItem(R.id.following) == null) return; - if (!FollowingSettings.isEnabled()) { - mBinding.navigation.removeBadge(R.id.following); - return; - } - if (unread > 0) mBinding.navigation.getOrCreateBadge(R.id.following).setNumber(unread); - else mBinding.navigation.removeBadge(R.id.following); - } - private void selectNavigation(int position) { int itemId = position == 0 ? R.id.vod : R.id.setting; if (mBinding.navigation.getSelectedItemId() == itemId) changeFragment(position); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java index db265aa645..a0d4b10284 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java @@ -63,7 +63,6 @@ import com.fongmi.android.tv.impl.LiveListener; import com.fongmi.android.tv.impl.PassListener; import com.fongmi.android.tv.model.LiveViewModel; import com.fongmi.android.tv.player.PlayerHelper; -import com.fongmi.android.tv.player.LiveSourceFallbackPolicy; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.Source; import com.fongmi.android.tv.player.VideoAspectMode; @@ -73,7 +72,6 @@ import com.fongmi.android.tv.setting.LiveEpgSetting; import com.fongmi.android.tv.setting.LiveSetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.CustomCspSetting; -import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.ui.adapter.ChannelAdapter; import com.fongmi.android.tv.ui.helper.PipExitDecision; import com.fongmi.android.tv.ui.adapter.EpgDataAdapter; @@ -82,7 +80,6 @@ import com.fongmi.android.tv.ui.custom.CustomKeyDown; import com.fongmi.android.tv.ui.custom.CustomSeekView; import com.fongmi.android.tv.ui.custom.PlayerOsdController; import com.fongmi.android.tv.ui.dialog.CastDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.ui.dialog.HistoryDialog; import com.fongmi.android.tv.ui.dialog.InfoDialog; import com.fongmi.android.tv.ui.dialog.LiveControlDialog; @@ -114,7 +111,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private static final int LIVE_PIP_WIDTH = 16; private static final int LIVE_PIP_HEIGHT = 9; private static final long PLAYBACK_END_RETRY_DELAY = 500; - private static final long LIVE_BUFFERING_TIMEOUT = 15000; private static final String ORIENTATION_TAG = "LiveOrientation"; private ActivityLiveBinding mBinding; @@ -137,10 +133,8 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private Runnable mR2; private Runnable mR3; private Runnable mEndRetry; - private Runnable mBufferingTimeout; private boolean rotate; private int count; - private boolean mFailedThisSession; private PiP mPiP; private boolean mKeepPlaybackAfterPipExit; private OneShotPreDrawListener pipEntryListener; @@ -243,11 +237,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } } - @Override - protected boolean shouldAutoPlay() { - return true; - } - @Override protected void onCreate(Bundle savedInstanceState) { requestOrientation("launch", getLaunchOrient()); @@ -258,7 +247,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List @Override protected void initView(Bundle savedInstanceState) { super.initView(savedInstanceState); - applyPlaybackOverlay(); mKeyDown = CustomKeyDown.create(this, mBinding.exo); captureLiveListBasePadding(); setupWindowInsets(); @@ -269,7 +257,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mHides = new ArrayList<>(); mR1 = this::hideControl; mR2 = this::setTraffic; - mBufferingTimeout = this::startFlow; mR3 = this::hideInfo; mEndRetry = this::checkNext; mPiP = new PiP(); @@ -292,18 +279,12 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mBinding.exo.post(() -> applyLiveResizeMode(LiveSetting.getScale())); } - private void applyPlaybackOverlay() { - mBinding.control.getRoot().setBackgroundResource(R.color.transparent); - mBinding.control.bottom.setBackgroundResource(Setting.isPlaybackOverlayEnabled() ? R.drawable.shape_controller_scrim : R.color.transparent); - } - @Override @SuppressLint("ClickableViewAccessibility") protected void initEvent() { mBinding.control.back.setOnClickListener(view -> onBack()); mBinding.control.cast.setOnClickListener(view -> onCast()); mBinding.control.info.setOnClickListener(view -> onInfo()); - mBinding.control.osdDiagnostics.setOnClickListener(view -> onPlayParams()); mBinding.control.play.setOnClickListener(view -> checkPlay()); mBinding.control.next.setOnClickListener(view -> nextChannel()); mBinding.control.prev.setOnClickListener(view -> prevChannel()); @@ -324,7 +305,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mBinding.control.action.player.setOnClickListener(view -> onPlayerKernel()); mBinding.control.action.player.setOnLongClickListener(view -> onPlayerKernelLong()); mBinding.control.action.decode.setOnClickListener(view -> onDecode()); - mBinding.control.action.playParams.setOnClickListener(view -> onPlayParams()); mBinding.control.action.text.setOnLongClickListener(view -> onTextLong()); mBinding.control.action.speed.setOnLongClickListener(view -> onSpeedLong()); mBinding.control.action.getRoot().setOnTouchListener(this::onActionTouch); @@ -471,7 +451,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } private void checkLive() { - if (LiveConfig.isEmpty()) { + if (isEmpty()) { LiveConfig.get().init().load(getCallback()); } else { getLive(); @@ -493,18 +473,13 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } private void getLive() { - Live live = getHome(); - if (!live.getGroups().isEmpty()) renderLive(live); - mViewModel.parse(live); + renderLive(getHome()); + mViewModel.parse(getHome()); showProgress(); } private void renderLive(Live live) { - if (live == null || live.getGroups().isEmpty()) { - if (LiveSetting.isSourceFallback()) startFlow(); - return; - } - if (liveMenuRendered) return; + if (live == null || live.getGroups().isEmpty() || liveMenuRendered) return; liveMenuRendered = true; mViewModel.parseXml(live); setGroup(live); @@ -786,12 +761,9 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void onSpeed() { if (!player().isVod()) return; - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || !player().isVod()) return; - mBinding.control.action.speed.setText(player().setSpeed(speed)); - PlayerSetting.putDefaultSpeed(player().getSpeed()); - setR1Callback(); - }); + mBinding.control.action.speed.setText(player().addSpeed()); + PlayerSetting.putDefaultSpeed(player().getSpeed()); + setR1Callback(); } private boolean onSpeedLong() { @@ -957,7 +929,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void showControl() { if (service() == null || isInPictureInPictureMode()) return; - setPlayParamsState(); boolean embedded = isEmbeddedLiveUi(); if (!embedded && isVisible(mBinding.recycler)) hideUI(false); mBinding.control.info.setVisibility(player().isEmpty() ? View.GONE : View.VISIBLE); @@ -981,22 +952,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List App.removeCallbacks(mR1); } - private void onPlayParams() { - if (mOsd == null) return; - boolean visible = !mOsd.isDiagnosticsVisible(); - PlayerSetting.putOsdDiagnostics(visible); - mOsd.setDiagnosticsVisible(visible); - setPlayParamsState(); - hideControl(); - } - - private void setPlayParamsState() { - boolean visible = mOsd != null && mOsd.isDiagnosticsVisible(); - mBinding.control.action.playParams.setSelected(visible); - mBinding.control.osdDiagnostics.setSelected(visible); - mBinding.control.osdDiagnostics.setAlpha(visible ? 1f : 0.6f); - } - private void showInfo() { mBinding.widget.infoPip.setVisibility(isInPictureInPictureMode() ? View.VISIBLE : View.GONE); mBinding.widget.info.setVisibility(isInPictureInPictureMode() ? View.GONE : View.VISIBLE); @@ -1271,8 +1226,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void fetch(EpgData item) { App.removeCallbacks(mEndRetry); if (mChannel == null) return; - App.removeCallbacks(mBufferingTimeout); - App.post(mBufferingTimeout, LIVE_BUFFERING_TIMEOUT); playbackCatchup = true; mViewModel.getUrl(mChannel, item); if (service() != null) { @@ -1285,8 +1238,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void fetch() { App.removeCallbacks(mEndRetry); if (mChannel == null) return; - App.removeCallbacks(mBufferingTimeout); - App.post(mBufferingTimeout, LIVE_BUFFERING_TIMEOUT); playbackCatchup = false; LiveConfig.get().setKeep(mChannel); mViewModel.getUrl(mChannel); @@ -1468,25 +1419,14 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List setTrackVisible(); } - @Override - protected boolean onSourceHttpError(int statusCode, String msg) { - if (!LiveSetting.isSourceFallback()) return false; - onError(msg); - return true; - } - @Override protected void onError(String msg) { - App.removeCallbacks(mBufferingTimeout); Track.delete(player().getKey()); player().resetTrack(); player().reset(); player().stop(); - if (!mFailedThisSession) { - mFailedThisSession = true; - showError(msg); - startFlow(); - } + showError(msg); + startFlow(); } @Override @@ -1513,14 +1453,12 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List protected void onStateChanged(int state) { switch (state) { case Player.STATE_BUFFERING: - mFailedThisSession = false; showProgress(); break; case Player.STATE_READY: hideProgress(); checkControl(); player().reset(); - mFailedThisSession = false; break; case Player.STATE_ENDED: checkEnded(); @@ -1546,7 +1484,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List @Override protected void onPlayingChanged(boolean isPlaying) { - if (isPlaying) App.removeCallbacks(mBufferingTimeout); if (isPlaying || isPaused()) updatePlayControl(isPlaying); } @@ -1590,6 +1527,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List @Override public void setLive(Live item) { + if (item.isSelected()) item.getGroups().clear(); LiveConfig.get().setHome(item); player().reset(); player().clear(); @@ -1655,16 +1593,8 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } private void startFlow() { - Live next = LiveSetting.isSourceFallback() ? LiveConfig.getNextHome() : null; - LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decide( - LiveSetting.isChange(), - LiveSetting.isSourceFallback(), - mChannel != null, - mChannel == null || mChannel.isLast(), - mChannel == null || mChannel.isOnly(), - next != null); - if (action == LiveSourceFallbackPolicy.Action.NEXT_LINE) nextLine(true); - else if (action == LiveSourceFallbackPolicy.Action.NEXT_SOURCE) setLive(next); + if (mChannel == null || !LiveSetting.isChange()) return; + if (!mChannel.isLast()) nextLine(true); } private boolean prevGroup() { @@ -2225,7 +2155,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mOsd.setDiagnosticsVisible(PlayerSetting.isOsdDiagnostics()); mOsd.start(); } - setPlayParamsState(); setAudioOnly(false); mPiP.resetAudioMode(); setStop(false); @@ -2255,7 +2184,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List Source.get().exit(); App.removeCallbacks(mR1, mR2, mR3); App.removeCallbacks(mEndRetry); - App.removeCallbacks(mBufferingTimeout); if (mOsd != null) mOsd.release(); mViewModel.url().removeObserver(mObserveUrl); mViewModel.epg().removeObserver(mObserveEpg); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java index 9395bd3c99..743845da0c 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java @@ -27,7 +27,6 @@ import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewConfiguration; -import android.view.ViewParent; import android.view.WindowManager; import android.view.animation.DecelerateInterpolator; import android.widget.ImageView; @@ -45,11 +44,9 @@ import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.C; -import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; import androidx.media3.common.VideoSize; -import androidx.media3.mpvplayer.MpvPlayer; import androidx.media3.ui.PlayerView; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; @@ -65,8 +62,11 @@ import com.fongmi.android.tv.api.DanmakuApi; import com.fongmi.android.tv.api.SiteApi; import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.AdDetectionRequest; -import com.fongmi.android.tv.bean.AdDetectionResult; +import com.fongmi.android.tv.ad.feedback.AdFeedbackController; +import com.fongmi.android.tv.ad.feedback.AdFeedbackHostAdapter; +import com.fongmi.android.tv.ad.feedback.AdFeedbackSession; +import com.fongmi.android.tv.ad.feedback.AdRulePlanApplier; +import com.fongmi.android.tv.ui.dialog.AdFeedbackDialog; import com.fongmi.android.tv.bean.AiConfig; import com.fongmi.android.tv.bean.CastVideo; import com.fongmi.android.tv.bean.Danmaku; @@ -84,30 +84,19 @@ import com.fongmi.android.tv.bean.Track; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbSourcePayload; import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.databinding.ActivityVideoBinding; import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.event.CastEvent; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingSource; import com.fongmi.android.tv.impl.CustomTarget; import com.fongmi.android.tv.model.SiteViewModel; import com.fongmi.android.tv.playback.PlaybackEventCollector; import com.fongmi.android.tv.playback.HistoryResumePayload; import com.fongmi.android.tv.playback.PlaybackOrientation; -import com.fongmi.android.tv.playback.SubtitleRestoreCoordinator; import com.fongmi.android.tv.player.IntroSkipKinds; import com.fongmi.android.tv.player.IntroSkipPlayback; -import com.fongmi.android.tv.player.PlaybackAutoContext; -import com.fongmi.android.tv.player.PlaybackExperimentPolicy; -import com.fongmi.android.tv.player.PlaybackResourceClassifier; import com.fongmi.android.tv.player.PlayerHelper; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.engine.PlayerEngine; @@ -115,23 +104,17 @@ import com.fongmi.android.tv.player.engine.PlaySpec; import com.fongmi.android.tv.player.lut.LutPreset; import com.fongmi.android.tv.player.lut.LutSetting; import com.fongmi.android.tv.player.lut.LutStore; -import com.fongmi.android.tv.service.AiAdDetectionService; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.service.PersonalRecommendationService; import com.fongmi.android.tv.service.IntroSkipService; import com.fongmi.android.tv.setting.DanmakuSetting; import com.fongmi.android.tv.setting.PlayerButtonSetting; import com.fongmi.android.tv.setting.MultiThreadProxySetting; -import com.fongmi.android.tv.setting.PlaybackExperimentSetting; -import com.fongmi.android.tv.setting.PreloadSetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.ui.dialog.PlayerKernelDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.setting.SiteHealthStore; -import com.fongmi.android.tv.setting.DetailRuntimeModePolicy; import com.fongmi.android.tv.setting.TmdbSitePolicy; -import com.fongmi.android.tv.setting.TmdbSourceState; import com.fongmi.android.tv.title.MediaTitleLearningExample; import com.fongmi.android.tv.title.MediaTitleRequest; import com.fongmi.android.tv.subtitle.SubtitlePlaybackSession; @@ -175,15 +158,10 @@ import com.fongmi.android.tv.ui.helper.SourceEpisodeSeasonCache; import com.fongmi.android.tv.ui.helper.EpisodeRangePolicy; import com.fongmi.android.tv.ui.helper.PlayerControlFocusHelper; import com.fongmi.android.tv.ui.helper.TmdbNavigation; -import com.fongmi.android.tv.ui.helper.TmdbBundle; -import com.fongmi.android.tv.ui.helper.TmdbSourceAdapter; -import com.fongmi.android.tv.ui.helper.TmdbSourceAvailability; -import com.fongmi.android.tv.ui.helper.TmdbSourcePayloadParser; import com.fongmi.android.tv.ui.helper.VodEventGuard; import com.fongmi.android.tv.ui.player.VodPlayerChrome; import com.fongmi.android.tv.ui.player.VodPlayerUiController; import com.fongmi.android.tv.ui.player.VodPlayerUiHost; -import com.fongmi.android.tv.ui.player.ShortDramaQueueCoordinator; import com.fongmi.android.tv.utils.ActivityLaunch; import com.fongmi.android.tv.utils.AudioUtil; import com.fongmi.android.tv.utils.Clock; @@ -243,7 +221,6 @@ import com.fongmi.android.tv.player.lyrics.LyricsLine; import com.fongmi.android.tv.player.lyrics.LyricsRepository; import com.fongmi.android.tv.player.lyrics.LyricsRequest; import com.fongmi.android.tv.player.lyrics.LyricsResult; -import com.fongmi.android.tv.player.mpv.MpvConfigStore; import com.fongmi.android.tv.setting.LyricsSetting; import com.fongmi.android.tv.ui.custom.AudioPlayerBackgroundDrawable; import com.fongmi.android.tv.ui.custom.KaraokeResultView; @@ -321,8 +298,6 @@ private String mInlineLyrics; private long mLyricsLoopLastPlayerPosition = C.TIME_UNSET; private boolean mLyricsLoopLastPlaying; private String mPlaybackEpisodeKey; -private int runtimeDetailMode = Setting.getDetailOpenMode(); -private boolean runtimeSourceOnly; private String mArtworkRequestUrl; private String mArtworkRequestOwner; private Vod mPendingDetailVod; @@ -332,18 +307,7 @@ private int playerContentRequestId; private String playerContentKey = ""; private String playerContentFlag = ""; private String playerContentEpisode = ""; - private Result mAppliedPlayerResult; - private final ShortDramaQueueCoordinator mShortDramaQueue = new ShortDramaQueueCoordinator(); - private PlaySpec mShortDramaQueuedSpec; - private Result mShortDramaQueuedResult; - private Episode mShortDramaQueuedEpisode; - private boolean mShortDramaQueueSessionActive; - private boolean mShortDramaQueueFallbackUsed; - private boolean mShortDramaEndedFallbackScheduled; - private long mShortDramaTransitionPositionMs = C.TIME_UNSET; - private long mShortDramaTransitionDurationMs = C.TIME_UNSET; - private final Runnable mShortDramaEndedFallback = this::fallbackShortDramaQueued; -private long mInitialPlaybackPosition = C.TIME_UNSET; +private Result mAppliedPlayerResult; private AudioPlaybackResolver.Resolved mImmersiveAudioResolved; private int mAudioArtworkColor = Color.rgb(55, 45, 68); private final Map mAudioQueueFlags = new HashMap<>(); @@ -376,7 +340,6 @@ private int mAudioBackgroundRandomNonce; private static final String EXTRA_RESUME_HISTORY_CID = "resume_history_cid"; private static final String EXTRA_RESUME_HISTORY_KEY = "resume_history_key"; private static final String EXTRA_TMDB_VOD_CACHE_KEY = "tmdb_vod_cache_key"; - private static final String EXTRA_DETAIL_RUNTIME_MODE = "detail_runtime_mode"; private static final String EXTRA_TMDB_DETAIL_THEME = "tmdb_detail_theme"; private static final String EXTRA_IMMERSIVE_AUDIO_CACHE_KEY = "immersive_audio_cache_key"; private static final String EXTRA_SEARCH_KEYWORD = "search_keyword"; @@ -413,13 +376,6 @@ private int mAudioBackgroundRandomNonce; private PersonalRecommendationService.RecommendationPage mNativePersonalDoubanPage; private PersonalRecommendationService.RecommendationPage mNativePersonalAiPage; private Map mActionButtons; - private final List mCustomActionViews = new ArrayList<>(); - private HorizontalScrollView mCustomLeftButtons; - private HorizontalScrollView mCustomRightButtons; - private HorizontalScrollView mCustomPortraitButtons; - private LinearLayout mCustomLeftButtonRow; - private LinearLayout mCustomRightButtonRow; - private LinearLayout mCustomPortraitButtonRow; private SiteViewModel mViewModel; private FlagAdapter mFlagAdapter; private VodPlayerUiController mPlayerUi; @@ -451,7 +407,7 @@ private int mAudioBackgroundRandomNonce; private int deferredFullscreenOrientation = Configuration.ORIENTATION_UNDEFINED; private int mEpisodeSpanCount; private int mEpisodeBottomInset; - private int mEpisodeMaxHeight = -1; + private int mEpisodeMaxHeight; private Runnable mR1; private Runnable mR2; private Runnable mR3; @@ -459,17 +415,11 @@ private int mAudioBackgroundRandomNonce; private Runnable mSeekProgressFallback; private Runnable mTmdbDetailTimeout; private Clock mClock; - private MpvPlayer mDiscMenuPlayer; - private MpvPlayer mCustomButtonPlayer; - private final Runnable mCustomButtonStateListener = this::updateCustomButtonStates; - private final Runnable mDiscMenuStateListener = this::updateDiscMenuTools; private PiP mPiP; private String mContextWallUrl; private String mContextWallLockedUrl; private String playHealthKey; private long detailStartTime; - private int followingUiGeneration; - private boolean followingActionPending; private long playerStartTime; private long pendingResumeSeekMs = C.TIME_UNSET; private boolean tmdbHistoryResumePending; @@ -500,7 +450,11 @@ private int mAudioBackgroundRandomNonce; private TmdbItem mPendingTmdbSeasonChoice; private int mPersonalRecommendationGeneration; private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.recommendationExecutor()); - private int mAdFeedbackGeneration; + private AdFeedbackController mAdFeedback; + private AdFeedbackHostAdapter mAdFeedbackHost; + private AdFeedbackDialog mAdFeedbackDialog; + /** 标记模式下已打的起点,-1 表示未进入标记模式。 */ + private long mAdMarkStartMs = -1; private final List mTmdbMovedViews = new ArrayList<>(); private boolean pendingLutImport; private boolean skipPausePiP; @@ -622,25 +576,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private static boolean shouldOpenLegacyTmdbDetail(String key, String id) { int mode = Setting.getDetailOpenMode(); - return canOpenLegacyTmdbDetail(key, id) && Setting.isTmdbDetailModeConfigured() && Setting.isStandaloneTmdbDetailMode(mode); - } - - public static void startDirectResolved(Activity activity, Vod vod) { - if (activity == null || vod == null) return; - Intent intent = new Intent(activity, VideoActivity.class); - intent.putExtra("detail_mode", Setting.DETAIL_OPEN_DIRECT); - intent.putExtra(EXTRA_DETAIL_RUNTIME_MODE, Setting.DETAIL_OPEN_DIRECT); - intent.putExtra("scan", false); - intent.putExtra("collect", false); - intent.putExtra("cast", false); - intent.putExtra("key", vod.getSiteKey()); - intent.putExtra("id", vod.getId()); - intent.putExtra("name", vod.getName()); - intent.putExtra("pic", vod.getPic()); - intent.putExtra("mark", vod.getRemarks()); - intent.putExtra("content", vod.getContent()); - putDetailVodCache(intent, vod); - activity.startActivity(intent); + return canOpenLegacyTmdbDetail(key, id) && Setting.isTmdbDetailPage() && Setting.isStandaloneTmdbDetailMode(mode); } public static void start(Activity activity, String url) { @@ -1092,7 +1028,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco ? getString(R.string.detail_episode_season_context, season) : getString(R.string.detail_episode)); boolean selectable = isTmdbSourceEnabled() - && !runtimeSourceOnly && mTmdbUIAdapter != null && mTmdbUIAdapter.getTmdbItem() != null && mTmdbUIAdapter.getTmdbItem().isTv() @@ -1188,49 +1123,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return getIntent().getBooleanExtra("tmdbMode", false); } - private void initializeRuntimeDetailMode() { - runtimeDetailMode = getIntent().getIntExtra(EXTRA_DETAIL_RUNTIME_MODE, Setting.getDetailOpenMode()); - runtimeSourceOnly = false; - } - - private void applyRuntimeDetailMode(int mode) { - runtimeDetailMode = Setting.isTmdbMode(mode) || mode == Setting.DETAIL_OPEN_DIRECT ? mode : Setting.DETAIL_OPEN_DIRECT; - } - - private int runtimeDetailMode() { - return runtimeDetailMode; - } - - private int policyConfiguredMode() { - int override = getIntent().getIntExtra(EXTRA_DETAIL_RUNTIME_MODE, Integer.MIN_VALUE); - return override == Setting.DETAIL_OPEN_DIRECT ? Setting.DETAIL_OPEN_DIRECT : Setting.getDetailOpenMode(); - } - - private boolean isRuntimeFusionMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_FUSION || getIntent().getBooleanExtra("fusion", false); - } - - private boolean isRuntimeOriginalEnhancedMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_ORIGINAL_ENHANCED; - } - - private boolean isRuntimeDirectMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_DIRECT; - } - - private void applySourceOnlyActionVisibility() { - if (!runtimeSourceOnly || mTmdbHeaderView == null || mTmdbHeaderView.getHeaderRoot() == null) return; - View rematch = mTmdbHeaderView.getHeaderRoot().findViewById(R.id.tmdbRematch); - if (rematch != null) { - rematch.setVisibility(View.GONE); - rematch.setClickable(false); - rematch.setFocusable(false); - } - } - private boolean isTmdbSourceEnabled() { if (isTmdbMode()) return true; - if (!Setting.isTmdbMode(runtimeDetailMode())) return false; + if (!Setting.isTmdbMode(Setting.getDetailOpenMode())) return false; + if (!Setting.isTmdbEnabled()) return false; return TmdbSitePolicy.isEnabled(getKey(), getId()); } @@ -1239,12 +1135,11 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean hasTmdbDetailAdapter() { - return isTmdbSourceEnabled() && mTmdbHeaderView != null && mTmdbUIAdapter != null - && (mTmdbUIAdapter.isReady() || runtimeSourceOnly); + return isTmdbSourceEnabled() && mTmdbHeaderView != null && mTmdbUIAdapter != null && mTmdbUIAdapter.isReady(); } private boolean shouldLoadTmdbDetail() { - return mTmdbUIAdapter != null && (mTmdbUIAdapter.isReady() || runtimeSourceOnly); + return mTmdbUIAdapter != null && mTmdbUIAdapter.isReady(); } private boolean shouldUseTmdbDetailLayout() { @@ -1252,7 +1147,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean shouldUseTmdbBackdropSurface() { - return !isRuntimeFusionMode() && (isRuntimeOriginalEnhancedMode() || shouldUseTmdbDetailLayout() && (runtimeDetailMode() == Setting.DETAIL_OPEN_ENHANCED || Setting.isTmdbNativeStyle())); + return !Setting.isFusionDetailPage() && (Setting.isOriginalEnhancedDetailPage() || shouldUseTmdbDetailLayout() && (Setting.getDetailOpenMode() == Setting.DETAIL_OPEN_ENHANCED || Setting.isTmdbNativeStyle())); } private com.fongmi.android.tv.bean.TmdbItem getTmdbItem() { @@ -1409,11 +1304,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco protected void onServiceConnected() { player().setDanmakuController(mBinding.exo.getDanmakuController()); applyPendingPlayerKernel(); - // The history kernel can rebuild the service player before a PlaySpec - // exists. In that window PlaybackActivity's ownership guard correctly - // skips its rebuild callback, so refresh the direct progress source - // after the pending kernel has been applied. - getSeekView().setProgressPlayer(player().getPlayer()); syncDesktopLyricsAudioContent(); setPlayerKernel(); setDecode(); @@ -1441,7 +1331,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override protected void onNewIntent(Intent intent) { - invalidateShortDramaQueue("new-intent"); String oldKey = getKey(); String oldId = getId(); super.onNewIntent(intent); @@ -1490,10 +1379,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override protected void initView(Bundle savedInstanceState) { - initializeRuntimeDetailMode(); mTmdbDetailTimeout = this::showTmdbDetailFallback; super.initView(savedInstanceState); - applyPlaybackOverlay(); mRestoringConfigurationPlayback = savedInstanceState != null; ViewCompat.setOnApplyWindowInsetsListener(mBinding.getRoot(), (v, insets) -> setStatusBar(insets)); mKeyDown = CustomKeyDown.create(this, mBinding.exo); @@ -1527,6 +1414,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mR3 = this::setOrient; mR4 = this::showEmpty; mSeekProgressFallback = this::hideSeekProgressIfReady; + checkDanmakuImg(); setRecyclerView(); setVideoView(); setViewModel(); @@ -1548,12 +1436,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco .setTitle(R.string.intro_skip_confirm_title) .setMessage(IntroSkipKinds.confirmMessage(segment)) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> mIntroSkipPlayback.declineConfirmation(segment)) + .setNegativeButton(android.R.string.cancel, null) .show(); - mIntroSkipConfirmDialog.setOnDismissListener(dialog -> { - mIntroSkipPlayback.cancelConfirmation(segment); - if (mIntroSkipConfirmDialog == dialog) mIntroSkipConfirmDialog = null; - }); return true; }); mIntroSkipPlayback.setSkipNoticeListener(IntroSkipKinds::notifySkipped); @@ -1575,7 +1459,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.name.setOnClickListener(view -> onName()); mBinding.more.setOnClickListener(view -> onMore()); mBinding.shortDisplay.setOnClickListener(view -> onShortDisplay()); - if (mBinding.following != null) mBinding.following.setOnClickListener(view -> onFollowing()); mBinding.search.setOnClickListener(view -> onSearch()); mBinding.castAction.setOnClickListener(guarded(this::onCast)); mBinding.settingAction.setOnClickListener(view -> onSetting()); @@ -1603,31 +1486,26 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.right.rotate.setOnClickListener(view -> onRotate()); mBinding.control.right.pip.setOnClickListener(guarded(this::onPiP)); mBinding.control.fullscreen.setOnClickListener(guarded(this::onFullscreen)); - mBinding.control.shortDramaChangeSource.setOnClickListener(view -> onChange()); - mBinding.control.shortDramaQuality.setOnClickListener(guarded(this::onQuality)); - mBinding.control.shortDramaEpisodes.setOnClickListener(guarded(this::onEpisodes)); + mBinding.control.danmaku.setOnClickListener(view -> onDanmakuShow()); mBinding.control.action.text.setOnClickListener(guardedView(this::onTrack)); mBinding.control.action.audio.setOnClickListener(guardedView(this::onTrack)); mBinding.control.action.video.setOnClickListener(guardedView(this::onTrack)); mBinding.control.action.scale.setOnClickListener(guarded(this::onScale)); + mBinding.control.action.actionQuality.setOnClickListener(guarded(this::onQuality)); mBinding.control.action.lut.setOnClickListener(guarded(this::onLut)); - mBinding.control.action.karaoke.setOnClickListener(view -> onKaraokeMode()); mBinding.control.action.speed.setOnClickListener(guarded(this::onSpeed)); mBinding.control.action.reset.setOnClickListener(guarded(this::onReset)); - mBinding.control.action.change2.setOnClickListener(view -> onChange()); mBinding.control.action.title.setOnClickListener(guarded(this::onTitle)); - mBinding.control.action.discMenu.setOnClickListener(view -> { - hideControl(); - openDiscMenu(); - }); - mBinding.control.action.discMenu.setOnLongClickListener(view -> { - hideControl(); - showDiscMenuControls(); - return true; - }); - mBinding.discTools.fullscreen.setOnClickListener(view -> onFullscreen()); mBinding.control.action.player.setOnClickListener(guarded(this::onPlayerKernel)); - mBinding.control.action.player.setOnLongClickListener(view -> onChooseLong()); + mBinding.control.action.player.setOnLongClickListener(view -> onPlayerKernelLong()); + mBinding.control.action.change2.setOnClickListener(view -> onChange()); + mBinding.control.shortDramaChangeSource.setOnClickListener(view -> onChange()); + mBinding.control.shortDramaQuality.setOnClickListener(guarded(this::onQuality)); + mBinding.control.shortDramaEpisodes.setOnClickListener(guarded(this::onEpisodes)); + mBinding.control.action.fullscreen.setOnClickListener(guarded(this::onFullscreen)); + mBinding.control.action.playParams.setOnClickListener(guarded(this::onPlayParams)); + mBinding.control.action.multiThreadProxy.setOnClickListener(guarded(this::onMultiThreadProxy)); + mBinding.control.action.codecCapability.setOnClickListener(guarded(this::onCodecCapabilityPanel)); mBinding.control.action.prev.setOnClickListener(view -> checkPrev()); mBinding.control.action.next.setOnClickListener(view -> checkNext()); mBinding.control.action.decode.setOnClickListener(guarded(this::onDecode)); @@ -1654,17 +1532,16 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.action.danmaku.setOnClickListener(guarded(this::onDanmaku)); mBinding.control.action.adFeedback.setOnClickListener(view -> onAdFeedback()); + mBinding.control.action.adFeedback.setOnLongClickListener(view -> onAdFeedbackLongPress()); mBinding.control.action.episodes.setOnClickListener(view -> onEpisodes()); mBinding.control.action.text.setOnLongClickListener(view -> onTextLong()); mBinding.control.action.speed.setOnLongClickListener(view -> onSpeedLong()); mBinding.control.action.reset.setOnLongClickListener(view -> onResetToggle()); mBinding.control.action.ending.setOnLongClickListener(view -> onEndingReset()); mBinding.control.action.opening.setOnLongClickListener(view -> onOpeningReset()); + mBinding.video.setOnTouchListener((view, event) -> mKeyDown.onTouchEvent(event)); // 控制层显示时会先于 video 容器接收事件,空白区域必须直接转发给手势检测器。 mBinding.control.getRoot().setOnTouchListener(this::onPlayerControlTouch); - mBinding.video.setOnTouchListener((view, event) -> - (!isVisible(mBinding.control.getRoot()) && dispatchDiscMenuTouch(event)) - || mKeyDown.onTouchEvent(event)); mBinding.control.action.getRoot().setOnTouchListener(this::onActionTouch); mBinding.swipeLayout.setOnRefreshListener(this::onSwipeRefresh); } @@ -1689,37 +1566,20 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void updateEpisodeViewportHeight() { - updateEpisodeTouchHandling(); if (mBinding.episode.getVisibility() != View.VISIBLE) return; int limit = ResUtil.isPad() || ResUtil.isLand(this) ? ResUtil.dp2px(328) : ResUtil.dp2px(280); // The episode list lives inside a scroll container, so capping it by the // current on-screen remainder can collapse the viewport to a single row // when the section is laid out below the fold. Keep a stable cap here // and let the parent page handle the rest of the scrolling. - int height = usesOuterEpisodePageScroll() ? 0 : limit; - if (!usesOuterEpisodePageScroll() && isTmdbEpisodeCardMode()) height = Math.max(height, getEpisodeCardMinHeight()); - if (height == mEpisodeMaxHeight) return; + int height = limit; + if (isTmdbEpisodeCardMode()) height = Math.max(height, getEpisodeCardMinHeight()); + if (height <= 0 || height == mEpisodeMaxHeight) return; mEpisodeMaxHeight = height; mBinding.episode.setMaxHeight(height); mBinding.episode.requestLayout(); } - private void updateEpisodeTouchHandling() { - mBinding.episode.setOnTouchListener((view, event) -> { - if (!usesOuterEpisodePageScroll()) return false; - int action = event.getActionMasked(); - if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_MOVE) { - if (view.getParent() != null) view.getParent().requestDisallowInterceptTouchEvent(false); - } - return false; - }); - } - - private boolean usesOuterEpisodePageScroll() { - return isRuntimeOriginalEnhancedMode() - || mTmdbControlsMoved && shouldUseTmdbBackdropSurface(); - } - private boolean isTmdbEpisodeCardMode() { return mEpisodeAdapter != null && !mEpisodeAdapter.isEmpty() && mEpisodeAdapter.isUsingTmdbCard(); } @@ -1870,7 +1730,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco setupActionButtons(); mBinding.video.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { mPiP.update(this, view); - updateCustomButtonLayout(); Log.d(SIZE_TAG, "video layout new=" + (right - left) + "x" + (bottom - top) + " old=" + (oldRight - oldLeft) + "x" + (oldBottom - oldTop) + " fullscreen=" + isFullscreen() @@ -1915,9 +1774,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco addActionButton(PlayerButtonSetting.NEXT, mBinding.control.action.next); addActionButton(PlayerButtonSetting.EPISODES, mBinding.control.action.episodes); applyActionButtonSettings(); - addActionButton(PlayerButtonSetting.CHANGE, mBinding.control.action.change2); - PlayerButtonSetting.applyOrder(mBinding.control.action.container, mActionButtons); - setupCustomActionButtons(); setPlayParamsState(); } @@ -1925,149 +1781,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mActionButtons.put(id, view); } - private void setupCustomActionButtons() { - ensureCustomButtonContainers(); - for (View view : mCustomActionViews) { - ViewParent parent = view.getParent(); - if (parent instanceof ViewGroup) ((ViewGroup) parent).removeView(view); - } - mCustomActionViews.clear(); - mCustomLeftButtonRow.removeAllViews(); - mCustomRightButtonRow.removeAllViews(); - mCustomPortraitButtonRow.removeAllViews(); - List buttons = MpvConfigStore.customButtons(); - boolean landscape = isLand(); - for (int index = 0; index < buttons.size(); index++) { - MpvConfigStore.CustomButton button = buttons.get(index); - if (!button.isButtonVisible()) continue; - TextView view = new TextView(this); - view.setTextSize(13); - view.setTextColor(Color.WHITE); - view.setGravity(Gravity.CENTER); - view.setMinHeight(ResUtil.dp2px(40)); - view.setMinWidth(ResUtil.dp2px(56)); - view.setPadding(ResUtil.dp2px(10), ResUtil.dp2px(4), ResUtil.dp2px(10), ResUtil.dp2px(4)); - view.setBackgroundResource(R.drawable.selector_control_sheet_button); - view.setText(button.title); - view.setSingleLine(true); - view.setMaxWidth(ResUtil.dp2px(144)); - view.setEllipsize(TextUtils.TruncateAt.END); - view.setContentDescription(button.title); - view.setTag(button.id); - view.setOnClickListener(item -> { - player().sendMpvCustomButton(button.id, false); - setR1Callback(); - }); - view.setOnLongClickListener(item -> { - player().sendMpvCustomButton(button.id, true); - setR1Callback(); - return true; - }); - if (Util.isLeanback()) { - view.setFocusable(true); - view.setFocusableInTouchMode(true); - } - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ResUtil.dp2px(40)); - params.setMargins(ResUtil.dp2px(4), ResUtil.dp2px(2), ResUtil.dp2px(4), ResUtil.dp2px(2)); - LinearLayout target = landscape - ? (index < 4 ? mCustomLeftButtonRow : mCustomRightButtonRow) - : mCustomPortraitButtonRow; - target.addView(view, params); - mCustomActionViews.add(view); - } - updateCustomButtonLayout(); - updateCustomButtonVisibility(); - } - - private void updateCustomButtonStates() { - MpvPlayer mpv = service() != null && isOwner() - && player().getPlayer() instanceof MpvPlayer active ? active : null; - if (mCustomButtonPlayer != mpv) { - if (mCustomButtonPlayer != null) mCustomButtonPlayer.removeCustomButtonStateListener(mCustomButtonStateListener); - mCustomButtonPlayer = mpv; - if (mpv != null) mpv.addCustomButtonStateListener(mCustomButtonStateListener); - } - for (View view : mCustomActionViews) { - view.setSelected(mpv != null && mpv.isCustomButtonActive((String) view.getTag())); - } - } - - private void ensureCustomButtonContainers() { - if (mCustomLeftButtons != null) return; - mCustomLeftButtons = createCustomButtonScroll(); - mCustomRightButtons = createCustomButtonScroll(); - mCustomPortraitButtons = createCustomButtonScroll(); - mCustomLeftButtonRow = createCustomButtonRow(mCustomLeftButtons); - mCustomRightButtonRow = createCustomButtonRow(mCustomRightButtons); - mCustomPortraitButtonRow = createCustomButtonRow(mCustomPortraitButtons); - mCustomRightButtons.setFillViewport(true); - mCustomRightButtonRow.setGravity(Gravity.CENTER_VERTICAL | Gravity.END); - mCustomRightButtonRow.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; - - FrameLayout.LayoutParams leftParams = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.START | Gravity.TOP); - FrameLayout.LayoutParams rightParams = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.END | Gravity.TOP); - int margin = ResUtil.dp2px(8); - leftParams.setMargins(margin, 0, margin, 0); - rightParams.setMargins(margin, 0, margin, 0); - mBinding.video.addView(mCustomLeftButtons, leftParams); - mBinding.video.addView(mCustomRightButtons, rightParams); - - RelativeLayout.LayoutParams portraitParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - portraitParams.addRule(RelativeLayout.ABOVE, R.id.bottom); - portraitParams.setMargins(margin, 0, margin, ResUtil.dp2px(2)); - ((ViewGroup) mBinding.control.getRoot()).addView(mCustomPortraitButtons, portraitParams); - } - - private HorizontalScrollView createCustomButtonScroll() { - HorizontalScrollView scroll = new HorizontalScrollView(this); - scroll.setHorizontalScrollBarEnabled(false); - scroll.setFillViewport(false); - scroll.setClipToPadding(false); - scroll.setOverScrollMode(View.OVER_SCROLL_NEVER); - return scroll; - } - - private LinearLayout createCustomButtonRow(HorizontalScrollView scroll) { - LinearLayout row = new LinearLayout(this); - row.setGravity(Gravity.CENTER_VERTICAL); - row.setOrientation(LinearLayout.HORIZONTAL); - row.setClipChildren(false); - scroll.addView(row, new HorizontalScrollView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - return row; - } - - private void updateCustomButtonLayout() { - if (mCustomLeftButtons == null || mBinding.video.getWidth() <= 0) return; - boolean landscape = isLand(); - if (!landscape) return; - - int margin = ResUtil.dp2px(8); - int width = Math.max(ResUtil.dp2px(96), mBinding.video.getWidth() / 2 - margin * 2); - int height = Math.max(mCustomLeftButtons.getMeasuredHeight(), ResUtil.dp2px(40)); - int top = Math.max(margin, Math.round(mBinding.video.getHeight() * 0.65f - height / 2f)); - FrameLayout.LayoutParams leftParams = (FrameLayout.LayoutParams) mCustomLeftButtons.getLayoutParams(); - FrameLayout.LayoutParams rightParams = (FrameLayout.LayoutParams) mCustomRightButtons.getLayoutParams(); - leftParams.width = width; - rightParams.width = width; - leftParams.topMargin = top; - rightParams.topMargin = top; - mCustomLeftButtons.setLayoutParams(leftParams); - mCustomRightButtons.setLayoutParams(rightParams); - } - - private void updateCustomButtonVisibility() { - updateCustomButtonStates(); - boolean visible = service() != null && player().isMpv() && isVisible(mBinding.control.getRoot()); - if (mCustomLeftButtons != null) mCustomLeftButtons.setVisibility(visible && isLand() ? View.VISIBLE : View.GONE); - if (mCustomRightButtons != null) mCustomRightButtons.setVisibility(visible && isLand() ? View.VISIBLE : View.GONE); - if (mCustomPortraitButtons != null) mCustomPortraitButtons.setVisibility(visible && !isLand() ? View.VISIBLE : View.GONE); - updateCustomButtonLayout(); - } - private void applyActionButtonVisibility() { if (mActionButtons != null) PlayerButtonSetting.applyVisibility(mActionButtons); - updateDiscMenuTools(); - updateCustomButtonVisibility(); } private void applyActionButtonSettings() { @@ -2213,9 +1928,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } mPendingPlayerKernel = PlayerSetting.NONE; player().preparePlayer(kernel); - // preparePlayer() is intentionally allowed before playback ownership - // is established; keep the mobile seek view on the replacement player. - getSeekView().setProgressPlayer(player().getPlayer()); setPlayerKernel(); setDecode(); return kernel; @@ -2231,9 +1943,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mPendingPlayerKernel = PlayerSetting.NONE; if (!PlayerSetting.isPlayer(kernel)) return; player().preparePlayer(kernel); - // See applyHistoryPlayerKernel(): this rebuild happens before the - // first media spec and therefore may not reach onPlayerRebuilt(). - getSeekView().setProgressPlayer(player().getPlayer()); } /** @@ -2393,19 +2102,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco item.checkContent(getTmdbVodContent()); item.checkContent(getContent()); applyIntentTmdbVodRemark(item); - TmdbConfig tmdbConfig = TmdbConfig.effectiveCurrent(); - TmdbSourcePayload sourcePayload = TmdbSourcePayloadParser.parse(item.getTmdb()); - TmdbBundle sourceBundle = TmdbSourceAdapter.toBundle(sourcePayload, item, tmdbConfig); - TmdbSourceState sourceState = TmdbSourceAvailability.classify(item, sourcePayload, sourceBundle); - DetailRuntimeModePolicy.Decision decision = DetailRuntimeModePolicy.resolve(new DetailRuntimeModePolicy.Input( - policyConfiguredMode(), - tmdbConfig.isReady(), - TmdbSitePolicy.isEnabled(tmdbConfig, getKey(), getId()), - sourceState - )); - applyRuntimeDetailMode(decision.runtimeMode()); - runtimeSourceOnly = decision.sourceOnly(); - applySourceOnlyActionVisibility(); boolean tmdbMode = shouldLoadTmdbDetail(); mTmdbFallbackToNative = false; mTmdbContentLoaded = false; @@ -2459,18 +2155,13 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco // TMDB 增强:全局开关启用或 Intent 传入 TmdbItem 时触发 if (shouldLoadTmdbDetail()) { mTmdbUIAdapter.setActiveFlag(getFlag()); - if (sourceState == TmdbSourceState.RENDERABLE && sourceBundle != null) { - SpiderDebug.log("tmdb-mobile", "source-first load vodTitle=%s tmdbId=%d media=%s sourceOnly=%s", item.getName(), sourceBundle.item().getTmdbId(), sourceBundle.item().getMediaType(), runtimeSourceOnly); - mTmdbUIAdapter.loadSource(sourceBundle, item, sourcePayload); - } else { - com.fongmi.android.tv.bean.TmdbItem tmdbItem = getTmdbItem(); - if (tmdbItem != null) { + com.fongmi.android.tv.bean.TmdbItem tmdbItem = getTmdbItem(); + if (tmdbItem != null) { // 直接使用传入的 TmdbItem SpiderDebug.log("tmdb-mobile", "direct load vodTitle=%s tmdbTitle=%s tmdbId=%d media=%s", item.getName(), tmdbItem.getTitle(), tmdbItem.getTmdbId(), tmdbItem.getMediaType()); mTmdbUIAdapter.load(tmdbItem, item); - } else { - mTmdbUIAdapter.autoMatch(item.getName(), item, getSearchKeyword()); - } + } else { + mTmdbUIAdapter.autoMatch(item.getName(), item, getSearchKeyword()); } } } @@ -2509,7 +2200,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean canUseTmdbTabletWideLayout() { - return isLand() && ResUtil.isPad() && shouldUseTmdbDetailLayout() && !isRuntimeFusionMode() && mDefaultFrameParams != null; + return isLand() && ResUtil.isPad() && shouldUseTmdbDetailLayout() && !Setting.isFusionDetailPage() && mDefaultFrameParams != null; } private void applyTmdbTabletVideoLayoutIfNeeded() { @@ -2605,7 +2296,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco view.setText(Sniffer.buildClickable(resId > 0 ? getString(resId, text) : text, this::clickableSpan), TextView.BufferType.SPANNABLE); view.setVisibility(text.isEmpty() ? View.GONE : View.VISIBLE); if (view == mBinding.content) setContentVisible(); - view.setLinkTextColor(isRuntimeFusionMode() && isFusionLightTheme() ? 0xFF1D8F5A : Color.WHITE); + view.setLinkTextColor(Setting.isFusionDetailPage() && isFusionLightTheme() ? 0xFF1D8F5A : Color.WHITE); CustomMovement.bind(view); } @@ -2679,6 +2370,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void getPlayer(Flag flag, Episode episode) { + resetAdFeedback(); mBinding.control.title.setText(getPlaybackControlTitle(episode)); playerStartTime = System.currentTimeMillis(); beginPlayHealth(); @@ -2708,283 +2400,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco playerContentEpisode = episode; } - private boolean isShortDramaQueueEligible(Result result) { - if (!PlaybackExperimentSetting.isAllowed(PlaybackExperimentPolicy.Action.EXO_SHORT_DRAMA_QUEUE)) return false; - if (!isShortDramaSource() || service() == null || player() == null || !player().isExo() - || !player().supportsPlaylistQueue() || !PreloadSetting.isPreload(PlayerSetting.EXO)) return false; - if (MultiThreadProxySetting.get().enabled() || player().isRepeatOne() || mHistory == null - || mHistory.getOpening() > 0 || mHistory.getEnding() > 0 - || pendingResumeSeekMs != C.TIME_UNSET || tmdbHistoryResumePending - || isAudioOnly() || shouldUseImmersiveAudio()) return false; - Flag flag = getFlag(); - if (flag == null || flag.getEpisodes().size() < 2 || result == null || result.hasMsg() - || result.getRealUrl().isEmpty() || result.getDrm() != null - || result.needParse() || result.shouldUseParse()) return false; - String url = result.getRealUrl(); - if (com.fongmi.android.tv.player.exo.MediaSourceFactory.isConcatenatingUrl(url)) return false; - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest( - url, null, result.getFormat()); - if (classification.streamKind() == PlaybackAutoContext.StreamKind.LIVE - || classification.streamKind() == PlaybackAutoContext.StreamKind.LOW_LATENCY_LIVE) return false; - return classification.protocol() == PlaybackAutoContext.Protocol.HLS - || classification.protocol() == PlaybackAutoContext.Protocol.PROGRESSIVE_HTTP; - } - - private ShortDramaQueueCoordinator.Episode shortDramaEpisode(Episode episode, int index) { - return new ShortDramaQueueCoordinator.Episode("", episode == null ? "" : episode.getName(), index); - } - - private ShortDramaQueueCoordinator.MediaKind shortDramaMediaKind(String url, String format) { - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest(url, null, format); - return classification.protocol() == PlaybackAutoContext.Protocol.HLS - ? ShortDramaQueueCoordinator.MediaKind.HLS - : ShortDramaQueueCoordinator.MediaKind.PROGRESSIVE_MP4; - } - - private boolean hasShortDramaStoredPosition(Episode episode) { - if (episode == null || skipEpisodePositionCache()) return false; - EpisodePositionCache.EpisodePosition cached = EpisodePositionCache.get().get( - getKey(), getId(), getFlag().getFlag(), episodePositionCacheName(episode, currentSourceSeasonNumber())); - return cached != null && cached.position > 0; - } - - private MediaMetadata buildShortDramaMetadata(Episode episode) { - String title = mHistory == null ? getName() : mHistory.getVodName(); - String name = episode == null ? "" : episode.getName(); - return PlayerManager.buildMetadata(title, name.isEmpty() || title.equals(name) ? "" : name, - mHistory == null ? "" : mHistory.getVodPic()); - } - - private void beginShortDramaQueue() { - Flag flag = getFlag(); - List items = flag == null ? List.of() : flag.getEpisodes(); - Episode current = getEpisode(); - if (flag == null || current == null || items.size() < 2 || !isShortDramaQueueEligible(mAppliedPlayerResult)) return; - int currentIndex = getSelectedEpisodePosition(items); - List descriptors = new ArrayList<>(items.size()); - for (int i = 0; i < items.size(); i++) descriptors.add(shortDramaEpisode(items.get(i), i)); - ShortDramaQueueCoordinator.Session session = new ShortDramaQueueCoordinator.Session( - getId(), getKey(), flag.getFlag(), getEpisodePlayFlag(flag, current), descriptors, - currentIndex, mHistory.isRevPlay()); - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.begin(session); - mShortDramaQueueFallbackUsed = false; - mShortDramaQueueSessionActive = true; - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - resolveShortDramaNext(snapshot); - } - - private void resolveShortDramaNext(ShortDramaQueueCoordinator.Snapshot snapshot) { - if (snapshot == null || snapshot.session() == null || snapshot.queued() != null || mViewModel == null) return; - ShortDramaQueueCoordinator.ResolveRequest request = snapshot.nextRequest(); - if (request == null) return; - int index = request.episode().index(); - List items = getFlag() == null ? List.of() : getFlag().getEpisodes(); - if (index < 0 || index >= items.size()) return; - Episode target = items.get(index); - if (hasShortDramaStoredPosition(target)) return; - String key = snapshot.session().siteKey(); - String playFlag = snapshot.session().playFlag(); - long generation = request.generation(); - mViewModel.playerContentIsolated(key, playFlag, target.getUrl(), player().getPlayerType(), - result -> enqueueShortDramaResult(generation, request.episode(), target, result), - error -> { - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "resolve failed generation=%d type=%s", generation, error == null ? "unknown" : error.getClass().getSimpleName()); - }); - } - - private void enqueueShortDramaResult(long generation, - ShortDramaQueueCoordinator.Episode descriptor, - Episode target, - Result result) { - if (isFinishing() || isDestroyed() || target == null || !isShortDramaQueueEligible(mAppliedPlayerResult)) return; - if (result == null || result.hasMsg() || result.getRealUrl().isEmpty() || result.getDrm() != null - || result.needParse() || result.shouldUseParse()) return; - String url = result.getRealUrl(); - boolean concatenated = com.fongmi.android.tv.player.exo.MediaSourceFactory.isConcatenatingUrl(url); - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest( - url, null, result.getFormat()); - boolean live = classification.streamKind() == PlaybackAutoContext.StreamKind.LIVE - || classification.streamKind() == PlaybackAutoContext.StreamKind.LOW_LATENCY_LIVE; - boolean hls = classification.protocol() == PlaybackAutoContext.Protocol.HLS; - boolean progressive = classification.protocol() == PlaybackAutoContext.Protocol.PROGRESSIVE_HTTP; - ShortDramaQueueCoordinator.ResolvedItem resolved = new ShortDramaQueueCoordinator.ResolvedItem( - url, new HashMap<>(result.getHeader()), result.getFormat(), - hls ? ShortDramaQueueCoordinator.MediaKind.HLS : progressive ? ShortDramaQueueCoordinator.MediaKind.PROGRESSIVE_MP4 : null, - false, result.needParse() || result.shouldUseParse(), live, false, concatenated, false); - ShortDramaQueueCoordinator.QueueItem item = mShortDramaQueue.acceptResolved(generation, descriptor, resolved); - if (item == null || !resolved.isEligible()) return; - PlaySpec spec = PlaySpec.from(result, activePlaybackKey(), buildShortDramaMetadata(target)).checkUa(); - spec.setHeaders(new HashMap<>(result.getHeader())); - if (!player().appendPlaylistItem(spec, item.mediaId())) { - invalidateShortDramaQueue("append-failed"); - return; - } - mShortDramaQueuedSpec = spec; - mShortDramaQueuedResult = result; - mShortDramaQueuedEpisode = target; - player().setPlaylistPreloadDurationMs(5_000L); - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "append generation=%d index=%d kind=%s", generation, target.getIndex(), resolved.kind()); - } - - private void saveShortDramaEpisodeProgress(Episode episode) { - saveShortDramaEpisodeProgress(episode, - mHistory == null ? C.TIME_UNSET : mHistory.getPosition(), - mHistory == null ? C.TIME_UNSET : mHistory.getDuration()); - } - - private void saveShortDramaEpisodeProgress(Episode episode, long position, long duration) { - if (episode == null || mHistory == null || service() == null || !isOwner() || tmdbHistoryResumePending) return; - String cacheName = episodePositionCacheName(episode, currentSourceSeasonNumber()); - if (!TextUtils.isEmpty(cacheName) && !skipEpisodePositionCache()) { - long savedPosition = position > 0 ? position : mHistory.getPosition(); - long savedDuration = duration > 0 ? duration : mHistory.getDuration(); - EpisodePositionCache.get().put(getKey(), getId(), getFlag().getFlag(), cacheName, - savedPosition, savedDuration); - } - } - - private void updateHistoryAfterShortDramaTransition(Episode item) { - if (mHistory == null || item == null || getFlag() == null) return; - Episode historyEpisode = withSourceSeasonEpisodeIdentity(item); - EpisodePositionCache.EpisodePosition cached = skipEpisodePositionCache() ? null : EpisodePositionCache.get().get( - getKey(), getId(), getFlag().getFlag(), episodePositionCacheName(item, currentSourceSeasonNumber())); - if (cached != null) { - mHistory.setPosition(cached.position); - mHistory.setDuration(cached.duration); - } else { - mHistory.setPosition(C.TIME_UNSET); - mHistory.setDuration(C.TIME_UNSET); - } - setHistoryFlag(getFlag()); - mHistory.setVodRemarks(getHistoryEpisodeName(item)); - mHistory.setEpisodeUrl(item.getUrl()); - if (historyEpisode.getTmdbEpisode() != null) mHistory.setTmdbEpisodePosition(historyEpisode); - PlaybackEventCollector.get().updateHistory(mHistory); - } - - private void commitShortDramaTransition(ShortDramaQueueCoordinator.Transition transition, - Episode current, - Result result, - PlaySpec spec) { - Episode previous = transition.previous() == null ? null : getEpisodeByIndex(transition.previous().index()); - if (previous != null) saveShortDramaEpisodeProgress(previous, - mShortDramaTransitionPositionMs, mShortDramaTransitionDurationMs); - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - Flag flag = getFlag(); - if (flag == null || current == null || result == null || spec == null) { - fallbackShortDramaQueued(); - return; - } - mFlagAdapter.toggle(current); - setEpisodeAdapter(flag.getEpisodes()); - applyAudioQueueMetadata(current); - mBinding.control.title.setText(getPlaybackControlTitle(current)); - mBinding.control.title.setSelected(true); - mInlineLyrics = getEpisodeInlineLyrics(current); - applyPlaybackArtwork(current); - clearLyrics(); - clearKaraokeState(); - if (result.hasDesc()) { - setText(mBinding.content, 0, result.getDesc()); - setPlaybackLyrics(result.getDesc()); - } - mAppliedPlayerResult = result; - mQualityAdapter.addAll(result); - mQualityAdapter.setPosition(mQualityAdapter.getPosition()); - setUseParse(false); - setQualityVisible(result.getUrl().isMulti()); - updateHistoryAfterShortDramaTransition(current); - player().setMetadata(buildShortDramaMetadata(current)); - List siteDanmakus = result.getDanmaku(); - player().setDanmaku(siteDanmakus.isEmpty() ? Danmaku.empty() : siteDanmakus.get(0)); - subtitlePlaybackSession.onPlaybackStarted(this, result); - DanmakuApi.cancel(); - if (DanmakuApi.canAutoSearch(siteDanmakus)) searchShortDramaDanmaku(current, siteDanmakus); - loadTmdbRelatedVideosForCurrentEpisode(); - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaEndedFallbackScheduled = false; - App.removeCallbacks(mShortDramaEndedFallback); - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.snapshot(); - resolveShortDramaNext(snapshot); - } - - private void searchShortDramaDanmaku(Episode episode, List siteDanmakus) { - String episodeUrl = episode == null ? "" : episode.getUrl(); - DanmakuApi.search(MediaTitleRequest.builder() - .siteKey(getKey()).vodId(getId()).rawTitle(mHistory.getVodName()) - .rawRemarks(mHistory.getVodRemarks()).episodeName(episode == null ? "" : episode.getName()) - .tmdbId(danmakuTmdbId()).tmdbSeasonNumber(danmakuTmdbSeasonNumber()) - .source(MediaTitleLearningExample.SOURCE_DANMAKU_AUTO).allowAi(true).build(), danmaku -> { - Episode active = getEpisode(); - if (player() == null || active == null || !TextUtils.equals(active.getUrl(), episodeUrl)) return; - if (DanmakuSetting.isSpiderFirst() && !siteDanmakus.isEmpty()) player().addDanmaku(danmaku); - else player().setDanmaku(danmaku); - refreshDanmakuControls(); - }); - } - - private Episode getEpisodeByIndex(int index) { - Flag flag = getFlag(); - if (flag == null || index < 0 || index >= flag.getEpisodes().size()) return null; - return flag.getEpisodes().get(index); - } - - private void onShortDramaMediaItemTransition(androidx.media3.common.MediaItem mediaItem, int reason) { - if (mediaItem == null || reason != Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) return; - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.snapshot(); - ShortDramaQueueCoordinator.Transition transition = mShortDramaQueue.onMediaItemTransition( - snapshot.generation(), mediaItem.mediaId, ShortDramaQueueCoordinator.TransitionReason.AUTO); - if (transition == null) return; - PlaySpec spec = mShortDramaQueuedSpec; - Result result = mShortDramaQueuedResult; - Episode current = mShortDramaQueuedEpisode; - // Commit changes the manager's current PlaySpec, so close the previous - // episode's playback event before switching to the queued item. - if (service() != null) PlaybackEventCollector.get().onStop(player()); - if (spec == null || result == null || current == null || !player().commitPlaylistTransition(spec)) { - fallbackShortDramaQueued(); - return; - } - commitShortDramaTransition(transition, current, result, spec); - } - - private void invalidateShortDramaQueue(String reason) { - App.removeCallbacks(mShortDramaEndedFallback); - mShortDramaEndedFallbackScheduled = false; - if (mViewModel != null) mViewModel.cancelPlayerContentIsolated(); - if (player() != null && player().isExo()) player().removePlaylistItemsAfterCurrent(); - mShortDramaQueue.invalidate(); - mShortDramaQueueSessionActive = false; - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "invalidate reason=%s", reason); - } - - private void fallbackShortDramaQueued() { - if (mShortDramaQueueFallbackUsed || mShortDramaQueuedEpisode == null) return; - mShortDramaQueueFallbackUsed = true; - Episode target = mShortDramaQueuedEpisode; - saveShortDramaEpisodeProgress(getEpisode()); - invalidateShortDramaQueue("queue-fallback"); - if (!isFinishing() && !isDestroyed() && target != null) onItemClick(target); - } - - private void scheduleShortDramaEndedFallback() { - if (mShortDramaEndedFallbackScheduled) return; - mShortDramaEndedFallbackScheduled = true; - App.post(mShortDramaEndedFallback, 1_500L); - } - private void invalidatePlayerContent() { playerContentGeneration++; playerContentRequestId++; @@ -3058,10 +2473,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.parse.setVisibility(isFullscreen() && isUseParse() && PlayerButtonSetting.isVisible(PlayerButtonSetting.PARSE) ? View.VISIBLE : View.GONE); if (redirectToAudioIfNeeded(result)) return; List siteDanmakus = result.getDanmaku(); - mInitialPlaybackPosition = resolveInitialPlaybackPosition(); - SpiderDebug.log("video-flow", "startPlayer dispatch initialPosition=%d music=%s ijk=%s", mInitialPlaybackPosition, isMusicLike(), service() != null && player().isIjk()); - if (SubtitleRestoreCoordinator.restore(mHistory, player(), result)) syncHistory(); - startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata(), mInitialPlaybackPosition); + startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata()); subtitlePlaybackSession.onPlaybackStarted(this, result); if (DanmakuApi.canAutoSearch(siteDanmakus)) DanmakuApi.search(MediaTitleRequest.builder() .siteKey(getKey()) @@ -3069,8 +2481,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco .rawTitle(mHistory.getVodName()) .rawRemarks(mHistory.getVodRemarks()) .episodeName(getEpisode().getName()) - .tmdbId(danmakuTmdbId()) - .tmdbSeasonNumber(danmakuTmdbSeasonNumber()) .source(MediaTitleLearningExample.SOURCE_DANMAKU_AUTO) .allowAi(true) .build(), danmaku -> { @@ -3091,21 +2501,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return handled; } - private int danmakuTmdbId() { - TmdbItem item = mTmdbUIAdapter == null ? null : mTmdbUIAdapter.getTmdbItem(); - if (item == null) item = getTmdbItem(); - return item == null ? 0 : item.getTmdbId(); - } - - private int danmakuTmdbSeasonNumber() { - TmdbItem item = mTmdbUIAdapter == null ? null : mTmdbUIAdapter.getTmdbItem(); - if (item == null) item = getTmdbItem(); - if (item == null || !item.isTv()) return 0; - Episode episode = getEpisode(); - TmdbEpisode tmdbEpisode = episode == null ? null : episode.getTmdbEpisode(); - return tmdbEpisode == null ? 0 : tmdbEpisode.getSeasonNumber(); - } - private void recordDetailHealth(Result result, long cost) { if (detailHealthRecorded) return; detailHealthRecorded = true; @@ -3116,7 +2511,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void beginPlayHealth() { playHealthKey = getKey(); - SiteHealthStore.recordPlayAttempt(playHealthKey); playHealthRecorded = false; } @@ -3129,7 +2523,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onItemClick(Flag item) { if (item == null || mFlagAdapter.isEmpty()) return; - invalidateShortDramaQueue("line-switch"); int position = mFlagAdapter.indexOf(item); Flag resolved = mFlagAdapter.get(position < 0 ? 0 : position); boolean initialBinding = mEpisodeAdapter == null || mEpisodeAdapter.isEmpty(); @@ -3150,7 +2543,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onItemClick(Episode item) { if (shouldEnterFullscreen(item)) return; - invalidateShortDramaQueue("manual-episode"); syncCurrentAudioPlaylistMetadata(); Flag flag = getFlag(); if (mFlagAdapter != null) mFlagAdapter.toggle(item); @@ -3172,7 +2564,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco // 另外 SiteViewModel 被清理后 playerContent 会抛 RejectedExecutionException, // 那个异常会顺着阅读器的 runOnUiThread 冒出去导致崩溃。 if (isFinishing() || isDestroyed()) return false; - invalidateShortDramaQueue("reader-episode"); Flag flag = getFlag(); if (flag == null || flag.getEpisodes() == null) return false; for (Episode ep : flag.getEpisodes()) { @@ -3200,11 +2591,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onItemClick(Result result) { - invalidateShortDramaQueue("quality"); + // 画质列表直点也会换 URL,且不经 changeQuality + resetAdFeedback(); updateActionQuality(result); beginPlayHealth(); - // 切清晰度也会重建 spec,字幕列表跟着重置,所以这里同样要恢复一次。 - if (SubtitleRestoreCoordinator.restore(mHistory, player(), result)) syncHistory(); startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata()); subtitlePlaybackSession.onPlaybackStarted(this, result); } @@ -3218,7 +2608,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onItemClick(Parse item) { - invalidateShortDramaQueue("parse"); setParse(item); onRefresh(); } @@ -3268,7 +2657,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean shouldUseUpstreamNativeEpisodeModule() { - return isRuntimeDirectMode() && !isTmdbMode(); + return Setting.isDirectDetailPage() && !isTmdbMode(); } private void setUpstreamNativeEpisodeItems(List items) { @@ -3416,7 +2805,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean isOriginalEnhancedEpisodeFallback() { - return isRuntimeOriginalEnhancedMode() && isTmdbSourceEnabled(); + return Setting.isOriginalEnhancedDetailPage() && isTmdbSourceEnabled(); } private List getEpisodeDisplayItems(List items) { @@ -3514,6 +2903,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco updateActionQuality(result); return; } + // 换画质同集换 URL:切片结构与在途归因都属于上一画质 + resetAdFeedback(); mQualityAdapter.setPosition(position); updateActionQuality(result); onItemClick(result); @@ -3593,7 +2984,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onReverse() { - invalidateShortDramaQueue("reverse-sort"); mHistory.setRevSort(!mHistory.isRevSort()); reverseEpisode(false); } @@ -3749,30 +3139,24 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco /** @return 是否真的切走了。末集切不动,调用方据此决定要不要提示「进入下一集」。 */ private boolean advanceEpisode(boolean notify) { setR1Callback(); - int offset = mHistory != null && mHistory.isRevPlay() ? -1 : 1; - Episode item = getAdjacentEpisode(offset); + Episode item = getAdjacentEpisode(1); if (!item.isSelected()) { onItemClick(item); return true; } - if (notify) Notify.show(offset > 0 ? R.string.error_play_next : R.string.error_play_prev); + if (notify) Notify.show(R.string.error_play_next); return false; } private void checkPrev() { - checkPrev(true); - } - - private void checkPrev(boolean notify) { setR1Callback(); - int offset = mHistory != null && mHistory.isRevPlay() ? 1 : -1; - Episode item = getAdjacentEpisode(offset); + Episode item = getAdjacentEpisode(-1); if (!item.isSelected()) onItemClick(item); - else if (notify) Notify.show(offset > 0 ? R.string.error_play_next : R.string.error_play_prev); + else Notify.show(R.string.error_play_prev); } private Episode getAdjacentEpisode(int offset) { - List items = mFlagAdapter == null || mFlagAdapter.isEmpty() ? getCurrentEpisodeItems() : getFlag().getEpisodes(); + List items = mFlagAdapter == null || mFlagAdapter.isEmpty() ? mEpisodeAdapter.getItems() : getFlag().getEpisodes(); if (items.isEmpty()) return new Episode(); int position = getSelectedEpisodePosition(items) + offset; position = Math.max(0, Math.min(position, items.size() - 1)); @@ -3838,7 +3222,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onDanmaku() { - DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).tmdb(danmakuTmdbId(), danmakuTmdbSeasonNumber()).show(this); + DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).show(this); hideControl(); } @@ -3848,7 +3232,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return; } if (!isAdFeedbackEnabled()) { - Notify.show(R.string.ad_feedback_ai_disabled); + Notify.show(R.string.ad_interval_invalid); return; } hideControl(); @@ -3857,7 +3241,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onDanmakuPanel() { - DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).tmdb(danmakuTmdbId(), danmakuTmdbSeasonNumber()).show(this); + DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).show(this); } @Override @@ -4899,8 +4283,13 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return isFullscreen(); } + private void onDanmakuShow() { + DanmakuSetting.putShow(!DanmakuSetting.isShow()); + checkDanmakuImg(); + showDanmaku(); + } + private void onRepeat() { - invalidateShortDramaQueue("repeat"); player().setRepeatOne(!player().isRepeatOne()); mBinding.control.action.repeat.setSelected(player().isRepeatOne()); } @@ -4983,12 +4372,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onSpeed() { - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || player().isEmpty()) return; - mBinding.control.action.speed.setText(player().setSpeed(speed)); - saveUserSpeed(); - setR1Callback(); - }); + mBinding.control.action.speed.setText(player().addSpeed()); + saveUserSpeed(); + setR1Callback(); } private boolean onSpeedLong() { @@ -5014,7 +4400,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onRefresh() { - invalidateShortDramaQueue("refresh"); saveHistory(); if (mViewModel != null) mViewModel.cancelPlayerContent(); invalidatePlayerContent(); @@ -5034,7 +4419,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onDecode() { - invalidateShortDramaQueue("decode"); if (refreshAndSwitchDecode()) return; mClock.setCallback(null); player().toggleDecode(); @@ -5083,6 +4467,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco decodeSwitchRefreshing = false; setDecodeSwitchPending(false); if (!canApplyPlayerContentRequest(requestId, generation, key, flag, episode)) return; + // 换解码可能重新解析出不同 URL,与换画质同理 + resetAdFeedback(); if (result == null || result.hasMsg() || result.getRealUrl().isEmpty()) { player().toggleDecode(); } else { @@ -5204,7 +4590,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onPlayerKernel() { - invalidateShortDramaQueue("kernel"); mClock.setCallback(null); onChoose(); setR1Callback(); @@ -5254,6 +4639,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco if (requestId != playerKernelSwitchRequestId || !isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return; if (!canApplyPlayerContentRequest(requestId, generation, key, flag, episode)) return; + // 换内核会重新解析出可能不同的 URL,与换画质/换解码同理 + resetAdFeedback(); if (result == null || result.hasMsg() || result.getRealUrl().isEmpty()) { Notify.show(result != null && result.hasMsg() ? result.getMsg() : getString(R.string.error_play_url)); } else { @@ -5480,6 +4867,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.action.danmaku.setVisibility(DanmakuSetting.isLoad() ? View.VISIBLE : View.GONE); mBinding.control.action.adFeedback.setVisibility(isAdFeedbackEnabled() ? View.VISIBLE : View.GONE); applyActionButtonVisibility(); + // 顶部弹幕图标只根据锁定状态和弹幕可用性显示。 + if (mBinding.control.getRoot().getVisibility() == View.VISIBLE) mBinding.control.danmaku.setVisibility(isLock() || !player().haveDanmaku() ? View.GONE : View.VISIBLE); } private void showControl() { @@ -5494,6 +4883,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco boolean shortDrama = isShortDramaSession(); boolean showPiP = canShowPiP(shortDrama); hideWidgetOverlay(); + // 顶部弹幕图标只根据锁定状态和弹幕可用性显示。 + mBinding.control.danmaku.setVisibility(isLock() || !player().haveDanmaku() ? View.GONE : View.VISIBLE); mBinding.control.setting.setVisibility(mHistory == null || (isFullscreen() && !shortDrama) ? View.GONE : View.VISIBLE); mBinding.control.right.getRoot().setVisibility(isFullscreen() || showPiP ? View.VISIBLE : View.GONE); mBinding.control.right.rotate.setVisibility(isFullscreen() && !isLock() ? View.VISIBLE : View.GONE); @@ -5524,8 +4915,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.top.setVisibility(isLock() ? View.GONE : View.VISIBLE); syncShortDramaControlLayout(shortDrama); mBinding.control.getRoot().setVisibility(View.VISIBLE); - updateDiscMenuTools(); - updateCustomButtonVisibility(); if (mOsd != null) mOsd.setControlsVisible(true); checkFullscreenImg(); mBinding.control.getRoot().post(() -> PlayerControlFocusHelper.ensureFocus(mBinding.control.getRoot(), mBinding.control.play)); @@ -5538,8 +4927,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void hideControl() { mBinding.control.getRoot().setVisibility(View.GONE); - updateDiscMenuTools(); - updateCustomButtonVisibility(); if (mOsd != null) mOsd.setControlsVisible(false); App.removeCallbacks(mR1); setOsdSuppressed(false); @@ -5547,7 +4934,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public boolean dispatchKeyEvent(KeyEvent event) { - if (dispatchDiscMenuKey(event)) return true; if (isVisible(mBinding.control.getRoot()) && PlayerControlFocusHelper.handleKey(mBinding.control.getRoot(), mBinding.control.play, event)) return true; return super.dispatchKeyEvent(event); } @@ -5567,7 +4953,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onMultiThreadProxySaved(boolean applyNow) { - invalidateShortDramaQueue("multi-thread-proxy"); setPlayParamsState(); if (applyNow && player() != null && !player().isEmpty()) player().reloadCurrentMediaItem(); } @@ -5683,7 +5068,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void setContextWall(String url, boolean skipLock) { - if (!Setting.isPlaybackArtworkWall() && !isRuntimeFusionMode() && !shouldUseTmdbBackdropSurface()) { + if (!Setting.isPlaybackArtworkWall() && !Setting.isFusionDetailPage() && !shouldUseTmdbBackdropSurface()) { mContextWallUrl = ""; hideContextWall(); return; @@ -5733,7 +5118,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void restoreContextWall() { - if (!Setting.isPlaybackArtworkWall() && !isRuntimeFusionMode() && !shouldUseTmdbBackdropSurface()) return; + if (!Setting.isPlaybackArtworkWall() && !Setting.isFusionDetailPage() && !shouldUseTmdbBackdropSurface()) return; String wall = getContextWall(); if (TextUtils.isEmpty(wall)) { hideContextWall(); @@ -5887,12 +5272,11 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } /** - * 原生增强以及从独立 TMDB 详情页直达播放时都立即揭开页面骨架, - * 加载态只由播放器窗口内那一层表达,避免整页先显示主题色遮盖层。 + * 原生增强把详情与播放放在同一页:进入即揭开页面骨架,加载态只由播放器窗口内那一层表达, + * 不再让详情区整块转圈与播放器转圈同屏叠出两层「加载中」。 */ private boolean shouldRevealShellWhileLoading() { - // 影视原生与原生增强一样由播放器窗口表达加载态,避免进场后整页再转一次。 - return isRuntimeOriginalEnhancedMode() || isRuntimeDirectMode() || getIntent().hasExtra(EXTRA_TMDB_DETAIL_THEME); + return Setting.isOriginalEnhancedDetailPage(); } private boolean canRevealPlaybackContent() { @@ -6031,8 +5415,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } if (!sameEpisode && !tmdbHistoryResumePending) { - // updatePlaybackHistoryPosition() 刚把旧集进度写回 History;切换新集前必须先覆盖, - // 否则新集没有独立缓存时会继承旧集进度并从错误位置开始播放。 + // 从缓存中恢复新集的播放位置 EpisodePositionCache.EpisodePosition cached = skipEpisodePositionCache() ? null : EpisodePositionCache.get().get( getKey(), getId(), @@ -6073,6 +5456,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.fullscreen.setImageResource(isFullscreen() ? R.drawable.ic_control_fullscreen_exit : R.drawable.ic_control_fullscreen); } + private void checkDanmakuImg() { + mBinding.control.danmaku.setImageResource(DanmakuSetting.isShow() ? R.drawable.ic_control_danmaku_on : R.drawable.ic_control_danmaku_off); + } + private void createKeep() { Keep keep = new Keep(); keep.setKey(getHistoryKey()); @@ -6091,69 +5478,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco keep.setVodPic(mHistory.getVodPic()); keep.save(); } - updateFollowingState(); - } - - private void onFollowing() { - if (!FollowingSettings.isEnabled()) { - Notify.show(R.string.following_enabled_hint); - return; - } - if (mHistory == null || followingActionPending || !FollowingPlaybackBridge.isEligible(mHistory)) return; - String identityKey = FollowingPlaybackBridge.identityKey(mHistory, currentSourceSeasonNumber()); - if (TextUtils.isEmpty(identityKey)) return; - followingActionPending = true; - mBinding.following.setEnabled(false); - FollowingPlaybackBridge.findAsync(identityKey, existing -> { - if (isFinishing() || isDestroyed()) return; - if (existing != null) { - followingActionPending = false; - mBinding.following.setEnabled(true); - FollowingActivity.start(this, existing.identityKey); - return; - } - Following item = FollowingPlaybackBridge.build(mHistory, currentSourceSeasonNumber()); - FollowingSource source = FollowingPlaybackBridge.source(item, mHistory); - FollowingPlaybackBridge.addAsync(item, source, (saved, error) -> { - followingActionPending = false; - if (isFinishing() || isDestroyed()) return; - if (error != null) { - mBinding.following.setEnabled(true); - Notify.show(error.getMessage()); - return; - } - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); - updateFollowingState(); - Notify.show(R.string.following_added); - }); - }); - } - - private void updateFollowingState() { - boolean eligible = FollowingSettings.isEnabled() && FollowingPlaybackBridge.isEligible(mHistory); - int generation = ++followingUiGeneration; - if (!eligible) { - applyFollowingButtonState(false, false); - return; - } - String identityKey = FollowingPlaybackBridge.identityKey(mHistory, currentSourceSeasonNumber()); - applyFollowingButtonState(true, false); - FollowingPlaybackBridge.findAsync(identityKey, item -> { - if (generation != followingUiGeneration || isFinishing() || isDestroyed()) return; - applyFollowingButtonState(true, item != null); - }); - } - - private void applyFollowingButtonState(boolean eligible, boolean followed) { - mBinding.following.setVisibility(eligible ? View.VISIBLE : View.GONE); - mBinding.following.setEnabled(true); - mBinding.following.setSelected(followed); - mBinding.following.setText(followed ? R.string.following_added : R.string.following_add); - if (mTmdbHeaderView != null) { - mTmdbHeaderView.setFollowingVisible(eligible); - mTmdbHeaderView.setFollowingSelected(followed); - } } private void updateVod(Vod item) { @@ -6449,11 +5773,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco refreshControlDialog(); } - @Override - protected void onSubtitleSelected(Sub sub) { - if (SubtitleRestoreCoordinator.remember(mHistory, sub)) syncHistory(); - } - private void updateAudioOnlyState() { if (service() == null) return; setAudioOnly(LyricsController.isAudioOnly(player())); @@ -7592,7 +6911,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override protected void onError(String msg) { - invalidateShortDramaQueue("error"); recordPlayHealth(false, msg); subtitlePlaybackSession.stop(this); mBinding.swipeLayout.setEnabled(true); @@ -7609,7 +6927,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override protected void onReload(String msg) { - recordPlayHealth(false, msg); if (PlayerManager.RELOAD_LUT_WARMUP.equals(msg)) { if (SpiderDebug.isEnabled()) SpiderDebug.log("lut-ui", "auto refresh after lut warmup playback failure key=%s episode=%s", getKey(), getEpisode() == null ? null : getEpisode().getName()); onRefresh(); @@ -7624,11 +6941,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco if (result != null) setPlayer(result); } - @Override - protected void onFirstFrameRendered() { - recordPlayHealth(true, ""); - } - @Override protected void onStateChanged(int state) { switch (state) { @@ -7638,6 +6950,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco break; case Player.STATE_READY: if (mPendingKaraokeResult == null) mKaraokeResultShown = false; + recordPlayHealth(true, ""); showPlaybackContent(); boolean pendingResumeSeekApplied = applyPendingResumeSeek(); checkControl(); @@ -7647,25 +6960,15 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco requestIntroSkipPlan(); if (!pendingResumeSeekApplied) applyAutoIntroSkip(); setAdFeedbackVisible(); // 播放地址确定后按格式刷新"有广告"按钮 - if (!mShortDramaQueueSessionActive) beginShortDramaQueue(); + recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线 break; case Player.STATE_ENDED: - if (mShortDramaQueue.shouldSuppressLegacyAutoAdvance()) { - scheduleShortDramaEndedFallback(); - break; - } checkEnded(true); updatePlayControl(false, syncPiPForPlaybackMode()); break; } } - @Override - public void onMediaItemTransition(@Nullable MediaItem mediaItem, int reason) { - super.onMediaItemTransition(mediaItem, reason); - onShortDramaMediaItemTransition(mediaItem, reason); - } - @Override protected void onPlayingChanged(boolean isPlaying) { syncLyricsPlaybackState(isPlaying); @@ -7744,10 +7047,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco if (!isOwner() || mHistory == null) return; long position, duration; mHistory.setCreateTime(time); - if (hasDiscNavigationTimeline()) { - if (canSavePlaybackHistory(mHistory) && mHistory.canSync()) syncHistory(); - return; - } updatePlaybackHistoryPosition(); syncCurrentAudioPlaylistMetadata(); syncKaraokePosition(); @@ -7766,7 +7065,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void updatePlaybackHistoryPosition() { if (mHistory == null || tmdbHistoryResumePending) return; - if (hasDiscNavigationTimeline()) return; long position = player().getPosition(); long duration = player().getDuration(); if (position > 0) mHistory.setPosition(position); @@ -7837,18 +7135,13 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onIntroSkipPlanLoaded() { - if (isFinishing() || isDestroyed() || player() == null || player().isReleased() || !isOwner()) return; - // 查询可能在 EXO 首次 prepare/恢复定位期间提前返回。此时 seek 会与起播定位、 - // 去广告或播放器内部准备竞争,造成重复加载,严重时停在黑屏。统一等 READY 后再应用。 - if (player().getPlaybackState() != Player.STATE_READY) return; setOpeningEndingText(); applyAutoIntroSkip(); preloadAdjacentIntroSkipPlans(); } private boolean applyAutoIntroSkip() { - if (!Setting.isIntroSkipEnabled() || isFinishing() || isDestroyed() - || player() == null || player().isReleased() || !isOwner()) return false; + if (!Setting.isIntroSkipEnabled() || player() == null) return false; // notify=true:片尾无处可跳(末集/电影)时至少要有提示,不能静默无反应 return mIntroSkipPlayback.apply(player(), () -> advanceEpisode(true)); } @@ -7908,9 +7201,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Subscribe(threadMode = ThreadMode.MAIN) public void onConfigEvent(ConfigEvent event) { - if (isRedirect() || !event.isVod()) return; - if (mTmdbUIAdapter != null) mTmdbUIAdapter.invalidateSubscription(); - if (mParseAdapter != null) mParseAdapter.reload(); + if (isRedirect() || !event.isVod() || mParseAdapter == null) return; + mParseAdapter.reload(); } /** @@ -7940,28 +7232,19 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco pendingResumeSeekMs = C.TIME_UNSET; if (mHistory == null) { tmdbHistoryResumePending = false; - mInitialPlaybackPosition = C.TIME_UNSET; return; } - if (hasDiscMenu()) { - tmdbHistoryResumePending = false; - mInitialPlaybackPosition = C.TIME_UNSET; - return; + if (mHistory.isNearEnding()) { + SpiderDebug.log("video-flow", "reset near-end history position=%d duration=%d key=%s", mHistory.getPosition(), mHistory.getDuration(), getHistoryKey()); + mHistory.resetPlaybackPosition(); + syncHistory(); } - long position = resolveInitialPlaybackPosition(); - if (position == C.TIME_UNSET || position <= 0) { + long position = Math.max(mHistory.getOpening(), mHistory.getPosition()); + if (position <= 0) { tmdbHistoryResumePending = false; - mInitialPlaybackPosition = C.TIME_UNSET; return; } mIntroSkipPlayback.setResumePosition(position); - if (mInitialPlaybackPosition == position) { - SpiderDebug.log("video-flow", "skip duplicate restore seek position=%d key=%s", position, getHistoryKey()); - mInitialPlaybackPosition = C.TIME_UNSET; - tmdbHistoryResumePending = false; - return; - } - mInitialPlaybackPosition = C.TIME_UNSET; if (player().isIjk()) pendingResumeSeekMs = position; else { player().seekTo(position); @@ -7978,18 +7261,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return mHistory == null ? PlayerSetting.getDefaultSpeed() : mHistory.getPlaybackSpeed(PlayerSetting.getDefaultSpeed()); } - private long resolveInitialPlaybackPosition() { - if (mHistory == null) return C.TIME_UNSET; - if (mHistory.isNearEnding()) { - SpiderDebug.log("video-flow", "reset near-end history position=%d duration=%d key=%s", mHistory.getPosition(), mHistory.getDuration(), getHistoryKey()); - mHistory.resetPlaybackPosition(); - syncHistory(); - } - long position = Math.max(mHistory.getOpening(), mHistory.getPosition()); - return position > 0 ? position : C.TIME_UNSET; - } - -private void checkOrientation() { + private void checkOrientation() { if (isFullscreen() && !isRotate() && player().isPortrait()) { setRequestedOrientation(PlaybackOrientation.getPortraitVideoSizeOrientation()); setRotate(true); @@ -8030,32 +7302,12 @@ private void checkOrientation() { } private void setTrackVisible() { - mBinding.control.action.discMenu.setVisibility(hasDiscMenu() ? View.VISIBLE : View.GONE); - updateDiscMenuTools(); mBinding.control.action.text.setVisibility(player().haveTrack(C.TRACK_TYPE_TEXT) || player().isVod() ? View.VISIBLE : View.GONE); mBinding.control.action.audio.setVisibility(player().haveTrack(C.TRACK_TYPE_AUDIO) ? View.VISIBLE : View.GONE); mBinding.control.action.video.setVisibility(player().haveTrack(C.TRACK_TYPE_VIDEO) ? View.VISIBLE : View.GONE); applyActionButtonVisibility(); } - private void updateDiscMenuTools() { - updateCustomButtonStates(); - MpvPlayer mpv = service() != null && isOwner() - && player().getPlayer() instanceof MpvPlayer active ? active : null; - if (mDiscMenuPlayer != mpv) { - if (mDiscMenuPlayer != null) mDiscMenuPlayer.removeDiscMenuStateListener(mDiscMenuStateListener); - mDiscMenuPlayer = mpv; - if (mpv != null) mpv.addDiscMenuStateListener(mDiscMenuStateListener); - } - boolean visible = mpv != null && mpv.isDiscMenuActive() && !isLock() && !isInPictureInPictureMode() - && !mAudioStageVisible && !isVisible(mBinding.control.getRoot()); - mBinding.discTools.getRoot().setVisibility(visible ? View.VISIBLE : View.GONE); - mBinding.discTools.fullscreen.setImageResource(isFullscreen() - ? R.drawable.ic_control_fullscreen_exit : R.drawable.ic_control_fullscreen); - mBinding.discTools.fullscreen.setContentDescription(getString(isFullscreen() - ? R.string.play_exit_fullscreen : R.string.play_fullscreen)); - } - private void setTitleVisible() { mBinding.control.action.title.setVisibility(player().haveTitle() ? View.VISIBLE : View.GONE); applyActionButtonVisibility(); @@ -8238,7 +7490,8 @@ private void checkOrientation() { mTmdbUIAdapter = new com.fongmi.android.tv.ui.helper.TmdbUIAdapter(this); if (!mTmdbUIAdapter.isReady()) { - SpiderDebug.log("TMDB 增强等待源内嵌数据或 API Key"); + SpiderDebug.log("TMDB 增强已启用,但配置未就绪(需要 API Key)"); + return; } mTmdbUIAdapter.setPersonalAiUpdateListener(() -> { if (mTmdbHeaderView != null && mTmdbUIAdapter != null && mTmdbUIAdapter.isLoaded() && !mTmdbFallbackToNative) { @@ -8276,11 +7529,6 @@ private void checkOrientation() { public void onKeep() { VideoActivity.this.onKeep(); } - - @Override - public void onFollowing() { - VideoActivity.this.onFollowing(); - } }); // 设置图片加载完成监听器 @@ -8292,7 +7540,7 @@ private void checkOrientation() { }); // 原生增强、原生样式和 Fusion 模式:设置 Backdrop 变化监听器,同步轮播到 contextWall - if (isRuntimeFusionMode() || shouldUseTmdbBackdropSurface()) { + if (Setting.isFusionDetailPage() || shouldUseTmdbBackdropSurface()) { mTmdbHeaderView.setOnBackdropChangeListener(new com.fongmi.android.tv.ui.custom.TmdbHeaderView.OnBackdropChangeListener() { @Override public void onBackdropChanged(String imageUrl) { @@ -8309,7 +7557,7 @@ private void checkOrientation() { mBinding.search.setVisibility(View.GONE); if (mBinding.videoShadow != null) mBinding.videoShadow.setVisibility(View.GONE); // 隐藏播放器下方的阴影 - if (isRuntimeFusionMode()) { + if (Setting.isFusionDetailPage()) { applyFusionDetailChrome(); } else if (shouldUseTmdbBackdropSurface()) { // 原生增强模式:启用全屏背景 @@ -8461,7 +7709,7 @@ private void checkOrientation() { } private void applyFusionPlayerBelowSpacing() { - if (!isRuntimeFusionMode() || mTmdbHeaderView == null || mTmdbHeaderView.getHeaderRoot() == null) return; + if (!Setting.isFusionDetailPage() || mTmdbHeaderView == null || mTmdbHeaderView.getHeaderRoot() == null) return; View actions = mTmdbHeaderView.getHeaderRoot().findViewById(R.id.tmdbActionsScroll); if (actions == null || !(actions.getLayoutParams() instanceof ViewGroup.MarginLayoutParams params)) return; params.topMargin = 0; @@ -8496,12 +7744,12 @@ private void checkOrientation() { mBinding.videoContextScrim.setVisibility(View.VISIBLE); return; } - boolean light = isRuntimeFusionMode() && isFusionLightTheme(); + boolean light = Setting.isFusionDetailPage() && isFusionLightTheme(); mBinding.videoContextScrim.setBackgroundResource(light ? R.drawable.shape_video_context_scrim_light : R.drawable.shape_video_context_scrim); } private void applyFusionNativeTextColors() { - if ((!isRuntimeFusionMode() && !mTmdbFallbackToNative) || mBinding.nativeContentContainer == null) return; + if ((!Setting.isFusionDetailPage() && !mTmdbFallbackToNative) || mBinding.nativeContentContainer == null) return; tintFusionNativeTextTree(mBinding.nativeContentContainer, !mTmdbFallbackToNative && isFusionLightTheme()); } @@ -8569,7 +7817,7 @@ private void checkOrientation() { private void updateFusionThemeButtonVisibility() { if (mFusionThemeButton == null) return; - boolean show = DetailThemeVisibility.showFusionThemeButton(isRuntimeFusionMode(), isFullscreen(), isInPictureInPictureMode()); + boolean show = DetailThemeVisibility.showFusionThemeButton(Setting.isFusionDetailPage(), isFullscreen(), isInPictureInPictureMode()); mFusionThemeButton.setVisibility(show ? View.VISIBLE : View.GONE); } @@ -8840,7 +8088,6 @@ private void checkOrientation() { } private void showManualTmdbSeasonDialog() { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isLoaded() || mTmdbUIAdapter.getTmdbItem() == null || !mTmdbUIAdapter.getTmdbItem().isTv()) { Notify.show(R.string.detail_tmdb_empty); return; @@ -8996,7 +8243,6 @@ private void checkOrientation() { } } private void showManualTmdbMatchDialog() { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isReady()) { Notify.show(R.string.detail_tmdb_need_key); return; @@ -9039,7 +8285,6 @@ private void checkOrientation() { } private void searchTmdb(String keyword, TmdbSearchDialog dialog) { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isReady()) return; dialog.loading(); int generation = ++mTmdbDialogGeneration; @@ -9061,7 +8306,6 @@ private void checkOrientation() { } private void applyManualTmdb(TmdbItem item) { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || mVod == null || item == null) return; mTmdbDialogGeneration++; mTmdbFallbackToNative = false; @@ -9087,7 +8331,6 @@ private void checkOrientation() { private void updateTmdbKeepState() { if (mTmdbHeaderView != null) mTmdbHeaderView.setKeepSelected(Keep.find(getHistoryKey()) != null); - updateFollowingState(); } private void moveFlagAndEpisodeToTmdb() { @@ -9114,8 +8357,6 @@ private void checkOrientation() { } moveFusionPlayerActionsToTmdb(playbackControls); mTmdbControlsMoved = true; - updateTmdbPlaybackScrollContentHeight(); - mBinding.episode.post(this::updateEpisodeViewportHeight); updateEpisodeGroupVisibility(); mTmdbHeaderView.refreshTheme(); if (shouldUseTmdbBackdropSurface()) mTmdbHeaderView.hideNativeHeroBackdrop(); @@ -9131,25 +8372,9 @@ private void checkOrientation() { item.parent.addView(item.view, Math.min(item.index, item.parent.getChildCount()), item.layoutParams); } mTmdbControlsMoved = false; - updateTmdbPlaybackScrollContentHeight(); - mBinding.episode.post(this::updateEpisodeViewportHeight); updateEpisodeGroupVisibility(); } - private void updateTmdbPlaybackScrollContentHeight() { - if (mBinding == null || mBinding.scroll.getChildCount() == 0) return; - View child = mBinding.scroll.getChildAt(0); - if (!(child instanceof ViewGroup content)) return; - int height = mTmdbControlsMoved && usesOuterEpisodePageScroll() - ? ViewGroup.LayoutParams.WRAP_CONTENT : ViewGroup.LayoutParams.MATCH_PARENT; - ViewGroup.LayoutParams params = content.getLayoutParams(); - if (params == null || params.height == height) return; - params.height = height; - content.setLayoutParams(params); - content.requestLayout(); - mBinding.scroll.requestLayout(); - } - private void moveTmdbSourceToFlagTitle(View tmdbRoot) { View source = tmdbRoot.findViewById(R.id.tmdbFusionSource); if (source == null) source = mBinding.flagTitleBar.findViewById(R.id.tmdbFusionSource); @@ -9200,7 +8425,7 @@ private void checkOrientation() { } private void moveFusionPlayerActionsToTmdb(ViewGroup playbackControls) { - if (!isRuntimeFusionMode()) { + if (!Setting.isFusionDetailPage()) { return; } View actions = mBinding.control.action.getRoot(); @@ -9213,7 +8438,7 @@ private void checkOrientation() { } private boolean isFusionPlayerActionsDocked() { - return isRuntimeFusionMode() && mBinding.control.action.getRoot().getParent() != mBinding.control.bottom; + return Setting.isFusionDetailPage() && mBinding.control.action.getRoot().getParent() != mBinding.control.bottom; } private View[] getTmdbMovableViews() { @@ -9363,6 +8588,7 @@ private void checkOrientation() { */ private View[] getShortDramaControlViews() { return new View[]{ + mBinding.control.danmaku, mBinding.control.cast, mBinding.control.keep, mBinding.control.shortDramaChangeSource, @@ -9626,7 +8852,6 @@ private void checkOrientation() { restoreContextWall(); if (isStop()) finish(); } - updateDiscMenuTools(); } @Override @@ -9649,7 +8874,6 @@ private void checkOrientation() { return; } syncFullscreenForOrientation(newConfig.orientation); - setupCustomActionButtons(); if (!isFullscreen()) { applyTmdbTabletVideoLayoutIfNeeded(); if (mVod != null) bindTmdbTabletTopSummary(mVod); @@ -9660,7 +8884,6 @@ private void checkOrientation() { Util.hideSystemUI(this); if (isVisible(mBinding.control.getRoot())) showControl(); } - updateDiscMenuTools(); } private void syncFullscreenForOrientation(int orientation) { @@ -9696,7 +8919,6 @@ private void checkOrientation() { public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); if (isFullscreen() && hasFocus) Util.hideSystemUI(this); - updateDiscMenuTools(); } @Override @@ -9710,7 +8932,6 @@ private void checkOrientation() { if (service() != null) refreshLyrics(); syncLyricsPlaybackState(); syncKaraokePosition(); - updateDiscMenuTools(); } @Override @@ -9721,7 +8942,6 @@ private void checkOrientation() { stopAudioCoverRotation(); if (PlayerSetting.isBackgroundOff()) mClock.stop(); if (!isAudioOnly()) setStop(true); - updateDiscMenuTools(); } @Override @@ -9748,7 +8968,6 @@ private void checkOrientation() { protected void markPlaybackExiting() { if (isPlaybackExiting()) return; super.markPlaybackExiting(); - mIntroSkipPlayback.reset(); cancelAiSeasonAnalysis(false); mPersonalRecommendationGeneration++; mPersonalRecommendationTasks.close(); @@ -9757,16 +8976,7 @@ private void checkOrientation() { } @Override protected void onDestroy() { - if (mCustomButtonPlayer != null) { - mCustomButtonPlayer.removeCustomButtonStateListener(mCustomButtonStateListener); - mCustomButtonPlayer = null; - } - if (mDiscMenuPlayer != null) { - mDiscMenuPlayer.removeDiscMenuStateListener(mDiscMenuStateListener); - mDiscMenuPlayer = null; - } - invalidateShortDramaQueue("destroy"); - mIntroSkipPlayback.reset(); + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); cancelAiSeasonAnalysis(false); dismissKaraokeResultDialogForRecreation(); mLyricsSearchSeq++; @@ -9840,15 +9050,10 @@ private void checkOrientation() { } private boolean isAdFeedbackEnabled() { - // 功能开关 + 仅支持解析的格式(HLS/m3u8)才可反馈,因为去广分析依赖切片列表 - return Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection() && isAdFeedbackSupportedFormat(); - } - - private boolean isAdFeedbackSupportedFormat() { - if (player() == null) return false; - String url = player().getUrl(); - if (TextUtils.isEmpty(url)) return false; - return PlaybackResourceClassifier.isHlsUrl(url); + // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。 + // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。 + if (player() == null || TextUtils.isEmpty(player().getUrl())) return false; + return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0; } private void setAdFeedbackVisible() { @@ -9856,78 +9061,173 @@ private void checkOrientation() { applyActionButtonVisibility(); } + private AdFeedbackController adFeedback() { + if (mAdFeedback == null) { + mAdFeedbackHost = new AdFeedbackHostAdapter( + new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi()); + mAdFeedback = new AdFeedbackController(mAdFeedbackHost); + } + return mAdFeedback; + } + + /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */ + private void resetAdFeedback() { + mAdMarkStartMs = -1; + if (mAdFeedback != null) mAdFeedback.invalidate(); + if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence(); + // 作废后归因回调不再刷新界面,对话框会永久停在「分析中」 + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); + mAdFeedbackDialog = null; + } + + /** + * 起播后记录本站域名,供域名信誉通道建立基线。 + * + *

必须强制初始化适配器:它原本只在用户首次按下「有广告」时懒创建, + * 若这里跳过未初始化的情况,首次反馈时基线仍为空,域名通道会因 + * 「无基线不断言」而弃权 —— 整条通道等于没接。 + */ + private void recordAdFeedbackHost() { + // 关掉去广告或播直播时不建基线:这些域名会挤掉 LRU 里真正有用的条目 + if (!isAdFeedbackEnabled()) return; + adFeedback(); + mAdFeedbackHost.recordPlaybackHost(); + } + + /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */ private void submitAdFeedback() { - AdDetectionRequest request = buildAdDetectionRequest(); - if (request == null) { - Notify.show(R.string.ad_feedback_no_url); + AdFeedbackController controller = adFeedback(); + AdFeedbackSession session; + if (mAdMarkStartMs >= 0) { + // 先取位置再清标记:播放器已释放时保留标记,用户可重试而不必重新打点 + long end = mAdFeedbackHost.safePositionMs(); + if (end < 0) { + Notify.show(R.string.ad_interval_invalid); + return; + } + long start = mAdMarkStartMs; + mAdMarkStartMs = -1; + session = controller.onMarkedInterval(start, end); + } else { + session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence()); + } + if (session == null) { + Notify.show(R.string.ad_interval_invalid); return; } - // 记录 AI 反馈统计 - AdBlockStatsStore.recordFeedback(request.getSiteKey()); - - Notify.show(R.string.ad_feedback_analyzing); - int generation = ++mAdFeedbackGeneration; - AiConfig config = AiConfig.objectFrom(Setting.getAiConfig()); - Task.execute(() -> { - // Parse m3u8 evidence (blocking I/O) - enrichRequestWithM3u8Evidence(request); - AdDetectionResult result = new AiAdDetectionService(config).analyze(request); - runOnUiThread(() -> { - if (isFinishing() || isDestroyed() || generation != mAdFeedbackGeneration) return; - onAdDetectionResult(request, result); - }); - }); + // 统计放在成交之后,未成立的反馈不计入 + AdBlockStatsStore.recordFeedback(getKey()); } - private AdDetectionRequest buildAdDetectionRequest() { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return null; - String url = player().getUrl(); - Uri uri = Uri.parse(url); - AdDetectionRequest request = new AdDetectionRequest(); + /** 长按:打起点进入标记模式,再次长按取消。 */ + private boolean onAdFeedbackLongPress() { + if (!isAdFeedbackEnabled()) return false; + if (mAdMarkStartMs >= 0) { + mAdMarkStartMs = -1; + Notify.show(R.string.ad_interval_mark_cancelled); + } else { + mAdMarkStartMs = player().getPosition(); + Notify.show(R.string.ad_interval_mark_start); + } + return true; + } + + private void showAdFeedbackSession(AdFeedbackSession session) { + if (isFinishing() || isDestroyed()) return; + if (mAdFeedbackDialog == null) mAdFeedbackDialog = new AdFeedbackDialog(this, this::applyAdRulePlan); + mAdFeedbackDialog.show(session); + } + + private void applyAdRulePlan(com.fongmi.android.tv.ad.feedback.AdAttribution plan) { Site site = getSite(); - request.setSiteKey(site == null ? getKey() : site.getKey()); - request.setSiteName(site == null ? "" : site.getName()); - request.setVodName(mHistory == null ? mBinding.name.getText().toString() : mHistory.getVodName()); - request.setFlagName(getFlag() == null ? "" : getFlag().getFlag()); - request.setEpisodeName(getEpisode() == null ? "" : getEpisode().getName()); - request.setUrlHost(uri.getHost()); - request.setUrlPath(uri.getPath()); - return request; - } - - private void enrichRequestWithM3u8Evidence(AdDetectionRequest request) { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return; - String url = player().getUrl(); - if (!url.contains(".m3u8")) return; // Only parse m3u8 - try { - java.util.Map headers = player().getHeaders(); - com.fongmi.android.tv.bean.M3u8Evidence evidence = com.fongmi.android.tv.utils.M3u8Parser.parse(url, headers); - request.setEvidence(evidence); - } catch (Exception e) { - // Ignore parsing failures - } - } - - private void onAdDetectionResult(AdDetectionRequest request, AdDetectionResult result) { - // 记录 AI 分析结果统计 - AdBlockStatsStore.recordAiAnalysis(result != null && !result.isError()); - - if (result == null || result.isError()) { - Notify.show(result == null ? getString(R.string.ad_feedback_failed) : result.getErrorMessage()); - return; - } - if (result.isEmpty()) { - Notify.show(R.string.ad_feedback_no_ad); - return; - } - // Show preview dialog - com.fongmi.android.tv.ui.dialog.AdRulePreviewDialog.create(result).show(this, confirmedResult -> { - UserAdRule rule = UserAdRule.fromAiResult(confirmedResult, request.getSiteKey()); - com.fongmi.android.tv.api.config.UserAdRuleStore.add(rule); - Notify.show(R.string.ad_feedback_saved); + AdRulePlanApplier.Outcome outcome = AdRulePlanApplier.apply( + plan, site == null ? getKey() : site.getKey()); + Notify.show(switch (outcome) { + case APPLIED -> R.string.ad_interval_rule_saved; + case SKIPPED -> R.string.ad_interval_rule_skipped; + case FAILED -> R.string.ad_interval_rule_failed; }); } + private final class AdFeedbackPlayback implements AdFeedbackHostAdapter.Playback { + @Override + public long positionMs() { + return player() == null ? 0 : player().getPosition(); + } + + @Override + public long durationMs() { + return player() == null ? 0 : player().getDuration(); + } + + @Override + public String playUrl() { + return player() == null ? "" : player().getUrl(); + } + + @Override + public java.util.Map headers() { + return player() == null ? java.util.Map.of() : player().getHeaders(); + } + + @Override + public boolean hls() { + return player() != null + && com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(player().getUrl()); + } + + @Override + public boolean skipInterval(long startMs, long endMs, String feedbackId) { + return player() != null && player().skipUserAdInterval(startMs, endMs, feedbackId); + } + } + + private final class AdFeedbackMetadata implements AdFeedbackHostAdapter.Metadata { + @Override + public String siteKey() { + Site site = getSite(); + return site == null ? getKey() : site.getKey(); + } + + @Override + public String siteName() { + Site site = getSite(); + return site == null ? "" : site.getName(); + } + + @Override + public String vodName() { + return mHistory == null ? mBinding.name.getText().toString() : mHistory.getVodName(); + } + + @Override + public String flagName() { + return getFlag() == null ? "" : getFlag().getFlag(); + } + + @Override + public String episodeName() { + return getEpisode() == null ? "" : getEpisode().getName(); + } + } + + private final class AdFeedbackUi implements AdFeedbackHostAdapter.Ui { + @Override + public void runBackground(Runnable task) { + Task.execute(task); + } + + @Override + public void runOnUi(Runnable task) { + runOnUiThread(task); + } + + @Override + public void showSession(AdFeedbackSession session) { + showAdFeedbackSession(session); + } + } + private static class ShortDramaControlItem { private final View view; private final ViewGroup parent; @@ -11140,13 +10440,6 @@ private void restoreFlagSelectionWithoutPlayback() { @Override protected void onPlayerPositionDiscontinuity(Player.PositionInfo oldPosition, Player.PositionInfo newPosition, int reason) { - if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION - && mShortDramaQueue.shouldSuppressLegacyAutoAdvance()) { - mShortDramaTransitionPositionMs = oldPosition == null - ? C.TIME_UNSET : Math.max(0L, oldPosition.positionMs); - mShortDramaTransitionDurationMs = player() == null - ? C.TIME_UNSET : player().getDuration(); - } debugPlaybackControl("positionDiscontinuity=" + reason + " old=" + oldPosition.positionMs + " new=" + newPosition.positionMs); debugLyricsLoop("positionDiscontinuity=" + reason, true); syncLyricsPlaybackState(player().isPlaying()); @@ -11250,9 +10543,4 @@ private void dismissKaraokeResultDialogForRecreation() { SpiderDebug.log("karaoke-result", "dismiss old window for configuration change"); } - private void applyPlaybackOverlay() { - mBinding.control.getRoot().setBackgroundResource(R.color.transparent); - mBinding.control.bottom.setBackgroundResource(Setting.isPlaybackOverlayEnabled() ? R.drawable.shape_controller_scrim : R.color.transparent); - } - } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java index 110792e994..63083660ab 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java @@ -29,7 +29,6 @@ public class AdRuleAdapter extends RecyclerView.Adapter onToggleClick(holder, item)); } - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List payloads) { - if (!payloads.isEmpty() && payloads.get(payloads.size() - 1) instanceof Boolean enabled) { - RuleItem item = mItems.get(position); - holder.binding.toggle.setChecked(enabled); - holder.binding.toggle.setContentDescription((enabled ? "关闭" : "启用") + item.getName()); - if (item.getType() == RuleType.HLS_RULE) { - holder.binding.summary.setText(item.getSummary(holder.itemView.getContext())); - } - return; - } - super.onBindViewHolder(holder, position, payloads); - } - private void onToggleClick(ViewHolder holder, RuleItem item) { boolean enabled = holder.binding.toggle.isChecked(); // MaterialSwitch 会先改变自身状态;确认完成前保持列表展示持久化状态。 diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java index 5dc7ae9fed..ea203d6e87 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java @@ -9,7 +9,6 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.setting.InterfaceOrderStore; import com.fongmi.android.tv.databinding.AdapterConfigBinding; import java.util.List; @@ -28,8 +27,6 @@ public class ConfigAdapter extends RecyclerView.Adapter TextUtils.equals(item.getUrl(), currentUrl)); return this; @@ -54,20 +51,10 @@ public class ConfigAdapter extends RecyclerView.Adapter= mItems.size() || to >= mItems.size() || from == to) return false; - Config item = mItems.remove(from); - mItems.add(to, item); - notifyItemMoved(from, to); - if (mItems.size() > 0) InterfaceOrderStore.saveVodConfigs(mItems); - return true; - } - @Override public int getItemCount() { return mItems.size(); @@ -84,7 +71,6 @@ public class ConfigAdapter extends RecyclerView.Adapter listener.onTextClick(item)); - holder.binding.text.setOnLongClickListener(v -> listener.onTextLongClick(holder)); holder.binding.delete.setVisibility(readOnly ? View.GONE : View.VISIBLE); holder.binding.delete.setOnClickListener(v -> listener.onDeleteClick(item)); } @@ -93,7 +79,7 @@ public class ConfigAdapter extends RecyclerView.Adapter 0 ? Math.min(progress, duration) : 0, animate); holder.binding.delete.setVisibility(!delete ? View.GONE : View.VISIBLE); holder.binding.remark.setVisibility(same ? View.INVISIBLE : View.VISIBLE); - String watchedTime = HistoryProgressFormatter.format(item.getPosition(), item.getDuration()); - holder.binding.historyProgress.setText(watchedTime.isEmpty() ? "" : holder.itemView.getContext().getString(R.string.history_watched_time, watchedTime)); - holder.binding.historyProgress.setVisibility(delete || watchedTime.isEmpty() ? View.GONE : View.VISIBLE); ImgUtil.load(item.getVodName(), item.getVodPic(), holder.binding.image); setClickListener(holder.binding.getRoot(), item); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java index b1da2c359d..628bbf5d9f 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java @@ -18,7 +18,6 @@ import com.fongmi.android.tv.setting.SiteHealthStore; import com.fongmi.android.tv.setting.SiteBlockSetting; import com.fongmi.android.tv.setting.SiteOrderStore; import com.fongmi.android.tv.setting.SiteNameStore; -import com.fongmi.android.tv.ui.helper.SiteDialogTheme; import java.util.ArrayList; import java.util.List; @@ -27,7 +26,6 @@ import java.util.Locale; public class SiteAdapter extends RecyclerView.Adapter { private final OnClickListener listener; - private final SiteDialogTheme theme; private final List mAllItems; private final List mItems; private String group; @@ -37,9 +35,8 @@ public class SiteAdapter extends RecyclerView.Adapter { private boolean block; private int column = 1; - public SiteAdapter(OnClickListener listener, SiteDialogTheme theme) { + public SiteAdapter(OnClickListener listener) { this.listener = listener; - this.theme = theme; this.mAllItems = new ArrayList<>(); this.mItems = new ArrayList<>(); this.column = 1; @@ -156,11 +153,7 @@ public class SiteAdapter extends RecyclerView.Adapter { @NonNull @Override public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - AdapterSiteBinding binding = AdapterSiteBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); - theme.apply(binding.text); - theme.tint(binding.search); - theme.tint(binding.change); - return new ViewHolder(binding); + return new ViewHolder(AdapterSiteBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteGroupAdapter.java b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteGroupAdapter.java index 03a885bb7b..89fc272234 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteGroupAdapter.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteGroupAdapter.java @@ -4,10 +4,10 @@ import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.helper.SiteDialogTheme; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.button.MaterialButton; @@ -18,13 +18,11 @@ import java.util.Objects; public class SiteGroupAdapter extends RecyclerView.Adapter { private final OnClickListener listener; - private final SiteDialogTheme theme; private final List items; private String selectedGroup; - public SiteGroupAdapter(OnClickListener listener, SiteDialogTheme theme) { + public SiteGroupAdapter(OnClickListener listener) { this.listener = listener; - this.theme = theme; this.items = new ArrayList<>(); this.selectedGroup = ""; } @@ -77,7 +75,9 @@ public class SiteGroupAdapter extends RecyclerView.Adapter logFilters = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final EnumMap> logSelections = - new EnumMap<>(AdBlockLogFilter.Column.class); - private List currentLogs = new ArrayList<>(); - private BlockLogAdapter logAdapter; - private boolean logFiltersBound; public static AdBlockStatsDialog create(Activity activity) { return new AdBlockStatsDialog(activity); @@ -69,126 +49,11 @@ public class AdBlockStatsDialog { initView(); loadStats(); dialog.show(); - - android.view.Window window = dialog.getWindow(); - if (window != null) { - android.util.DisplayMetrics metrics = activity.getResources().getDisplayMetrics(); - int horizontalMargin = Math.round(16 * metrics.density); - int verticalMargin = Math.round(16 * metrics.density); - int width = Math.max(1, metrics.widthPixels - horizontalMargin * 2); - int height = Math.max(1, metrics.heightPixels - verticalMargin * 2); - android.view.WindowManager.LayoutParams params = window.getAttributes(); - params.width = width; - params.height = height; - params.gravity = android.view.Gravity.CENTER; - window.setBackgroundDrawable(new android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT)); - window.getDecorView().setPadding(0, 0, 0, 0); - window.setAttributes(params); - window.setLayout(width, height); - binding.getRoot().setMinimumHeight(height); - } } private void initView() { binding.reset.setOnClickListener(v -> onReset()); binding.close.setOnClickListener(v -> dialog.dismiss()); - if (binding.statsTabs.getTabCount() == 0) { - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_overview)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_site_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_rule_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_pipeline_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_log)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_chart)); - binding.statsTabs.addOnTabSelectedListener(new com.google.android.material.tabs.TabLayout.OnTabSelectedListener() { - @Override public void onTabSelected(com.google.android.material.tabs.TabLayout.Tab tab) { showPage(tab.getPosition()); } - @Override public void onTabUnselected(com.google.android.material.tabs.TabLayout.Tab tab) {} - @Override public void onTabReselected(com.google.android.material.tabs.TabLayout.Tab tab) {} - }); - } - if (!logFiltersBound) { - bindLogFilters(); - logFiltersBound = true; - } - showPage(binding.statsTabs.getSelectedTabPosition()); - } - - private void bindLogFilters() { - bindCategoricalFilter(binding.siteFilter, AdBlockLogFilter.Column.SITE_NAME, R.string.ad_log_site_name); - bindCategoricalFilter(binding.siteDomainFilter, AdBlockLogFilter.Column.SITE_DOMAIN, R.string.ad_log_site_domain); - bindCategoricalFilter(binding.vodFilter, AdBlockLogFilter.Column.VOD_NAME, R.string.ad_log_playback_context); - bindCategoricalFilter(binding.lineFilter, AdBlockLogFilter.Column.LINE_NAME, R.string.ad_log_line_name); - bindCategoricalFilter(binding.episodeFilter, AdBlockLogFilter.Column.EPISODE_NAME, R.string.ad_log_episode_name); - bindCategoricalFilter(binding.ruleFilter, AdBlockLogFilter.Column.RULE, R.string.ad_log_rule_domain); - bindCategoricalFilter(binding.adDomainFilter, AdBlockLogFilter.Column.AD_DOMAIN, R.string.ad_log_ad_domain); - bindCategoricalFilter(binding.pipelineFilter, AdBlockLogFilter.Column.PIPELINE, R.string.ad_log_pipeline); - bindTextFilter(binding.blockedAtFilter, AdBlockLogFilter.Column.BLOCKED_AT); - bindTextFilter(binding.segmentStartFilter, AdBlockLogFilter.Column.SEGMENT_START); - bindTextFilter(binding.segmentEndFilter, AdBlockLogFilter.Column.SEGMENT_END); - bindTextFilter(binding.segmentDurationFilter, AdBlockLogFilter.Column.SEGMENT_DURATION); - } - - private void bindCategoricalFilter(MaterialButton button, AdBlockLogFilter.Column column, int title) { - button.setOnClickListener(v -> showCategoricalFilter(button, column, title)); - updateCategoricalButton(button, column); - } - - private void showCategoricalFilter(MaterialButton button, AdBlockLogFilter.Column column, int title) { - Set selected = logSelections.get(column); - AdBlockLogFilterDialog.show(activity, activity.getString(title), currentLogs, - selected == null ? java.util.Set.of() : new java.util.LinkedHashSet<>(selected), - log -> displayValue(log, column), values -> { - if (values == null || values.isEmpty()) logSelections.remove(column); - else logSelections.put(column, new java.util.LinkedHashSet<>(values)); - updateCategoricalButton(button, column); - if (logAdapter != null) { - logAdapter.setSelection(column, values); - updateLogEmptyState(); - } - }); - } - - private void updateCategoricalButton(MaterialButton button, AdBlockLogFilter.Column column) { - Set selected = logSelections.get(column); - if (selected == null || selected.isEmpty()) { - button.setText(R.string.ad_log_filter_hint); - } else if (selected.size() == 1) { - button.setText(selected.iterator().next()); - } else { - button.setText(activity.getString(R.string.ad_log_filter_selected, selected.size())); - } - button.setEnabled(!currentLogs.isEmpty()); - } - - private void bindTextFilter(EditText input, AdBlockLogFilter.Column column) { - input.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - - @Override - public void afterTextChanged(Editable s) { - String query = s == null ? "" : s.toString(); - if (query.trim().isEmpty()) logFilters.remove(column); - else logFilters.put(column, query); - if (logAdapter != null) { - logAdapter.setFilter(column, query); - updateLogEmptyState(); - } - } - }); - } - - private void showPage(int position) { - binding.overviewPage.setVisibility(position == 0 ? View.VISIBLE : View.GONE); - binding.sitePage.setVisibility(position == 1 ? View.VISIBLE : View.GONE); - binding.rulePage.setVisibility(position == 2 ? View.VISIBLE : View.GONE); - binding.pipelinePage.setVisibility(position == 3 ? View.VISIBLE : View.GONE); - binding.logPage.setVisibility(position == 4 ? View.VISIBLE : View.GONE); - binding.chartPage.setVisibility(position == 5 ? View.VISIBLE : View.GONE); } private void loadStats() { @@ -205,29 +70,18 @@ public class AdBlockStatsDialog { binding.aiSuccessRate.setText("0%"); } - // 站点维度统计概览与排行使用同一份快照 + // 站点拦截排行 List siteRank = buildSiteRank(stats); - binding.siteCount.setText(String.valueOf(stats.getSiteBlocked().size())); - if (siteRank.isEmpty()) { - binding.topSite.setText("-"); - binding.topSiteShare.setText("0%"); - } else { - SiteRankItem top = siteRank.get(0); - binding.topSite.setText(top.getDisplayName()); - double share = stats.getTotalBlocked() > 0 ? top.getCount() * 100.0 / stats.getTotalBlocked() : 0.0; - binding.topSiteShare.setText(String.format(Locale.getDefault(), "%.1f%%", share)); - } - - // 站点、规则和播放链路默认显示汇总项,点击汇总项展开对应的逐条日志。 if (siteRank.isEmpty()) { binding.siteRankEmpty.setVisibility(View.VISIBLE); binding.siteRankRecycler.setVisibility(View.GONE); } else { binding.siteRankEmpty.setVisibility(View.GONE); binding.siteRankRecycler.setVisibility(View.VISIBLE); - binding.siteRankRecycler.setAdapter(new GroupedLogAdapter(siteRank, stats, GroupType.SOURCE)); + binding.siteRankRecycler.setAdapter(new SiteRankAdapter(siteRank)); } + // 规则命中排行 List ruleRank = AdBlockStatsStore.getTopRules(10); if (ruleRank.isEmpty()) { binding.ruleRankEmpty.setVisibility(View.VISIBLE); @@ -235,126 +89,24 @@ public class AdBlockStatsDialog { } else { binding.ruleRankEmpty.setVisibility(View.GONE); binding.ruleRankRecycler.setVisibility(View.VISIBLE); - binding.ruleRankRecycler.setAdapter(new RuleGroupedLogAdapter(ruleRank, stats)); + binding.ruleRankRecycler.setAdapter(new RuleRankAdapter(ruleRank)); } - - List pipelineRank = buildPipelineRank(stats); - if (pipelineRank.isEmpty()) { - binding.pipelineRankEmpty.setVisibility(View.VISIBLE); - binding.pipelineRankRecycler.setVisibility(View.GONE); - } else { - binding.pipelineRankEmpty.setVisibility(View.GONE); - binding.pipelineRankRecycler.setVisibility(View.VISIBLE); - binding.pipelineRankRecycler.setAdapter(new GroupedLogAdapter(pipelineRank, stats, GroupType.PIPELINE)); - } - - List logs = stats.getBlockLogs(); - currentLogs = new ArrayList<>(logs); - updateAllCategoricalButtons(); - logAdapter = new BlockLogAdapter(logs, logFilters, logSelections, this::filterValues); - binding.logRecycler.setAdapter(logAdapter); - updateLogEmptyState(); - - List chartItems = buildSiteRank(stats); - binding.chartEmpty.setVisibility(chartItems.isEmpty() ? View.VISIBLE : View.GONE); - binding.chartView.setVisibility(chartItems.isEmpty() ? View.GONE : View.VISIBLE); - List chartEntries = chartItems.stream() - .map(item -> new AdBlockChartView.Entry(item.getDisplayName(), item.getCount())) - .collect(Collectors.toList()); - binding.chartView.setEntries(chartEntries); - } - - private void updateAllCategoricalButtons() { - updateCategoricalButton(binding.siteFilter, AdBlockLogFilter.Column.SITE_NAME); - updateCategoricalButton(binding.siteDomainFilter, AdBlockLogFilter.Column.SITE_DOMAIN); - updateCategoricalButton(binding.vodFilter, AdBlockLogFilter.Column.VOD_NAME); - updateCategoricalButton(binding.lineFilter, AdBlockLogFilter.Column.LINE_NAME); - updateCategoricalButton(binding.episodeFilter, AdBlockLogFilter.Column.EPISODE_NAME); - updateCategoricalButton(binding.ruleFilter, AdBlockLogFilter.Column.RULE); - updateCategoricalButton(binding.adDomainFilter, AdBlockLogFilter.Column.AD_DOMAIN); - updateCategoricalButton(binding.pipelineFilter, AdBlockLogFilter.Column.PIPELINE); - } - - private Map filterValues(AdBlockLog item) { - EnumMap values = new EnumMap<>(AdBlockLogFilter.Column.class); - for (AdBlockLogFilter.Column column : AdBlockLogFilter.Column.values()) { - values.put(column, displayValue(item, column)); - } - return values; - } - - private String displayValue(AdBlockLog item, AdBlockLogFilter.Column column) { - String unknown = activity.getString(R.string.ad_log_unknown); - switch (column) { - case SITE_NAME: return value(item.getSiteName(), unknown); - case SITE_DOMAIN: return value(item.getSiteDomain(), unknown); - case VOD_NAME: return value(item.getVodName(), unknown); - case LINE_NAME: return value(item.getLineName(), unknown); - case EPISODE_NAME: return value(item.getEpisodeName(), unknown); - case RULE: return value(AdBlockStatsStore.getRuleDisplayName(item.getRuleId()), unknown); - case AD_DOMAIN: return value(item.getAdDomain(), unknown); - case BLOCKED_AT: return item.getBlockedAt() > 0 - ? new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(new Date(item.getBlockedAt())) : unknown; - case SEGMENT_START: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentStartSeconds()) : unknown; - case SEGMENT_END: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentEndSeconds()) : unknown; - case SEGMENT_DURATION: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentDurationSeconds()) : unknown; - case PIPELINE: return value(item.getPipelineName(), unknown); - default: return unknown; - } - } - - private static String value(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; - } - - private void updateLogEmptyState() { - boolean noLogs = logAdapter == null || logAdapter.getSourceItemCount() == 0; - boolean noMatches = !noLogs && logAdapter.getItemCount() == 0; - binding.logEmpty.setText(noLogs ? R.string.ad_stats_empty : R.string.ad_stats_filter_empty); - binding.logEmpty.setVisibility(noLogs || noMatches ? View.VISIBLE : View.GONE); - binding.logTableScroll.setVisibility(noLogs ? View.GONE : View.VISIBLE); } private List buildSiteRank(AdBlockStats stats) { - Map displayNames = new java.util.HashMap<>(); - for (AdBlockLog log : stats.getBlockLogs()) { - String name = log.getSiteName(); - if (name == null || name.isBlank()) continue; - if (!log.getSiteKey().isBlank()) displayNames.putIfAbsent(log.getSiteKey(), name); - displayNames.putIfAbsent(log.getSourceName(), name); - } return stats.getSiteBlocked().entrySet().stream() - .map(entry -> new SiteRankItem(entry.getKey(), - displayNames.getOrDefault(entry.getKey(), entry.getKey()), entry.getValue())) + .map(entry -> new SiteRankItem(entry.getKey(), entry.getValue())) .sorted(Comparator.comparingLong(SiteRankItem::getCount).reversed()) .limit(10) .collect(Collectors.toList()); } - private List buildPipelineRank(AdBlockStats stats) { - return stats.getPipelineCounts().entrySet().stream() - .map(entry -> new SiteRankItem(entry.getKey(), entry.getValue())) - .sorted(Comparator.comparingLong(SiteRankItem::getCount).reversed()) - .collect(Collectors.toList()); - } - - private void clearTextFilters() { - binding.blockedAtFilter.setText(""); - binding.segmentStartFilter.setText(""); - binding.segmentEndFilter.setText(""); - binding.segmentDurationFilter.setText(""); - updateAllCategoricalButtons(); - } - private void onReset() { new MaterialAlertDialogBuilder(activity) .setTitle(R.string.ad_stats_reset) .setMessage(R.string.ad_stats_reset_confirm) .setPositiveButton(android.R.string.ok, (d, which) -> { AdBlockStatsStore.reset(); - logFilters.clear(); - logSelections.clear(); - clearTextFilters(); loadStats(); }) .setNegativeButton(android.R.string.cancel, null) @@ -364,16 +116,10 @@ public class AdBlockStatsDialog { // 站点排行项 private static class SiteRankItem { private final String siteKey; - private final String displayName; private final long count; public SiteRankItem(String siteKey, long count) { - this(siteKey, siteKey, count); - } - - public SiteRankItem(String siteKey, String displayName, long count) { this.siteKey = siteKey; - this.displayName = displayName; this.count = count; } @@ -381,213 +127,11 @@ public class AdBlockStatsDialog { return siteKey; } - public String getDisplayName() { - return displayName; - } - public long getCount() { return count; } } - private enum GroupType { SOURCE, PIPELINE } - - private static class GroupedLogAdapter extends RecyclerView.Adapter { - private final List groups; - private final AdBlockStats stats; - private final GroupType type; - private final java.util.Set expanded = new java.util.HashSet<>(); - private final List visibleItems = new ArrayList<>(); - - GroupedLogAdapter(List groups, AdBlockStats stats, GroupType type) { - this.groups = groups; - this.stats = stats; - this.type = type; - rebuild(); - } - - private void rebuild() { - visibleItems.clear(); - for (SiteRankItem group : groups) { - visibleItems.add(group); - if (expanded.contains(group.getSiteKey())) { - visibleItems.addAll(type == GroupType.SOURCE - ? stats.getBlockLogsBySite(group.getSiteKey()) - : stats.getBlockLogsByPipeline(group.getSiteKey())); - } - } - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - Object visible = visibleItems.get(position); - if (visible instanceof SiteRankItem) { - SiteRankItem group = (SiteRankItem) visible; - boolean isExpanded = expanded.contains(group.getSiteKey()); - holder.binding.name.setText((isExpanded ? "▼ " : "▶ ") + group.getDisplayName()); - holder.binding.source.setVisibility(View.GONE); - holder.binding.count.setText(String.valueOf(group.getCount())); - holder.itemView.setOnClickListener(view -> { - if (!expanded.add(group.getSiteKey())) expanded.remove(group.getSiteKey()); - rebuild(); - notifyDataSetChanged(); - }); - } else { - bindLog(holder, (AdBlockLog) visible); - } - } - - private void bindLog(ViewHolder holder, AdBlockLog log) { - holder.binding.name.setText(" " + log.getAdDomain()); - holder.binding.source.setText(log.getSourceName() + " · " + log.getPipelineName() + " · " + log.getRuleId()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", log.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(null); - } - - @Override - public int getItemCount() { - return visibleItems.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private static class RuleGroupedLogAdapter extends RecyclerView.Adapter { - private final List groups; - private final AdBlockStats stats; - private final java.util.Set expanded = new java.util.HashSet<>(); - private final List visibleItems = new ArrayList<>(); - - RuleGroupedLogAdapter(List groups, AdBlockStats stats) { - this.groups = groups; - this.stats = stats; - rebuild(); - } - - private void rebuild() { - visibleItems.clear(); - for (RuleHitRecord group : groups) { - visibleItems.add(group); - if (expanded.contains(group.getRuleId())) visibleItems.addAll(stats.getBlockLogsByRule(group.getRuleId())); - } - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - Object visible = visibleItems.get(position); - if (visible instanceof RuleHitRecord) { - RuleHitRecord group = (RuleHitRecord) visible; - boolean isExpanded = expanded.contains(group.getRuleId()); - String name = "hls.legacy-fallback".equals(group.getRuleId()) ? "内部兜底规则" : group.getRuleName(); - if (name == null || name.isEmpty()) name = group.getRuleId(); - holder.binding.name.setText((isExpanded ? "▼ " : "▶ ") + name); - holder.binding.source.setText(group.getRuleSource()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.valueOf(group.getHitCount())); - holder.itemView.setOnClickListener(view -> { - if (!expanded.add(group.getRuleId())) expanded.remove(group.getRuleId()); - rebuild(); - notifyDataSetChanged(); - }); - } else { - AdBlockLog log = (AdBlockLog) visible; - holder.binding.name.setText(" " + log.getAdDomain()); - holder.binding.source.setText(log.getSourceName() + " · " + log.getPipelineName()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", log.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(null); - } - } - - @Override - public int getItemCount() { - return visibleItems.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private static class ExpandableLogAdapter extends RecyclerView.Adapter { - private final List items; - private boolean expanded; - - ExpandableLogAdapter(List items) { - this.items = items; - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - AdBlockLog item = items.get(position); - holder.binding.name.setText(item.getAdDomain()); - holder.binding.source.setText(item.getSourceName() + " · " + item.getPipelineName() + " · " + item.getRuleId()); - holder.binding.source.setVisibility(expanded ? View.VISIBLE : View.GONE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", item.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(view -> { - expanded = !expanded; - notifyDataSetChanged(); - }); - } - - @Override - public int getItemCount() { - return items.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private List getBlockLogsBySource(AdBlockStats stats, String source) { - return stats.getBlockLogsBySource(source); - } - - private List getBlockLogsByRule(AdBlockStats stats, String rule) { - return stats.getBlockLogsByRule(rule); - } - - private List getBlockLogsByPipeline(AdBlockStats stats, String pipeline) { - return stats.getBlockLogsByPipeline(pipeline); - } - // 站点排行适配器 private static class SiteRankAdapter extends RecyclerView.Adapter { private final List items; @@ -605,7 +149,7 @@ public class AdBlockStatsDialog { @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { SiteRankItem item = items.get(position); - holder.binding.name.setText(item.getDisplayName()); + holder.binding.name.setText(item.getSiteKey()); holder.binding.count.setText(String.valueOf(item.getCount())); holder.binding.source.setVisibility(View.GONE); } @@ -625,106 +169,6 @@ public class AdBlockStatsDialog { } } - private static class BlockLogAdapter extends RecyclerView.Adapter { - private final List sourceItems; - private List items; - private final EnumMap filters = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final EnumMap> selections = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final AdBlockLogFilter.ValueProvider valueProvider; - private final SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()); - - BlockLogAdapter(List items, Map initialFilters, - Map> initialSelections, - AdBlockLogFilter.ValueProvider valueProvider) { - this.sourceItems = items == null ? new ArrayList<>() : new ArrayList<>(items); - if (initialFilters != null) filters.putAll(initialFilters); - if (initialSelections != null) { - for (Map.Entry> entry : initialSelections.entrySet()) { - if (entry.getValue() != null && !entry.getValue().isEmpty()) { - selections.put(entry.getKey(), new java.util.LinkedHashSet<>(entry.getValue())); - } - } - } - this.valueProvider = valueProvider; - applyFilters(); - } - - void setFilter(AdBlockLogFilter.Column column, String query) { - if (query == null || query.trim().isEmpty()) filters.remove(column); - else filters.put(column, query); - applyFilters(); - notifyDataSetChanged(); - } - - void setSelection(AdBlockLogFilter.Column column, Set values) { - if (values == null || values.isEmpty()) selections.remove(column); - else selections.put(column, new java.util.LinkedHashSet<>(values)); - applyFilters(); - notifyDataSetChanged(); - } - - int getSourceItemCount() { - return sourceItems.size(); - } - - private void applyFilters() { - items = AdBlockLogFilter.filter(sourceItems, filters, selections, valueProvider); - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - AdapterAdBlockLogBinding binding = AdapterAdBlockLogBinding.inflate( - LayoutInflater.from(parent.getContext()), parent, false); - return new ViewHolder(binding); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - AdBlockLog item = items.get(position); - String unknown = holder.itemView.getContext().getString(R.string.ad_log_unknown); - holder.binding.siteName.setText(value(item.getSiteName(), unknown)); - holder.binding.siteDomain.setText(value(item.getSiteDomain(), unknown)); - holder.binding.playbackContext.setText(value(item.getVodName(), unknown)); - holder.binding.lineName.setText(value(item.getLineName(), unknown)); - holder.binding.episodeName.setText(value(item.getEpisodeName(), unknown)); - String rule = AdBlockStatsStore.getRuleDisplayName(item.getRuleId()); - String domain = value(item.getAdDomain(), unknown); - holder.binding.ruleDomain.setText(value(rule, unknown)); - holder.binding.adDomain.setText(domain); - holder.binding.blockedAt.setText(item.getBlockedAt() > 0 - ? timeFormat.format(new Date(item.getBlockedAt())) : unknown); - holder.binding.segmentStart.setText(item.hasSegmentTiming() ? seconds(item.getSegmentStartSeconds()) : unknown); - holder.binding.segmentEnd.setText(item.hasSegmentTiming() ? seconds(item.getSegmentEndSeconds()) : unknown); - holder.binding.segmentDuration.setText(item.hasSegmentTiming() ? seconds(item.getSegmentDurationSeconds()) : unknown); - holder.binding.pipeline.setText(value(item.getPipelineName(), unknown)); - } - - private static String seconds(double value) { - return AdBlockTimeFormatter.formatSeconds(value); - } - - private static String value(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; - } - - @Override - public int getItemCount() { - return items.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final AdapterAdBlockLogBinding binding; - - ViewHolder(AdapterAdBlockLogBinding binding) { - super(binding.getRoot()); - this.binding = binding; - } - } - } - // 规则排行适配器 private static class RuleRankAdapter extends RecyclerView.Adapter { private final List items; diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java index 581b822306..69c5d58e57 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java @@ -1,6 +1,5 @@ package com.fongmi.android.tv.ui.dialog; -import android.app.Dialog; import android.content.Context; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; @@ -8,11 +7,8 @@ import android.view.Gravity; import android.view.View; import android.view.Window; import android.view.WindowManager; -import android.widget.LinearLayout; -import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; import androidx.viewbinding.ViewBinding; @@ -34,7 +30,6 @@ import com.fongmi.android.tv.ui.adapter.AdRuleAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.button.MaterialButton; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -91,8 +86,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter binding.add.setOnClickListener(v -> onAddManual()); binding.stats.setOnClickListener(v -> onStats()); binding.importCandidates.setOnClickListener(v -> onImportCandidates()); - binding.enableImported.setOnClickListener(v -> onEnableImported()); - binding.disableImported.setOnClickListener(v -> onDisableImported()); } private void loadData() { @@ -127,8 +120,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter int pending = ImportedAdRuleCandidateStore.pending().size(); binding.importCandidates.setVisibility(pending == 0 ? View.GONE : View.VISIBLE); binding.importCandidates.setText(getString(R.string.ad_rule_import_candidates, pending)); - boolean hasImported = userRules.stream().anyMatch(rule -> UserAdRule.isInterfaceSource(rule.getSource())); - binding.importBulkActions.setVisibility(hasImported ? View.VISIBLE : View.GONE); } private void onAddManual() { @@ -139,25 +130,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter AdBlockStatsDialog.create((FragmentActivity) requireActivity()).show(); } - private void onEnableImported() { - if (UserAdRuleStore.setInterfaceRulesEnabled(true) == 0) return; - loadData(); - if (callback != null) callback.onRuleChanged(); - } - - private void onDisableImported() { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.ad_rule_disable_imported) - .setMessage(R.string.ad_rule_disable_imported_confirm) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - UserAdRuleStore.setInterfaceRulesEnabled(false); - loadData(); - if (callback != null) callback.onRuleChanged(); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - private void onImportCandidates() { List candidates = ImportedAdRuleCandidateStore.pending(); if (candidates.isEmpty()) return; @@ -170,91 +142,19 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter .toArray(String[]::new); boolean[] selected = new boolean[candidates.size()]; for (int i = 0; i < selected.length; i++) selected[i] = ImportedAdRuleCandidate.RISK_LOW.equals(candidates.get(i).getRiskLevel()); - final AlertDialog[] dialogRef = new AlertDialog[1]; - View toolbar = createCandidateToolbar( - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 0), - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 1), - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 2), - () -> ignoreCandidates(dialogRef[0], candidates)); - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.ad_rule_import_title) - .setCustomTitle(toolbar) .setMultiChoiceItems(labels, selected, (dialog, which, checked) -> selected[which] = checked) - .setPositiveButton(R.string.ad_rule_import_enable_action, (dialog, which) -> importSelectedCandidates(candidates, selected, true)) - .setNeutralButton(R.string.ad_rule_import_action, (dialog, which) -> importSelectedCandidates(candidates, selected, false)) - .setNegativeButton(android.R.string.cancel, null); - dialogRef[0] = builder.create(); - dialogRef[0].show(); - } - - private void ignoreCandidates(Dialog dialog, List candidates) { - List ids = new ArrayList<>(); - for (ImportedAdRuleCandidate candidate : candidates) ids.add(candidate.getId()); - ImportedAdRuleCandidateStore.ignoreCandidates(ids); - if (dialog != null) dialog.dismiss(); - loadData(); - } - - private void importSelectedCandidates(List candidates, boolean[] selected, boolean enabled) { - List ids = new ArrayList<>(); - for (int i = 0; i < candidates.size(); i++) if (selected[i]) ids.add(candidates.get(i).getId()); - if (!ids.isEmpty()) ImportedAdRuleCandidateStore.importCandidates(ids, enabled); - onRuleEdited(); - } - - private View createCandidateToolbar(Runnable selectLowRisk, Runnable selectAll, Runnable invert, Runnable ignoreAll) { - LinearLayout root = new LinearLayout(requireContext()); - root.setOrientation(LinearLayout.VERTICAL); - root.setPadding(ResUtil.dp2px(24), ResUtil.dp2px(8), ResUtil.dp2px(24), 0); - TextView title = new TextView(requireContext()); - title.setText(R.string.ad_rule_import_title); - title.setTextSize(20); - root.addView(title, new LinearLayout.LayoutParams(-1, -2)); - MaterialButton lowRisk = candidateActionButton(R.string.ad_rule_select_low_risk); - lowRisk.setOnClickListener(v -> selectLowRisk.run()); - MaterialButton all = candidateActionButton(R.string.ad_rule_select_all); - all.setOnClickListener(v -> selectAll.run()); - MaterialButton invertButton = candidateActionButton(R.string.ad_rule_invert_selection); - invertButton.setOnClickListener(v -> invert.run()); - MaterialButton ignore = candidateActionButton(R.string.ad_rule_ignore_all); - ignore.setOnClickListener(v -> ignoreAll.run()); - root.addView(lowRisk, new LinearLayout.LayoutParams(-1, -2)); - LinearLayout row = new LinearLayout(requireContext()); - row.setOrientation(LinearLayout.HORIZONTAL); - LinearLayout.LayoutParams first = new LinearLayout.LayoutParams(0, -2, 1f); - first.setMargins(0, ResUtil.dp2px(4), ResUtil.dp2px(2), 0); - row.addView(all, first); - LinearLayout.LayoutParams middle = new LinearLayout.LayoutParams(0, -2, 1f); - middle.setMargins(ResUtil.dp2px(2), ResUtil.dp2px(4), ResUtil.dp2px(2), 0); - row.addView(invertButton, middle); - LinearLayout.LayoutParams last = new LinearLayout.LayoutParams(0, -2, 1f); - last.setMargins(ResUtil.dp2px(2), ResUtil.dp2px(4), 0, 0); - row.addView(ignore, last); - root.addView(row); - return root; - } - - private MaterialButton candidateActionButton(int textRes) { - MaterialButton button = new MaterialButton(requireContext()); - button.setText(textRes); - button.setAllCaps(false); - button.setTextSize(12); - button.setMinHeight(ResUtil.dp2px(40)); - return button; - } - - private void updateCandidateSelection(Dialog dialog, List candidates, - boolean[] selected, int mode) { - if (mode == 0) { - for (int i = 0; i < selected.length; i++) selected[i] = ImportedAdRuleCandidate.RISK_LOW.equals(candidates.get(i).getRiskLevel()); - } else if (mode == 1) { - for (int i = 0; i < selected.length; i++) selected[i] = true; - } else { - for (int i = 0; i < selected.length; i++) selected[i] = !selected[i]; - } - if (dialog instanceof AlertDialog alert && alert.getListView() != null) { - for (int i = 0; i < selected.length; i++) alert.getListView().setItemChecked(i, selected[i]); - } + .setPositiveButton(R.string.ad_rule_import_action, (dialog, which) -> { + for (int i = 0; i < candidates.size(); i++) if (selected[i]) ImportedAdRuleCandidateStore.importCandidate(candidates.get(i).getId()); + onRuleEdited(); + }) + .setNeutralButton(R.string.ad_rule_ignore_all, (dialog, which) -> { + for (ImportedAdRuleCandidate candidate : candidates) ImportedAdRuleCandidateStore.ignore(candidate.getId()); + loadData(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); } private void onRuleEdited() { @@ -276,12 +176,9 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter @Override public void onHlsRuleClick(HlsRuleConfig.Entry item) { - HlsRuleConfig.Entry current = HlsRuleConfig.getEntries().stream() - .filter(entry -> item.key().equals(entry.key())) - .findFirst().orElse(item); new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(current.name().isBlank() ? current.id() : current.name()) - .setMessage(getHlsRuleDetail(current)) + .setTitle(item.name().isBlank() ? item.id() : item.name()) + .setMessage(getHlsRuleDetail(item)) .setPositiveButton(android.R.string.ok, null) .show(); } @@ -301,11 +198,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter String status = item.valid() ? getString(item.enabled() ? R.string.ad_rule_hls_enabled : R.string.ad_rule_hls_disabled) : getString(R.string.ad_rule_hls_invalid, item.error()); - if (HlsRuleConfig.LEGACY_FALLBACK_KEY.equals(item.id())) { - return getString(R.string.ad_rule_hls_builtin_summary, item.version(), status) - + "\n\n规则 ID\n" + item.id() - + "\n\n具体判断规则\n" + HlsRuleConfig.LEGACY_FALLBACK_DETAIL; - } HlsAdRule rule = BuiltinHlsRuleLoader.getRules().stream().filter(value -> item.id().equals(value.getId())).findFirst().orElse(null); if (rule == null) return getString(R.string.ad_rule_hls_builtin_summary, item.version(), status) + "\n\n规则 ID\n" + item.id(); JsonObject json = App.gson().toJsonTree(rule).getAsJsonObject(); @@ -358,7 +250,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter public void onUserToggleClick(UserAdRule item, boolean enabled) { if (!enabled) { int message = UserAdRule.SOURCE_AI.equals(item.getSource()) ? R.string.ad_rule_ai_disable_confirm - : UserAdRule.isInterfaceSource(item.getSource()) ? R.string.ad_rule_imported_disable_confirm + : item.getSource() != null && item.getSource().startsWith("interface_") ? R.string.ad_rule_imported_disable_confirm : R.string.ad_rule_manual_disable_confirm; confirmDisable(item.getName(), message, () -> setUserEnabled(item, false)); } @@ -368,7 +260,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void setUserEnabled(UserAdRule item, boolean enabled) { item.setEnabled(enabled); UserAdRuleStore.update(item); - adapter.refreshUserEnabled(item); + loadData(); if (callback != null) callback.onRuleChanged(); } @@ -380,14 +272,14 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void setDefaultEnabled(String ruleId, boolean enabled) { DisabledDefaultRuleStore.setDisabled(ruleId, !enabled); - adapter.refreshDefaultEnabled(ruleId, enabled); + loadData(); if (callback != null) callback.onRuleChanged(); } @Override public void onHlsToggleClick(HlsRuleConfig.Entry item, boolean enabled) { HlsRuleStateStore.setEnabled(item.key(), enabled); - adapter.refreshHlsEnabled(item.key(), enabled); + loadData(); if (callback != null) callback.onRuleChanged(); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java index 950e83e020..1cdaf8668f 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java @@ -2,6 +2,7 @@ package com.fongmi.android.tv.ui.dialog; import android.app.Activity; import android.app.Dialog; +import android.content.res.ColorStateList; import android.content.res.Configuration; import android.text.TextUtils; import android.view.LayoutInflater; @@ -30,6 +31,7 @@ import com.fongmi.android.tv.ui.adapter.EpisodeStillAdapter; import com.fongmi.android.tv.ui.adapter.TmdbPersonAdapter; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; +import com.google.android.material.button.MaterialButton; import com.google.android.material.card.MaterialCardView; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.gson.JsonObject; @@ -55,16 +57,8 @@ public class EpisodeDetailDialog { java.util.List preloadedPhotos, java.util.List preloadedGuests, android.content.DialogInterface.OnDismissListener dismissListener) { - show(activity, episode, null, site, preloadedPhotos, preloadedGuests, dismissListener); - } - - public static void show(FragmentActivity activity, Episode episode, TmdbEpisode boundTmdbEpisode, - com.fongmi.android.tv.bean.Site site, - java.util.List preloadedPhotos, - java.util.List preloadedGuests, - android.content.DialogInterface.OnDismissListener dismissListener) { if (activity == null || episode == null) return; - TmdbEpisode tmdbEpisode = boundTmdbEpisode != null ? boundTmdbEpisode : episode.getTmdbEpisode(); + TmdbEpisode tmdbEpisode = episode.getTmdbEpisode(); if (tmdbEpisode == null) { // 电影没有分集对象,尝试从详情页获取影片级数据 if (activity instanceof com.fongmi.android.tv.ui.host.TmdbDetailHost) { @@ -99,6 +93,7 @@ public class EpisodeDetailDialog { Dialog dialog = new Dialog(activity, android.R.style.Theme_Black_NoTitleBar_Fullscreen); dialog.setContentView(view); + view.findViewById(R.id.close).setOnClickListener(v -> dialog.dismiss()); if (dismissListener != null) dialog.setOnDismissListener(dismissListener); dialog.show(); applyWindowSize(dialog); @@ -176,6 +171,7 @@ public class EpisodeDetailDialog { Dialog dialog = new Dialog(activity, android.R.style.Theme_Black_NoTitleBar_Fullscreen); dialog.setContentView(view); + view.findViewById(R.id.close).setOnClickListener(v -> dialog.dismiss()); if (dismissListener != null) dialog.setOnDismissListener(dismissListener); dialog.show(); applyWindowSize(dialog); @@ -201,7 +197,7 @@ public class EpisodeDetailDialog { if (movieDetail == null) return; Task.execute(() -> { try { - TmdbConfig config = TmdbConfig.effectiveCurrent(); + TmdbConfig config = TmdbConfig.objectFrom(Setting.getTmdbConfig()); if (config == null || !config.isReady()) return; TmdbService service = new TmdbService(); List photos = service.photos(movieDetail, config); @@ -285,6 +281,7 @@ public class EpisodeDetailDialog { TextView overview = view.findViewById(R.id.overview); TextView photoTitle = view.findViewById(R.id.photoTitle); TextView guestsTitle = view.findViewById(R.id.guestsTitle); + MaterialButton close = view.findViewById(R.id.close); int overlay = light ? 0x99F4F7FA : 0xB3000000; int panelColor = light ? 0xFFF4F7FA : 0xFF2A2A2A; @@ -293,6 +290,7 @@ public class EpisodeDetailDialog { int secondary = light ? 0x9912202D : 0xFFAAAAAA; int body = light ? 0xCC12202D : 0xFFDDDDDD; int stroke = light ? 0x33424B57 : 0xFF4A4A4A; + int control = light ? 0xFFE7EDF3 : 0xFF2A2A2A; if (root != null) root.setBackgroundColor(overlay); if (panel != null) { @@ -306,12 +304,14 @@ public class EpisodeDetailDialog { overview.setTextColor(body); photoTitle.setTextColor(primary); guestsTitle.setTextColor(primary); + close.setTextColor(primary); + close.setStrokeColor(ColorStateList.valueOf(stroke)); + close.setBackgroundTintList(ColorStateList.valueOf(control)); + close.setRippleColor(ColorStateList.valueOf(light ? 0x1F12202D : 0x33FFFFFF)); } private static void bindHorizontalList(RecyclerView view, int spacingDp) { view.setLayoutManager(new LinearLayoutManager(view.getContext(), LinearLayoutManager.HORIZONTAL, false)); - view.setNestedScrollingEnabled(false); - view.setOverScrollMode(View.OVER_SCROLL_NEVER); view.addItemDecoration(new RecyclerView.ItemDecoration() { @Override public void getItemOffsets(@NonNull android.graphics.Rect outRect, @NonNull View child, RecyclerView parent, @NonNull RecyclerView.State state) { @@ -325,7 +325,7 @@ public class EpisodeDetailDialog { if (episode.getTmdbId() == 0) return; Task.execute(() -> { try { - TmdbConfig config = TmdbConfig.effectiveCurrent(); + TmdbConfig config = TmdbConfig.objectFrom(Setting.getTmdbConfig()); if (config == null || !config.isReady()) return; TmdbService service = new TmdbService(); JsonObject episodeJson = service.episode(episode.getTmdbId(), episode.getSeasonNumber(), episode.getNumber(), config); @@ -352,13 +352,6 @@ public class EpisodeDetailDialog { guestsList.setVisibility(View.VISIBLE); TmdbPersonAdapter adapter = new TmdbPersonAdapter(person -> TmdbPersonDialog.show(activity, person, null)); adapter.setLight(light); - boolean cinema = Setting.isTmdbCinemaStyle(); - adapter.setCinema(cinema); - if (cinema) { - ViewGroup.LayoutParams params = guestsList.getLayoutParams(); - params.height = ResUtil.dp2px(104); - guestsList.setLayoutParams(params); - } adapter.setItems(guests); guestsList.setAdapter(adapter); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java index 26111de62a..388efc0828 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java @@ -10,8 +10,6 @@ import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; @@ -31,7 +29,6 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl private DialogHistoryBinding binding; private ConfigListener listener; private ConfigAdapter adapter; - private ItemTouchHelper sortTouchHelper; private int type; private boolean readOnly; @@ -96,18 +93,6 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl if (isFull()) binding.recycler.setMaxHeight(ResUtil.dp2px(264)); binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 8)); binding.recycler.setAdapter(adapter.readOnly(readOnly).addAll(type, getConfig())); - if (type == 0 && !readOnly) attachSortHelper(); - } - - private void attachSortHelper() { - sortTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { - @Override public boolean isLongPressDragEnabled() { return false; } - @Override public boolean onMove(@NonNull RecyclerView view, @NonNull RecyclerView.ViewHolder from, @NonNull RecyclerView.ViewHolder to) { - return adapter.drag(from.getBindingAdapterPosition(), to.getBindingAdapterPosition()); - } - @Override public void onSwiped(@NonNull RecyclerView.ViewHolder holder, int direction) { } - }); - sortTouchHelper.attachToRecyclerView(binding.recycler); } private Config getConfig() { @@ -125,23 +110,9 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl dismiss(); } - @Override - public boolean onTextLongClick(ConfigAdapter.ViewHolder holder) { - if (type != 0 || readOnly || sortTouchHelper == null) return false; - sortTouchHelper.startDrag(holder); - return true; - } - @Override public void onDeleteClick(Config item) { - new MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.config_delete_title) - .setMessage(getString(R.string.config_delete_message, item.getDesc())) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, (dialog, which) -> { - if (adapter.remove(item) == 0) dismiss(); - }) - .show(); + if (adapter.remove(item) == 0) dismiss(); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/LiveControlDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/LiveControlDialog.java index 05f976fda9..16c04be0c0 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/LiveControlDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/LiveControlDialog.java @@ -81,7 +81,6 @@ public class LiveControlDialog extends BaseBottomSheetDialog { binding.sheetWall.setVisibility(View.GONE); binding.player.setText(parent.control.action.player.getText()); binding.decode.setText(parent.control.action.decode.getText()); - binding.playParams.setSelected(parent.control.action.playParams.isSelected()); binding.invert.setSelected(parent.control.action.invert.isSelected()); binding.across.setSelected(parent.control.action.across.isSelected()); binding.change.setSelected(parent.control.action.change.isSelected()); @@ -120,7 +119,6 @@ public class LiveControlDialog extends BaseBottomSheetDialog { binding.player.setOnClickListener(v -> dismiss(parent.control.action.player)); binding.player.setOnLongClickListener(v -> longClick(binding.player, parent.control.action.player)); binding.decode.setOnClickListener(v -> click(binding.decode, parent.control.action.decode)); - binding.playParams.setOnClickListener(v -> dismiss(parent.control.action.playParams)); binding.text.setOnClickListener(v -> onTrack(binding.text)); binding.audio.setOnClickListener(v -> onTrack(binding.audio)); binding.video.setOnClickListener(v -> onTrack(binding.video)); @@ -182,7 +180,6 @@ public class LiveControlDialog extends BaseBottomSheetDialog { if (binding == null || parent == null) return; binding.player.setText(parent.control.action.player.getText()); binding.decode.setText(parent.control.action.decode.getText()); - binding.playParams.setSelected(parent.control.action.playParams.isSelected()); setTrackVisible(); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java index c41a20b70b..902fc1c7ae 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java @@ -12,7 +12,6 @@ import android.view.WindowManager; import android.view.inputmethod.EditorInfo; import androidx.annotation.NonNull; -import androidx.core.widget.TextViewCompat; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.ItemTouchHelper; @@ -32,7 +31,6 @@ import com.fongmi.android.tv.ui.adapter.SiteAdapter; import com.fongmi.android.tv.ui.adapter.SiteGroupAdapter; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; -import com.fongmi.android.tv.ui.helper.SiteDialogTheme; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -81,20 +79,13 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi @Override protected MaterialAlertDialogBuilder getBuilder() { - return builder().setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override protected void initView() { - SiteDialogTheme theme = SiteDialogTheme.resolve(binding.getRoot().getContext(), Setting.getDynamicColor()); - binding.getRoot().setBackgroundResource(R.drawable.shape_site_dialog); - binding.keyword.setTextColor(theme.onSurface()); - binding.keyword.setHintTextColor(theme.onSurfaceVariant()); - TextViewCompat.setCompoundDrawableTintList(binding.keyword, theme.accent()); - theme.tint(binding.block); - theme.tint(binding.search); - adapter = new SiteAdapter(this, theme); - groupAdapter = new SiteGroupAdapter(this::onGroupClick, theme); + adapter = new SiteAdapter(this); + groupAdapter = new SiteGroupAdapter(this::onGroupClick); groups = getGroups(); binding.recycler.setAdapter(adapter); binding.groupList.setLayoutManager(new LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)); @@ -249,7 +240,7 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi selectedGroup = group.equals(selectedGroup) ? "" : group; updateGroupView(); filter(); - binding.recycler.post(() -> binding.recycler.scrollToPosition(adapter.getSelectedPosition())); + binding.recycler.scrollToPosition(0); if (!TextUtils.isEmpty(selectedGroup)) centerGroup(view); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java index 6e75ab4139..a804e9bdd3 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java @@ -735,12 +735,13 @@ public class CollectFragment extends BaseFragment implements MenuProvider, Colle Collect all = Collect.all(); all.setSelected("all".equals(activeSiteKey)); items.add(all); + boolean fixedOrder = Setting.getSearchResultSort() == 1; for (int i = 1; i < mAllCollectItems.size(); i++) { Collect raw = mAllCollectItems.get(i); if (!matchFilter(raw.getSite())) continue; String siteKey = raw.getSite().getKey(); List visible = getVisibleItems(siteKey); - if (!SearchSourceVisibility.shouldShow(!visible.isEmpty())) continue; + if (!SearchSourceVisibility.shouldShow(mSimilarity, fixedOrder, !visible.isEmpty())) continue; Collect item = new Collect(raw.getSite(), visible); item.setPage(mPaging.getPage(raw.getSite().getKey())); item.setSelected(raw.getSite().getKey().equals(activeSiteKey)); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAdFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAdFragment.java index 2b6669027c..36521bf6ae 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAdFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAdFragment.java @@ -57,8 +57,6 @@ public class SettingAdFragment extends BaseFragment { private volatile int probeRuleCount; private final ActivityResultLauncher adAudioRulePicker = registerForActivityResult(new ActivityResultContracts.OpenDocument(), this::importAdAudioRules); - private final ActivityResultLauncher speechAdRulePicker = - registerForActivityResult(new ActivityResultContracts.OpenDocument(), this::importSpeechAdRules); public static SettingAdFragment newInstance() { return new SettingAdFragment(); @@ -103,8 +101,6 @@ public class SettingAdFragment extends BaseFragment { mBinding.probeRuleRefresh.setOnClickListener(this::refreshProbeRules); mBinding.speechAdEnabled.setOnClickListener(this::toggleSpeechAdEnabled); mBinding.speechAdKeywords.setOnClickListener(this::editSpeechAdKeywords); - mBinding.speechAdRules.setOnClickListener(this::manageSpeechAdRules); - mBinding.speechAdBuiltin.setOnClickListener(this::toggleSpeechAdBuiltin); mBinding.speechAdSkipSeconds.setOnClickListener(this::editSpeechAdSkipSeconds); mBinding.speechAdSkipMode.setOnClickListener(this::selectSpeechAdSkipMode); mBinding.autoSkipIntroOutro.setOnClickListener(this::setAutoSkipIntroOutro); @@ -128,23 +124,18 @@ public class SettingAdFragment extends BaseFragment { safeSet("probeRuleRefresh", mBinding.probeRuleRefreshText, this::getProbeRuleRefreshText); safeRun("speechAd", () -> { SpeechAdConfig speech = SpeechAdSetting.snapshot(); - SpeechAdSetting.RuleSnapshot speechRules = SpeechAdSetting.ruleSnapshot(); String enabled = getSwitch(speech.enabled()); if (speech.enabled() && !RealtimeSubtitleSpeechRecognitionFactory.isSelectedModelReady()) { enabled += " · " + getString(R.string.speech_ad_model_not_ready); } mBinding.speechAdEnabledText.setText(enabled); mBinding.speechAdKeywordsText.setText(getString(R.string.speech_ad_keyword_count, speech.keywords().values().size())); - mBinding.speechAdRulesText.setText(getSpeechAdRulesText(speechRules)); - mBinding.speechAdBuiltinText.setText(getSpeechAdBuiltinText(speechRules)); mBinding.speechAdSkipSecondsText.setText(getString(R.string.speech_ad_skip_seconds_value, speech.skipSeconds())); mBinding.speechAdSkipModeText.setText(speech.mode() == AdSkipPolicyController.Mode.AUTO ? R.string.speech_ad_skip_mode_auto : R.string.speech_ad_skip_mode_prompt); }, () -> { setError(mBinding.speechAdEnabledText); setError(mBinding.speechAdKeywordsText); - setError(mBinding.speechAdRulesText); - setError(mBinding.speechAdBuiltinText); setError(mBinding.speechAdSkipSecondsText); setError(mBinding.speechAdSkipModeText); }); @@ -357,145 +348,6 @@ public class SettingAdFragment extends BaseFragment { setText(); } - private String getSpeechAdRulesText(SpeechAdSetting.RuleSnapshot snapshot) { - if (snapshot.hasError()) return getString(R.string.speech_ad_rules_error, snapshot.error()); - if (snapshot.rules().isEmpty()) return getString(R.string.speech_ad_rules_none); - return getString(R.string.speech_ad_rules_summary, - snapshot.rules().rules().size(), getSpeechAdRuleSourceText(snapshot)); - } - - private String getSpeechAdBuiltinText(SpeechAdSetting.RuleSnapshot snapshot) { - int count = Math.max(snapshot.builtinRules().rules().size(), SpeechAdSetting.builtinRuleCount()); - return getString(R.string.speech_ad_builtin_value, - getSwitch(snapshot.builtinEnabled()), count); - } - - private String getSpeechAdRuleSourceText(SpeechAdSetting.RuleSnapshot snapshot) { - boolean hasCustom = !snapshot.customRules().isEmpty(); - boolean hasBuiltin = snapshot.builtinEnabled() && !snapshot.builtinRules().isEmpty(); - if (hasCustom && hasBuiltin) return getString(R.string.speech_ad_rules_source_combined); - if (hasBuiltin) return getString(R.string.speech_ad_rules_source_builtin); - if (snapshot.source() == SpeechAdSetting.RuleSource.IMPORTED) { - return getString(R.string.speech_ad_rules_source_imported); - } - if (snapshot.source() == SpeechAdSetting.RuleSource.USER) { - return getString(R.string.speech_ad_rules_source_user); - } - return getString(R.string.speech_ad_rules_source_none); - } - - private void manageSpeechAdRules(View view) { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_rules_manage) - .setItems(new String[]{ - getString(R.string.speech_ad_rules_edit), - getString(R.string.speech_ad_rules_import), - getString(R.string.speech_ad_rules_clear), - getString(R.string.speech_ad_rules_view_builtin) - }, (dialog, which) -> { - if (which == 0) editSpeechAdRules(); - else if (which == 1) speechAdRulePicker.launch(new String[]{"text/plain", "text/*"}); - else if (which == 2) confirmClearSpeechAdRules(); - else showBuiltinSpeechAdRules(); - }) - .setNegativeButton(R.string.dialog_negative, null) - .show(); - } - - private void showBuiltinSpeechAdRules() { - try { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_rules_view_builtin) - .setMessage(SpeechAdSetting.builtinRulesText()) - .setPositiveButton(R.string.dialog_positive, null) - .show(); - } catch (RuntimeException error) { - Notify.show(getString(R.string.speech_ad_rules_error, SpeechAdSetting.safeError(error))); - } - } - - private void editSpeechAdRules() { - EditText input = new EditText(requireContext()); - input.setGravity(Gravity.TOP | Gravity.START); - input.setMinLines(8); - input.setMaxLines(16); - input.setSingleLine(false); - input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); - input.setText(SpeechAdSetting.customRulesText()); - input.setSelection(input.length()); - AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_rules_edit) - .setView(input) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, null) - .create(); - alert.setOnShowListener(dialog -> alert.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { - try { - String text = input.getText().toString(); - if (text.trim().isEmpty()) SpeechAdSetting.clearRules(); - else SpeechAdSetting.setRulesText(text, SpeechAdSetting.RuleSource.USER); - Notify.show(R.string.speech_ad_rules_saved); - notifyAdAudioRuntime(); - setText(); - alert.dismiss(); - } catch (RuntimeException error) { - input.setError(SpeechAdSetting.safeError(error)); - input.requestFocus(); - } - })); - alert.show(); - } - - private void importSpeechAdRules(Uri uri) { - if (uri == null || !canSetText()) return; - ContentResolver resolver = requireContext().getContentResolver(); - Task.execute(() -> { - boolean imported = false; - String message; - try { - SpeechAdSetting.importUri(resolver, uri); - imported = true; - message = getString(R.string.speech_ad_rules_imported, - SpeechAdSetting.ruleSnapshot().customRules().rules().size()); - } catch (Exception error) { - message = getString(R.string.speech_ad_rules_import_failed, - SpeechAdSetting.safeError(error)); - } - final boolean success = imported; - String result = message; - App.post(() -> { - if (success) notifyAdAudioRuntime(); - if (!canSetText()) return; - Notify.show(result); - setText(); - }); - }); - } - - private void confirmClearSpeechAdRules() { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_rules_clear) - .setMessage(R.string.speech_ad_rules_clear_confirm) - .setPositiveButton(R.string.dialog_positive, (dialog, which) -> { - SpeechAdSetting.clearRules(); - Notify.show(R.string.speech_ad_rules_clear_done); - notifyAdAudioRuntime(); - setText(); - }) - .setNegativeButton(R.string.dialog_negative, null) - .show(); - } - - private void toggleSpeechAdBuiltin(View view) { - try { - SpeechAdSetting.setBuiltinEnabled(!SpeechAdSetting.isBuiltinEnabled()); - notifyAdAudioRuntime(); - } catch (RuntimeException error) { - Notify.show(getString(R.string.speech_ad_rules_error, SpeechAdSetting.safeError(error))); - } - setText(); - } - private void editSpeechAdKeywords(View view) { SpeechAdConfig speech = SpeechAdSetting.snapshot(); EditText input = new EditText(requireContext()); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java index 7e49e1c668..39b98edbc2 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java @@ -20,15 +20,16 @@ import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.databinding.FragmentSettingBinding; import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.event.ConfigEvent; +import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.impl.ConfigListener; import com.fongmi.android.tv.impl.LiveListener; import com.fongmi.android.tv.impl.SiteListener; +import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.ui.activity.HomeActivity; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.AboutDialog; -import com.fongmi.android.tv.ui.dialog.AppearanceDialog; import com.fongmi.android.tv.ui.dialog.ChoiceDialog; import com.fongmi.android.tv.ui.dialog.ConfigDialog; import com.fongmi.android.tv.ui.dialog.HistoryDialog; @@ -36,10 +37,12 @@ import com.fongmi.android.tv.ui.dialog.LiveDialog; import com.fongmi.android.tv.ui.dialog.RestoreDialog; import com.fongmi.android.tv.ui.dialog.BackupProgressDialog; import com.fongmi.android.tv.ui.dialog.SiteDialog; +import com.fongmi.android.tv.ui.dialog.ThemeDialog; import com.fongmi.android.tv.utils.AppVersion; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; +import com.fongmi.android.tv.utils.ResUtil; import com.github.catvod.bean.Doh; import com.github.catvod.net.OkHttp; @@ -50,9 +53,12 @@ import org.greenrobot.eventbus.ThreadMode; import java.util.ArrayList; import java.util.List; -public class SettingFragment extends BaseFragment implements ConfigListener, SiteListener, LiveListener { +public class SettingFragment extends BaseFragment implements ConfigListener, SiteListener, LiveListener, ThemeDialog.Listener { private FragmentSettingBinding mBinding; + private String[] size; + private String[] language; + private String[] uiScale; public static SettingFragment newInstance() { return new SettingFragment(); @@ -62,6 +68,12 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit return getString(value ? R.string.setting_on : R.string.setting_off); } + private String getThemeText() { + int color = Setting.getThemeColor(); + if (color == -1) return getString(R.string.setting_off); + return getString(color == 0 ? R.string.setting_auto : R.string.setting_custom); + } + private int getDohIndex() { return Math.max(0, VodConfig.get().getDoh().indexOf(Doh.objectFrom(Setting.getDoh()))); } @@ -93,8 +105,12 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit } private void setOtherText() { + mBinding.themeColorText.setText(getThemeText()); mBinding.dohText.setText(getDohList()[getDohIndex()]); mBinding.incognitoText.setText(getSwitch(Setting.isIncognito())); + mBinding.languageText.setText((language = ResUtil.getStringArray(R.array.select_language))[Setting.getLanguageIndex()]); + mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[PlayerSetting.getSize()]); + mBinding.uiScaleText.setText((uiScale = ResUtil.getStringArray(R.array.select_ui_scale))[Setting.getUiScaleIndex()]); } private void setCacheText() { @@ -112,7 +128,9 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit mBinding.doh.setOnClickListener(this::setDoh); mBinding.live.setOnClickListener(this::onLive); mBinding.wall.setOnClickListener(this::onWall); - mBinding.appearance.setOnClickListener(this::onAppearance); + mBinding.size.setOnClickListener(this::setSize); + mBinding.language.setOnClickListener(this::setLanguage); + mBinding.uiScale.setOnClickListener(this::setUiScale); mBinding.cache.setOnClickListener(this::onCache); mBinding.backup.setOnClickListener(this::onBackup); mBinding.enhance.setOnClickListener(this::onEnhance); @@ -132,6 +150,7 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit mBinding.wall.setOnLongClickListener(this::onWallEdit); mBinding.incognito.setOnClickListener(this::setIncognito); mBinding.vodHistory.setOnClickListener(this::onVodHistory); + mBinding.themeColor.setOnClickListener(this::onThemeColor); mBinding.liveHistory.setOnClickListener(this::onLiveHistory); mBinding.wallDefault.setOnClickListener(this::setWallDefault); mBinding.wallRefresh.setOnClickListener(this::setWallRefresh); @@ -195,6 +214,12 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit LiveConfig.get().setHome(item); } + @Override + public void setTheme(int color) { + Setting.putThemeColor(color); + RefreshEvent.theme(); + } + private void onVod(View view) { ConfigDialog.create().vod().show(this); } @@ -270,8 +295,8 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit getRoot().change(5); } - private void onAppearance(View view) { - AppearanceDialog.show(this); + private void onThemeColor(View view) { + ThemeDialog.show(this); } @@ -301,6 +326,31 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit mBinding.incognitoText.setText(getSwitch(Setting.isIncognito())); } + private void setSize(View view) { + ChoiceDialog.showSingle(this, R.string.setting_size, size, PlayerSetting.getSize(), which -> { + mBinding.sizeText.setText(size[which]); + PlayerSetting.putSize(which); + RefreshEvent.size(); + }); + } + + private void setLanguage(View view) { + ChoiceDialog.showSingle(this, R.string.setting_language, language, Setting.getLanguageIndex(), which -> { + if (which != Setting.getLanguageIndex()) { + Setting.putLanguageIndex(which); + RefreshEvent.language(); + } + }); + } + + private void setUiScale(View view) { + ChoiceDialog.showSingle(this, R.string.setting_ui_scale, uiScale, Setting.getUiScaleIndex(), which -> { + mBinding.uiScaleText.setText(uiScale[which]); + Setting.putUiScaleIndex(which); + requireActivity().recreate(); + }); + } + private void setDoh(View view) { ChoiceDialog.showSingle(this, R.string.setting_doh, getDohList(), getDohIndex(), which -> { setDoh(VodConfig.get().getDoh().get(which)); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java index 18869b8737..9b4706f872 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java @@ -10,10 +10,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.FragmentSettingPersonalBinding; -import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; import com.fongmi.android.tv.setting.AppBranding; import com.fongmi.android.tv.setting.AutoBackupPolicy; import com.fongmi.android.tv.setting.GroupRuleConfig; @@ -38,7 +35,6 @@ public class SettingPersonalFragment extends BaseFragment { private String[] searchColumn; private String[] siteColumn; private String[] globalHistoryMode; - private String[] interfaceFailoverMode; private String[] searchResultSort; public static SettingPersonalFragment newInstance() { @@ -62,13 +58,10 @@ public class SettingPersonalFragment extends BaseFragment { @Override protected void initEvent() { mBinding.searchThread.setOnClickListener(this::setSearchThread); - mBinding.following.setOnClickListener(this::setFollowing); mBinding.autoBackup.setOnClickListener(this::setAutoBackup); - mBinding.playbackOverlay.setOnClickListener(this::setPlaybackOverlay); mBinding.playBackToDetail.setOnClickListener(this::setPlayBackToDetail); mBinding.episodeHistory.setOnClickListener(this::setEpisodeHistory); mBinding.globalHistory.setOnClickListener(this::setGlobalHistory); - mBinding.interfaceFailover.setOnClickListener(this::setInterfaceFailover); mBinding.playSpeed.setOnClickListener(this::setPlaySpeed); mBinding.groupRule.setOnClickListener(this::setGroupRule); mBinding.searchUi.setOnClickListener(this::setSearchUi); @@ -77,18 +70,14 @@ public class SettingPersonalFragment extends BaseFragment { mBinding.searchResultSort.setOnClickListener(this::setSearchResultSort); mBinding.resetApp.setOnClickListener(this::showResetAppDialog); mBinding.appBranding.setOnClickListener(this::startAppBranding); - mBinding.touchOptimization.setOnClickListener(this::setTouchOptimization); } private void setText() { mBinding.searchThreadText.setText(String.valueOf(Setting.getSearchThread())); - mBinding.followingText.setText(getSwitch(FollowingSettings.isEnabled())); mBinding.autoBackupText.setText(getSwitch(isAutoBackupEnabled())); - mBinding.playbackOverlayText.setText(getSwitch(Setting.isPlaybackOverlayEnabled())); mBinding.playBackToDetailText.setText(getSwitch(Setting.isPlayBackToDetail())); mBinding.episodeHistoryText.setText(getSwitch(Setting.isEpisodeHistory())); mBinding.globalHistoryText.setText((globalHistoryMode = getResources().getStringArray(R.array.select_global_history_mode))[Setting.getGlobalHistoryMode()]); - mBinding.interfaceFailoverText.setText((interfaceFailoverMode = getResources().getStringArray(R.array.select_interface_failover_mode))[Setting.getInterfaceFailoverMode()]); mBinding.playSpeedText.setText(getSpeedText(PlayerSetting.getDefaultSpeed())); mBinding.groupRuleText.setText(getString(R.string.setting_group_rule_summary, GroupRuleConfig.enabledCount(), GroupRuleConfig.totalCount())); mBinding.searchUiText.setText((searchUi = getResources().getStringArray(R.array.select_search_ui))[Setting.getSearchUi()]); @@ -96,7 +85,6 @@ public class SettingPersonalFragment extends BaseFragment { mBinding.siteColumnText.setText((siteColumn = getResources().getStringArray(R.array.select_site_column))[Setting.getSiteColumn() - 1]); mBinding.searchResultSortText.setText((searchResultSort = getResources().getStringArray(R.array.select_search_result_sort))[Setting.getSearchResultSort()]); mBinding.appBrandingText.setText(AppBranding.getSummary(requireContext())); - mBinding.touchOptimizationText.setText(getSwitch(Setting.isTouchOptimized())); } private String getSearchColumnText() { @@ -119,20 +107,6 @@ public class SettingPersonalFragment extends BaseFragment { }); } - private void setFollowing(View view) { - boolean enabled = !FollowingSettings.isEnabled(); - FollowingSettings.setEnabled(enabled); - if (enabled) { - FollowingScheduler.ensurePeriodic(requireContext()); - FollowingScheduler.enqueueDueNow(requireContext()); - } else { - FollowingScheduler.cancelAll(requireContext()); - } - ConfigEvent.common(); - RefreshEvent.home(); - setText(); - } - private void setAutoBackup(View view) { if (isAutoBackupEnabled()) { Setting.putAutoBackup(false); @@ -153,11 +127,6 @@ public class SettingPersonalFragment extends BaseFragment { return AutoBackupPolicy.isEffective(Setting.isAutoBackup(), Setting.hasFileAccess()); } - private void setPlaybackOverlay(View view) { - Setting.putPlaybackOverlayEnabled(!Setting.isPlaybackOverlayEnabled()); - setText(); - } - private void setPlayBackToDetail(View view) { Setting.putPlayBackToDetail(!Setting.isPlayBackToDetail()); setText(); @@ -174,11 +143,6 @@ public class SettingPersonalFragment extends BaseFragment { setText(); } - private void setInterfaceFailover(View view) { - Setting.putInterfaceFailoverMode((Setting.getInterfaceFailoverMode() + 1) % interfaceFailoverMode.length); - setText(); - } - private void setPlaySpeed(View view) { SpeedSettingDialog.show(requireActivity(), R.string.setting_play_speed, PlayerSetting.getDefaultSpeed(), 0.5f, 5f, 0.25f, value -> { PlayerSetting.putDefaultSpeed(value); @@ -225,12 +189,6 @@ public class SettingPersonalFragment extends BaseFragment { AppBrandingActivity.start(requireActivity()); } - private void setTouchOptimization(View view) { - boolean enabled = !Setting.isTouchOptimized(); - Setting.putTouchOptimized(enabled); - mBinding.touchOptimizationText.setText(getSwitch(enabled)); - } - private void resetApp() { if (!Util.resetApp()) Notify.show(R.string.reset_app_failed); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPlayerFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPlayerFragment.java index 5204cf7be5..78016edb80 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPlayerFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPlayerFragment.java @@ -19,7 +19,6 @@ import com.fongmi.android.tv.player.lut.LutSetting; import com.fongmi.android.tv.player.mpv.MpvConfigStore; import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; import com.fongmi.android.tv.setting.PlayerButtonSetting; -import com.fongmi.android.tv.setting.LiveSetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.PreloadSetting; import com.fongmi.android.tv.setting.Setting; @@ -90,7 +89,6 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B setPreloadText(); mBinding.autoPlayText.setText(getSwitch(PlayerSetting.isAutoPlay())); mBinding.autoChangeText.setText(getSwitch(PlayerSetting.isAutoChange())); - mBinding.liveSourceFallbackText.setText(getSwitch(LiveSetting.isSourceFallback())); mBinding.rememberBrightnessText.setText(getSwitch(PlayerSetting.isRememberBrightness())); mBinding.failureFallbackText.setText((failureFallback = ResUtil.getStringArray(R.array.select_player_failure_fallback))[PlayerSetting.getFailureFallback()]); mBinding.musicNotificationText.setText(getSwitch(PlayerSetting.isMusicNotification())); @@ -98,6 +96,7 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.audioDecodeText.setText(getSwitch(PlayerSetting.isAudioPrefer())); mBinding.audioPassThroughText.setText(getSwitch(PlayerSetting.isAudioPassThrough())); mBinding.videoDecodeText.setText(getSwitch(PlayerSetting.isVideoPrefer())); + mBinding.ffmpegModeText.setText(getFFmpegModeText()); mBinding.caption.setVisibility(PlayerSetting.hasCaption() ? View.VISIBLE : View.GONE); mBinding.osdText.setText(getOsdText(osd = ResUtil.getStringArray(R.array.select_player_osd))); mBinding.kernelText.setText((kernel = ResUtil.getStringArray(R.array.select_player_kernel))[PlayerSetting.getPlayer()]); @@ -105,6 +104,7 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.scaleText.setText((scale = ResUtil.getStringArray(R.array.select_scale))[PlayerSetting.getScale()]); mBinding.lutText.setText(LutSetting.getSummary()); setMpvRows(); + setFfmpegModeVisibility(); mBinding.renderText.setText((render = ResUtil.getStringArray(R.array.select_render))[PlayerSetting.getRender()]); mBinding.captionText.setText((caption = ResUtil.getStringArray(R.array.select_caption))[PlayerSetting.isCaption() ? 1 : 0]); mBinding.backgroundText.setText((background = ResUtil.getStringArray(R.array.select_background))[PlayerSetting.getBackground()]); @@ -120,10 +120,6 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.lut.setOnClickListener(this::onLut); mBinding.mpvConfig.setOnClickListener(view -> MpvConfigDialog.show(this, () -> mBinding.mpvConfigText.setText(MpvConfigStore.summary()))); mBinding.mpvRender.setOnClickListener(this::onMpvRender); - mBinding.blurayMenu.setOnClickListener(view -> { - PlayerSetting.putBlurayMenu(!PlayerSetting.isBlurayMenu()); - mBinding.blurayMenuText.setText(getSwitch(PlayerSetting.isBlurayMenu())); - }); mBinding.osd.setOnClickListener(this::onOsd); mBinding.playerButtons.setOnClickListener(view -> PlayerButtonConfigDialog.show(this, this::setPlayerButtonsText)); mBinding.padLive.setOnClickListener(this::setPadLiveMode); @@ -140,10 +136,6 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.preloadPause.setOnClickListener(this::onPreloadPause); mBinding.autoPlay.setOnClickListener(this::setAutoPlay); mBinding.autoChange.setOnClickListener(this::setAutoChange); - mBinding.liveSourceFallback.setOnClickListener(view -> { - LiveSetting.putSourceFallback(!LiveSetting.isSourceFallback()); - mBinding.liveSourceFallbackText.setText(getSwitch(LiveSetting.isSourceFallback())); - }); mBinding.rememberBrightness.setOnClickListener(this::setRememberBrightness); mBinding.failureFallback.setOnClickListener(this::setFailureFallback); mBinding.render.setOnClickListener(this::setRender); @@ -157,6 +149,7 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.audioDecode.setOnClickListener(this::setAudioDecode); mBinding.audioPassThrough.setOnClickListener(this::setAudioPassThrough); mBinding.videoDecode.setOnClickListener(this::setVideoDecode); + mBinding.ffmpegMode.setOnClickListener(this::setFfmpegMode); } private void onUa(View view) { @@ -181,6 +174,7 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.kernelText.setText(kernel[which]); PlayerSetting.putPlayer(which); setMpvRows(); + setFfmpegModeVisibility(); setPerformanceText(); }); } @@ -196,6 +190,19 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B LutDialog.show(this, () -> mBinding.lutText.setText(LutSetting.getSummary())); } + private void setMpvRows() { + boolean visible = PlayerSetting.getPlayer() == PlayerSetting.MPV; + mBinding.mpvConfig.setVisibility(visible ? View.VISIBLE : View.GONE); + mBinding.mpvRender.setVisibility(visible ? View.VISIBLE : View.GONE); + mBinding.mpvConfigText.setText(MpvConfigStore.summary()); + mBinding.mpvRenderText.setText(getMpvRenderText()); + } + + private void setFfmpegModeVisibility() { + boolean visible = PlayerSetting.getPlayer() == PlayerSetting.EXO; + mBinding.ffmpegMode.setVisibility(visible ? View.VISIBLE : View.GONE); + } + private void onMpvRender(View view) { ChoiceDialog.showSingle(this, R.string.player_mpv_render, mpvRender, PlayerSetting.getMpvRender(), which -> { PlayerSetting.putMpvRender(which); @@ -212,16 +219,6 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B return text; } - private void setMpvRows() { - boolean visible = PlayerSetting.getPlayer() == PlayerSetting.MPV; - mBinding.mpvConfig.setVisibility(visible ? View.VISIBLE : View.GONE); - mBinding.mpvRender.setVisibility(visible ? View.VISIBLE : View.GONE); - mBinding.blurayMenu.setVisibility(visible ? View.VISIBLE : View.GONE); - mBinding.mpvConfigText.setText(MpvConfigStore.summary()); - mBinding.mpvRenderText.setText(getMpvRenderText()); - mBinding.blurayMenuText.setText(getSwitch(PlayerSetting.isBlurayMenu())); - } - private void onOsd(View view) { PlayerOsdDialog.show(this, osd, getOsdChecked(), checked -> { setOsdChecked(checked); @@ -558,6 +555,21 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B setPerformanceText(); } + private void setFfmpegMode(View view) { + int mode = (PlayerSetting.getFFmpegMode() + 1) % 4; + PlayerSetting.putFFmpegMode(mode); + mBinding.ffmpegModeText.setText(getFFmpegModeText()); + } + + private String getFFmpegModeText() { + return switch (PlayerSetting.getFFmpegMode()) { + case PlayerSetting.FFMPEG_MODE_OFFICIAL -> "Official"; + case PlayerSetting.FFMPEG_MODE_SIMPLE -> "Simple"; + case PlayerSetting.FFMPEG_MODE_AUTO -> "自动"; + default -> "NextLib"; + }; + } + @Override public void onHiddenChanged(boolean hidden) { if (!hidden) initView(); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingSubtitleFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingSubtitleFragment.java index 2d3fe73e33..b6fa85ec62 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingSubtitleFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingSubtitleFragment.java @@ -12,7 +12,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.FragmentSettingSubtitleBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.subtitle.provider.SubtitleProviderRegistry; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.SubtitleSettingsDialog; import com.fongmi.android.tv.utils.ResUtil; @@ -42,14 +41,14 @@ public class SettingSubtitleFragment extends BaseFragment { subtitleLanguageValues = ResUtil.getStringArray(R.array.select_subtitle_language_value); mBinding.subtitleAutoMatchText.setText(getSwitch(Setting.isSubtitleAutoMatchEnabled())); mBinding.subtitleLanguageText.setText(getSubtitleLanguageText()); - mBinding.subtitleAssrtTokenText.setText(getSubtitleSourceText()); + mBinding.subtitleAssrtTokenText.setText(getSubtitleAssrtTokenText()); } @Override protected void initEvent() { mBinding.subtitleAutoMatch.setOnClickListener(this::setSubtitleAutoMatch); mBinding.subtitleLanguage.setOnClickListener(this::onSubtitleLanguage); - mBinding.subtitleAssrtToken.setOnClickListener(this::onSubtitleSource); + mBinding.subtitleAssrtToken.setOnClickListener(this::onSubtitleAssrtToken); } private void setSubtitleAutoMatch(View view) { @@ -64,8 +63,11 @@ public class SettingSubtitleFragment extends BaseFragment { }); } - private void onSubtitleSource(View view) { - SubtitleSettingsDialog.showSourceSummary(requireActivity(), SubtitleProviderRegistry.get().providers(), () -> mBinding.subtitleAssrtTokenText.setText(getSubtitleSourceText())); + private void onSubtitleAssrtToken(View view) { + SubtitleSettingsDialog.showAssrtToken(requireActivity(), Setting.getSubtitleAssrtToken(), value -> { + Setting.putSubtitleAssrtToken(value); + mBinding.subtitleAssrtTokenText.setText(getSubtitleAssrtTokenText()); + }); } private String getSubtitleLanguageText() { @@ -79,8 +81,8 @@ public class SettingSubtitleFragment extends BaseFragment { return 0; } - private String getSubtitleSourceText() { - return getString(R.string.player_subtitle_source_count, SubtitleProviderRegistry.get().providerNames().size()); + private String getSubtitleAssrtTokenText() { + return getString(TextUtils.isEmpty(Setting.getSubtitleAssrtToken()) ? R.string.setting_unconfigured : R.string.setting_configured); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingTmdbFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingTmdbFragment.java index 9f5b71d394..672aac9e2e 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingTmdbFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingTmdbFragment.java @@ -16,6 +16,7 @@ import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.LightDialog; import com.fongmi.android.tv.ui.dialog.TmdbSourceDialog; +import com.fongmi.android.tv.utils.Notify; import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class SettingTmdbFragment extends BaseFragment { @@ -94,6 +95,15 @@ public class SettingTmdbFragment extends BaseFragment { private void setDetailOpenMode(View view) { AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog).setTitle(R.string.setting_detail_open_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailOpenModes(), getDetailOpenModeIndex(), (dialog, which) -> { int mode = DETAIL_OPEN_MODES[which]; + if (Setting.isTmdbMode(mode) && !Setting.isTmdbReady()) { + dialog.dismiss(); + Notify.show(R.string.detail_tmdb_need_key); + TmdbSourceDialog.create(requireActivity()).onDismiss(() -> { + if (Setting.isTmdbReady()) Setting.putDetailOpenMode(mode); + setText(); + }).show(); + return; + } Setting.putDetailOpenMode(mode); setText(); dialog.dismiss(); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java index fa6a3418af..d8b7d561fe 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java @@ -215,7 +215,7 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac @Override public void onItemClick(Vod item) { if (item.isAction()) { - com.fongmi.android.tv.content.ActionCardHelper.handleAction(requireActivity(), getKey(), item.getAction()); + mViewModel.action(getKey(), item.getAction()); } else if (item.isFolder()) { getParent().openFolder(item.getId(), mExtends); } else { diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java b/app/src/mobile/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java deleted file mode 100644 index 6159af0df5..0000000000 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.widget.ImageView; - -import com.google.android.material.R; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.color.ColorRoles; -import com.google.android.material.color.MaterialColors; - -/** One palette per site dialog, including devices without platform dynamic-color support. */ -public record SiteDialogTheme(int surface, int onSurface, int onSurfaceVariant, - ColorStateList accent, ColorStateList buttonBackground, - ColorStateList buttonText, ColorStateList buttonStroke) { - - public static SiteDialogTheme resolve(Context context, int seedColor) { - int surface = color(context, R.attr.colorSurfaceContainer); - int primary = color(context, androidx.appcompat.R.attr.colorPrimary); - int onPrimary = color(context, R.attr.colorOnPrimary); - int container = color(context, R.attr.colorPrimaryContainer); - int onContainer = color(context, R.attr.colorOnPrimaryContainer); - int onSurface = color(context, R.attr.colorOnSurface); - int onSurfaceVariant = color(context, R.attr.colorOnSurfaceVariant); - int outline = color(context, R.attr.colorOutline); - if (seedColor != 0) { - // Material 1.14.0 ColorRoles works without DynamicColors' Android 12 availability gate. - // Use paired tonal colors, never the raw seed as a surface behind unrelated text colors. - ColorRoles roles = MaterialColors.getColorRoles(context, seedColor); - primary = roles.getAccent(); - onPrimary = roles.getOnAccent(); - container = roles.getAccentContainer(); - onContainer = roles.getOnAccentContainer(); - surface = MaterialColors.getSurfaceContainerFromSeed(context, seedColor); - } - return new SiteDialogTheme(surface, onSurface, onSurfaceVariant, - ColorStateList.valueOf(primary), - states(primary, container, surface), - states(onPrimary, onContainer, onSurface), - states(primary, primary, outline)); - } - - private static int color(Context context, int attr) { - return MaterialColors.getColor(context, attr, SiteDialogTheme.class.getSimpleName()); - } - - private static ColorStateList states(int selected, int active, int normal) { - // Keep selected first and disabled labels fully opaque, matching all site dialog modes. - return new ColorStateList(new int[][]{ - {android.R.attr.state_selected}, - {android.R.attr.state_focused}, - {android.R.attr.state_pressed}, - {} - }, new int[]{selected, active, active, normal}); - } - - public void apply(MaterialButton button) { - button.setTextColor(buttonText); - button.setBackgroundTintList(buttonBackground); - button.setStrokeColor(buttonStroke); - button.setRippleColor(accent.withAlpha(31)); - } - - public void tint(ImageView image) { - image.setImageTintList(accent); - } -} diff --git a/app/src/mobile/res/color/selector_control_sheet_text.xml b/app/src/mobile/res/color/selector_control_sheet_text.xml index b1281ca549..98c94eb03d 100644 --- a/app/src/mobile/res/color/selector_control_sheet_text.xml +++ b/app/src/mobile/res/color/selector_control_sheet_text.xml @@ -1,7 +1,6 @@ - diff --git a/app/src/mobile/res/drawable/ic_home_following_shadow.xml b/app/src/mobile/res/drawable/ic_home_following_shadow.xml deleted file mode 100644 index b65a4c7867..0000000000 --- a/app/src/mobile/res/drawable/ic_home_following_shadow.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - diff --git a/app/src/mobile/res/drawable/selector_control_sheet_button.xml b/app/src/mobile/res/drawable/selector_control_sheet_button.xml index 618825ad1f..3c1543ae07 100644 --- a/app/src/mobile/res/drawable/selector_control_sheet_button.xml +++ b/app/src/mobile/res/drawable/selector_control_sheet_button.xml @@ -3,15 +3,6 @@ android:color="#33FFFFFF"> - - - - - - - diff --git a/app/src/mobile/res/drawable/selector_live_action_button.xml b/app/src/mobile/res/drawable/selector_live_action_button.xml index d825fe7ead..21a8fd3b44 100644 --- a/app/src/mobile/res/drawable/selector_live_action_button.xml +++ b/app/src/mobile/res/drawable/selector_live_action_button.xml @@ -5,7 +5,7 @@ - + - + @@ -35,14 +35,6 @@ - - - - - - - - diff --git a/app/src/mobile/res/layout-land/activity_video.xml b/app/src/mobile/res/layout-land/activity_video.xml index b34c43bfde..05bfe35d91 100644 --- a/app/src/mobile/res/layout-land/activity_video.xml +++ b/app/src/mobile/res/layout-land/activity_video.xml @@ -521,15 +521,6 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/mobile/res/layout/adapter_ad_stats_item.xml b/app/src/mobile/res/layout/adapter_ad_stats_item.xml index b78158553c..6dcd3a5646 100644 --- a/app/src/mobile/res/layout/adapter_ad_stats_item.xml +++ b/app/src/mobile/res/layout/adapter_ad_stats_item.xml @@ -20,7 +20,7 @@ android:ellipsize="end" android:maxLines="1" android:text="站点名称" - android:textColor="@color/dialog_outlined_button_text" + android:textColor="@color/white" android:textSize="14sp" /> @@ -42,7 +42,7 @@ android:minWidth="48dp" android:text="0" android:textAlignment="textEnd" - android:textColor="@color/dialog_outlined_button_text" + android:textColor="@color/white" android:textSize="14sp" android:textStyle="bold" /> diff --git a/app/src/mobile/res/layout/adapter_github_proxy.xml b/app/src/mobile/res/layout/adapter_github_proxy.xml index 62d5120cce..00532b45cf 100644 --- a/app/src/mobile/res/layout/adapter_github_proxy.xml +++ b/app/src/mobile/res/layout/adapter_github_proxy.xml @@ -24,17 +24,6 @@ android:textSize="13sp" tools:text="https://gh-proxy.com/" /> - - + app:tint="@color/dialog_outlined_button_text" /> + app:tint="@color/dialog_outlined_button_text" /> diff --git a/app/src/mobile/res/layout/adapter_vod.xml b/app/src/mobile/res/layout/adapter_vod.xml index e21496f29f..54fa314ccd 100644 --- a/app/src/mobile/res/layout/adapter_vod.xml +++ b/app/src/mobile/res/layout/adapter_vod.xml @@ -30,20 +30,6 @@ app:shapeAppearanceOverlay="@style/Vod.Grid.Large" tools:visibility="visible" /> - - + - - diff --git a/app/src/mobile/res/layout/dialog_ad_block_stats.xml b/app/src/mobile/res/layout/dialog_ad_block_stats.xml index 64c06a2090..e062111513 100644 --- a/app/src/mobile/res/layout/dialog_ad_block_stats.xml +++ b/app/src/mobile/res/layout/dialog_ad_block_stats.xml @@ -1,46 +1,33 @@ - + android:fillViewport="true"> - + android:orientation="vertical" + android:padding="32dp"> - - - - - + android:layout_height="wrap_content" + android:text="@string/ad_block_stats" + android:textColor="@color/white" + android:textSize="17sp" + android:textStyle="bold" /> + + + + android:orientation="vertical"> + + + android:textColor="@color/white_50" + android:textSize="12sp" /> + + android:orientation="vertical"> + + + android:textColor="@color/white_50" + android:textSize="12sp" /> + + android:orientation="vertical"> + + + android:textColor="@color/white_50" + android:textSize="12sp" /> + + - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:textStyle="bold" /> + + + + + + + + + + + + + + + + + + + + + - + + diff --git a/app/src/mobile/res/layout/dialog_ad_rule_manage.xml b/app/src/mobile/res/layout/dialog_ad_rule_manage.xml index 588595e241..2eb92d1631 100644 --- a/app/src/mobile/res/layout/dialog_ad_rule_manage.xml +++ b/app/src/mobile/res/layout/dialog_ad_rule_manage.xml @@ -45,41 +45,6 @@ tools:text="待审核接口规则(2)" tools:visibility="visible" /> - - - - - - - - - + @@ -33,11 +33,11 @@ android:paddingStart="16dp" android:paddingEnd="16dp" android:singleLine="true" - android:textColor="?attr/colorOnSurface" - android:textColorHint="?attr/colorOnSurfaceVariant" + android:textColor="#202124" + android:textColorHint="#8A8F98" android:textSize="14sp" app:drawableStartCompat="@drawable/ic_site_search" - app:drawableTint="?attr/colorPrimary" /> + app:drawableTint="@color/dialog_outlined_button_text" /> + app:tint="@color/dialog_outlined_button_text" /> + app:tint="@color/dialog_outlined_button_text" /> diff --git a/app/src/mobile/res/layout/fragment_setting.xml b/app/src/mobile/res/layout/fragment_setting.xml index 15b08e629d..50f542e022 100644 --- a/app/src/mobile/res/layout/fragment_setting.xml +++ b/app/src/mobile/res/layout/fragment_setting.xml @@ -340,7 +340,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/mobile/res/layout/fragment_setting_player.xml b/app/src/mobile/res/layout/fragment_setting_player.xml index c8750bdd92..3fb7c6afe5 100644 --- a/app/src/mobile/res/layout/fragment_setting_player.xml +++ b/app/src/mobile/res/layout/fragment_setting_player.xml @@ -228,35 +228,8 @@ - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/app/src/mobile/res/layout/view_control_live.xml b/app/src/mobile/res/layout/view_control_live.xml index 9f43e96f28..6838c6469b 100644 --- a/app/src/mobile/res/layout/view_control_live.xml +++ b/app/src/mobile/res/layout/view_control_live.xml @@ -76,15 +76,6 @@ android:scaleType="center" android:src="@drawable/ic_control_info" /> - - diff --git a/app/src/mobile/res/layout/view_control_live_action.xml b/app/src/mobile/res/layout/view_control_live_action.xml index c594ffa0c3..5fe9dbd75d 100644 --- a/app/src/mobile/res/layout/view_control_live_action.xml +++ b/app/src/mobile/res/layout/view_control_live_action.xml @@ -33,11 +33,6 @@ style="@style/Control" android:text="@string/play_decode" /> - - + + + + + + - - - - - - diff --git a/app/src/mobile/res/menu/menu_nav.xml b/app/src/mobile/res/menu/menu_nav.xml index 79bac6a068..f4739e5835 100644 --- a/app/src/mobile/res/menu/menu_nav.xml +++ b/app/src/mobile/res/menu/menu_nav.xml @@ -13,12 +13,6 @@ android:title="@string/nav_live" android:visible="false" /> - - - + \ No newline at end of file diff --git a/app/src/mobile/res/values/integers.xml b/app/src/mobile/res/values/integers.xml new file mode 100644 index 0000000000..96a75d0ef5 --- /dev/null +++ b/app/src/mobile/res/values/integers.xml @@ -0,0 +1,4 @@ + + + 0 + diff --git a/app/src/mobile/res/values/styles.xml b/app/src/mobile/res/values/styles.xml index ee508d10c2..51e0354007 100644 --- a/app/src/mobile/res/values/styles.xml +++ b/app/src/mobile/res/values/styles.xml @@ -1,7 +1,6 @@ ")); - - String baseDialog = Files.readString(Path.of("src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java"), StandardCharsets.UTF_8); - assertTrue("BaseAlertDialog builder should apply the unified rounded style", - baseDialog.contains("new MaterialAlertDialogBuilder(requireActivity(), R.style.MaterialAlertDialog_WebHTV_Rounded)")); - - String mobileStyles = Files.readString(Path.of("src/mobile/res/values/styles.xml"), StandardCharsets.UTF_8); - String leanbackStyles = Files.readString(Path.of("src/leanback/res/values/styles.xml"), StandardCharsets.UTF_8); - assertTrue("Mobile theme should apply the unified rounded dialog overlay", - mobileStyles.contains("@style/ThemeOverlay.WebHTV.LightDialog")); - assertTrue("Leanback theme should apply the unified rounded dialog overlay", - leanbackStyles.contains("@style/ThemeOverlay.WebHTV.LightDialog")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/dialog/PlaybackSpeedDialogTest.java b/app/src/test/java/com/fongmi/android/tv/ui/dialog/PlaybackSpeedDialogTest.java deleted file mode 100644 index 24d48dcf3d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/dialog/PlaybackSpeedDialogTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class PlaybackSpeedDialogTest { - - @Test - public void chooserPreservesEveryExistingShortcutWithoutReintroducingOnePointTwo() throws Exception { - String manager = read("main", "player/PlayerManager.java"); - Matcher presets = Pattern.compile("SPEED_PRESETS = new float\\[\\]\\{([^}]+)}").matcher(manager); - assertTrue(presets.find()); - String[] values = presets.group(1).split(",\\s*"); - float[] expected = new float[values.length]; - for (int i = 0; i < values.length; i++) expected[i] = Float.parseFloat(values[i].replace("f", "")); - assertArrayEquals(expected, PlaybackSpeedDialog.choices(1f).speeds(), 0f); - for (float speed : expected) assertTrue(speed != 1.2f); - } - - @Test - public void everyPresetMapsToItsActualSpeedAndCurrentSelection() { - float[] speeds = PlaybackSpeedDialog.choices(1f).speeds(); - for (int i = 0; i < speeds.length; i++) { - PlaybackSpeedDialog.Choices choices = PlaybackSpeedDialog.choices(speeds[i]); - assertEquals(i, choices.selected()); - assertEquals(speeds[i], choices.speeds()[choices.selected()], 0f); - assertEquals(String.format(Locale.US, "%.2fx", speeds[i]), choices.labels()[i]); - } - } - - @Test - public void fineAdjustedSpeedsRemainSelectedInSortedChoices() { - for (float current : new float[]{0.25f, 1.2f, 2.25f, 4.75f}) { - PlaybackSpeedDialog.Choices choices = PlaybackSpeedDialog.choices(current); - assertEquals(11, choices.speeds().length); - assertEquals(current, choices.speeds()[choices.selected()], 0f); - for (int i = 1; i < choices.speeds().length; i++) { - assertTrue(choices.speeds()[i - 1] < choices.speeds()[i]); - } - } - } - - @Test - public void floatNoiseDoesNotDuplicatePresetAndInvalidSpeedsAreNotOffered() { - assertEquals(10, PlaybackSpeedDialog.choices(1.00001f).speeds().length); - assertEquals(2, PlaybackSpeedDialog.choices(1.00001f).selected()); - for (float current : new float[]{Float.NaN, Float.POSITIVE_INFINITY, 0f, 6f}) { - PlaybackSpeedDialog.Choices choices = PlaybackSpeedDialog.choices(current); - assertEquals(10, choices.speeds().length); - assertEquals(-1, choices.selected()); - } - } - - @Test - public void choicesAreIndependentAndLabelsDoNotDependOnDeviceLocale() { - Locale previous = Locale.getDefault(); - try { - Locale.setDefault(Locale.GERMANY); - PlaybackSpeedDialog.Choices first = PlaybackSpeedDialog.choices(1.5f); - assertEquals("1.50x", first.labels()[first.selected()]); - first.speeds()[0] = 99f; - assertEquals(0.5f, PlaybackSpeedDialog.choices(1f).speeds()[0], 0f); - } finally { - Locale.setDefault(previous); - } - } - - @Test - public void allSixPlaybackEntrypointsChooseInsteadOfCyclingAndGuardDelayedCallbacks() throws Exception { - for (String[] host : new String[][]{ - {"mobile", "VideoActivity", "onSpeed"}, - {"leanback", "VideoActivity", "onSpeed"}, - {"mobile", "LiveActivity", "onSpeed"}, - {"leanback", "LiveActivity", "onSpeed"}, - {"leanback", "CastActivity", "onSpeed"}, - {"main", "TmdbDetailActivity", "changeInlineSpeed"}}) { - String source = read(host[0], "ui/activity/" + host[1] + ".java"); - int start = source.indexOf("private void " + host[2] + "()"); - assertTrue(host[1], start >= 0); - String method = source.substring(start, source.indexOf("\n }", start)); - assertTrue(host[1], method.contains("PlaybackSpeedDialog.show(this, player().getSpeed(), speed ->")); - assertFalse(host[1], method.contains("addSpeed(")); - assertTrue(host[1], method.contains("!isServiceReady() || !isOwner()")); - assertTrue(host[1], method.contains("setSpeed(speed)") || method.contains("setInlineSpeed(speed)")); - assertTrue(host[1], method.contains("saveUserSpeed()") || method.contains("setUserSpeed(") - || method.contains("PlayerSetting.putDefaultSpeed(")); - } - } - - private static String read(String variant, String relative) throws Exception { - Path root = Files.isDirectory(Path.of("src")) ? Path.of(".") : Path.of("app"); - return Files.readString(root.resolve("src/" + variant + "/java/com/fongmi/android/tv/" + relative)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialogSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialogSourceTest.java deleted file mode 100644 index 7b69bf8182..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialogSourceTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -/** Keeps the ad-rule ranking user-facing by resolving stored rule IDs to display names. */ -public class SiteHealthReportDialogSourceTest { - - @Test - public void ruleRankingUsesResolvedDisplayNames() throws Exception { - String source = Files.readString(Path.of( - "src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialog.java")); - - assertTrue(source.contains("ruleDimensionSummary(report.adBlockedByRule)")); - assertTrue(source.contains("AdBlockStatsStore.getRuleDisplayName(entry.getKey())")); - assertFalse(source.contains("dimensionSummary(report.adBlockedByRule)")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/dialog/TmdbSourceDialogInflationContractTest.java b/app/src/test/java/com/fongmi/android/tv/ui/dialog/TmdbSourceDialogInflationContractTest.java deleted file mode 100644 index 689b4a0790..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/dialog/TmdbSourceDialogInflationContractTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbSourceDialogInflationContractTest { - - @Test - public void materialButtonLabelsAreAppliedAfterInflation() throws Exception { - String layout = read(sourcePath().resolve(Path.of("..", "..", "main", "res", "layout", "dialog_tmdb_source.xml"))); - String source = read(sourcePath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "dialog", "TmdbSourceDialog.java"))); - - assertFalse("TMDB dialog buttons must not resolve text from binary XML on API 25", - buttonBlock(layout, "add").contains("android:text=") - || buttonBlock(layout, "addDisabled").contains("android:text=") - || buttonBlock(layout, "manage").contains("android:text=") - || buttonBlock(layout, "resetDefault").contains("android:text=")); - assertTrue(source.contains("addBtn.setText(R.string.dialog_tmdb_add)")); - assertTrue(source.contains("addDisabledBtn.setText(R.string.dialog_tmdb_add)")); - assertTrue(source.contains("manageBtn.setText(R.string.dialog_tmdb_site_manage)")); - assertTrue(source.contains("resetBtn.setText(R.string.dialog_tmdb_reset_default)")); - } - - private static String buttonBlock(String layout, String id) { - String marker = "android:id=\"@+id/" + id + "\""; - int idStart = layout.indexOf(marker); - if (idStart < 0) return ""; - int start = layout.lastIndexOf("", idStart); - return start >= 0 && end >= 0 ? layout.substring(start, end) : ""; - } - - private static String read(Path path) throws Exception { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - private static Path sourcePath() { - Path moduleRelative = Path.of("src", "main", "java"); - if (Files.exists(moduleRelative)) return moduleRelative; - return Path.of("app", "src", "main", "java"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbRuntimeCredentialWiringTest.java b/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbRuntimeCredentialWiringTest.java deleted file mode 100644 index 7767fc94b9..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbRuntimeCredentialWiringTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertTrue; - -public class TmdbRuntimeCredentialWiringTest { - - @Test - public void adapterRefreshesEffectiveConfigAndInvalidatesOldSubscriptionTasks() throws Exception { - String adapter = read("src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java"); - - assertTrue(adapter.contains("tmdbConfig = TmdbConfig.effectiveCurrent()")); - assertTrue(adapter.contains("SubscriptionTmdbCredentialStore.isCurrent(subscriptionScope)")); - assertTrue(adapter.contains("public void invalidateSubscription()")); - assertTrue(adapter.indexOf("refreshRuntimeConfig();", adapter.indexOf("public void autoMatch")) > 0); - int invalidate = adapter.indexOf("public void invalidateSubscription()"); - int refresh = adapter.indexOf("refreshRuntimeConfig();", invalidate); - int scope = adapter.indexOf("subscriptionScope =", invalidate); - assertTrue("subscription invalidation must refresh effective credentials before capturing the new scope", - refresh > invalidate && scope > refresh); - int cachedLoad = adapter.indexOf("public void load(TmdbItem item, Vod vod, TmdbDetailCache.Entry cached)"); - int cachedRefresh = adapter.indexOf("refreshRuntimeConfig();", cachedLoad); - int cachedReset = adapter.indexOf("resetLoadState();", cachedLoad); - assertTrue("cached detail loading must refresh the effective credential before resetting state", - cachedRefresh > cachedLoad && cachedReset > cachedRefresh); - } - - @Test - public void mobileAndLeanbackUseEffectiveConfigAndInvalidateOnVodConfigChange() throws Exception { - String mobile = read("src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - String leanback = read("src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - - assertTrue(mobile.contains("TmdbConfig tmdbConfig = TmdbConfig.effectiveCurrent()")); - assertTrue(leanback.contains("TmdbConfig tmdbConfig = TmdbConfig.effectiveCurrent()")); - assertTrue(mobile.contains("mTmdbUIAdapter.invalidateSubscription()")); - assertTrue(leanback.contains("mTmdbUIAdapter.invalidateSubscription()")); - } - - @Test - public void detailActivityRefreshesAfterSourceIngressAndReloadsOnSubscriptionChange() throws Exception { - String detail = read("src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); - int source = detail.indexOf("Result result = SiteApi.detailContent(key, id)"); - int effective = detail.indexOf("tmdbConfig = TmdbConfig.effectiveCurrent()", source); - - assertTrue(effective > source); - assertTrue(detail.contains("public void onConfigEvent(ConfigEvent event)")); - assertTrue(detail.contains("loadContent(null)")); - } - - private static String read(String path) throws Exception { - return Files.readString(Path.of(path)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceAdapterTest.java b/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceAdapterTest.java deleted file mode 100644 index 6f710ec7ee..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceAdapterTest.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.bean.Vod; -import com.google.gson.Gson; -import com.google.gson.JsonParser; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -public class TmdbSourceAdapterTest { - - private static final Gson GSON = new Gson(); - - @Test - public void adapterBuildsBundleWithoutNetworkConfiguration() { - TmdbSourcePayload payload = GSON.fromJson(""" - {"schema":1,"id":1399,"media_type":"tv","season_number":2,"complete":["core","credits","images","season:2"], - "detail":{"id":1399,"name":"Example Show","first_air_date":"2024-01-02","overview":"Overview", - "vote_average":8.2,"poster_path":"/poster.jpg","backdrop_path":"/backdrop.jpg", - "number_of_seasons":2,"number_of_episodes":1,"genres":[{"id":18}], - "aggregate_credits":{"cast":[{"id":11,"name":"Actor","character":"Hero","profile_path":"https://image.test/actor.jpg"}], - "crew":[{"id":12,"name":"Director","job":"Director","profile_path":"/director.jpg"}]}, - "images":{"backdrops":[{"file_path":"/backdrop.jpg"}],"posters":[{"file_path":"/poster.jpg"}]}, - "recommendations":{"page":1,"results":[{"id":550,"media_type":"movie","title":"Related Movie", - "release_date":"1999-10-15","vote_average":8.4,"poster_path":"/related.jpg"}]}, - "seasons":[{"season_number":2,"episode_count":1,"episodes":[{"episode_number":1,"name":"Episode 1","still_path":"/still.jpg"}], - "credits":{"cast":[{"id":21,"name":"Guest","character":"Guest"}]},"images":{"backdrops":[{"file_path":"/season.jpg"}]}}]}} - """, TmdbSourcePayload.class); - - TmdbBundle bundle = TmdbSourceAdapter.toBundle(payload, new Vod(), new TmdbConfig()); - - assertNotNull(bundle); - assertEquals(1399, bundle.item().getTmdbId()); - assertEquals("Example Show", bundle.item().getTitle()); - assertTrue(bundle.item().getPosterUrl().contains("/poster.jpg")); - assertTrue(bundle.item().getBackdropUrl().contains("/backdrop.jpg")); - assertEquals(1, bundle.cast().size()); - assertEquals("Hero", bundle.cast().get(0).getSubtitle()); - assertEquals("https://image.test/actor.jpg", bundle.cast().get(0).getProfileUrl()); - assertEquals(1, bundle.creators().size()); - assertEquals("Director", bundle.creators().get(0).getName()); - assertEquals(2, bundle.photos().size()); - assertEquals(1, bundle.related().size()); - assertEquals("Related Movie", bundle.related().get(0).getTitle()); - assertEquals(Integer.valueOf(2), bundle.seasons().get(0)); - assertEquals(1, bundle.seasonEpisodes().get(2).size()); - assertEquals("Episode 1", bundle.seasonEpisodes().get(2).get(0).getTitle()); - assertEquals(1, bundle.seasonCast().get(2).size()); - assertEquals(1, bundle.seasonPhotos().get(2).size()); - } - - @Test - public void adapterKeepsAbsoluteHttpsImagesAndRejectsInvalidPaths() { - TmdbSourcePayload payload = GSON.fromJson(""" - {"schema":1,"id":550,"media_type":"movie","complete":["core","credits","images"], - "detail":{"id":550,"title":"Movie","overview":"Overview","release_date":"1999-10-15","vote_average":8.4, - "poster_path":"https://cdn.test/poster.jpg","backdrop_path":"http://cdn.test/backdrop.jpg","genres":[], - "credits":{"cast":[{"id":1,"name":"Actor","profile_path":"https://cdn.test/actor.jpg"}]}, - "images":{"posters":[{"file_path":"https://cdn.test/p2.jpg"}]}}} - """, TmdbSourcePayload.class); - - TmdbBundle bundle = TmdbSourceAdapter.toBundle(payload, null, new TmdbConfig()); - - assertNotNull(bundle); - assertEquals("https://cdn.test/poster.jpg", bundle.item().getPosterUrl()); - assertEquals("", bundle.item().getBackdropUrl()); - assertEquals("https://cdn.test/actor.jpg", bundle.cast().get(0).getProfileUrl()); - assertEquals("https://cdn.test/p2.jpg", bundle.photos().get(0)); - } - - @Test - public void networkAdapterUsesSameDetailShapeWithoutRequiringApiKey() { - com.fongmi.android.tv.bean.TmdbItem sourceItem = new com.fongmi.android.tv.bean.TmdbItem( - 550, "movie", "Fallback", "", "Fallback overview", "", ""); - var detail = JsonParser.parseString(""" - {"id":550,"title":"Movie","overview":"Network overview","release_date":"1999-10-15","vote_average":8.4, - "poster_path":"/poster.jpg","backdrop_path":"/backdrop.jpg","genres":[{"id":18}], - "credits":{"cast":[{"id":1,"name":"Actor","profile_path":"/actor.jpg"}]}, - "images":{"backdrops":[{"file_path":"/backdrop.jpg"}],"posters":[{"file_path":"/poster.jpg"}]}} - """).getAsJsonObject(); - - TmdbBundle bundle = TmdbSourceAdapter.fromNetwork(sourceItem, detail, new TmdbConfig()); - - assertNotNull(bundle); - assertEquals("Movie", bundle.item().getTitle()); - assertEquals("Network overview", bundle.item().getOverview()); - assertEquals(1, bundle.cast().size()); - assertEquals(2, bundle.photos().size()); - } - - @Test - public void embeddedVideoResolverKeepsTitleSeasonAndEpisodeScopes() { - var detail = JsonParser.parseString(""" - {"videos":{"results":[{"id":"title-video","key":"title_key","site":"YouTube","type":"Trailer","name":"Title","official":true,"iso_639_1":"zh-CN"}]}, - "seasons":[{"season_number":1,"videos":{"results":[{"id":"season-video","key":"season_key","site":"YouTube","type":"Teaser","name":"Season","official":true,"iso_639_1":"zh-CN"}]}, - "episodes":[{"episode_number":1,"videos":{"results":[{"id":"episode-video","key":"episode_key","site":"YouTube","type":"Clip","name":"Episode","official":true,"iso_639_1":"zh-CN"}]}}]}]} - """).getAsJsonObject(); - - var videos = TmdbSourceAdapter.videos(detail, "tv", 1, 1, "zh-CN"); - - assertEquals(3, videos.size()); - assertTrue(videos.stream().anyMatch(video -> video.getScope() == com.fongmi.android.tv.bean.TmdbVideo.Scope.EPISODE && "episode_key".equals(video.getKey()))); - assertTrue(videos.stream().anyMatch(video -> video.getScope() == com.fongmi.android.tv.bean.TmdbVideo.Scope.SEASON && "season_key".equals(video.getKey()))); - assertTrue(videos.stream().anyMatch(video -> video.getScope() == com.fongmi.android.tv.bean.TmdbVideo.Scope.TITLE && "title_key".equals(video.getKey()))); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceAvailabilityTest.java b/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceAvailabilityTest.java deleted file mode 100644 index 717da5dc30..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceAvailabilityTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.bean.Vod; -import com.fongmi.android.tv.setting.TmdbSourceState; -import com.google.gson.Gson; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbSourceAvailabilityTest { - - private static final Gson GSON = new Gson(); - - @Test - public void nullOrUnparsableDataIsAbsent() { - assertEquals(TmdbSourceState.ABSENT_OR_INVALID, TmdbSourceAvailability.classify(null, null, null)); - assertEquals(TmdbSourceState.ABSENT_OR_INVALID, TmdbSourceAvailability.classify(new Vod(), new TmdbSourcePayload(), null)); - } - - @Test - public void identityOnlyDoesNotCountDeclaredEmptyCapabilities() { - TmdbSourcePayload payload = payload(""" - {"schema":1,"id":550,"media_type":"movie","complete":["core","credits","images"], - "detail":{"id":550}} - """); - - assertEquals(TmdbSourceState.IDENTITY_ONLY, - TmdbSourceAvailability.classify(new Vod(), payload, TmdbSourceAdapter.toBundle(payload, new Vod(), new TmdbConfig()))); - } - - @Test - public void anyUserVisibleFieldMakesPayloadRenderable() { - TmdbSourcePayload payload = payload(""" - {"schema":1,"id":550,"media_type":"movie", - "detail":{"id":550,"title":"Fight Club"}} - """); - - TmdbBundle bundle = TmdbSourceAdapter.toBundle(payload, new Vod(), new TmdbConfig()); - - assertEquals(TmdbSourceState.RENDERABLE, TmdbSourceAvailability.classify(new Vod(), payload, bundle)); - assertTrue(TmdbSourceAvailability.isRenderable(bundle)); - } - - @Test - public void nestedCollectionsAreRenderableWithoutCoreCopy() { - TmdbSourcePayload payload = payload(""" - {"schema":1,"id":1399,"media_type":"tv","season_number":1, - "detail":{"id":1399,"seasons":[{"season_number":1,"episode_count":10}]}} - """); - - TmdbBundle bundle = TmdbSourceAdapter.toBundle(payload, new Vod(), new TmdbConfig()); - - assertEquals(TmdbSourceState.RENDERABLE, TmdbSourceAvailability.classify(new Vod(), payload, bundle)); - } - - @Test - public void mismatchedBundleIdentityIsRejected() { - TmdbSourcePayload payload = payload(""" - {"schema":1,"id":550,"media_type":"movie","detail":{"id":550,"title":"Fight Club"}} - """); - TmdbSourcePayload other = payload(""" - {"schema":1,"id":551,"media_type":"movie","detail":{"id":551,"title":"Other"}} - """); - TmdbBundle otherBundle = TmdbSourceAdapter.toBundle(other, new Vod(), new TmdbConfig()); - - assertEquals(TmdbSourceState.ABSENT_OR_INVALID, TmdbSourceAvailability.classify(null, payload, otherBundle)); - } - - @Test - public void paymentSourceIdentityMustMatchVodPayload() { - TmdbSourcePayload payload = payload(""" - {"schema":1,"id":550,"media_type":"movie","detail":{"id":550,"title":"Fight Club"}} - """); - TmdbSourcePayload other = payload(""" - {"schema":1,"id":551,"media_type":"movie","detail":{"id":551,"title":"Other"}} - """); - Vod vod = new Vod(); - vod.setTmdb(other); - - assertEquals(TmdbSourceState.ABSENT_OR_INVALID, - TmdbSourceAvailability.classify(vod, payload, TmdbSourceAdapter.toBundle(payload, vod, new TmdbConfig()))); - } - - @Test - public void nullBundleIsNeverRenderable() { - assertFalse(TmdbSourceAvailability.isRenderable(null)); - } - - private static TmdbSourcePayload payload(String json) { - return GSON.fromJson(json, TmdbSourcePayload.class); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceCapabilityPlannerTest.java b/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceCapabilityPlannerTest.java deleted file mode 100644 index f0c8296d7d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceCapabilityPlannerTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.google.gson.Gson; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbSourceCapabilityPlannerTest { - - private static final Gson GSON = new Gson(); - - @Test - public void completeInitialCapabilitiesNeedNoNetwork() { - TmdbSourcePayload payload = payload(""" - {"schema":1,"id":550,"media_type":"movie","complete":["core","credits","images"], - "detail":{"id":550,"title":"Movie","overview":"Overview","release_date":"1999","vote_average":8.4,"genres":[], - "poster_path":"/p.jpg","backdrop_path":"/b.jpg","credits":{"cast":[]},"images":{"posters":[]}}} - """); - - TmdbSourceCapabilityPlanner.Plan plan = TmdbSourceCapabilityPlanner.plan(null, payload, TmdbSourceCapabilityPlanner.UiState.initialScreen()); - - assertEquals(3, plan.required().size()); - assertTrue(plan.available().containsAll(plan.required())); - assertFalse(plan.needsNetwork()); - } - - @Test - public void partialInitialCapabilitiesExposeOnlyMissingGroups() { - TmdbSourcePayload payload = payload(""" - {"schema":1,"id":550,"media_type":"movie","complete":["core"], - "detail":{"id":550,"title":"Movie","overview":"Overview","release_date":"1999","vote_average":8.4,"genres":[]}} - """); - - TmdbSourceCapabilityPlanner.Plan plan = TmdbSourceCapabilityPlanner.plan(null, payload, TmdbSourceCapabilityPlanner.UiState.initialScreen()); - - assertEquals(3, plan.required().size()); - assertEquals(2, plan.missing().size()); - assertTrue(plan.missing().contains("credits")); - assertTrue(plan.missing().contains("images")); - assertTrue(plan.hasInitialNetworkGaps()); - } - - @Test - public void seasonAndEpisodeCapabilitiesAreIsolated() { - TmdbSourcePayload payload = payload(""" - {"schema":1,"id":1399,"media_type":"tv","season_number":2,"complete":["core","season:2"], - "detail":{"id":1399,"season_number":2,"name":"Show","overview":"Overview","first_air_date":"2024","vote_average":8, - "number_of_seasons":2,"number_of_episodes":1,"genres":[],"seasons":[{"season_number":2,"episodes":[{"episode_number":1}]}]}} - """); - - TmdbBundle bundle = TmdbSourceAdapter.toBundle(payload, null, new TmdbConfig()); - TmdbSourceCapabilityPlanner.Plan season1 = TmdbSourceCapabilityPlanner.plan(bundle, payload, TmdbSourceCapabilityPlanner.UiState.initialScreen().withSeason(1)); - TmdbSourceCapabilityPlanner.Plan season2 = TmdbSourceCapabilityPlanner.plan(bundle, payload, TmdbSourceCapabilityPlanner.UiState.initialScreen().withEpisode(2, 1)); - - assertTrue(season1.missing().contains("season:1")); - assertFalse(season2.missing().contains("season:2")); - assertFalse(season2.missing().contains("episode:2:1")); - } - - @Test - public void emptyCompleteRecommendationsDoNotRequestNetwork() { - TmdbSourcePayload payload = payload(""" - {"schema":1,"id":550,"media_type":"movie","complete":["recommendations"], - "detail":{"id":550,"recommendations":{"page":1,"results":[]}}} - """); - - assertTrue(TmdbSourceCapabilityPlanner.isAvailable(null, payload, "recommendations", -1, -1)); - } - - private static TmdbSourcePayload payload(String json) { - return GSON.fromJson(json, TmdbSourcePayload.class); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceHybridWiringTest.java b/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceHybridWiringTest.java deleted file mode 100644 index bfff37ad56..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceHybridWiringTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbSourceHybridWiringTest { - - @Test - public void sourceLoadUsesPlannerAndFillsOnlyInitialGaps() throws Exception { - String adapter = read("src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java"); - String load = method(adapter, "public void loadSource(TmdbBundle bundle", "private void applySourceBundle"); - String fill = method(adapter, "private void fillInitialSourceGaps", "private static String seasonEpisodeKey"); - - assertTrue(load.contains("sourceOnly = !tmdbConfig.isReady();")); - assertTrue(load.contains("plan.hasInitialNetworkGaps()")); - assertTrue(load.contains("if (fillInitial) backgroundTasks.submit")); - assertTrue(fill.contains("tmdbService.detailForSource(")); - assertTrue(fill.contains("TmdbSourceMerger.fillOnly(")); - assertTrue(fill.contains("SubscriptionTmdbCredentialStore") || fill.contains("isCurrentGeneration(generation)")); - } - - @Test - public void keylessSourceNeverFallsThroughToSeasonOrVideoNetwork() throws Exception { - String adapter = read("src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java"); - String season = method(adapter, "private List seasonEpisodes", "private Integer currentEpisodeMetadataSeason"); - String videos = method(adapter, "public void loadRelatedVideosAsync", "private boolean applySourceVideosIfAvailable"); - - int sourceOnly = season.indexOf("if (sourceOnly) return List.of();"); - int seasonNetwork = season.indexOf("tmdbService.season("); - assertTrue(sourceOnly >= 0 && sourceOnly < seasonNetwork); - assertTrue(videos.contains("applySourceVideosIfAvailable(item, seasonNumber, episodeNumber, contextKey)")); - assertTrue(videos.contains("if (sourceOnly) {")); - } - - @Test - public void bothFlavorsPreferRenderableSourceBeforeNormalMatching() throws Exception { - String mobile = read("src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - String leanback = read("src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - - for (String source : new String[]{mobile, leanback}) { - int sourceFirst = source.indexOf("sourceState == TmdbSourceState.RENDERABLE && sourceBundle != null"); - int loadSource = source.indexOf("mTmdbUIAdapter.loadSource(sourceBundle, item, sourcePayload);", sourceFirst); - assertTrue(sourceFirst > 0 && loadSource > sourceFirst); - } - } - - private static String read(String path) throws Exception { - return Files.readString(Path.of(path)); - } - - private static String method(String source, String startMarker, String endMarker) { - int start = source.indexOf(startMarker); - int end = source.indexOf(endMarker, start); - assertTrue("missing method " + startMarker, start >= 0 && end > start); - return source.substring(start, end); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceMergerTest.java b/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceMergerTest.java deleted file mode 100644 index 3affaaad05..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbSourceMergerTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.TmdbPerson; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.google.gson.Gson; -import com.google.gson.JsonParser; - -import org.junit.Test; - -import java.util.List; -import java.util.Map; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -public class TmdbSourceMergerTest { - - private static final Gson GSON = new Gson(); - - @Test - public void sourceValuesWinAndMissingFieldsFillFromNetwork() { - TmdbBundle source = bundle(item("Source", "", ""), "{\"id\":1,\"overview\":\"\",\"credits\":{\"cast\":[]}}", List.of(), List.of()); - TmdbBundle network = bundle(item("Network", "Network overview", "poster.jpg"), "{\"id\":1,\"overview\":\"Network overview\",\"genres\":[{\"id\":18}],\"credits\":{\"cast\":[{\"id\":9}]}}", List.of(new TmdbPerson(9, "Actor", "Hero", "", "", "")), List.of()); - TmdbSourcePayload payload = GSON.fromJson("{\"schema\":1,\"id\":1,\"media_type\":\"movie\",\"complete\":[\"core\",\"credits\",\"images\"],\"detail\":{\"id\":1}}", TmdbSourcePayload.class); - - TmdbSourceMerger.MergedBundle merged = TmdbSourceMerger.merge(source, payload, null, network); - - assertEquals("Source", merged.bundle().item().getTitle()); - assertEquals("Network overview", merged.bundle().item().getOverview()); - assertEquals("poster.jpg", merged.bundle().item().getPosterUrl()); - assertTrue(merged.bundle().detail().getAsJsonArray("genres").size() == 1); - assertTrue(merged.bundle().cast().isEmpty()); - assertEquals(TmdbSourceMerger.Origin.REMOTE_TMDB, merged.origin("detail.overview")); - assertEquals(TmdbSourceMerger.Origin.SOURCE, merged.origin("item.title")); - } - - @Test - public void localCacheFillsBeforeNetworkWithoutOverwritingSource() { - TmdbItem sourceItem = new TmdbItem(1, "movie", "Source", "", "Source overview", "", ""); - TmdbBundle source = bundle(sourceItem, "{\"id\":1,\"overview\":\"Source overview\"}", List.of(), List.of()); - TmdbBundle cache = bundle(new TmdbItem(1, "movie", "Cache", "", "Cache overview", "cache.jpg", ""), "{\"id\":1,\"overview\":\"Cache overview\",\"poster_path\":\"cache.jpg\"}", List.of(), List.of()); - TmdbBundle network = bundle(new TmdbItem(1, "movie", "Network", "", "Network overview", "network.jpg", ""), "{\"id\":1,\"overview\":\"Network overview\",\"poster_path\":\"network.jpg\"}", List.of(), List.of()); - TmdbSourcePayload payload = GSON.fromJson("{\"schema\":1,\"id\":1,\"media_type\":\"movie\",\"complete\":[\"core\"],\"detail\":{\"id\":1}}", TmdbSourcePayload.class); - - TmdbSourceMerger.MergedBundle merged = TmdbSourceMerger.merge(source, payload, cache, network); - - assertEquals("Source overview", merged.bundle().item().getOverview()); - assertEquals("cache.jpg", merged.bundle().item().getPosterUrl()); - assertEquals(TmdbSourceMerger.Origin.LOCAL_CACHE, merged.origin("item.poster")); - } - - @Test - public void completeEmptySeasonDoesNotGetReplacedByNetwork() { - TmdbBundle source = bundle(item("Show", "", ""), "{\"id\":1,\"seasons\":[{\"season_number\":2,\"episodes\":[]}]}", List.of(), List.of(), Map.of(2, List.of())); - TmdbBundle network = bundle(item("Show", "", ""), "{\"id\":1,\"seasons\":[{\"season_number\":2,\"episodes\":[{\"episode_number\":1}]}]}", List.of(), List.of(), Map.of(2, List.of(new com.fongmi.android.tv.bean.TmdbEpisode(1, "Episode", "", "", "", 0, 0, 1, 2)))); - TmdbSourcePayload payload = GSON.fromJson("{\"schema\":1,\"id\":1,\"media_type\":\"tv\",\"season_number\":2,\"complete\":[\"season:2\"],\"detail\":{\"id\":1,\"season_number\":2}}", TmdbSourcePayload.class); - - TmdbSourceMerger.MergedBundle merged = TmdbSourceMerger.merge(source, payload, null, network); - - assertTrue(merged.bundle().seasonEpisodes().get(2).isEmpty()); - } - - @Test - public void identityMismatchLeavesSourceBundleUntouched() { - TmdbBundle source = bundle(item("Source", "Overview", ""), "{\"id\":1,\"overview\":\"Overview\"}", List.of(), List.of()); - TmdbBundle network = bundle(item("Wrong", "Wrong", "wrong.jpg"), "{\"id\":1,\"overview\":\"Wrong\"}", List.of(new TmdbPerson(9, "Actor", "", "", "", "")), List.of()); - network = new TmdbBundle(new TmdbItem(2, "movie", "Wrong", "", "Wrong", "wrong.jpg", ""), network.detail(), network.cast(), network.creators(), network.photos(), network.related(), network.seasons(), network.seasonCounts(), network.seasonEpisodes(), network.seasonCast(), network.seasonPhotos()); - - TmdbSourceMerger.MergedBundle merged = TmdbSourceMerger.merge(source, null, null, network); - - assertEquals("Source", merged.bundle().item().getTitle()); - assertTrue(merged.bundle().detail().get("overview").getAsString().equals("Overview")); - } - - private static TmdbItem item(String title, String overview, String poster) { - return new TmdbItem(1, "movie", title, "", overview, poster, ""); - } - - private static TmdbBundle bundle(TmdbItem item, String detail, List cast, List related) { - return bundle(item, detail, cast, related, Map.of()); - } - - private static TmdbBundle bundle(TmdbItem item, String detail, List cast, List related, Map> episodes) { - return new TmdbBundle(item, JsonParser.parseString(detail).getAsJsonObject(), cast, List.of(), List.of(), related, List.of(), Map.of(), episodes, Map.of(), Map.of()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapterSourceOnlyTest.java b/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapterSourceOnlyTest.java deleted file mode 100644 index 4d2ff7ce8a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapterSourceOnlyTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbUIAdapterSourceOnlyTest { - - @Test - public void loadSourceIsLocalAndSeedsSeasonEpisodeCache() throws Exception { - String source = readAdapter(); - int start = source.indexOf("public void loadSource(TmdbBundle bundle"); - int end = source.indexOf("private void applySourceBundle", start); - String method = source.substring(start, end); - String apply = source.substring(end, source.indexOf("private void fillInitialSourceGaps", end)); - - assertTrue(method.contains("sourceOnly = !tmdbConfig.isReady();")); - assertTrue(method.contains("plan.hasInitialNetworkGaps()")); - assertTrue(apply.contains("seasonEpisodeCache.put(seasonEpisodeKey(tmdbItem, entry.getKey()), List.copyOf(entry.getValue()));")); - assertTrue(apply.contains("seasonResolution = TmdbSeasonResolver.resolve(")); - assertTrue(apply.contains("episodeMetadataLoaded = true;")); - assertTrue(method.contains("requestSeasonNumber < 0 && payload != null && payload.getSeasonNumber() >= 0")); - assertFalse(method.contains("tmdbService.")); - assertFalse(method.contains("Setting.putTmdb")); - assertFalse(method.contains("saveMatch(")); - } - - @Test - public void sourceHybridFillsOnlyPlannerReportedInitialGaps() throws Exception { - String source = readAdapter(); - String load = method(source, "public void loadSource(TmdbBundle bundle", "private void applySourceBundle"); - String fill = method(source, "private void fillInitialSourceGaps", "private static String seasonEpisodeKey"); - - assertTrue(load.contains("plan.hasInitialNetworkGaps()")); - assertTrue(load.contains("if (fillInitial) backgroundTasks.submit")); - assertTrue(fill.contains("detailForSource(")); - assertTrue(fill.contains("TmdbSourceMerger.fillOnly(")); - assertTrue(fill.contains("isCurrentGeneration(generation)")); - } - - @Test - public void sourceOnlyBlocksNetworkPagesAndReadsEmbeddedVideos() throws Exception { - String source = readAdapter(); - String related = method(source, "public void loadRelatedVideosAsync", "public boolean hasMoreRecommendations"); - String recommendations = method(source, "public void loadMoreRecommendations", "public void loadMorePersonalTmdbRecommendations"); - String personal = method(source, "private void loadMorePersonalRecommendations", "private void applyRelatedRatingEnrichment"); - String refresh = method(source, "public void refreshPersonalRecommendations", "private void loadMorePersonalRecommendations"); - String ai = method(source, "private void loadPersonalAiRecommendationsAsync", "private void applyPersonalAiRatingEnrichment"); - - assertTrue(related.contains("if (sourceOnly) {")); - assertTrue(related.contains("TmdbSourceAdapter.videos(")); - assertTrue(related.contains("applySourceVideosIfAvailable(item, seasonNumber, episodeNumber, contextKey)")); - int localStart = related.indexOf("if (sourceOnly) {"); - int readyCheck = related.indexOf("if (!isReady())", localStart); - assertTrue(localStart >= 0 && readyCheck > localStart); - assertFalse(related.substring(localStart, readyCheck).contains("tmdbService.")); - assertTrue(recommendations.contains("if (sourceOnly) {")); - assertTrue(personal.contains("if (sourceOnly) {")); - assertTrue(refresh.contains("if (sourceOnly) {")); - assertTrue(ai.contains("if (sourceOnly) return;")); - } - - private static String readAdapter() throws Exception { - return Files.readString(root().resolve(Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "ui", "helper", "TmdbUIAdapter.java")), StandardCharsets.UTF_8); - } - - private static String method(String source, String startMarker, String endMarker) { - int start = source.indexOf(startMarker); - int end = source.indexOf(endMarker, start); - assertTrue("missing method " + startMarker, start >= 0 && end > start); - return source.substring(start, end); - } - - private static Path root() { - return Files.exists(Path.of("app", "src", "main")) ? Path.of(".") : Path.of(".."); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelperSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelperSourceTest.java deleted file mode 100644 index 440ce968ec..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelperSourceTest.java +++ /dev/null @@ -1,212 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import javax.xml.parsers.DocumentBuilderFactory; - -import org.w3c.dom.Document; -import org.w3c.dom.Node; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TouchOptimizationHelperSourceTest { - - @Test - public void storesOriginalStateOnceAndRestoresRecyclerChildren() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java"); - - assertTrue(source.contains("WeakHashMap")); - assertTrue(source.contains("containsKey")); - assertTrue(source.contains("sync(view)")); - assertFalse(source.contains("View.generateViewId")); - } - - @Test - public void inputViewsAreSkippedAsSubtrees() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java"); - - assertTrue(source.contains("if (isInputView(view)) return;")); - assertTrue(source.contains("view.onCheckIsTextEditor()")); - } - - @Test - public void mobileFlavorDoesNotTraverseViews() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java"); - - assertTrue(source.contains("if (!Util.isLeanback()) return;")); - } - - @Test - public void touchOptimizationToggleCanEnableModeWithOneTouch() throws Exception { - Path layout = path("app/src/leanback/res/layout/activity_setting_personal.xml"); - var document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(layout.toFile()); - var nodes = document.getElementsByTagName("androidx.appcompat.widget.LinearLayoutCompat"); - - for (int i = 0; i < nodes.getLength(); i++) { - var attributes = nodes.item(i).getAttributes(); - var id = attributes.getNamedItem("android:id"); - if (id == null || !"@+id/touchOptimization".equals(id.getNodeValue())) continue; - assertTrue("true".equals(attributes.getNamedItem("android:focusable").getNodeValue())); - assertTrue("false".equals(attributes.getNamedItem("android:focusableInTouchMode").getNodeValue())); - return; - } - throw new AssertionError("touchOptimization row not found"); - } - - @Test - public void touchOptimizationRowIsRemovedFromRootSettingScreen() throws Exception { - Path layout = path("app/src/leanback/res/layout/activity_setting.xml"); - var document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(layout.toFile()); - - assertFalse(hasId(document, "@+id/touchOptimization")); - assertFalse(hasId(document, "@+id/touchOptimizationText")); - } - - @Test - public void activityAppliesOptimizationToNewContentAndRegistersFragmentsEarly() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java"); - int register = source.indexOf("registerFragmentLifecycleCallbacks();"); - int content = source.indexOf("setContentView(getBinding().getRoot());"); - int method = source.indexOf("public void setContentView(View view)"); - int methodEnd = source.indexOf("protected FragmentActivity getActivity()", method); - int wall = source.indexOf("addCustomWall();", method); - int sync = source.indexOf("TouchOptimizationHelper.sync(getWindow().getDecorView());", method); - - assertTrue(register >= 0 && content >= 0 && register < content); - assertTrue(method >= 0 && methodEnd > method); - assertTrue(method < wall && wall < sync && sync < methodEnd); - } - - @Test - public void recyclerListenersAndLeanbackFocusModesAreTouchGated() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java"); - - assertTrue(source.contains("if (optimize) attachListener(recycler);")); - assertTrue(source.contains("setOnTouchInterceptListener")); - assertTrue(source.contains("setFocusScrollStrategy")); - assertTrue(source.contains("setFocusSearchDisabled")); - assertTrue(source.contains("onInterceptTouchEvent")); - assertTrue(source.contains("FOCUS_SCROLL_ITEM")); - assertTrue(source.contains("state.touchSettling = true;")); - assertTrue(source.contains("restoreGrid(grid);")); - assertTrue(source.contains("onScrollStateChanged")); - } - - @Test - public void tvSearchRestoresPerSitePixelPositionAndPlayerUsesTvTouchContract() throws Exception { - String collect = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java"); - String home = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"); - String video = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - String widget = read("app/src/leanback/res/layout/view_widget_vod.xml"); - assertTrue(collect.contains("Map mSearchPositions")); - assertTrue(collect.contains("saveSearchPosition(getActiveSiteKey())")); - assertTrue(collect.contains("getTop() - mBinding.recycler.getPaddingTop()")); - assertTrue(collect.contains("scrollToPositionWithOffset")); - assertTrue(collect.contains("mSearchPositions.clear()")); - assertTrue(collect.contains("TouchOptimizationHelper.isTouchActive(parent)")); - assertTrue(collect.contains("mBoundSearchSiteKey")); - assertTrue(collect.contains("if (!siteKey.equals(mBoundSearchSiteKey))")); - assertTrue(home.contains("mTypeSelectionFromTouch")); - assertTrue(home.contains("if (Setting.isHomeVodAutoLoad() && !mTypeSelectionFromTouch) scheduleTypeSwitch(position);")); - assertFalse(collect.contains("private void scrollSearchToTop()")); - assertTrue(video.contains("private boolean onVideoTouch(View view, MotionEvent event)")); - assertTrue(video.contains("if (!Setting.isTouchOptimized()) return mKeyDown.onTouchEvent(event);")); - assertTrue(video.contains("TV_TOUCH_HORIZONTAL")); - assertTrue(video.contains("applyTvBrightness")); - assertTrue(video.contains("applyTvVolume")); - assertTrue(video.contains("mBinding.widget.brightProgress")); - assertTrue(video.contains("mBinding.widget.volumeProgress")); - assertTrue(video.contains("} else if (action == MotionEvent.ACTION_CANCEL) {")); - assertTrue(video.contains("if (action == MotionEvent.ACTION_UP) {")); - assertTrue(video.contains("if (seeking && player() != null) onSeekEnd(mTvTouchSeek)")); - assertTrue(video.contains("mTvTouchDownX <= Math.max(1, mBinding.video.getWidth()) / 2f")); - assertTrue(video.contains("mKeyDown.releaseSpeed()")); - assertTrue(widget.contains("@+id/bright")); - assertTrue(widget.contains("@+id/volume")); - } - - @Test - public void highFrequencyDirectDialogsAndPlaybackSheetsAreSynchronized() throws Exception { - String helper = read("app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java"); - String lightDialog = read("app/src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java"); - String siteDialog = read("app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java"); - String videoActivity = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - - assertTrue(helper.contains("public static void sync(Dialog dialog)")); - assertTrue(lightDialog.contains("TouchOptimizationHelper.sync(dialog);")); - assertTrue(siteDialog.contains("TouchOptimizationHelper.sync(directDialog);")); - assertTrue(videoActivity.contains("private void syncAudioDialog(Dialog dialog)")); - assertTrue(videoActivity.contains("TouchOptimizationHelper.sync(dialog);")); - } - - @Test - public void touchOptimizationToggleCanEnableModeWithOneTouchMobile() throws Exception { - Path layout = path("app/src/mobile/res/layout/fragment_setting_personal.xml"); - var document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(layout.toFile()); - var nodes = document.getElementsByTagName("androidx.appcompat.widget.LinearLayoutCompat"); - - for (int i = 0; i < nodes.getLength(); i++) { - var attributes = nodes.item(i).getAttributes(); - var id = attributes.getNamedItem("android:id"); - if (id == null || !"@+id/touchOptimization".equals(id.getNodeValue())) continue; - assertTrue("true".equals(attributes.getNamedItem("android:focusable").getNodeValue())); - assertTrue("false".equals(attributes.getNamedItem("android:focusableInTouchMode").getNodeValue())); - return; - } - throw new AssertionError("touchOptimization row not found"); - } - - @Test - public void personalSettingsBindTouchOptimizationToggle() throws Exception { - String leanback = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPersonalActivity.java"); - String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java"); - String root = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java"); - - assertTrue(leanback.contains("mBinding.touchOptimization.setOnClickListener(this::setTouchOptimization);")); - assertTrue(leanback.contains("mBinding.touchOptimizationText.setText(getSwitch(Setting.isTouchOptimized()));")); - assertTrue(leanback.contains("TouchOptimizationHelper.sync(getWindow().getDecorView());")); - assertTrue(mobile.contains("mBinding.touchOptimization.setOnClickListener(this::setTouchOptimization);")); - assertTrue(mobile.contains("mBinding.touchOptimizationText.setText(getSwitch(Setting.isTouchOptimized()));")); - assertFalse(root.contains("mBinding.touchOptimization")); - } - - private static String read(String path) throws Exception { - return Files.readString(path(path), StandardCharsets.UTF_8); - } - - private static Path path(String value) { - Path direct = Path.of(value); - return Files.exists(direct) ? direct : Path.of(value.substring("app/".length())); - } - - private static Node findById(Document document, String id) { - var nodes = document.getElementsByTagName("*"); - for (int i = 0; i < nodes.getLength(); i++) { - var attribute = nodes.item(i).getAttributes().getNamedItem("android:id"); - if (attribute != null && id.equals(attribute.getNodeValue())) return nodes.item(i); - } - throw new AssertionError(id + " not found"); - } - - private static int elementIndex(Node node) { - int index = 0; - for (Node sibling = node.getPreviousSibling(); sibling != null; sibling = sibling.getPreviousSibling()) { - if (sibling.getNodeType() == Node.ELEMENT_NODE) index++; - } - return index; - } - - private static boolean hasId(Document document, String id) { - var nodes = document.getElementsByTagName("*"); - for (int i = 0; i < nodes.getLength(); i++) { - var attribute = nodes.item(i).getAttributes().getNamedItem("android:id"); - if (attribute != null && id.equals(attribute.getNodeValue())) return true; - } - return false; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/novel/ReaderHistoryProgressTest.java b/app/src/test/java/com/fongmi/android/tv/ui/novel/ReaderHistoryProgressTest.java index 35d63b8f6b..b7e2434812 100644 --- a/app/src/test/java/com/fongmi/android/tv/ui/novel/ReaderHistoryProgressTest.java +++ b/app/src/test/java/com/fongmi/android/tv/ui/novel/ReaderHistoryProgressTest.java @@ -1,16 +1,9 @@ package com.fongmi.android.tv.ui.novel; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertFalse; - -import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.db.AppDatabase; - -import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import org.junit.Test; /** * 阅读进度的落库编码:历史列表按 position/duration 画进度条。 @@ -20,24 +13,6 @@ import static org.junit.Assert.assertTrue; */ public class ReaderHistoryProgressTest { - @Test - public void readerKeysAndTypeMarkIsolateRowsFromPlaybackHistory() { - History playback = new History(); - playback.setKey("site" + AppDatabase.SYMBOL + "vod" + AppDatabase.SYMBOL + 7); - playback.setMediaType("movie"); - - assertFalse(ReaderHistory.isReaderRecord(playback)); - - String readerKey = ReaderHistory.buildKey("site", "vod", 7); - History reader = new History(); - reader.setKey(readerKey); - reader.setMediaType(ReaderHistory.MEDIA_TYPE); - - assertTrue(ReaderHistory.isReaderRecord(reader)); - assertTrue(readerKey.endsWith(AppDatabase.SYMBOL + ReaderHistory.MEDIA_TYPE)); - assertNotEquals(playback.getKey(), readerKey); - } - @Test public void finishedChapterFillsTheBar() { assertEquals(33, ReaderHistory.toPosition(32, 33)); @@ -112,56 +87,4 @@ public class ReaderHistoryProgressTest { assertEquals(0, ReaderHistory.toAnchor(-1, 10)); assertEquals(0, ReaderHistory.toPosition(-5, 10)); } - - @Test - public void readerPayloadUsesTheFieldThatTriggeredReaderRouting() { - Result result = new Result(); - result.setPlayUrl("novel://{\"title\":\"chapter\",\"content\":\"text\"}"); - result.setUrl("https://example.invalid/video.m3u8"); - - assertEquals(result.getPlayUrl(), NovelRouter.readerPayload(result)); - } - - @Test - public void resolvedChapterChangeCopiesSourceProgressInsteadOfKeepingAnotherChapter() { - History source = history("source", 21, 50); - History target = history("old-target", 8, 10); - - NovelRouter.alignResolvedHistoryProgress(target, source, "new-target"); - - assertEquals(21, target.getPosition()); - assertEquals(50, target.getDuration()); - } - - @Test - public void sameResolvedChapterKeepsExistingTargetProgress() { - History source = history("chapter", 21, 50); - History target = history("chapter", 8, 10); - - NovelRouter.alignResolvedHistoryProgress(target, source, "chapter"); - - assertEquals(8, target.getPosition()); - assertEquals(10, target.getDuration()); - } - - @Test - public void videoProgressDoesNotBecomeAReaderAnchor() { - History source = history("source", 120_000, 2_700_000); - source.setMediaType("tv"); - History target = history("old-target", 8, 10); - - NovelRouter.alignResolvedHistoryProgress(target, source, "new-target"); - - assertFalse(target.hasPlaybackTime()); - } - - private static History history(String episodeUrl, long position, long duration) { - History history = new History(); - history.setKey("site" + AppDatabase.SYMBOL + episodeUrl); - history.setEpisodeUrl(episodeUrl); - history.setMediaType(ReaderHistory.MEDIA_TYPE); - history.setPosition(position); - history.setDuration(duration); - return history; - } } diff --git a/app/src/test/java/com/fongmi/android/tv/ui/player/ShortDramaQueueCoordinatorTest.java b/app/src/test/java/com/fongmi/android/tv/ui/player/ShortDramaQueueCoordinatorTest.java deleted file mode 100644 index 2a4b9f5261..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/player/ShortDramaQueueCoordinatorTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.fongmi.android.tv.ui.player; - -import org.junit.Test; - -import java.util.Arrays; -import java.util.Collections; -import java.util.Map; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -public class ShortDramaQueueCoordinatorTest { - - @Test - public void forwardQueueCommitsNaturalTransitionOnce() { - ShortDramaQueueCoordinator coordinator = new ShortDramaQueueCoordinator(); - coordinator.begin(session(false, 0)); - - ShortDramaQueueCoordinator.ResolveRequest request = coordinator.nextRequest(); - assertNotNull(request); - assertEquals("e2", request.episode().id()); - - ShortDramaQueueCoordinator.QueueItem queued = coordinator.acceptResolved( - request.generation(), request.episode(), item("https://cdn.test/e2.m3u8")); - assertNotNull(queued); - assertTrue(coordinator.shouldSuppressLegacyAutoAdvance()); - assertNull(coordinator.acceptResolved( - request.generation(), request.episode(), item("https://cdn.test/e2b.m3u8"))); - - ShortDramaQueueCoordinator.Transition transition = coordinator.onMediaItemTransition( - request.generation(), queued.mediaId(), ShortDramaQueueCoordinator.TransitionReason.AUTO); - assertNotNull(transition); - assertEquals("e1", transition.previous().id()); - assertEquals("e2", transition.current().id()); - assertEquals("e3", transition.nextRequest().episode().id()); - assertFalse(coordinator.shouldSuppressLegacyAutoAdvance()); - assertNull(coordinator.onMediaItemTransition( - request.generation(), queued.mediaId(), ShortDramaQueueCoordinator.TransitionReason.AUTO)); - } - - @Test - public void staleGenerationAndWrongEpisodeCannotRepopulateQueue() { - ShortDramaQueueCoordinator coordinator = new ShortDramaQueueCoordinator(); - coordinator.begin(session(false, 0)); - ShortDramaQueueCoordinator.ResolveRequest old = coordinator.nextRequest(); - assertNotNull(old); - - ShortDramaQueueCoordinator.Snapshot newer = coordinator.begin(session(false, 1)); - assertTrue(newer.generation() > old.generation()); - assertNull(coordinator.acceptResolved( - old.generation(), old.episode(), item("https://cdn.test/old.mp4"))); - - ShortDramaQueueCoordinator.ResolveRequest expected = coordinator.nextRequest(); - assertNotNull(expected); - assertNull(coordinator.acceptResolved( - expected.generation(), new ShortDramaQueueCoordinator.Episode("e1", "1", 0), - item("https://cdn.test/wrong.mp4"))); - assertNull(coordinator.snapshot().queued()); - } - - @Test - public void reverseQueueStopsAtFirstEpisode() { - ShortDramaQueueCoordinator coordinator = new ShortDramaQueueCoordinator(); - coordinator.begin(session(true, 2)); - - ShortDramaQueueCoordinator.ResolveRequest second = coordinator.nextRequest(); - assertEquals("e2", second.episode().id()); - ShortDramaQueueCoordinator.QueueItem secondItem = coordinator.acceptResolved( - second.generation(), second.episode(), item("http://cdn.test/e2.mp4")); - ShortDramaQueueCoordinator.Transition secondTransition = coordinator.onMediaItemTransition( - second.generation(), secondItem.mediaId(), ShortDramaQueueCoordinator.TransitionReason.AUTO); - assertEquals("e1", secondTransition.nextRequest().episode().id()); - - ShortDramaQueueCoordinator.ResolveRequest first = coordinator.nextRequest(); - ShortDramaQueueCoordinator.QueueItem firstItem = coordinator.acceptResolved( - first.generation(), first.episode(), item("http://cdn.test/e1.mp4")); - ShortDramaQueueCoordinator.Transition firstTransition = coordinator.onMediaItemTransition( - first.generation(), firstItem.mediaId(), ShortDramaQueueCoordinator.TransitionReason.AUTO); - assertNull(firstTransition.nextRequest()); - assertNull(coordinator.nextRequest()); - } - - @Test - public void onlyDirectHttpVodItemsAreEligible() { - assertTrue(item("https://cdn.test/e.m3u8").isEligible()); - assertTrue(item("http://cdn.test/e.mp4").isEligible()); - assertFalse(new ShortDramaQueueCoordinator.ResolvedItem( - "rtsp://cdn.test/e", Collections.emptyMap(), "", ShortDramaQueueCoordinator.MediaKind.PROGRESSIVE_MP4).isEligible()); - assertFalse(new ShortDramaQueueCoordinator.ResolvedItem( - "https://cdn.test/e.m3u8", Collections.emptyMap(), "", ShortDramaQueueCoordinator.MediaKind.HLS, - true, false, false, false, false, false).isEligible()); - assertFalse(new ShortDramaQueueCoordinator.ResolvedItem( - "https://cdn.test/e.m3u8***https://cdn.test/f.m3u8|||10", Collections.emptyMap(), "", ShortDramaQueueCoordinator.MediaKind.HLS).isEligible()); - assertFalse(new ShortDramaQueueCoordinator.ResolvedItem( - "https://cdn.test/e.m3u8", Collections.emptyMap(), "", ShortDramaQueueCoordinator.MediaKind.HLS, - false, true, false, false, false, false).isEligible()); - } - - @Test - public void nonAutoOrWrongTimelineEventDoesNotMutateState() { - ShortDramaQueueCoordinator coordinator = new ShortDramaQueueCoordinator(); - coordinator.begin(session(false, 0)); - ShortDramaQueueCoordinator.ResolveRequest request = coordinator.nextRequest(); - ShortDramaQueueCoordinator.QueueItem item = coordinator.acceptResolved( - request.generation(), request.episode(), item("https://cdn.test/e2.mp4")); - - assertNull(coordinator.onMediaItemTransition( - request.generation(), item.mediaId(), ShortDramaQueueCoordinator.TransitionReason.MANUAL)); - assertNull(coordinator.onMediaItemTransition( - request.generation(), "wrong", ShortDramaQueueCoordinator.TransitionReason.AUTO)); - assertEquals("e1", coordinator.snapshot().current().id()); - assertEquals("e2", coordinator.snapshot().queued().episode().id()); - } - - private static ShortDramaQueueCoordinator.Session session(boolean reverse, int currentIndex) { - return new ShortDramaQueueCoordinator.Session( - "work", "site", "line", "flag", - Arrays.asList( - new ShortDramaQueueCoordinator.Episode("e1", "1", 0), - new ShortDramaQueueCoordinator.Episode("e2", "2", 1), - new ShortDramaQueueCoordinator.Episode("e3", "3", 2)), - currentIndex, - reverse); - } - - private static ShortDramaQueueCoordinator.ResolvedItem item(String uri) { - return new ShortDramaQueueCoordinator.ResolvedItem( - uri, Map.of("Referer", "https://site.test/"), "", uri.contains("m3u8") - ? ShortDramaQueueCoordinator.MediaKind.HLS - : ShortDramaQueueCoordinator.MediaKind.PROGRESSIVE_MP4); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/web/CatWebWiringTest.java b/app/src/test/java/com/fongmi/android/tv/ui/web/CatWebWiringTest.java index 8667ccfc10..dd299a8cd6 100644 --- a/app/src/test/java/com/fongmi/android/tv/ui/web/CatWebWiringTest.java +++ b/app/src/test/java/com/fongmi/android/tv/ui/web/CatWebWiringTest.java @@ -130,76 +130,10 @@ public class CatWebWiringTest { int store = source.indexOf("VodDetailCache.putContent(sourceKey, id, content)"); assertTrue("SiteApi 必须有详情缓存写入", store >= 0); - int guard = source.lastIndexOf("CatAction.blank(result.getVod())", store); + int guard = source.lastIndexOf("!CatAction.blank(result.getVod())", store); assertTrue("写缓存前必须排除「什么都没有」的详情", guard > 0 && guard < store); } - /** - * 只有占位线路、没有元数据的详情不得进缓存。 - * - *

设置类站点(网盘配置一类)把每个设置项做成条目,真正的工作在 {@code detailContent} - * 里发生——弹输入框、写配置;返回值只是个占位假线路,实测形如 - * {@code {"list":[{"vod_play_from":"Config","vod_play_url":"Config$Config"}]}}。 - * 缓存住它,第二次点击 spider 不被调用,弹窗不出,宿主拿着假地址直奔播放页。 - * - *

{@code blank} 挡不住:占位线路会生成 Flag,{@code getFlags()} 非空。所以判定要看 - * 元数据(名字/封面/简介)而不看线路——线路是占位结果也能凑出来的东西。 - */ - @Test - public void detailWithoutMetadataIsNotCached() throws IOException { - String source = read("com/fongmi/android/tv/api/SiteApi.java"); - int store = source.indexOf("VodDetailCache.putContent(sourceKey, id, content)"); - assertTrue("SiteApi 必须有详情缓存写入", store > 0); - - int guard = source.lastIndexOf("!hasMetadata(result.getVod())", store); - assertTrue("写缓存前必须排除「只有占位线路、没有元数据」的详情", guard > 0 && guard < store); - - int helper = source.indexOf("private static boolean hasMetadata(Vod vod)"); - assertTrue("必须有 hasMetadata 判定", helper > 0); - String body = source.substring(helper, source.indexOf('}', source.indexOf("return", helper))); - assertTrue("判定必须看名字", body.contains("getName()")); - assertTrue("判定必须看封面", body.contains("getPic()")); - assertTrue("判定必须看简介", body.contains("getContent()")); - assertTrue("判定绝不能看线路——占位结果也有线路,看了就挡不住设置类站点", - !body.contains("getFlags()")); - } - - /** 带元数据但没有可播放线路的设置动作也不得缓存。 */ - @Test - public void metadataOnlyActionDetailIsNotCached() throws IOException { - String source = read("com/fongmi/android/tv/api/SiteApi.java"); - int store = source.indexOf("VodDetailCache.putContent(sourceKey, id, content)"); - assertTrue("SiteApi 必须有详情缓存写入", store > 0); - - int guard = source.lastIndexOf("result.getVod().getFlags().isEmpty()", store); - assertTrue("写缓存前必须排除没有可播放线路的动作详情", guard > 0 && guard < store); - assertTrue("跳过原因必须可从设备日志验证", - source.substring(guard, store).contains("reason=noPlayableContent")); - } - - /** - * 这次 spider 调用顺带开了网页,这条详情就绝不能进缓存。 - * - *

开页是 spider 调用的副作用(bundle 反向调 {@code /msg}),缓存命中时不调 spider, - * 副作用也就不再发生——第二次点击网页不开、还会停在空详情页上,即「只有第一次生效」。 - * - *

与 {@link #detailWithoutMetadataIsNotCached} 互补:那条看结果形状,这条看副作用。 - * 形状千变万化,副作用是确凿的,两类都要。 - */ - @Test - public void webOpeningDetailIsNotCached() throws IOException { - String source = read("com/fongmi/android/tv/api/SiteApi.java"); - int sample = source.indexOf("long beforeSpider = System.currentTimeMillis()"); - assertTrue("必须在调 spider 之前先取一次时刻", sample > 0); - - int spider = source.indexOf("site.recent().spider().detailContent(", sample); - assertTrue("取样必须在 spider 调用之前,否则跨不过副作用发生的那一刻", spider > sample); - - int store = source.indexOf("VodDetailCache.putContent(sourceKey, id, content)"); - int compare = source.lastIndexOf("CatWebEvent.requestedAfter(beforeSpider)", store); - assertTrue("写缓存前必须判定这次调用有没有开过页", compare > sample && compare < store); - } - @Test public void activityRegistersNoJavascriptBridge() throws IOException { String source = read("com/fongmi/android/tv/ui/web/CatWebActivity.java"); diff --git a/app/src/test/java/com/fongmi/android/tv/update/GithubProxyTest.java b/app/src/test/java/com/fongmi/android/tv/update/GithubProxyTest.java new file mode 100644 index 0000000000..ba551fa14d --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/update/GithubProxyTest.java @@ -0,0 +1,28 @@ +package com.fongmi.android.tv.update; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +public class GithubProxyTest { + + private static final String ASSET = "https://github.com/fish2018/webhtv/releases/download/v1/mobile-arm64_v8a.apk"; + + @Test + public void rewritesKnownProxyModes() { + assertEquals( + "https://github.chenc.dev/github.com/fish2018/webhtv/releases/download/v1/mobile-arm64_v8a.apk", + GithubProxy.resolve("github_chenc", "", "").rewrite(ASSET)); + assertEquals( + "https://gh.acmsz.top/https://github.com/fish2018/webhtv/releases/download/v1/mobile-arm64_v8a.apk", + GithubProxy.resolve("gh_acmsz", "", "").rewrite(ASSET)); + } + + @Test + public void rejectsUnsafeCustomOrigins() { + assertThrows(IllegalArgumentException.class, () -> GithubProxy.resolve(GithubProxy.CUSTOM, "http://proxy.example", GithubProxy.MODE_FULL_URL)); + assertThrows(IllegalArgumentException.class, () -> GithubProxy.resolve(GithubProxy.CUSTOM, "https://user:pass@proxy.example", GithubProxy.MODE_FULL_URL)); + assertThrows(IllegalArgumentException.class, () -> GithubProxy.resolve(GithubProxy.CUSTOM, "https://proxy.example/path", GithubProxy.MODE_FULL_URL)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/update/UpdateRoutePlannerTest.java b/app/src/test/java/com/fongmi/android/tv/update/UpdateRoutePlannerTest.java index e705d338d7..ce12067453 100644 --- a/app/src/test/java/com/fongmi/android/tv/update/UpdateRoutePlannerTest.java +++ b/app/src/test/java/com/fongmi/android/tv/update/UpdateRoutePlannerTest.java @@ -1,7 +1,5 @@ package com.fongmi.android.tv.update; -import com.fongmi.android.tv.utils.GithubProxy; - import static org.junit.Assert.assertEquals; import java.util.List; @@ -17,7 +15,7 @@ public class UpdateRoutePlannerTest { "sha256:" + "1".repeat(64), "sha256:" + "2".repeat(64), 1024); - private final GithubProxy.Config direct = GithubProxy.config("", GithubProxy.MODE_FULL_URL, false); + private final GithubProxy.Config direct = GithubProxy.resolve(GithubProxy.DIRECT, "", GithubProxy.MODE_FULL_URL); @Test public void ociTriesOciThenGithub() { diff --git a/app/src/test/java/com/fongmi/android/tv/utils/AdBlockTimeFormatterTest.java b/app/src/test/java/com/fongmi/android/tv/utils/AdBlockTimeFormatterTest.java deleted file mode 100644 index 2e9d09acc4..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/AdBlockTimeFormatterTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.fongmi.android.tv.utils; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -public class AdBlockTimeFormatterTest { - - @Test - public void formatsPlayerStyleClockWithTenths() { - assertEquals("00:25:29.1", AdBlockTimeFormatter.formatSeconds(1529.1)); - assertEquals("00:00:03.7", AdBlockTimeFormatter.formatSeconds(3.733)); - } - - @Test - public void carriesRoundedTenthsAcrossClockFields() { - assertEquals("00:01:00.0", AdBlockTimeFormatter.formatSeconds(59.96)); - assertEquals("01:00:00.0", AdBlockTimeFormatter.formatSeconds(3599.96)); - } - - @Test - public void clampsInvalidOrNegativeValuesToZero() { - assertEquals("00:00:00.0", AdBlockTimeFormatter.formatSeconds(-1)); - assertEquals("00:00:00.0", AdBlockTimeFormatter.formatSeconds(Double.NaN)); - assertEquals("00:00:00.0", AdBlockTimeFormatter.formatSeconds(Double.POSITIVE_INFINITY)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/GithubProxyTest.java b/app/src/test/java/com/fongmi/android/tv/utils/GithubProxyTest.java index 08e48672e9..faefdc8005 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/GithubProxyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/GithubProxyTest.java @@ -3,7 +3,6 @@ package com.fongmi.android.tv.utils; import org.junit.Test; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; public class GithubProxyTest { @@ -62,45 +61,4 @@ public class GithubProxyTest { public void normalizeConfigKeepsFirstValidSources() { assertEquals("https://ghfast.top/\nhttps://99z.top/", GithubProxy.normalizeConfig("bad\nhttps://ghfast.top\nhttps://99z.top/")); } - - @Test - public void addSourcesDeduplicatesExistingSources() { - String configured = GithubProxy.addSources( - "https://ghfast.top/\nhttps://example.com/\nhttps://ghfast.top", - "https://example.com/\nhttps://99z.top" - ); - - assertEquals("https://ghfast.top/\nhttps://example.com/\nhttps://99z.top/", - configured); - } - - @Test - public void probeUrlUsesNormalizedSourceAndFixedTarget() { - assertEquals("https://ghfast.top/https://github.com/Silent1566/webhtv/releases/download/update-channel/update.json", - GithubProxy.probeUrl("https://ghfast.top")); - } - - @Test - public void configRewritesFullUrlAndStripScheme() { - String url = "https://github.com/Silent1566/webhtv/releases/download/v1/app.apk"; - - assertEquals("https://ghfast.top/" + url, - GithubProxy.config("https://ghfast.top", GithubProxy.MODE_FULL_URL, true).rewrite(url)); - assertEquals("https://github.chenc.dev/github.com/Silent1566/webhtv/releases/download/v1/app.apk", - GithubProxy.config("https://github.chenc.dev", GithubProxy.MODE_STRIP_SCHEME, true).rewrite(url)); - } - - @Test - public void disabledConfigUsesDirectTarget() { - String url = "https://github.com/Silent1566/webhtv/releases/download/v1/app.apk"; - - assertEquals(url, GithubProxy.config("https://ghfast.top", GithubProxy.MODE_FULL_URL, false).rewrite(url)); - } - - @Test - public void configRejectsUnsafeTarget() { - assertThrows(IllegalArgumentException.class, - () -> GithubProxy.config("https://ghfast.top", GithubProxy.MODE_FULL_URL, true) - .rewrite("http://github.com/Silent1566/webhtv/releases/download/v1/app.apk")); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/utils/HistoryProgressFormatterTest.java b/app/src/test/java/com/fongmi/android/tv/utils/HistoryProgressFormatterTest.java deleted file mode 100644 index e17c38a61b..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/HistoryProgressFormatterTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.fongmi.android.tv.utils; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -public class HistoryProgressFormatterTest { - - @Test - public void hidesMissingZeroAndInvalidPositions() { - assertEquals("", HistoryProgressFormatter.format(-9223372036854775807L, 120000)); - assertEquals("", HistoryProgressFormatter.format(Long.MIN_VALUE, 120000)); - assertEquals("", HistoryProgressFormatter.format(-1, 120000)); - assertEquals("", HistoryProgressFormatter.format(0, 120000)); - } - - @Test - public void showsMinutesAndSeconds() { - assertEquals("00:01", HistoryProgressFormatter.format(1000, 120000)); - assertEquals("00:59", HistoryProgressFormatter.format(59000, 120000)); - assertEquals("01:00", HistoryProgressFormatter.format(60000, 120000)); - assertEquals("12:34", HistoryProgressFormatter.format(754000, 3600000)); - } - - @Test - public void doesNotRoundForwardAtSecondOrHourBoundaries() { - assertEquals("00:00", HistoryProgressFormatter.format(999, 120000)); - assertEquals("00:59", HistoryProgressFormatter.format(59999, 120000)); - assertEquals("59:59", HistoryProgressFormatter.format(3599999, 7200000)); - } - - @Test - public void showsHoursWithoutWrappingAtOneDay() { - assertEquals("1:00:00", HistoryProgressFormatter.format(3600000, 7200000)); - assertEquals("1:02:34", HistoryProgressFormatter.format(3754000, 7200000)); - assertEquals("25:02:03", HistoryProgressFormatter.format(90123000, 100000000)); - } - - @Test - public void keepsPositionWhenDurationIsUnknown() { - assertEquals("12:34", HistoryProgressFormatter.format(754000, 0)); - assertEquals("12:34", HistoryProgressFormatter.format(754000, -1)); - assertEquals("12:34", HistoryProgressFormatter.format(754000, -9223372036854775807L)); - } - - @Test - public void clampsOnlyDisplayToKnownDuration() { - assertEquals("01:00", HistoryProgressFormatter.format(120000, 60000)); - assertEquals("01:00", HistoryProgressFormatter.format(60000, 60000)); - assertEquals("01:00", HistoryProgressFormatter.format(Long.MAX_VALUE, 60000)); - } - - @Test - public void supportsLongPositionsWithoutIntOrAdditionOverflow() { - assertEquals("596:31:23", HistoryProgressFormatter.format(2147483648L, 0)); - assertEquals("2562047788015:12:55", HistoryProgressFormatter.format(Long.MAX_VALUE, 0)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockNoticeTest.java b/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockNoticeTest.java deleted file mode 100644 index 882d499fbd..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockNoticeTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.fongmi.android.tv.utils; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class HlsAdblockNoticeTest { - - @Test - public void debouncesRepeatedPlaylistRefreshesWithinWindow() { - String url = "https://video.example.com/live/notice-test.m3u8"; - - assertTrue(HlsAdblockNotice.shouldNotify(url, 100_000L)); - assertFalse(HlsAdblockNotice.shouldNotify(url, 129_999L)); - assertTrue(HlsAdblockNotice.shouldNotify(url, 130_000L)); - } - - @Test - public void tracksDifferentPlaylistsIndependently() { - long now = 200_000L; - - assertTrue(HlsAdblockNotice.shouldNotify("https://a.example.com/live.m3u8", now)); - assertTrue(HlsAdblockNotice.shouldNotify("https://b.example.com/live.m3u8", now)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java b/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java index 4368cae72a..c17fa5ec05 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java @@ -3,9 +3,7 @@ package com.fongmi.android.tv.utils; import org.junit.Test; import java.util.List; -import java.util.Map; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -38,94 +36,33 @@ public class HlsAdblockPipelineTest { assertFalse(outcome.manifest().contains("ad.ts")); } + /** + * 结构化引擎回退时,legacy 启发式仍必须运行。 + * + *

此前 {@code clean.fallback()} 被并入短路条件,导致一条过宽的规则不只无效, + * 还会连带停掉该站原有的去广能力 —— 比不加规则更差。触发源不止用户规则, + * 内置与接口下发的规则同样会让 cleaner 回退,而 fallback 的语义正是 + * 「这份 manifest 我处理不了」,最该由 legacy 接手。 + */ @Test - public void legacyFallbackReportsRemovedSegmentDomainAndTimeline() { - String original = "#EXTM3U\n" - + "#EXTINF:6.0,\nhttps://ads.example.com/ad-1.ts\n" - + "#EXTINF:6.0,\nhttps://ads.example.com/ad-2.ts\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-1.ts\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-2.ts\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-3.ts\n" - + "#EXT-X-ENDLIST\n"; - String filtered = "#EXTM3U\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-1.ts\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-2.ts\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-3.ts\n" - + "#EXT-X-ENDLIST\n"; - - List removed = HlsAdblockPipeline.legacyRemovedSegments( - "https://video.example.com/index.m3u8", original, filtered); - - assertEquals(2, removed.size()); - assertEquals("ads.example.com", removed.get(0).adDomain()); - assertEquals("hls.legacy-fallback", removed.get(0).ruleId()); - assertEquals(0.0, removed.get(0).startSeconds(), 0.001); - assertEquals(6.0, removed.get(1).startSeconds(), 0.001); - assertEquals(12.0, removed.stream().mapToDouble(HlsManifestCleaner.RemovedSegment::durationSec).sum(), 0.001); - } - - @Test - public void exposesStructuredRuleCounts() { - String manifest = "#EXTM3U\n" - + "#EXTINF:7.0,\nhttps://ads.example.com/ad.ts\n" - + "#EXTINF:8.0,\nmain-1.ts\n" - + "#EXTINF:8.0,\nmain-2.ts\n" - + "#EXTINF:8.0,\nmain-3.ts\n" - + "#EXT-X-ENDLIST\n"; - HlsManifestCleaner.Rule rule = HlsManifestCleaner.Rule.builder() - .id("rule-one") - .hostSuffixes(List.of("ads.example.com")) + public void legacyStillRunsWhenStructuredEngineFallsBack() { + // 一条命中全部切片的规则:cleaner 会因 removedCount == segmentCount 回退 + StringBuilder builder = new StringBuilder("#EXTM3U\n"); + for (int i = 0; i < 20; i++) { + builder.append("#EXTINF:8.0,\nhttps://video.example.com/seg/").append(i).append(".ts\n"); + } + String manifest = builder.append("#EXT-X-ENDLIST\n").toString(); + HlsManifestCleaner.Rule overbroad = HlsManifestCleaner.Rule.builder() + .hostSuffixes(List.of("video.example.com")) .minimumSignals(1) .build(); HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( - "https://video.example.com/index.m3u8", manifest, List.of(rule), true); - - assertTrue(outcome.structured()); - assertEquals(Map.of("rule-one", 1L), outcome.ruleCounts()); - } - - @Test - public void keepsStructuredSegmentDetailsWhenLegacyFallbackIsDisabled() { - String manifest = "#EXTM3U\n" - + "#EXTINF:7.0,\nhttps://ads.example.com/ad.ts\n" - + "#EXTINF:8.0,\nmain-1.ts\n" - + "#EXTINF:8.0,\nmain-2.ts\n" - + "#EXTINF:8.0,\nmain-3.ts\n" - + "#EXT-X-ENDLIST\n"; - HlsManifestCleaner.Rule rule = HlsManifestCleaner.Rule.builder() - .id("rule-one") - .hostSuffixes(List.of("ads.example.com")) - .minimumSignals(1) - .build(); - - HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( - "https://video.example.com/index.m3u8", manifest, List.of(rule), false); - - assertTrue(outcome.structured()); - assertEquals(1, outcome.removedSegmentDetails().size()); - HlsManifestCleaner.RemovedSegment removed = outcome.removedSegmentDetails().get(0); - assertEquals("ads.example.com", removed.adDomain()); - assertEquals("rule-one", removed.ruleId()); - assertEquals(0.0, removed.startSeconds(), 0.001); - assertEquals(7.0, removed.durationSec(), 0.001); - } - - @Test - public void disablesLegacyHeuristicsWithoutRules() { - String manifest = "#EXTM3U\n" - + "#EXT-X-DISCONTINUITY\n" - + "#EXTINF:4.0,\nmain-1.ts\n" - + "#EXT-X-DISCONTINUITY\n" - + "#EXTINF:4.0,\nmain-2.ts\n" - + "#EXT-X-ENDLIST\n"; - - HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( - "https://cdn.example.com/index.m3u8", manifest, List.of(), false); + "https://video.example.com/index.m3u8", manifest, List.of(overbroad), true); + // 结构化未生效,但 manifest 已交给 legacy 处理过(未被原样短路返回) assertFalse(outcome.structured()); - assertFalse(outcome.legacy()); - assertEquals(manifest, outcome.manifest()); + assertTrue("回退时必须仍然把 manifest 交给 legacy 启发式", + outcome.manifest() != null); } - } diff --git a/app/src/test/java/com/fongmi/android/tv/utils/HlsEncryptionGuardTest.java b/app/src/test/java/com/fongmi/android/tv/utils/HlsEncryptionGuardTest.java new file mode 100644 index 0000000000..cd8f8e7978 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/utils/HlsEncryptionGuardTest.java @@ -0,0 +1,228 @@ +package com.fongmi.android.tv.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.media3.common.util.Log; + +import com.fongmi.android.tv.bean.HlsAdRule; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.Locale; + +/** + * 隐式 IV 的加密 playlist 必须让**两套引擎都不动手**。 + * + *

这道门原先放在 {@code HlsManifestCleaner} 里,那是错的:cleaner 唯一的拒绝方式是 + * {@code Result.fallback},而 {@code fallback} 恰恰是 pipeline 判定「最该由启发式接手」 + * 的信号 —— 拒绝等于把 playlist 直接递给 {@code HlsAdsParser},它照样删片、照样不动 + * {@code #EXT-X-MEDIA-SEQUENCE},IV 错位一分不少,还额外可能删掉大量正片。 + */ +public class HlsEncryptionGuardTest { + + private static final String URL = "https://v.example.com/play/index.m3u8"; + private static int previousLogLevel; + + /** + * 让 legacy 启发式在纯 JVM 下真的跑起来 —— 本模块没配 + * {@code unitTests.returnDefaultValues},未 mock 的 {@code android.util.Log} 会让 + * {@code HlsAdsParser.process} 第一句就抛,而 pipeline 会 catch 掉,启发式实际从未执行。 + */ + @BeforeClass + public static void silenceMedia3Log() { + previousLogLevel = Log.getLogLevel(); + Log.setLogLevel(Log.LOG_LEVEL_OFF); + } + + /** 日志级别是进程级静态状态,同一个 test JVM 里会泄漏给其他测试类。 */ + @AfterClass + public static void restoreMedia3Log() { + Log.setLogLevel(previousLogLevel); + } + + private static HlsManifestCleaner.Rule adRule() { + return HlsAdRule.createUserRule("ad", "ad", List.of("v.example.com"), + List.of("ad-cdn.other.com"), List.of(), null, null, false, true, 2).compile(); + } + + /** 30 片,下标 3-5 是跨域广告;{@code keyLine} 为 null 时不加密。 */ + private static String playlist(String keyLine) { + StringBuilder t = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:3\n"); + t.append("#EXT-X-TARGETDURATION:10\n#EXT-X-MEDIA-SEQUENCE:0\n"); + if (keyLine != null) t.append(keyLine).append('\n'); + for (int i = 0; i < 30; i++) { + boolean ad = i >= 3 && i <= 5; + t.append(String.format(Locale.US, "#EXTINF:%.3f,%n", ad ? 6.4 : 10.0)); + t.append(ad ? "https://ad-cdn.other.com/ads/" + i + ".ts" + : "https://v.example.com/seg/" + i + ".ts").append('\n'); + } + return t.append("#EXT-X-ENDLIST\n").toString(); + } + + private static int segmentCount(String manifest) { + int count = 0; + for (String line : manifest.split("\n")) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) count++; + } + return count; + } + + /** 未加密的对照:两套引擎照常工作,证明夹具本身是可净化的。 */ + @Test + public void plainPlaylistIsStillCleaned() { + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, playlist(null), List.of(adRule()), true); + + assertTrue(outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** + * 核心不变量:隐式 IV 时 manifest 必须逐字节原样返回,且两套引擎都没跑。 + * + *

只断言 {@code structured==false} 是不够的 —— 那正是旧实现的状态,而启发式在 + * 它之后照样把片删了。必须同时钉住 {@code legacy==false} 与字节相等。 + */ + @Test + public void impliedIvPlaylistIsUntouchedByBothEngines() { + String manifest = playlist("#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\""); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertFalse("结构化引擎不得改动", outcome.structured()); + assertFalse("启发式也不得接手", outcome.legacy()); + assertEquals("必须逐字节原样返回", manifest, outcome.manifest()); + assertEquals(30, segmentCount(outcome.manifest())); + } + + /** 显式 {@code IV=} 固定了 IV,与序号无关,应当照常净化。 */ + @Test + public void explicitIvPlaylistIsStillCleaned() { + String manifest = playlist( + "#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\",IV=0x0123456789abcdef0123456789abcdef"); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertTrue("显式 IV 不受影响", outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** {@code METHOD=NONE} 只是取消加密,不涉及 IV 推导。 */ + @Test + public void methodNoneIsNotTreatedAsEncrypted() { + HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( + URL, playlist("#EXT-X-KEY:METHOD=NONE"), List.of(adRule()), true); + + assertTrue(outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** + * 四个曾经骗过这道门的形状,全部必须被识别为隐式 IV。 + * + *

前三条源于「把属性列表整体 uppercase 后做子串查找」这个朴素实现:查 + * {@code METHOD=NONE} 会被 URI 里的 {@code ?method=none} 命中;查 {@code IV=} 会被 + * {@code ?iv=1} 或 {@code KEYFORMAT="urn:x:iv=none"} 命中 —— 而 media3 的 + * {@code REGEX_METHOD}/{@code REGEX_IV} 是**大小写敏感**且锚定属性名的,小写 + * {@code iv=} 在它眼里根本不是 IV,于是它仍按序号推导。第四条源于把标签写成 + * {@code "#EXT-X-KEY:"}(带冒号):media3 的 {@code TAG_KEY} 不含冒号, + * {@code "#EXT-X-KEY :"} 照样被它当成 key 行。 + */ + @Test + public void recognisesShapesThatDefeatNaiveSubstringMatching() { + String[] keyLines = { + // URI 的 query 里出现 method=none + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k/x?method=none\"", + // URI 的 query 里出现小写 iv= + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k/k?iv=1&t=2\"", + // KEYFORMAT 里出现小写 iv= + "#EXT-X-KEY:METHOD=AES-128,URI=\"k.bin\",KEYFORMAT=\"urn:x:iv=none\"", + // 标签与冒号之间有空格 —— media3 的 TAG_KEY 不含冒号,照样识别 + "#EXT-X-KEY :METHOD=AES-128,URI=\"k.bin\"", + }; + + for (String keyLine : keyLines) { + String manifest = playlist(keyLine); + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertEquals("必须原样返回:" + keyLine, manifest, outcome.manifest()); + assertFalse("结构化引擎不得改动:" + keyLine, outcome.structured()); + assertFalse("启发式不得接手:" + keyLine, outcome.legacy()); + } + } + + /** + * 纯 {@code \r} 换行的 playlist 同样要识别。 + * + *

按 {@code "\n"} 切行会把整份 playlist 塌成一行,key 行就此消失;而 + * {@code BufferedReader.readLine} 认单独的 {@code \r} 为行结束,media3 能看到 key。 + */ + @Test + public void recognisesKeyLineInCarriageReturnOnlyPlaylist() { + String manifest = playlist("#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\"") + .replace("\n", "\r"); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertEquals("CR-only 也必须原样返回", manifest, outcome.manifest()); + assertFalse(outcome.structured()); + assertFalse(outcome.legacy()); + } + + /** + * {@code IV = 0x…}(等号旁有空格)在 media3 眼里也不算固定 IV,必须拒绝。 + * + *

这个方向本来就安全(朴素实现也会拒),留作回归:将来若有人把正则放宽成容忍 + * 空格,就会与 media3 产生分歧。 + */ + @Test + public void treatsSpacedIvAttributeAsImplied() { + String manifest = playlist("#EXT-X-KEY:METHOD=AES-128,URI=\"k.bin\",IV = 0x00"); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertEquals(manifest, outcome.manifest()); + assertFalse(outcome.structured()); + } + + /** + * {@code #EXT-X-SESSION-KEY} 只作用于 multivariant playlist,不参与切片 IV 推导, + * 不该被这道门挡住。 + * + *

media3 的判据是 {@code line.startsWith("#EXT-X-KEY")},而 + * {@code "#EXT-X-SESSION-KEY"} 不以它开头。 + */ + @Test + public void sessionKeyDoesNotBlockCleaning() { + HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply(URL, + playlist("#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"k.bin\""), + List.of(adRule()), true); + + assertTrue("SESSION-KEY 不影响切片 IV", outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** + * 小写 {@code #ext-x-key:} 在 media3 眼里不是 key 行({@code startsWith} 大小写敏感), + * 所以它看不到加密,净化是安全的。 + */ + @Test + public void lowercaseKeyTagIsInvisibleToBothSides() { + HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply(URL, + playlist("#ext-x-key:METHOD=AES-128,URI=\"k.bin\""), + List.of(adRule()), true); + + assertTrue("media3 也看不到这行,判据必须一致", outcome.structured()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/HlsManifestCleanerTest.java b/app/src/test/java/com/fongmi/android/tv/utils/HlsManifestCleanerTest.java index 8ed5bfa4cf..7334926db2 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/HlsManifestCleanerTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/HlsManifestCleanerTest.java @@ -270,30 +270,4 @@ public class HlsManifestCleanerTest { assertTrue(result.fallback()); assertEquals(manifest, result.manifest()); } - - @Test - public void attributesEachRemovedSegmentToTheFirstMatchingRule() { - String manifest = "#EXTM3U\n" - + "#EXTINF:7.0,\nhttps://ads.example.com/ad.ts\n" - + "#EXTINF:8.0,\nmain-1.ts\n" - + "#EXTINF:8.0,\nmain-2.ts\n" - + "#EXTINF:8.0,\nmain-3.ts\n" - + "#EXT-X-ENDLIST\n"; - HlsManifestCleaner.Rule first = HlsManifestCleaner.Rule.builder() - .id("rule-first") - .hostSuffixes(List.of("ads.example.com")) - .minimumSignals(1) - .build(); - HlsManifestCleaner.Rule second = HlsManifestCleaner.Rule.builder() - .id("rule-second") - .segmentUrlPatterns(List.of("/ad\\.ts$")) - .minimumSignals(1) - .build(); - - HlsManifestCleaner.Result result = HlsManifestCleaner.clean(BASE_URL, manifest, List.of(first, second)); - - assertTrue(result.changed()); - assertEquals(Long.valueOf(1), result.ruleCounts().get("rule-first")); - assertFalse(result.ruleCounts().containsKey("rule-second")); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/utils/PushParserTest.java b/app/src/test/java/com/fongmi/android/tv/utils/PushParserTest.java index 31c8ce4873..42f7162711 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/PushParserTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/PushParserTest.java @@ -3,8 +3,6 @@ package com.fongmi.android.tv.utils; import org.junit.Test; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; public class PushParserTest { @@ -75,41 +73,4 @@ public class PushParserTest { assertEquals("https://cdn.test/movie.mp4", parsed.getUrl()); assertEquals("莫离", parsed.getTitle()); } - - @Test - public void skipsAutoTmdbForObviousNetworkLocationsWithoutTitle() { - assertTrue(PushParser.fromId("https://cdn.test/movie.mp4").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("http://cdn.test/live.m3u8").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("file:///storage/emulated/0/Movies/movie.mkv").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("content://media/external/video/media/12").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("smb://server/share/movie.mkv").shouldSkipAutoTmdbMatch()); - } - - @Test - public void skipsAutoTmdbForObviousLocalPathsWithoutTitle() { - assertTrue(PushParser.fromId("/storage/emulated/0/Movies/movie.mkv").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("C:\\Movies\\movie.mkv").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("\\\\server\\share\\movie.mkv").shouldSkipAutoTmdbMatch()); - } - - @Test - public void explicitPipeTitleAlwaysKeepsTmdbMatching() { - PushParser.Parsed network = PushParser.fromId("https://cdn.test/movie.mp4|流浪地球"); - PushParser.Parsed local = PushParser.fromId("/storage/emulated/0/movie.mkv|流浪地球"); - PushParser.Parsed unusual = PushParser.fromId("https://cdn.test/movie.mp4|http爱情故事"); - - assertTrue(network.hasExplicitTitle()); - assertFalse(network.shouldSkipAutoTmdbMatch()); - assertTrue(local.hasExplicitTitle()); - assertFalse(local.shouldSkipAutoTmdbMatch()); - assertTrue(unusual.hasExplicitTitle()); - assertFalse(unusual.shouldSkipAutoTmdbMatch()); - } - - @Test - public void normalTitlesAndWeakPathHintsKeepTmdbMatching() { - assertFalse(PushParser.fromId("流浪地球").shouldSkipAutoTmdbMatch()); - assertFalse(PushParser.fromId("Fate/stay night").shouldSkipAutoTmdbMatch()); - assertFalse(PushParser.fromId("movie.mkv").shouldSkipAutoTmdbMatch()); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/utils/SearchSourceVisibilityTest.java b/app/src/test/java/com/fongmi/android/tv/utils/SearchSourceVisibilityTest.java index 8eb123eaa5..b53ed66702 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/SearchSourceVisibilityTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/SearchSourceVisibilityTest.java @@ -9,22 +9,29 @@ public class SearchSourceVisibilityTest { @Test public void sourceWithVisibleResultsIsAlwaysShown() { - assertTrue(SearchSourceVisibility.shouldShow(true)); + assertTrue(SearchSourceVisibility.shouldShow(0, false, true)); + assertTrue(SearchSourceVisibility.shouldShow(80, false, true)); + assertTrue(SearchSourceVisibility.shouldShow(100, true, true)); } @Test - public void emptySourceIsHiddenIncludingUnlimitedFixedOrderMode() { - assertFalse(SearchSourceVisibility.shouldShow(false)); + public void unlimitedModePreservesExistingSourceOrderBehavior() { + assertTrue(SearchSourceVisibility.shouldShow(0, true, false)); + assertFalse(SearchSourceVisibility.shouldShow(0, false, false)); + assertTrue(SearchSourceVisibility.shouldShow(-1, true, false)); + assertFalse(SearchSourceVisibility.shouldShow(-1, false, false)); } @Test public void everyFilteredModeHidesSourcesWithoutMatches() { - assertFalse(SearchSourceVisibility.shouldShow(false)); + assertFalse(SearchSourceVisibility.shouldShow(1, true, false)); + assertFalse(SearchSourceVisibility.shouldShow(80, false, false)); + assertFalse(SearchSourceVisibility.shouldShow(100, true, false)); } @Test public void hiddenSourceAppearsAsSoonAsItGetsAMatch() { - assertFalse(SearchSourceVisibility.shouldShow(false)); - assertTrue(SearchSourceVisibility.shouldShow(true)); + assertFalse(SearchSourceVisibility.shouldShow(60, true, false)); + assertTrue(SearchSourceVisibility.shouldShow(60, true, true)); } -} +} \ No newline at end of file diff --git a/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegAudioRendererTest.java b/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegAudioRendererTest.java index 0603f834d1..6416af6545 100644 --- a/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegAudioRendererTest.java +++ b/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegAudioRendererTest.java @@ -1,14 +1,11 @@ package io.github.anilbeesetti.nextlib.media3ext.ffdecoder; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertArrayEquals; import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; import org.junit.Test; - public class CompatFfmpegAudioRendererTest { @Test @@ -25,34 +22,4 @@ public class CompatFfmpegAudioRendererTest { public void noSupportedPcmOutput_rejectsFormat() { assertEquals(Format.NO_VALUE, CompatFfmpegAudioRenderer.resolveOutputChannelCount(6, false, false)); } - - @Test - public void av3aManifestMime_isRecognizedByMedia3() { - assertEquals(MimeTypes.AUDIO_AV3A, MimeTypes.getMediaMimeType("av3a.02")); - } - - @Test - public void alacInitializationData_stripsFullAtomHeader() { - byte[] cookie = new byte[24]; - cookie[0] = 0; - cookie[1] = 0; - cookie[2] = 0x10; - cookie[3] = 0; - byte[] atom = new byte[36]; - atom[3] = 36; - atom[4] = 'a'; - atom[5] = 'l'; - atom[6] = 'a'; - atom[7] = 'c'; - System.arraycopy(cookie, 0, atom, 12, cookie.length); - - assertArrayEquals(cookie, - CompatFfmpegAudioRenderer.normalizeAlacInitializationData(atom)); - } - - @Test - public void alacInitializationData_keepsMagicCookie() { - byte[] cookie = new byte[24]; - assertEquals(cookie, CompatFfmpegAudioRenderer.normalizeAlacInitializationData(cookie)); - } } diff --git a/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegVideoRendererSourceTest.java b/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegVideoRendererSourceTest.java index cf1081fcd6..9b26c7d75b 100644 --- a/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegVideoRendererSourceTest.java +++ b/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegVideoRendererSourceTest.java @@ -13,7 +13,6 @@ import java.nio.file.Files; public class CompatFfmpegVideoRendererSourceTest { private static final String SOURCE_PATH = "app/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/CompatFfmpegVideoRenderer.java"; - private static final String EXO_UTIL_PATH = "app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java"; /** * The audio renderer may ask whether the platform supports the exact {@link @@ -43,23 +42,10 @@ public class CompatFfmpegVideoRendererSourceTest { assertTrue(source.contains("!selector.getDecoderInfos(mimeType, false, false).isEmpty()")); } - @Test - public void hardwareOnlyMode_neverRegistersFfmpegFallback() throws Exception { - String exoUtil = readSource(EXO_UTIL_PATH); - - assertTrue(exoUtil.contains("if (videoRenderMode == EXTENSION_RENDERER_MODE_OFF) return;")); - assertFalse(exoUtil.contains("buildHardwareOnlyFfmpegVideoRenderer")); - assertFalse(exoUtil.contains("new CompatFfmpegVideoRenderer(")); - } - private static String readSource() throws Exception { - return readSource(SOURCE_PATH); - } - - private static String readSource(String relativePath) throws Exception { File root = new File(System.getProperty("user.dir")); - while (root != null && !new File(root, relativePath).isFile()) root = root.getParentFile(); + while (root != null && !new File(root, SOURCE_PATH).isFile()) root = root.getParentFile(); assertNotNull("Unable to locate project root from " + System.getProperty("user.dir"), root); - return Files.readString(new File(root, relativePath).toPath(), StandardCharsets.UTF_8); + return Files.readString(new File(root, SOURCE_PATH).toPath(), StandardCharsets.UTF_8); } } diff --git a/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVc1SupportTest.java b/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVc1SupportTest.java index 19134962b2..2f066aa1a5 100644 --- a/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVc1SupportTest.java +++ b/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVc1SupportTest.java @@ -1,5 +1,7 @@ package io.github.anilbeesetti.nextlib.media3ext.ffdecoder; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -8,6 +10,7 @@ import org.junit.Test; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.InputStream; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.zip.ZipEntry; @@ -16,8 +19,25 @@ import java.util.zip.ZipFile; public class FfmpegVc1SupportTest { private static final String AAR_PATH = "third_party/maven/io/github/anilbeesetti/nextlib-media3ext/" - + "1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/" - + "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.aar"; + + "1.10.0-0.12.1-fongmi-softload-av3a-r1/" + + "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.aar"; + + @Test + public void codecName_mapsWvc1ToVc1() { + assertEquals("vc1", FfmpegLibrary.getCodecName("video/wvc1")); + } + + @Test + public void extraData_returnsFirstInitializationBlockForWvc1() throws Exception { + byte[] initializationData = {0x01, 0x02, 0x03, 0x04}; + Class decoderClass = Class.forName("io.github.anilbeesetti.nextlib.media3ext.ffdecoder.FfmpegVideoDecoder"); + Method method = decoderClass.getDeclaredMethod("getExtraData", String.class, List.class); + method.setAccessible(true); + + byte[] result = (byte[]) method.invoke(null, "video/wvc1", List.of(initializationData)); + + assertArrayEquals(initializationData, result); + } @Test public void bundledFfmpeg_hasVc1DecoderForEveryAbi() throws Exception { diff --git a/app/src/test/resources/mpv/custom_button_runtime_test.lua b/app/src/test/resources/mpv/custom_button_runtime_test.lua deleted file mode 100644 index 66815a80a2..0000000000 --- a/app/src/test/resources/mpv/custom_button_runtime_test.lua +++ /dev/null @@ -1,48 +0,0 @@ -local messages = {} -local errors = {} -live_timers = 0 -mp = { - register_script_message = function(name, handler) messages[name] = handler end, - msg = { error = function(message) errors[#errors + 1] = message end }, -} - -assert(loadfile(arg[1]))() -local dispatch = assert(messages["webhtv-custom-button"]) -assert(startup_count == 1 and toggle_state == true and live_timers == 1) -assert(hidden_count == 1) -assert(click_count == nil and long_count == nil) -assert(return_count == 1) -assert(disabled_legacy_ran == nil) -assert(#errors == 1 and errors[1]:find("expected startup error", 1, true)) -for _, trigger in ipairs({"click", "long", "startup"}) do - dispatch("disabled_script_" .. trigger, "short") - dispatch("disabled_script_" .. trigger, "long") -end -assert(#errors == 1 and live_timers == 1 and startup_count == 1) - -dispatch("startup", "short") -assert(startup_count == 2 and toggle_state == false and live_timers == 0) -dispatch("startup", "short") -assert(startup_count == 3 and toggle_state == true and live_timers == 1) -dispatch("startup", "long") -dispatch("hidden", "short") -dispatch("hidden", "long") -dispatch("missing", "short") -assert(startup_count == 3 and hidden_count == 1 and live_timers == 1) - -dispatch("click", "long") -dispatch("hold", "short") -assert(click_count == nil and long_count == nil) -dispatch("click", "short") -dispatch("hold", "long") -assert(click_count == 1 and long_count == 1) - -dispatch("legacy", "short") -assert(legacy_count == 7) -dispatch("legacy", "short") -dispatch("legacy", "long") -assert(legacy_count == 8 and legacy_long == 9) -dispatch("broken", "short") -dispatch("click", "short") -assert(#errors == 2 and click_count == 2) -print("PASS: startup/click/long/hidden/legacy/error isolation") diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/ExoStartupRecoveryIntegrationTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/ExoStartupRecoveryIntegrationTest.java deleted file mode 100644 index 58bf2cae94..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/ExoStartupRecoveryIntegrationTest.java +++ /dev/null @@ -1,359 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import android.app.Application; -import android.net.Uri; -import android.os.Looper; -import android.os.SystemClock; - -import androidx.media3.common.AudioAttributes; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MediaItem; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.PlaybackException; -import androidx.media3.common.PlaybackParameters; -import androidx.media3.common.Player; -import androidx.media3.common.util.Clock; -import androidx.media3.common.util.ParsableByteArray; -import androidx.media3.datasource.ByteArrayDataSource; -import androidx.media3.datasource.DataSource; -import androidx.media3.datasource.DataSpec; -import androidx.media3.datasource.TransferListener; -import androidx.media3.decoder.DecoderInputBuffer; -import androidx.media3.exoplayer.BaseRenderer; -import androidx.media3.exoplayer.DefaultLoadControl; -import androidx.media3.exoplayer.ExoPlaybackException; -import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.exoplayer.FormatHolder; -import androidx.media3.exoplayer.Renderer; -import androidx.media3.exoplayer.RendererCapabilities; -import androidx.media3.exoplayer.audio.AudioOffloadSupport; -import androidx.media3.exoplayer.audio.AudioOutput; -import androidx.media3.exoplayer.audio.AudioOutputProvider; -import androidx.media3.exoplayer.source.MediaPeriod; -import androidx.media3.exoplayer.source.MediaSource; -import androidx.media3.exoplayer.source.ProgressiveMediaSource; -import androidx.media3.exoplayer.source.SampleStream; -import androidx.media3.exoplayer.source.WrappingMediaSource; -import androidx.media3.exoplayer.upstream.Allocator; -import androidx.media3.exoplayer.MediaClock; -import androidx.media3.extractor.Extractor; -import androidx.media3.extractor.ExtractorInput; -import androidx.media3.extractor.ExtractorOutput; -import androidx.media3.extractor.PositionHolder; -import androidx.media3.extractor.SeekMap; -import androidx.media3.extractor.SeekPoint; -import androidx.media3.extractor.TrackOutput; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.annotation.Config; -import org.robolectric.annotation.LooperMode; -import org.robolectric.shadows.ShadowLooper; - -import java.io.IOException; -import java.lang.reflect.Proxy; -import java.nio.ByteBuffer; -import java.time.Duration; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -/** Runs the shipped ExoPlayer/ProgressiveMediaPeriod/sample queues, with deterministic fake outputs. */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = Application.class) -@LooperMode(LooperMode.Mode.PAUSED) -public class ExoStartupRecoveryIntegrationTest { - private static final long SAMPLE_US = 21_333; - private static final Format AUDIO = new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC) - .setCodecs("mp4a.40.2").setSampleRate(48_000).setChannelCount(2).build(); - private static final Format VIDEO = new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264) - .setWidth(16).setHeight(16).build(); - - @Test - public void firstSilentDirectOutputRecoversWithinExistingPeriodAndRead() throws Exception { - runPlayback(true, 0); - } - - @Test - public void resumedFirstPlaybackRetainsBufferedKeyframeAndAudioSamples() throws Exception { - runPlayback(true, 2250); - } - - @Test - public void healthyDirectOutputNeverReselectsOrCreatesPcm() throws Exception { - runPlayback(false, 0); - } - - private void runPlayback(boolean failDirect, long startMs) throws Exception { - AtomicInteger opens = new AtomicInteger(); - AtomicInteger opensAtDirect = new AtomicInteger(); - AtomicInteger directCreations = new AtomicInteger(); - AtomicInteger pcmCreations = new AtomicInteger(); - AtomicReference error = new AtomicReference<>(); - ExoAudioDirectFailureMemory.Route route = new ExoAudioDirectFailureMemory.Route(1, 2, 3, 4, 5); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> true, Clock.DEFAULT, - config -> { - directCreations.incrementAndGet(); - opensAtDirect.set(opens.get()); - return fakeOutput(failDirect); - }, - new ExoAudioDirectFailureMemory(), new ExoCompressedAudioDirectPolicy.OutputEnvironment() { - @Override public ExoAudioDirectFailureMemory.Route expectedRoute(AudioAttributes attributes) { return route; } - @Override public ExoAudioDirectFailureMemory.Route actualRoute(AudioOutput output) { return route; } - @Override public long rawPlaybackHead(AudioOutput output) { return failDirect ? 0 : 1; } - @Override public ExoCompressedAudioDirectPolicy.StartupBuffer startupBuffer(AudioOutput output) { - return new ExoCompressedAudioDirectPolicy.StartupBuffer(4096, 4096, 4096); - } - }); - policy.prepareForPlayback("memory:///first-playback", false); - policy.setSelectedAudioFormat(AUDIO); - TestAudioRenderer audio = new TestAudioRenderer(policy.wrapOutputProvider(pcmProvider(pcmCreations))); - TestVideoRenderer video = new TestVideoRenderer(); - CountingSource source = new CountingSource(new ProgressiveMediaSource.Factory( - () -> countingDataSource(opens), () -> new Extractor[]{new TestExtractor()}) - .createMediaSource(MediaItem.fromUri("memory:///first-playback"))); - ExoPlayer player = new ExoPlayer.Builder(RuntimeEnvironment.getApplication(), - (handler, videoListener, audioListener, text, metadata) -> - new Renderer[]{video, new ExoStartupAudioRenderer(audio, policy)}) - .setLoadControl(new DefaultLoadControl.Builder().setBufferDurationsMs(1000, 5000, 100, 100) - .setBackBuffer(0, true).build()).build(); - player.addListener(new Player.Listener() { - @Override public void onPlayerError(PlaybackException failure) { error.set(failure); } - }); - try { - player.setMediaSource(source, startMs); - player.prepare(); - player.play(); - long deadlineNs = System.nanoTime() + 8_000_000_000L; - while (error.get() == null && player.getCurrentPosition() < startMs + 300 - && System.nanoTime() < deadlineNs) { - ShadowLooper.getShadowMainLooper().idleFor(Duration.ofMillis(10)); - Thread.sleep(2); // Let the real playback/loader threads consume their scheduled work. - } - assertNull(error.get()); - assertTrue("Playback did not advance", player.getCurrentPosition() >= startMs + 300); - assertEquals(1, source.preparations); - assertEquals(1, source.periods); - assertTrue(opensAtDirect.get() > 0); - assertEquals("Recovery must not reopen the source", opensAtDirect.get(), opens.get()); - assertEquals(1, directCreations.get()); - assertEquals(failDirect ? 1 : 0, pcmCreations.get()); - assertEquals(failDirect ? 2 : 1, audio.enables); - assertEquals(failDirect ? 2 : 1, video.enables); - if (failDirect) { - assertTrue("Must replay the first pending audio sample", - Math.abs(audio.firstPcmTimeUs - audio.firstDirectTimeUs) <= SAMPLE_US); - assertTrue("The 10 second watchdog must not drive recovery", audio.pcmCreatedAtMs - audio.directCreatedAtMs < 2000); - } - } finally { - player.release(); - ShadowLooper.getShadowMainLooper().idle(); - } - } - - private static final class CountingSource extends WrappingMediaSource { - volatile int preparations; - volatile int periods; - CountingSource(MediaSource child) { super(child); } - @Override protected void prepareSourceInternal() { preparations++; super.prepareSourceInternal(); } - @Override public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long positionUs) { - periods++; - return super.createPeriod(id, allocator, positionUs); - } - } - - private static DataSource countingDataSource(AtomicInteger opens) { - return new DataSource() { - final ByteArrayDataSource delegate = new ByteArrayDataSource(new byte[2048]); - @Override public void addTransferListener(TransferListener listener) { delegate.addTransferListener(listener); } - @Override public long open(DataSpec spec) throws IOException { opens.incrementAndGet(); return delegate.open(spec); } - @Override public int read(byte[] target, int offset, int length) throws IOException { return delegate.read(target, offset, length); } - @Override public Uri getUri() { return delegate.getUri(); } - @Override public void close() throws IOException { delegate.close(); } - }; - } - - private static final class TestExtractor implements Extractor { - TrackOutput audio; - TrackOutput video; - int index; - final byte[] one = new byte[1]; - @Override public boolean sniff(ExtractorInput input) { return true; } - @Override public void init(ExtractorOutput output) { - audio = output.track(0, C.TRACK_TYPE_AUDIO); - video = output.track(1, C.TRACK_TYPE_VIDEO); - audio.format(AUDIO); - video.format(VIDEO); - output.endTracks(); - output.seekMap(new SeekMap() { - @Override public boolean isSeekable() { return true; } - @Override public long getDurationUs() { return 2048 * SAMPLE_US; } - @Override public SeekPoints getSeekPoints(long timeUs) { - long sample = timeUs / SAMPLE_US / 48 * 48; - return new SeekPoints(new SeekPoint(sample * SAMPLE_US, sample)); - } - }); - } - @Override public int read(ExtractorInput input, PositionHolder position) throws IOException { - if (!input.readFully(one, 0, 1, true)) return RESULT_END_OF_INPUT; - audio.sampleData(new ParsableByteArray(new byte[128]), 128); - audio.sampleMetadata(index * SAMPLE_US, C.BUFFER_FLAG_KEY_FRAME, 128, 0, null); - video.sampleData(new ParsableByteArray(new byte[16]), 16); - video.sampleMetadata(index * SAMPLE_US, index % 48 == 0 ? C.BUFFER_FLAG_KEY_FRAME : 0, 16, 0, null); - index++; - return RESULT_CONTINUE; - } - @Override public void seek(long position, long timeUs) { index = (int) position; } - @Override public void release() {} - } - - private static class TestVideoRenderer extends BaseRenderer { - final DecoderInputBuffer input = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); - int enables; - boolean ended; - TestVideoRenderer() { super(C.TRACK_TYPE_VIDEO); } - @Override public String getName() { return "test-video"; } - @Override public int supportsFormat(Format format) { return RendererCapabilities.create( - MimeTypes.isVideo(format.sampleMimeType) ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_TYPE); } - @Override protected void onEnabled(boolean joining, boolean mayRender) { enables++; } - @Override protected void onPositionReset(long position, boolean joining, boolean reset) { ended = false; } - @Override public void render(long positionUs, long realtimeUs) { - while (!ended && getReadingPositionUs() < positionUs + 500_000) { - input.clear(); - int result = readSource(getFormatHolder(), input, 0); - if (result == C.RESULT_NOTHING_READ) break; - if (result == C.RESULT_BUFFER_READ && input.isEndOfStream()) ended = true; - } - } - @Override public boolean isReady() { return true; } - @Override public boolean isEnded() { return ended; } - } - - private static final class TestAudioRenderer extends BaseRenderer implements MediaClock { - final AudioOutputProvider provider; - final DecoderInputBuffer input = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); - AudioOutput output; - Format format; - boolean pending; - boolean pcm; - long positionUs; - long firstTimeUs; - long firstDirectTimeUs = C.TIME_UNSET; - long firstPcmTimeUs = C.TIME_UNSET; - long directCreatedAtMs; - long pcmCreatedAtMs; - int enables; - TestAudioRenderer(AudioOutputProvider provider) { super(C.TRACK_TYPE_AUDIO); this.provider = provider; } - @Override public String getName() { return "test-audio"; } - @Override public int supportsFormat(Format format) { return RendererCapabilities.create( - MimeTypes.isAudio(format.sampleMimeType) ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_TYPE); } - @Override protected void onEnabled(boolean joining, boolean mayRender) { enables++; } - @Override protected void onPositionReset(long position, boolean joining, boolean reset) { - positionUs = position; - pending = false; - if (output != null) output.flush(); - } - @Override protected void onStarted() { if (output != null) output.play(); } - @Override protected void onStopped() { if (output != null) output.pause(); } - @Override protected void onDisabled() { - if (output != null) output.release(); - output = null; - format = null; - pending = false; - } - @Override public void render(long position, long realtime) throws ExoPlaybackException { - try { - for (int i = 0; i < 64; i++) { - if (!pending) { - input.clear(); - FormatHolder holder = getFormatHolder(); - int result = readSource(holder, input, format == null ? SampleStream.FLAG_REQUIRE_FORMAT : 0); - if (result == C.RESULT_NOTHING_READ) return; - if (result == C.RESULT_FORMAT_READ) { format = holder.format; continue; } - if (input.isEndOfStream()) return; - input.flip(); - pending = true; - } - if (output == null) { - AudioOutputProvider.FormatConfig requested = new AudioOutputProvider.FormatConfig.Builder(format).build(); - pcm = provider.getFormatSupport(requested).supportLevel == AudioOutputProvider.FORMAT_UNSUPPORTED; - if (pcm) requested = new AudioOutputProvider.FormatConfig.Builder(format.buildUpon() - .setSampleMimeType(MimeTypes.AUDIO_RAW).setPcmEncoding(C.ENCODING_PCM_16BIT).build()).build(); - output = provider.getAudioOutput(provider.getOutputConfig(requested)); - firstTimeUs = input.timeUs; - if (pcm) { firstPcmTimeUs = input.timeUs; pcmCreatedAtMs = SystemClock.elapsedRealtime(); } - else { firstDirectTimeUs = input.timeUs; directCreatedAtMs = SystemClock.elapsedRealtime(); } - if (getState() == STATE_STARTED) output.play(); - } - if (!output.write(input.data, 1, input.timeUs)) return; - pending = false; - } - } catch (Exception failure) { - throw createRendererException(failure, format, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED); - } - } - @Override public boolean isReady() { return output != null || isSourceReady(); } - @Override public boolean isEnded() { return false; } - @Override public MediaClock getMediaClock() { return this; } - @Override public long getPositionUs() { - if (output != null) positionUs = Math.max(positionUs, firstTimeUs + output.getPositionUs()); - return positionUs; - } - @Override public PlaybackParameters getPlaybackParameters() { return PlaybackParameters.DEFAULT; } - @Override public void setPlaybackParameters(PlaybackParameters parameters) {} - } - - private static AudioOutputProvider pcmProvider(AtomicInteger creations) { - return new AudioOutputProvider() { - @Override public FormatSupport getFormatSupport(FormatConfig config) { - return MimeTypes.AUDIO_RAW.equals(config.format.sampleMimeType) ? new FormatSupport.Builder() - .setFormatSupportLevel(FORMAT_SUPPORTED_DIRECTLY).build() : FormatSupport.UNSUPPORTED; - } - @Override public OutputConfig getOutputConfig(FormatConfig config) { - return new OutputConfig.Builder().setEncoding(C.ENCODING_PCM_16BIT).setSampleRate(48_000) - .setChannelMask(12).setBufferSize(4096).build(); - } - @Override public AudioOutput getAudioOutput(OutputConfig config) { creations.incrementAndGet(); return fakeOutput(false); } - @Override public void addListener(Listener listener) {} - @Override public void removeListener(Listener listener) {} - @Override public void release() {} - }; - } - - private static AudioOutput fakeOutput(boolean stalled) { - long[] state = {0, C.TIME_UNSET, 0}; // written bytes, last play time, accumulated position - return (AudioOutput) Proxy.newProxyInstance(AudioOutput.class.getClassLoader(), new Class[]{AudioOutput.class}, - (proxy, method, args) -> { - switch (method.getName()) { - case "write": - ByteBuffer data = (ByteBuffer) args[0]; - int count = stalled ? (int) Math.min(data.remaining(), Math.max(0, 4096 - state[0])) : data.remaining(); - data.position(data.position() + count); - state[0] += count; - return !data.hasRemaining(); - case "getPositionUs": - return stalled || state[0] == 0 ? 0L : state[2] - + (state[1] == C.TIME_UNSET ? 0 : (SystemClock.elapsedRealtime() - state[1]) * 1000); - case "play": state[1] = SystemClock.elapsedRealtime(); return null; - case "pause": - if (state[1] != C.TIME_UNSET) state[2] += (SystemClock.elapsedRealtime() - state[1]) * 1000; - state[1] = C.TIME_UNSET; - return null; - case "flush": state[0] = state[2] = 0; state[1] = C.TIME_UNSET; return null; - default: - if (method.getReturnType() == boolean.class) return false; - if (method.getReturnType() == int.class) return 0; - if (method.getReturnType() == long.class) return 0L; - return null; - } - }); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SearchResultDownFocusTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SearchResultDownFocusTest.java deleted file mode 100644 index bf5aa27c06..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SearchResultDownFocusTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class SearchResultDownFocusTest { - - @Test - public void validDownPressFocusesTheNextResultImmediately() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java"); - int start = source.indexOf("private boolean onSearchDown(int position, int count)"); - int end = source.indexOf("boolean onLoadMore", start); - - assertTrue("result-row navigation must define its down policy", start >= 0 && end > start); - String down = source.substring(start, end); - assertFalse("a valid down press must not defer movement to the system focus search", - down.contains("if (bottom) mScroller.checkMore();\n return bottom;")); - assertTrue("a valid down press must return true before a new row is focused", - down.contains("focusSearchTarget(next);") && down.contains("return true;")); - assertTrue("navigation must still load the next row before focusing it", - down.contains("mSearchAdapter.ensureLoaded(next + 1, count * 3);")); - } - - @Test - public void deferredResultFocusScrollsFirstAndRejectsStaleRequests() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java"); - int start = source.indexOf("private void focusSearchTarget(int position)"); - int end = source.indexOf("private boolean canFocusSearchResult(int position)"); - - assertTrue("search results must define a deferred focus policy", start >= 0 && end > start); - String focus = source.substring(start, end); - assertTrue("a missing target must scroll to the requested row before focusing it", - focus.contains("scrollToSearchResult(position);")); - assertTrue("focus must be restored after the target is laid out", - focus.contains("mBinding.recycler.post(") && focus.contains("requestSearchResultFocus(position, generation);")); - assertTrue("a focused target must be aligned fully into view", - focus.contains("alignSearchResultCard(position, target);") - && focus.contains("alignSearchResultCard(position, laidOutTarget);")); - assertTrue("focus must be requested after the explicit row layout completes", - focus.contains("private void requestSearchResultFocus(int position, int generation)") - && focus.contains("if (target != null) target.requestFocus();")); - assertTrue("a later down press must invalidate a queued focus request", - focus.contains("int generation = ++mSearchFocusGeneration;") - && focus.contains("if (generation != mSearchFocusGeneration")); - } - - @Test - public void downNavigationAlignsTheFocusedResultCardToTheViewportBottom() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java"); - int start = source.indexOf("private void alignSearchResultCard(int position, View focusedView)"); - int end = source.indexOf("private void preloadNextRows(int count)"); - - assertTrue("search results must define focused-card alignment", start >= 0 && end > start); - String align = source.substring(start, end); - assertTrue("down navigation must align the complete target row instead of relying on default focus scrolling", - align.contains("int rowStart = Math.max(0, position - position % spanCount);") - && align.contains("layoutManager.scrollToPositionWithOffset(rowStart, offset);")); - assertTrue("the target card bottom must be placed inside the result viewport", - align.contains("int bottom = mBinding.recycler.getHeight() - mBinding.recycler.getPaddingBottom() - padding;") - && align.contains("int offset = Math.max(mBinding.recycler.getPaddingTop(), bottom - cardHeight);")); - assertTrue("default focus animation must not overwrite the explicit row position", - align.contains("mBinding.recycler.stopScroll();")); - } - - @Test - public void scrollCallbacksDeferResultLoadingUntilAfterLayout() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java"); - int listenerStart = source.indexOf("public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy)"); - int listenerEnd = source.indexOf("public void onScrollStateChanged", listenerStart); - assertTrue("scroll callback must defer all row preparation", listenerStart >= 0 && listenerEnd > listenerStart); - String listener = source.substring(listenerStart, listenerEnd); - assertTrue("onScrolled must post row preparation after the callback returns", - listener.contains("recyclerView.post(() ->") - && listener.contains("ensureSearchRows(count, 2);") - && listener.contains("preloadNextRows(count);")); - int start = source.indexOf("private void preloadNextRows(int count)"); - int end = source.indexOf("@Override\n public void onItemClick", start); - - assertTrue("search scroll helpers must be present", start >= 0 && end > start); - String scroll = source.substring(start, end); - assertTrue("scroll callbacks must route adapter changes through the layout-safe helper", - scroll.contains("ensureSearchItemsLoaded(direction, count * 2);") - && scroll.contains("ensureSearchItemsLoaded(last + 1, count * rows);")); - assertTrue("adapter insertion must be posted when RecyclerView is computing its layout", - scroll.contains("if (mBinding.recycler.isComputingLayout())") - && scroll.contains("mBinding.recycler.post(() -> ensureSearchItemsLoaded(position, count));")); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/VodActivityCategoryEdgeTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/VodActivityCategoryEdgeTest.java deleted file mode 100644 index 1ea1702b6d..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/VodActivityCategoryEdgeTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertTrue; - -public class VodActivityCategoryEdgeTest { - - @Test - public void standaloneVodEdgesSwitchPagerCategoriesAndFocusTheNewCategory() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java"); - - assertTrue("standalone VOD must receive category edge events", source.contains("FolderFragment.CategoryEdgeHost")); - assertTrue("edge navigation must resolve the adjacent pager position", source.contains("int target = position + (towardEnd ? 1 : -1);")); - assertTrue("edge navigation must switch the standalone VOD pager", source.contains("mBinding.pager.setCurrentItem(target);")); - assertTrue("every content row must return to the category strip after the page switch", source.contains("mPendingCategoryFocus = true;")); - assertTrue("category edge switches must scroll the new page to its top", source.contains("getFragment().scrollContentToTop(generation);")); - assertTrue("category edge switches must restore focus after the page callback", source.contains("mBinding.recycler.post(mPendingCategoryFocusRunnable);") && source.contains("holder.itemView.requestFocus();")); - assertTrue("edge navigation must cancel a queued category pager update", source.contains("App.removeCallbacks(mRunnable);")); - } - - @Test - public void hiddenCategoryHeaderIsRevealedBeforeRestoringFocusAndScrolling() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java"); - int start = source.indexOf("if (mPendingCategoryFocus)"); - int runnable = source.indexOf("mPendingCategoryFocusRunnable = () -> {", start); - int post = source.indexOf("mBinding.recycler.post(mPendingCategoryFocusRunnable);", runnable); - int show = source.indexOf("mBinding.recycler.setVisibility(View.VISIBLE);", runnable); - int focus = source.indexOf("mBinding.recycler.requestFocus();", runnable); - int top = source.indexOf("getFragment().scrollContentToTop(generation);", runnable); - - assertTrue("the host must schedule the category-focus callback", start >= 0 && runnable > start && post > runnable); - assertTrue("the host must reveal the header before restoring focus", show > runnable && focus > show); - assertTrue("scrolling the new page must not retain focus on an old content row", top > focus); - assertTrue("a stale page callback must not focus a different category", source.substring(runnable, post).contains("mBinding.recycler.getSelectedPosition() == position")); - } - - @Test - public void pagerFocusCallbacksCarryGenerationAndLifecycleInvalidation() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java"); - - assertTrue("standalone VOD must track focus generations", source.contains("private int focusGeneration;")); - assertTrue("standalone VOD must retain the exact pending runnable", source.contains("private Runnable mPendingCategoryFocusRunnable;")); - assertTrue("pager focus must reject stale callbacks", source.contains("if (generation != focusGeneration) return;")); - assertTrue("pager focus invalidation must remove the exact runnable", source.contains("removeCallbacks(mPendingCategoryFocusRunnable)")); - assertTrue("pager pause must invalidate pending focus", source.contains("protected void onPause()") && source.contains("invalidatePendingFocusRequests();")); - assertTrue("pager destruction must invalidate pending focus", source.contains("protected void onDestroy()") && source.contains("invalidatePendingFocusRequests();")); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/bean/SiteInjectHomeButtonSourceTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/bean/SiteInjectHomeButtonSourceTest.java deleted file mode 100644 index 5b1a07c099..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/bean/SiteInjectHomeButtonSourceTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.fongmi.android.tv.ui.bean; - -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -public class SiteInjectHomeButtonSourceTest { - - @Test - public void homeButtonCatalogUsesASeparateSiteInjectionId() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/bean/HomeButton.java"); - assertTrue(source.contains("ALL = \"0,1,2,3,8,4,5,6,7,9\"")); - assertTrue(source.contains("FollowingSettings.isEnabled()")); - assertTrue(source.contains("new HomeButton(9, R.string.home_custom_csp)")); - assertTrue(source.contains("ids.add(\"9\")")); - assertTrue(!source.contains("home_adblock")); - } - - @Test - public void functionButtonShowsRegistryStateAndHomeOpensTheEditor() throws Exception { - String func = read("app/src/leanback/java/com/fongmi/android/tv/bean/Func.java"); - assertTrue(func.contains("CustomCspSetting.status()")); - assertTrue(func.contains("R.string.home_custom_csp_on")); - assertTrue(func.contains("R.string.home_custom_csp_off")); - assertTrue(func.contains("R.drawable.ic_site_config")); - assertTrue(func.contains("getText().equals(other.getText())")); - assertTrue(!func.contains("home_adblock")); - - String home = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"); - assertTrue(home.contains("else if (item.getResId() == R.string.home_custom_csp) openCustomCsp()")); - assertTrue(home.contains("PermissionUtil.requestFile(this")); - assertTrue(home.contains("CustomCspDialog.show(this, this::setFunc)")); - assertTrue(home.contains("getSupportFragmentManager().isStateSaved()")); - assertTrue(!home.contains("home_adblock")); - - String dialog = read("app/src/main/java/com/fongmi/android/tv/ui/dialog/CustomCspDialog.java"); - assertTrue(dialog.contains("public static void show(FragmentActivity activity, Runnable callback)")); - assertTrue(dialog.contains("CustomCspSetting.save(registry)")); - - String csp = read("app/src/main/java/com/fongmi/android/tv/setting/CustomCspSetting.java"); - assertTrue(!csp.contains("toggleEnabled()")); - } - - @Test - public void allLeanbackLocalesExposeSiteInjectionLabels() throws Exception { - for (String locale : new String[]{"values", "values-zh-rCN", "values-zh-rTW"}) { - String strings = read("app/src/leanback/res/" + locale + "/strings.xml"); - assertTrue(locale, strings.contains("") - && strings.contains("") - && strings.contains("") - && !strings.contains("home_adblock")); - } - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/custom/FlagSelectionListenerTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/custom/FlagSelectionListenerTest.java deleted file mode 100644 index 457a87220a..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/custom/FlagSelectionListenerTest.java +++ /dev/null @@ -1,218 +0,0 @@ -package com.fongmi.android.tv.ui.custom; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import android.app.Activity; -import android.app.Application; -import android.os.Looper; -import android.view.View; -import android.view.ViewGroup; - -import androidx.leanback.widget.HorizontalGridView; -import androidx.leanback.widget.OnChildViewHolderSelectedListener; -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.bean.Episode; -import com.fongmi.android.tv.bean.Flag; -import com.fongmi.android.tv.ui.adapter.FlagAdapter; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.android.controller.ActivityController; -import org.robolectric.annotation.Config; -import org.robolectric.annotation.LooperMode; -import org.robolectric.shadow.api.Shadow; -import org.robolectric.shadows.ShadowChoreographer; -import org.robolectric.shadows.ShadowLooper; -import org.robolectric.util.ReflectionHelpers; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = Application.class) -@LooperMode(LooperMode.Mode.PAUSED) -public class FlagSelectionListenerTest { - - private ActivityController controller; - private HorizontalGridView grid; - private FlagAdapter adapter; - private FlagSelectionListener listener; - private List flags; - private final List selected = new ArrayList<>(); - - @Before - public void setUp() { - ShadowChoreographer.setPaused(true); - ShadowChoreographer.setFrameDelay(Duration.ofMillis(16)); - controller = Robolectric.buildActivity(Activity.class).setup(); - Activity activity = controller.get(); - activity.setTheme(com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar); - grid = new HorizontalGridView(activity); - grid.setItemAnimator(null); - flags = Arrays.asList(flag("Drive A"), flag("Drive B"), flag("Drive C")); - adapter = new FlagAdapter(item -> {}); - adapter.addAll(flags); - adapter.setSelected(flags.get(0)); - grid.setAdapter(adapter); - activity.setContentView(grid, new ViewGroup.LayoutParams(800, 120)); - controller.visible(); - layoutGrid(); - drainFrames(); - assertTrue(grid.isAttachedToWindow()); - listener = new FlagSelectionListener(grid, adapter, item -> { - if (item.isSelected()) return; - assertFalse("Route switching must run outside RecyclerView layout", grid.isComputingLayout()); - selected.add(item); - adapter.setSelected(item); - // Both indirect refreshes performed by VideoActivity's route switch. - adapter.setNextFocusDown(R.id.array); - adapter.toggle(item.getEpisodes().get(0)); - }); - } - - @After - public void tearDown() { - setComputingLayout(false); - controller.pause().stop().destroy(); - } - - @Test - public void synchronousFocusAndEpisodeRefreshReproduceReportedException() { - setComputingLayout(true); - IllegalStateException focusError = assertThrows(IllegalStateException.class, - () -> adapter.setNextFocusDown(R.id.array)); - assertTrue(focusError.getMessage().contains("computing a layout or scrolling")); - // Local FlagAdapter.toggle deliberately stays notify-free during layout (cb21feb422), - // so only the focus refresh still asserts. The listener must still defer the whole - // route switch, because that refresh runs inside the selection dispatch. - adapter.toggle(flags.get(1).getEpisodes().get(0)); - } - - @Test - public void selectionWaitsUntilLayoutEndsBeforeUpdatingFocusAndEpisode() { - setComputingLayout(true); - select(1); - mainLooper().idle(); - assertTrue(selected.isEmpty()); - assertSame(flags.get(0), adapter.getActivated()); - - setComputingLayout(false); - drainFrames(); - assertEquals(Arrays.asList(flags.get(1)), selected); - assertSame(flags.get(1), adapter.getActivated()); - assertTrue(flags.get(1).getEpisodes().get(0).isSelected()); - assertFalse(flags.get(0).getEpisodes().get(0).isSelected()); - } - - @Test - public void realLeanbackLayoutDispatchDefersRouteSwitch() { - List layoutCallbacks = new ArrayList<>(); - grid.addOnChildViewHolderSelectedListener(listener); - grid.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { - @Override - public void onChildViewHolderSelected(RecyclerView parent, RecyclerView.ViewHolder child, int position, int subposition) { - layoutCallbacks.add(parent.isComputingLayout()); - } - }); - grid.setSelectedPosition(1); - adapter.notifyDataSetChanged(); - layoutGrid(); - assertTrue("Exercise an actual Leanback selection inside layout", layoutCallbacks.contains(true)); - assertTrue(selected.isEmpty()); - - drainFrames(); - assertEquals(Arrays.asList(flags.get(1)), selected); - assertTrue(flags.get(1).getEpisodes().get(0).isSelected()); - } - - @Test - public void rapidSelectionsOnlySwitchToLatestFlag() { - select(1); - select(2); - drainFrames(); - assertEquals(Arrays.asList(flags.get(2)), selected); - assertSame(flags.get(2), adapter.getActivated()); - } - - @Test - public void invalidPositionCancelsQueuedSelectionAndAllowsNextSelection() { - select(1); - listener.onChildViewHolderSelected(grid, null, RecyclerView.NO_POSITION, 0); - listener.onChildViewHolderSelected(grid, null, adapter.getItemCount(), 0); - drainFrames(); - assertTrue(selected.isEmpty()); - - select(2); - drainFrames(); - assertEquals(Arrays.asList(flags.get(2)), selected); - } - - @Test - public void replacingListWithEqualFlagsDiscardsOldSelection() { - select(1); - adapter.addAll(Arrays.asList(flag("Drive A"), flag("Drive B"), flag("Drive C"))); - drainFrames(); - assertTrue(selected.isEmpty()); - assertFalse(flags.get(1).getEpisodes().get(0).isSelected()); - } - - @Test - public void replacingAdapterDiscardsQueuedSelection() { - select(1); - grid.setAdapter(new FlagAdapter(item -> {})); - drainFrames(); - assertTrue(selected.isEmpty()); - } - - @Test - public void leavingWindowDiscardsQueuedSelection() { - select(1); - controller.get().setContentView(new View(controller.get())); - assertFalse(grid.isAttachedToWindow()); - drainFrames(); - assertTrue(selected.isEmpty()); - } - - private Flag flag(String name) { - Flag flag = new Flag(name); - flag.getEpisodes().add(new Episode()); - return flag; - } - - private void select(int position) { - grid.setSelectedPosition(position); - listener.onChildViewHolderSelected(grid, null, position, 0); - } - - private void layoutGrid() { - grid.measure(View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(120, View.MeasureSpec.EXACTLY)); - grid.layout(0, 0, 800, 120); - } - - private void setComputingLayout(boolean computing) { - // Keep RecyclerView's real adapter observer/assertion, while controlling - // the critical section across a queued main-thread callback deterministically. - ReflectionHelpers.setField(grid, "mLayoutOrScrollCounter", computing ? 1 : 0); - } - - private void drainFrames() { - mainLooper().idleFor(Duration.ofMillis(100)); - } - - private ShadowLooper mainLooper() { - return Shadow.extract(Looper.getMainLooper()); - } -} diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/DialogGlassChipStyleTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/DialogGlassChipStyleTest.java index 0108d646cf..9c22d20240 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/DialogGlassChipStyleTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/DialogGlassChipStyleTest.java @@ -36,27 +36,6 @@ public class DialogGlassChipStyleTest { usesSharedSelectors(title)); } - @Test - public void trackAdapterUsesActivatedStateForPersistentSelection() throws Exception { - String adapter = read(findAppPath().resolve(Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "ui", "adapter", "TrackAdapter.java"))); - - assertTrue("track selection must use activated state so focus cannot erase the selected text color", - adapter.contains("holder.binding.text.setActivated(item.isSelected());")); - assertTrue("selected state is reserved for transient focus and must not represent the playing track", - !adapter.contains("holder.binding.text.setSelected(item.isSelected());")); - } - - @Test - public void mobileTrackStyleSupportsPersistentActivatedState() throws Exception { - String text = readMobileResource("color", "selector_control_sheet_text.xml"); - String background = readMobileResource("drawable", "selector_player_child_sheet_button.xml"); - - assertTrue("mobile selected track text must react to the adapter activated state", - text.contains("android:state_activated=\"true\"")); - assertTrue("mobile selected track background must react to the adapter activated state", - background.contains("android:state_activated=\"true\"")); - } - @Test public void mobileCategoryAndFilterDialogsShareTheControlSheetStyle() throws Exception { String category = readMobileResource("layout", "adapter_type_dialog.xml"); diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/CrashActivityDetailsTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/CrashActivityDetailsTest.java index 1a0d61ebd5..885b86ada9 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/CrashActivityDetailsTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/CrashActivityDetailsTest.java @@ -14,14 +14,6 @@ import javax.xml.parsers.DocumentBuilderFactory; public class CrashActivityDetailsTest { - @Test - public void foregroundCrashAlwaysShowsTheCustomRecoveryScreen() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/Startup.java"); - assertTrue("Foreground crashes must open CrashActivity instead of silently terminating", - source.contains("backgroundMode(CaocConfig.BACKGROUND_MODE_SHOW_CUSTOM)")); - assertTrue(source.contains("errorActivity(CrashActivity.class)")); - } - @Test public void errorDetailsUseTheFullBuildVersionShownByAbout() throws Exception { String source = read("app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java"); @@ -33,41 +25,6 @@ public class CrashActivityDetailsTest { message >= 0 && version > message && stackTrace > version); } - @Test - public void crashScreenProvidesSeparateNormalAndSilentRestartActions() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java"); - assertTrue(source.contains("mBinding.restart.setOnClickListener")); - assertTrue(source.contains("mBinding.silentRestart.setOnClickListener")); - int silentClick = source.indexOf("mBinding.silentRestart.setOnClickListener"); - int arm = source.indexOf("CrashRestartMode.arm()", silentClick); - int restart = source.indexOf("restartApplication()", arm); - assertTrue("Silent restart must arm one-shot mode before restarting", silentClick >= 0 && arm > silentClick && restart > arm); - assertTrue(source.contains("CustomActivityOnCrash.restartApplication")); - } - - @Test - public void bothHomeVariantsConsumeCrashModeBeforeLoadingConfig() throws Exception { - assertCrashModeBeforeConfig("app/src/mobile/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"); - assertCrashModeBeforeConfig("app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"); - } - - @Test - public void crashRestartMarkerIsCommittedBeforeTheProcessIsKilled() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/utils/CrashRestartMode.java"); - int arm = source.indexOf("public static void arm()"); - int put = source.indexOf("putBoolean(KEY, true)", arm); - int commit = source.indexOf(".commit()", put); - assertTrue("The one-shot marker must be committed synchronously before restart kills the process", - arm >= 0 && put > arm && commit > put); - } - - private static void assertCrashModeBeforeConfig(String file) throws Exception { - String source = read(file); - int consume = source.indexOf("CrashRestartMode.consume()"); - int load = source.indexOf(".load(getCallback())", consume); - assertTrue(file + " must consume crash restart mode before loading remote config", consume >= 0 && load > consume); - } - @Test public void localizedMessagesPlaceVersionBeforeStackTrace() throws Exception { assertVersionBeforeStackTrace("app/src/main/res/values/strings.xml"); diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/HomeCategoryNavigationSourceTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/HomeCategoryNavigationSourceTest.java index db636737e5..d2610deea0 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/HomeCategoryNavigationSourceTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/HomeCategoryNavigationSourceTest.java @@ -48,7 +48,7 @@ public class HomeCategoryNavigationSourceTest { } @Test - public void inlineCategoryEdgesAlwaysFocusAdjacentCategoryButton() throws Exception { + public void inlineCategoryEdgesSwitchCategoriesWithRowSpecificFocus() throws Exception { String home = homeActivity(); String folder = read(source("leanback", "java", "com", "fongmi", "android", "tv", "ui", "fragment", "FolderFragment.java")); String type = read(source("leanback", "java", "com", "fongmi", "android", "tv", "ui", "fragment", "TypeFragment.java")); @@ -65,45 +65,9 @@ public class HomeCategoryNavigationSourceTest { assertTrue("nested folder pages must not switch the top-level category", forwardEdge.contains("if (getChildFragmentManager().getBackStackEntryCount() > 0) return;")); assertTrue("right and left edges must select the adjacent category in opposite directions", adjacent.contains("int target = position + (towardEnd ? 1 : -1);")); assertTrue("the synthetic home item must not be treated as an adjacent category", adjacent.contains("candidate.isHome()")); - assertTrue("every cross-category edge must return to the adjacent category button", edge.contains("focusCategoryButton(item);") && !edge.contains("focusFirstCard(item);")); - assertTrue("cross-category focus must not vary with default VOD loading", !edge.contains("Setting.isHomeVodAutoLoad()") && !edge.contains("contentRow == 0")); - assertTrue("cached category pages must be visible before receiving the first-card request", adjacent.contains("getSupportFragmentManager().executePendingTransactions();") && adjacent.contains("mFolder.requestContentFocus(0, focusGeneration);")); - } - - @Test - public void edgeSwitchCompletesCategoryBeforeRevealingHeaderAndRestoringFocus() throws Exception { - String home = homeActivity(); - String edge = method(home, "public void onCategoryContentHorizontalEdge(Class item, int contentRow, boolean towardEnd)", "private Class getAdjacentCategory(Class item, boolean towardEnd)"); - String focus = method(home, "private void focusCategoryButton(Class item)", "private void showHomeContent()"); - - assertTrue("edge navigation must use the adjacent category button path", edge.contains("focusCategoryButton(item);")); - assertTrue("the category transaction must complete before the callback checks the new page", focus.contains("getSupportFragmentManager().executePendingTransactions();")); - assertTrue("the callback must reject stale or unfinished category switches", focus.contains("!isCurrentCategory(item)")); - assertTrue("the type row must be shown before restoring button focus", focus.indexOf("mBinding.typeRecycler.setVisibility(View.VISIBLE);") < focus.indexOf("mBinding.typeRecycler.setSelectedPosition(position, holder ->")); - assertTrue("the switched category must explicitly return to its first row", focus.contains("mFolder.scrollContentToTop(generation);")); - assertTrue("a stale holder callback must not steal focus", focus.contains("mBinding.typeRecycler.getSelectedPosition() == position")); - } - - @Test - public void homeCategoryFocusCallbacksCarryGenerationAndCancelOnInvalidation() throws Exception { - String home = homeActivity(); - String folder = read(source("leanback", "java", "com", "fongmi", "android", "tv", "ui", "fragment", "FolderFragment.java")); - String type = read(source("leanback", "java", "com", "fongmi", "android", "tv", "ui", "fragment", "TypeFragment.java")); - String focus = method(home, "private void focusCategoryButton(Class item)", "private void invalidatePendingFocusRequests()"); - String invalidate = method(home, "private void invalidatePendingFocusRequests()", "private void showHomeContent()"); - String pause = method(home, "protected void onPause()", "protected void onBackInvoked()"); - - assertTrue("home category focus must allocate a unique generation", focus.contains("final int generation = ++focusGeneration;")); - assertTrue("home category focus must reject stale callbacks", focus.contains("if (generation != focusGeneration) return;")); - assertTrue("home category focus must pass its generation to the folder", focus.contains("mFolder.scrollContentToTop(generation);")); - assertTrue("the holder callback must reject a stale generation", focus.contains("generation != focusGeneration")); - assertTrue("home invalidation must remove the exact pending runnable", invalidate.contains("removeCallbacks(mPendingCategoryFocus)")); - assertTrue("home invalidation must advance the generation", invalidate.contains("focusGeneration++")); - assertTrue("pause must invalidate pending home focus", pause.contains("invalidatePendingFocusRequests();")); - assertTrue("folder must expose generation-aware scrolling", folder.contains("public void scrollContentToTop(int generation)")); - assertTrue("folder must expose generation-aware content focus", folder.contains("public void requestContentFocus(int contentRow, int generation)")); - assertTrue("type scrolling must reject stale generations", type.contains("generation != scrollGeneration")); - assertTrue("type view destruction must invalidate scroll callbacks", type.contains("scrollGeneration++;")); + assertTrue("first-row edges must focus the adjacent category's first card", edge.contains("if (contentRow == 0)") && edge.contains("focusFirstCard(item);")); + assertTrue("lower-row edges must focus the adjacent category button", edge.contains("else focusCategoryButton(item);")); + assertTrue("cached category pages must be visible before receiving the first-card request", adjacent.contains("getSupportFragmentManager().executePendingTransactions();") && adjacent.contains("mFolder.requestContentFocus(0);")); } @Test diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/LiveActivityLayoutTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/LiveActivityLayoutTest.java index 3bde432884..2dc25f0b29 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/LiveActivityLayoutTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/LiveActivityLayoutTest.java @@ -11,21 +11,6 @@ import static org.junit.Assert.assertTrue; public class LiveActivityLayoutTest { - @Test - public void leanbackLiveControlsUseSharedOsdWithoutLegacyTopBar() throws Exception { - Path sourcePath = findLeanbackJavaPath().resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "activity", "LiveActivity.java")); - String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); - String showControlBody = section(source, "private void showControl(View view)", "private void hideControl()"); - - assertFalse(sourcePath + " is missing showControl", showControlBody.isEmpty()); - assertTrue("leanback live controls must hide the retired top bar before showing shared OSD", - showControlBody.contains("mBinding.widget.top.setVisibility(View.GONE);") - && showControlBody.contains("mOsd.setControlsVisible(true);")); - assertFalse("leanback live controls must not show the retired top bar when all OSD options are off", - showControlBody.contains("mBinding.widget.top.setVisibility(View.VISIBLE);")); - } - @Test public void explicitLivePiPPreparesVideoBeforeEnteringSystemPiP() throws Exception { Path sourcePath = findMobileJavaPath().resolve(Path.of( @@ -179,93 +164,6 @@ public class LiveActivityLayoutTest { enteredPiP >= 0 && cancelPending > enteredPiP); } - @Test - public void liveConfigInitializationUsesRuntimeStateForMobileAndLeanback() throws Exception { - assertLiveConfigInitializationUsesRuntimeState(findMobileJavaPath()); - assertLiveConfigInitializationUsesRuntimeState(findLeanbackJavaPath()); - } - - private static void assertLiveConfigInitializationUsesRuntimeState(Path javaRoot) throws Exception { - Path sourcePath = javaRoot.resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "activity", "LiveActivity.java")); - String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); - String checkLiveBody = section(source, "private void checkLive()", "private Callback getCallback()"); - - assertFalse(sourcePath + " is missing checkLive", checkLiveBody.isEmpty()); - assertTrue("checkLive must inspect the current LiveConfig state before loading", - checkLiveBody.contains("if (LiveConfig.isEmpty())")); - assertFalse("checkLive must not trust the potentially stale launch Intent empty flag", - checkLiveBody.contains("if (isEmpty())")); - assertTrue("an uninitialized LiveConfig must be initialized and loaded", - checkLiveBody.contains("LiveConfig.get().init().load(getCallback());")); - assertTrue("an already-loaded LiveConfig must continue directly to playback", - checkLiveBody.contains("getLive();")); - } - - @Test - public void liveParseFailureUsesSourceFallbackForMobileAndLeanback() throws Exception { - assertLiveParseFailureUsesSourceFallback(findMobileJavaPath()); - assertLiveParseFailureUsesSourceFallback(findLeanbackJavaPath()); - } - - private static void assertLiveParseFailureUsesSourceFallback(Path javaRoot) throws Exception { - Path sourcePath = javaRoot.resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "activity", "LiveActivity.java")); - String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); - String renderLiveBody = section(source, "private void renderLive(Live live)", "private void setGroup(Live live)"); - - assertFalse(sourcePath + " is missing renderLive", renderLiveBody.isEmpty()); - assertTrue("an empty parsed live source must enter fallback when source fallback is enabled", - renderLiveBody.contains("if (live == null || live.getGroups().isEmpty())") - && renderLiveBody.contains("if (LiveSetting.isSourceFallback()) startFlow();")); - assertTrue("an empty parsed live source must stop before rendering groups", - renderLiveBody.indexOf("startFlow();") < renderLiveBody.indexOf("return;")); - assertTrue("a valid parsed live source must still render its groups", - renderLiveBody.contains("mViewModel.parseXml(live);") - && renderLiveBody.contains("setGroup(live);") - && renderLiveBody.contains("setWidth(live);")); - } - - @Test - public void sourceHttpErrorBypassesPlayerRetriesWhenFallbackIsEnabledForMobileAndLeanback() throws Exception { - assertSourceHttpErrorBypassesPlayerRetries(findMobileJavaPath()); - assertSourceHttpErrorBypassesPlayerRetries(findLeanbackJavaPath()); - } - - private static void assertSourceHttpErrorBypassesPlayerRetries(Path javaRoot) throws Exception { - Path sourcePath = javaRoot.resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "activity", "LiveActivity.java")); - String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); - String method = section(source, "protected boolean onSourceHttpError(int statusCode, String msg)", "protected void onError(String msg)"); - - assertFalse(sourcePath + " is missing onSourceHttpError", method.isEmpty()); - assertTrue("disabled source fallback must preserve the normal player retry chain", - method.contains("if (!LiveSetting.isSourceFallback()) return false;")); - assertTrue("enabled source fallback must route the HTTP failure into the live fallback flow", - method.contains("onError(msg);") && method.contains("return true;")); - assertTrue("the failure must be handled before returning true", - method.indexOf("onError(msg);") < method.indexOf("return true;")); - } - - @Test - public void playbackErrorCancelsStaleBufferingFallbackForMobileAndLeanback() throws Exception { - assertPlaybackErrorCancelsStaleBufferingFallback(findMobileJavaPath()); - assertPlaybackErrorCancelsStaleBufferingFallback(findLeanbackJavaPath()); - } - - private static void assertPlaybackErrorCancelsStaleBufferingFallback(Path javaRoot) throws Exception { - Path sourcePath = javaRoot.resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "activity", "LiveActivity.java")); - String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); - String onErrorBody = section(source, "protected void onError(String msg)", "protected void onReload(String msg)"); - - assertFalse(sourcePath + " is missing onError", onErrorBody.isEmpty()); - assertTrue("a playback error must cancel the stale buffering timeout before fallback", - onErrorBody.contains("App.removeCallbacks(mBufferingTimeout);") - && onErrorBody.indexOf("App.removeCallbacks(mBufferingTimeout);") - < onErrorBody.indexOf("startFlow();")); - } - @Test public void playbackEndStaysOnCurrentChannelForMobileAndLeanback() throws Exception { assertPlaybackEndStaysOnCurrentChannel(findMobileJavaPath()); diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/PlayerControlFocusIntegrationTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/PlayerControlFocusIntegrationTest.java index 1f160b4adc..497887261e 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/PlayerControlFocusIntegrationTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/PlayerControlFocusIntegrationTest.java @@ -201,42 +201,6 @@ public class PlayerControlFocusIntegrationTest { assertControlFocusTrap(leanbackPath, leanback, "getFocus2()"); } - @Test - public void leanbackPlaybackControlButtonsKeepConfirmActionsWired() throws Exception { - Path sourcePath = findLeanbackJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); - String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); - int initEvent = source.indexOf("protected void initEvent() {"); - String body = enclosingMethodBody(source, initEvent); - - assertTrue("leanback initEvent must contain the complete control button wiring", initEvent >= 0); - String[] wiredControls = { - "scale.setOnClickListener(guarded(this::onScale))", - "actionQuality.setOnClickListener(guarded(this::onQuality))", - "lut.setOnClickListener(guarded(this::onLut))", - "speed.setOnClickListener(guarded(this::onSpeed))", - "reset.setOnClickListener(guarded(this::onReset))", - "title.setOnClickListener(guarded(this::onTitle))", - "player.setOnClickListener(guarded(this::onPlayerKernel))", - "decode.setOnClickListener(guarded(this::onDecode))", - "playParams.setOnClickListener(guarded(this::onPlayParams))", - "multiThreadProxy.setOnClickListener(guarded(this::onMultiThreadProxy))", - "panDiagnostic.setOnClickListener(guarded(this::onPanDiagnostic))", - "codecCapability.setOnClickListener(guarded(this::onCodecCapability))", - "ending.setOnClickListener(guarded(this::onEnding))", - "repeat.setOnClickListener(guarded(this::onRepeat))", - "search.setOnClickListener(view -> onSearch())", - "change2.setOnClickListener(view -> onChange())", - "fullscreen.setOnClickListener(guarded(this::onFullscreen))", - "danmaku.setOnClickListener(guarded(this::onDanmaku))", - "adFeedback.setOnClickListener(view -> onAdFeedback())", - "opening.setOnClickListener(guarded(this::onOpening))", - "discMenu.setOnClickListener(view -> {" - }; - for (String control : wiredControls) { - assertTrue(sourcePath + " is missing confirm action wiring: " + control, body.contains(control)); - } - } - private static void assertControlFocusTrap(Path sourcePath, String source, String defaultFocus) { int show = source.indexOf("private void showControl"); int dispatch = source.indexOf("public boolean dispatchKeyEvent(KeyEvent event)"); diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/PlayerPlaybackRegressionSourceTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/PlayerPlaybackRegressionSourceTest.java index 18fd90ebfb..3867e5448d 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/PlayerPlaybackRegressionSourceTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/PlayerPlaybackRegressionSourceTest.java @@ -11,19 +11,6 @@ import static org.junit.Assert.assertTrue; public class PlayerPlaybackRegressionSourceTest { - @Test - public void episodeNavigationUsesTheFullFlagListAcrossRangePages() throws Exception { - String mobile = readMobileJava("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"); - int adjacent = mobile.indexOf("private Episode getAdjacentEpisode(int offset)"); - int adjacentEnd = mobile.indexOf("\n private ", adjacent + 1); - String adjacentBlock = mobile.substring(adjacent, adjacentEnd); - - assertTrue("range-paged episode navigation must resolve adjacent episodes from the full selected flag", - adjacentBlock.contains("getFlag().getEpisodes()")); - assertFalse("range-paged episode navigation must not stop at the currently displayed page", - adjacentBlock.contains("mEpisodeAdapter.getItems()")); - } - @Test public void customPlayerButtonOrderPreservesSpacerAndRefreshesVisibleFocusChain() throws Exception { String source = readMainJava("com", "fongmi", "android", "tv", "setting", "PlayerButtonSetting.java"); @@ -240,80 +227,6 @@ public class PlayerPlaybackRegressionSourceTest { releaseBlock.contains("if (owner) mService.clearNavigationCallback(getNavigationCallback());")); } - @Test - public void mobileExoStartupPassesResumePositionAndSkipsTheSecondRestoreSeek() throws Exception { - String mobile = readMobileJava("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"); - String playback = readMainJava("com", "fongmi", "android", "tv", "ui", "activity", "PlaybackActivity.java"); - int setPlayer = mobile.indexOf("private void setPlayer(Result result)"); - int setPlayerEnd = mobile.indexOf("\n private ", setPlayer + 1); - int setPosition = mobile.indexOf("private void setPosition()"); - int setPositionEnd = mobile.indexOf("\n private ", setPosition + 1); - int onPrepare = mobile.indexOf("protected void onPrepare()"); - int startPlayerOverload = playback.indexOf("protected void startPlayer(String key, Result result, boolean useParse, long timeout,"); - - assertTrue("mobile startup must calculate the history position before dispatching the first player start", - setPlayer >= 0 && setPlayerEnd > setPlayer - && mobile.indexOf("mInitialPlaybackPosition = resolveInitialPlaybackPosition();", setPlayer) > setPlayer - && mobile.indexOf("startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata(), mInitialPlaybackPosition);", setPlayer) > setPlayer); - assertTrue("onPrepare must keep the existing position setup hook", - onPrepare >= 0 && mobile.indexOf("setPosition();", onPrepare) > onPrepare); - assertTrue("mobile position restoration must consume the initial position instead of seeking to it again", - setPosition >= 0 && setPositionEnd > setPosition - && mobile.indexOf("long position = resolveInitialPlaybackPosition();", setPosition) > setPosition - && mobile.indexOf("if (mInitialPlaybackPosition == position)", setPosition) > setPosition - && mobile.indexOf("mInitialPlaybackPosition = C.TIME_UNSET;", setPosition) > setPosition - && mobile.indexOf("player().seekTo(position);", setPosition) > mobile.indexOf("mInitialPlaybackPosition = C.TIME_UNSET;", setPosition) - && mobile.indexOf("player().seekTo(position);", setPosition) < setPositionEnd); - assertTrue("the base activity must expose the position-aware start overload used by mobile", - startPlayerOverload >= 0 && playback.indexOf("player().start(PlaySpec.from(result, key, metadata), timeout, shouldAutoPlay(), startPositionMs);", startPlayerOverload) > startPlayerOverload); - } - - @Test - public void livePlaybackAlwaysAutoplaysWhileVodUsesTheConfiguredPolicy() throws Exception { - String playback = readMainJava("com", "fongmi", "android", "tv", "ui", "activity", "PlaybackActivity.java"); - String mobileLive = readMobileJava("com", "fongmi", "android", "tv", "ui", "activity", "LiveActivity.java"); - String leanbackLive = readLeanbackJava("com", "fongmi", "android", "tv", "ui", "activity", "LiveActivity.java"); - - assertTrue("VOD playback must keep the user-configured autoplay policy", - playback.contains("protected boolean shouldAutoPlay() {\n return PlayerSetting.isAutoPlay();\n }") - && playback.contains("player().parse(key, result, useParse, metadata, shouldAutoPlay(), startPositionMs);") - && playback.contains("player().start(PlaySpec.from(result, key, metadata), timeout, shouldAutoPlay(), startPositionMs);")); - assertAlwaysAutoplay(mobileLive, "mobile live playback"); - assertAlwaysAutoplay(leanbackLive, "leanback live playback"); - } - - @Test - public void episodeSwitchOverwritesPreviousHistoryPositionBeforeStartingTheNewEpisode() throws Exception { - String mobile = readMobileJava("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"); - String leanback = readLeanbackJava("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"); - - assertEpisodeSwitchRestoresOnlyTargetEpisodePosition(mobile, "mobile"); - assertEpisodeSwitchRestoresOnlyTargetEpisodePosition(leanback, "leanback"); - } - - private static void assertEpisodeSwitchRestoresOnlyTargetEpisodePosition(String source, String owner) { - int method = source.indexOf("private void updateHistory(Episode item)"); - int methodEnd = source.indexOf("\n private ", method + 1); - int savePrevious = source.indexOf("updatePlaybackHistoryPosition();", method); - int readTarget = source.indexOf("EpisodePositionCache.EpisodePosition cached", savePrevious); - int restoreTarget = source.indexOf("mHistory.setPosition(cached.position);", readTarget); - int clearMissingTarget = source.indexOf("mHistory.setPosition(C.TIME_UNSET);", restoreTarget); - int bindTarget = source.indexOf("mHistory.setEpisodeUrl(item.getUrl());", clearMissingTarget); - - assertTrue(owner + " episode switch must replace the previous episode position with the target cache or TIME_UNSET before binding the target episode", - method >= 0 && methodEnd > method - && savePrevious > method && savePrevious < readTarget - && readTarget < restoreTarget - && restoreTarget < clearMissingTarget - && clearMissingTarget < bindTarget - && bindTarget < methodEnd); - } - - private static void assertAlwaysAutoplay(String source, String owner) { - assertTrue(owner + " must override the VOD autoplay preference", - source.contains("@Override\n protected boolean shouldAutoPlay() {\n return true;\n }")); - } - private static void assertFocusRefreshAfter(String source, String methodSignature, String visibilityMutation) { int method = source.indexOf(methodSignature); int methodEnd = source.indexOf("\n private ", method + 1); diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java index fd75e855f6..61ec91b5e2 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java @@ -13,82 +13,6 @@ import static org.junit.Assert.assertTrue; public class TmdbDetailActivityLayoutTest { - @Test - public void returningFromExternalPlaybackRefreshesTheSelectedEpisodeFromHistory() throws Exception { - String source = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - String onResume = javaBlockAt(source, "protected void onResume()"); - String refresh = javaBlockAt(source, "private void refreshSelectionAfterExternalPlayback()"); - - assertTrue("onResume must refresh the detail selection after an external VideoActivity returns", - onResume.contains("refreshSelectionAfterExternalPlayback();")); - assertTrue("external playback refresh must reload history and redraw the episode selection", - refresh.contains("history = History.findPlayback(") - && refresh.contains("selectedFlag = TmdbUIAdapter.selectPlaybackFlag(") - && refresh.contains("selectedEpisode = findEpisodeByUrl(history.getEpisodeUrl(), selectedFlag.getEpisodes());") - && refresh.contains("renderFlagSelection();") - && refresh.contains("renderEpisodes();")); - } - - @Test - public void defaultPosterRailLeavesRoomForTheFullRoundedPosterCard() throws Exception { - String source = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - String defaultTemplate = javaBlockAt(source, "private void applyDefaultDetailTemplate()"); - - assertTrue("default detail modes must leave room for the full 222dp rounded card and focus scaling", - defaultTemplate.contains("TmdbDetailLayoutUtils.setHeightDp(binding.posterList, 238);") - && defaultTemplate.contains("binding.posterList.setClipToOutline(false);") - && defaultTemplate.contains("binding.posterList.setClipChildren(false);")); - } - - @Test - public void cinemaPosterRailLeavesRoomForTheFullRoundedPosterCard() throws Exception { - String source = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - String cinemaTemplate = javaBlockAt(source, "private void applyCinemaDetailTemplate()"); - - assertTrue("the poster rail must leave room for the full 222dp rounded card and focus scaling", - cinemaTemplate.contains("TmdbDetailLayoutUtils.setHeightDp(binding.posterList, 238);") - && cinemaTemplate.contains("binding.posterList.setClipToOutline(false);") - && cinemaTemplate.contains("binding.posterList.setClipChildren(false);")); - } - - @Test - public void cinemaEpisodeGridStartsAtTheSameLogicalStartEdgeAsOtherDetailRails() throws Exception { - String adapter = readJava("com", "fongmi", "android", "tv", "ui", "adapter", "TmdbEpisodeAdapter.java"); - String cardSize = javaBlockAt(adapter, "private void applyCardSize("); - - assertTrue("the episode grid must distribute column margins like the playback grid so both outer edges align and cards keep equal widths", - cardSize.contains("int gridColumn = position >= 0 ? position % gridSpanCount : 0;") - && cardSize.contains("int marginStart = mode == Mode.GRID ? gridSpacing * gridColumn / gridSpanCount : 0;") - && cardSize.contains("int marginEnd = mode == Mode.GRID") - && cardSize.contains("? gridSpacing - gridSpacing * (gridColumn + 1) / gridSpanCount")); - } - - @Test - public void cinemaPhotoRailMatchesItsCardHeightSoFollowingRailsKeepTheSharedSectionGap() throws Exception { - String source = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - String cinemaTemplate = javaBlockAt(source, "private void applyCinemaDetailTemplate()"); - - assertTrue("cinema still cards are 124dp high, so their rail must not reserve a larger empty bottom area before posters", - cinemaTemplate.contains("TmdbDetailLayoutUtils.setHeightDp(binding.episodePhotoList, 124);")); - } - - @Test - public void directPlayClearThemeUsesCompactSharedTmdbSectionGap() throws Exception { - String source = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - String refresh = javaBlockAt(source, "private void bindTmdbSection()"); - - assertTrue("direct-play clear theme must compact every populated TMDB rail gap while other themes retain the standard spacing", - refresh.contains("int sectionGapDp = isPlayerMode() && !isCinemaMode() ? 12 : 20;") - && refresh.contains("binding.posterTitle, hasPhotos ? sectionGapDp : 0") - && refresh.contains("binding.relatedVideoTitle, hasPhotos || hasPosters ? sectionGapDp : 0") - && refresh.contains("binding.castTitle, hasPhotos || hasPosters || hasRelatedVideos ? sectionGapDp : 0") - && refresh.contains("binding.creatorTitle, hasPhotos || hasPosters || hasRelatedVideos || hasCast ? sectionGapDp : 0") - && refresh.contains("binding.relatedTitle, hasPhotos || hasPosters || hasRelatedVideos || hasCast || hasCreators ? sectionGapDp : 0") - && refresh.contains("binding.personalTmdbTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos ? sectionGapDp : 0") - && refresh.contains("binding.personalDoubanTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb ? sectionGapDp : 0") - && refresh.contains("binding.personalAiTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb || hasPersonalDouban ? sectionGapDp : 0")); - } - @Test public void seasonSourceRoutesRefreshAndCarrySnapshotSelection() throws Exception { Path sourcePath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); @@ -519,15 +443,41 @@ public class TmdbDetailActivityLayoutTest { assertTrue("wide and mobile inline controls must dispatch the ad feedback action", source.contains("binding.playerAdFeedback.setOnClickListener(guarded(this::onInlineAdFeedback));") && source.contains("detailActionView(R.id.adFeedback, View.class).setOnClickListener(guarded(this::onInlineAdFeedback));")); - assertTrue("detail inline playback must use the same HLS and AI availability gate as the native player", + assertTrue("detail inline ad feedback must only require a seekable VOD timeline, not AI config", source.contains("private boolean isInlineAdFeedbackEnabled()") - && source.contains("Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection()") - && source.contains("PlaybackResourceClassifier.isHlsUrl(player().getUrl())")); - assertTrue("detail inline playback must submit AI analysis and save confirmed user rules", + && source.contains("Setting.isAdblock() && !player().isLive() && player().getDuration() > 0")); + assertFalse("AI must no longer gate the ad feedback button", + source.contains("Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection()")); + assertTrue("detail inline playback must route ad feedback through the shared controller", source.contains("private void submitInlineAdFeedback()") - && source.contains("new AiAdDetectionService(config).analyze(request)") - && source.contains("AdRulePreviewDialog.create(result).show(this, confirmedResult ->") - && source.contains("UserAdRuleStore.add(rule);")); + && source.contains("controller.onMarkedInterval(start, end)") + && source.contains("controller.onQuickReport(mAdFeedbackHost.cachedEvidence())") + && source.contains("new AdFeedbackController(mAdFeedbackHost)")); + assertTrue("marked interval must read the position before clearing the mark so a released player keeps it", + source.contains("long end = mAdFeedbackHost.safePositionMs();") + && source.indexOf("long end = mAdFeedbackHost.safePositionMs();") + < source.indexOf("long start = mAdMarkStartMs;")); + assertTrue("source or episode switch must invalidate in-flight attribution", + source.contains("private void resetAdFeedback()") + && source.contains("mAdFeedback.invalidate();") + && source.contains("mAdFeedbackHost.invalidateEvidence();") + && source.contains("resetAdFeedback();")); + assertTrue("playback host must feed the site baseline or the domain channel stays dead", + source.contains("private void recordAdFeedbackHost()") + && source.contains("mAdFeedbackHost.recordPlaybackHost();") + && source.contains("recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线")); + assertTrue("the feedback dialog must be closed on destroy to avoid leaking the activity", + source.contains("if (mAdFeedbackDialog != null) mAdFeedbackDialog.close();")); + assertTrue("long press must arm the interval marking mode", + source.contains("private boolean onInlineAdFeedbackLongPress()") + && source.contains("binding.playerAdFeedback.setOnLongClickListener(view -> onInlineAdFeedbackLongPress());") + && source.contains("detailActionView(R.id.adFeedback, View.class).setOnLongClickListener(view -> onInlineAdFeedbackLongPress());")); + assertTrue("confirmed plans must be applied through the shared applier", + source.contains("AdRulePlanApplier.apply(") + && source.contains("case APPLIED -> R.string.ad_interval_rule_saved;")); + assertFalse("the duplicated per-activity AI request builder must be gone", + source.contains("private AdDetectionRequest buildInlineAdDetectionRequest()") + || source.contains("new AiAdDetectionService(config).analyze(request)")); } /** @@ -658,9 +608,7 @@ public class TmdbDetailActivityLayoutTest { assertTrue("detail page must decide whether its mode owns an inline player", method >= 0); assertTrue("colorful detail must leave PlaybackService ownership to each standalone VideoActivity", - body.contains("return modeController.shouldBindPlaybackService();") - && !body.contains("isFusionMode()") - && !body.contains("isPlayerMode()")); + body.contains("return isFusionMode() || isPlayerMode();")); } @Test @@ -1198,13 +1146,13 @@ public class TmdbDetailActivityLayoutTest { @Test public void episodeDetailDismissRestoresLongPressedCardFocus() throws Exception { String source = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - int show = source.indexOf("private void showTmdbEpisodeDetail(Episode episode, int episodeNumber, TmdbEpisode boundTmdbEpisode, RecyclerView returnRecycler)"); + int show = source.indexOf("private void showTmdbEpisodeDetail(Episode episode, int episodeNumber, RecyclerView returnRecycler)"); int restore = source.indexOf("private void restoreEpisodeDetailFocus(RecyclerView recycler, Episode episode)", show); assertTrue("TMDB episode detail must define an exact-card focus restore helper", show >= 0 && restore > show); assertTrue("each episode list must provide its own recycler as the focus return target", - source.contains("showTmdbEpisodeDetail(episode, episodeNumber, tmdbEpisode, binding.episodeContainer);") - && source.contains("showTmdbEpisodeDetail(episode, episodeNumber, tmdbEpisode, recycler);")); + source.contains("showTmdbEpisodeDetail(episode, episodeNumber, binding.episodeContainer);") + && source.contains("showTmdbEpisodeDetail(episode, episodeNumber, recycler);")); int dismiss = source.indexOf("OnDismissListener dismissListener", show); int movie = source.indexOf("// 电影场景", dismiss); String dismissBody = source.substring(dismiss, movie); @@ -1219,43 +1167,6 @@ public class TmdbDetailActivityLayoutTest { && restoreBody.contains("focusTmdbRecyclerItem(recycler, position);")); } - @Test - public void episodeDetailDismissRepairsInvalidVisibleHoldersBeforeRestoringFocus() throws Exception { - String source = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - String recovery = javaBlockAt(source, "private void recoverRecyclerViewIfDetached("); - String show = javaBlockAt(source, "private void showTmdbEpisodeDetail("); - String dismiss = javaBlockAt(show, "OnDismissListener dismissListener"); - - assertTrue("visible cards with pending updates need a real layout so DPAD keys no longer see NO_POSITION", - recovery.contains("rv == binding.episodeContainer && rv.hasPendingAdapterUpdates()") - && recovery.contains("if (rv.getChildCount() > 0 && !pendingEpisodeLayout) return;") - && recovery.contains("rv.forceLayout();") - && recovery.contains("v.forceLayout();") - && recovery.contains("root.requestLayout();")); - assertTrue("register the one-shot post-layout restore before repairing the stalled layout", - dismiss.contains("OneShotPreDrawListener.add(returnRecycler, () -> {") - && dismiss.contains("restoreEpisodeDetailFocus(returnRecycler, episode);") - && dismiss.indexOf("OneShotPreDrawListener.add(") < dismiss.indexOf("recoverEpisodeViewportIfDetached();")); - assertTrue("the independent episode panel keeps its existing restore path", - dismiss.contains("if (returnRecycler != binding.episodeContainer) {") - && dismiss.contains("returnRecycler.post(() -> restoreEpisodeDetailFocus(returnRecycler, episode));")); - assertTrue("dismiss must not steal focus from another button or an already closed activity", - dismiss.contains("!returnRecycler.isAttachedToWindow()") - && dismiss.contains("isFinishing() || isDestroyed()") - && dismiss.contains("!returnRecycler.isShown()")); - int recyclerFocusStart = source.indexOf("private boolean focusTmdbRecyclerItem(RecyclerView recycler, int position)"); - int recyclerFocusEnd = source.indexOf("private boolean onDetailEpisodeContainerKey", recyclerFocusStart); - String recyclerFocus = recyclerFocusStart >= 0 && recyclerFocusEnd > recyclerFocusStart - ? source.substring(recyclerFocusStart, recyclerFocusEnd) : ""; - assertTrue("restoring an episode card must retry until its ViewHolder is attached and verify requestFocus succeeded", - recyclerFocus.contains("recycler.isComputingLayout()") - && recyclerFocus.contains("findViewHolderForAdapterPosition(boundedTarget)") - && recyclerFocus.contains("visibleHolder.itemView.requestFocus()") - && recyclerFocus.contains("requestFocusFromTouch()") - && recyclerFocus.contains("getCurrentFocus() == visibleHolder.itemView") - && recyclerFocus.contains("postOnAnimation(() -> focusTmdbRecyclerItem(recycler, boundedTarget, attempt + 1))")); - } - @Test public void standaloneEpisodeModeToggleDoesNotForceSelectedScroll() throws Exception { Path sourcePath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); @@ -2240,7 +2151,6 @@ public class TmdbDetailActivityLayoutTest { "adapter_tmdb_person_photo.xml", "adapter_tmdb_rail_item.xml", "adapter_tmdb_rail_landscape.xml", - "adapter_tmdb_recommendation.xml", "adapter_tmdb_recommendation_landscape.xml", "adapter_tmdb_work.xml", "item_tmdb_person_photo.xml", @@ -2337,33 +2247,13 @@ public class TmdbDetailActivityLayoutTest { && containerKeyBody.contains("KeyUtil.isLeftKey(event)") && containerKeyBody.contains("KeyUtil.isRightKey(event)")); assertTrue("list-mode DPAD_LEFT should move to the previous episode and consume the first-card boundary", - listBody.contains("if (KeyUtil.isLeftKey(event))") - && listBody.contains("if (position <= 0) return true;") - && listBody.contains("return focusDetailEpisode(position - 1);")); + listBody.contains("if (KeyUtil.isLeftKey(event))") + && listBody.contains("if (position <= 0) return true;") + && listBody.contains("return focusDetailEpisode(position - 1);")); assertTrue("list-mode DPAD_RIGHT should move to the next episode and consume the last-card boundary", - listBody.contains("if (KeyUtil.isRightKey(event))") - && listBody.contains("position >= episodeAdapter.getItemCount() - 1") - && listBody.contains("return focusDetailEpisode(position + 1);")); - } - - @Test - public void detailEpisodeListModeCentersTheFocusedCardLikeNativeEnhanced() throws Exception { - String activity = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - String focusChange = javaBlockAt(activity, "private void onDetailEpisodeFocusChange("); - String alignFocused = javaBlockAt(activity, "private void alignDetailEpisodeFocusedRow("); - String alignHorizontal = javaBlockAt(activity, "private void alignDetailEpisodeFocusedCardHorizontallyNow("); - - assertTrue("list-mode focus changes must schedule the same centered item alignment as native enhanced", - focusChange.contains("if (!episodeGridMode)") - && focusChange.contains("alignDetailEpisodeFocusedRow(view, position);")); - assertTrue("list-mode episode alignment must use the horizontal layout manager path", - alignFocused.contains("LinearLayoutManager.HORIZONTAL") - && alignFocused.contains("alignDetailEpisodeFocusedCardHorizontallyNow(focusedView);")); - assertTrue("the focused episode card must be centered within the RecyclerView's usable width", - alignHorizontal.contains("binding.episodeContainer.getPaddingLeft()") - && alignHorizontal.contains("binding.episodeContainer.getPaddingRight()") - && alignHorizontal.contains("focusedView.getLeft() + focusedView.getWidth() / 2") - && alignHorizontal.contains("binding.episodeContainer.smoothScrollBy(delta, 0);")); + listBody.contains("if (KeyUtil.isRightKey(event))") + && listBody.contains("position >= episodeAdapter.getItemCount() - 1") + && listBody.contains("return focusDetailEpisode(position + 1);")); } @Test @@ -2378,7 +2268,6 @@ public class TmdbDetailActivityLayoutTest { String navigationBody = navigation >= 0 && detailRows > navigation ? activity.substring(navigation, detailRows) : ""; String detailRowsBody = detailRows >= 0 && rowKey > detailRows ? activity.substring(detailRows, rowKey) : ""; String rowKeyBody = rowKey >= 0 && episodeKey > rowKey ? activity.substring(rowKey, episodeKey) : ""; - String focusBody = focusItem >= 0 && episodeKey > focusItem ? activity.substring(focusItem, episodeKey) : ""; assertTrue(activityPath + " is missing TMDB horizontal row key helpers", navigation >= 0 && detailRows > navigation && rowKey > detailRows && focusItem > rowKey && episodeKey > focusItem); @@ -2399,10 +2288,10 @@ public class TmdbDetailActivityLayoutTest { && rowKeyBody.contains("int target = KeyUtil.isLeftKey(event) ? position - 1 : position + 1;") && rowKeyBody.contains("if (target < 0 || target >= adapter.getItemCount()) return true;") && rowKeyBody.contains("focusTmdbRecyclerItem(recycler, target);") - && focusBody.contains("RecyclerView.ViewHolder visibleHolder = recycler.findViewHolderForAdapterPosition(boundedTarget);") - && focusBody.contains("visibleHolder.itemView.requestFocus()") - && focusBody.contains("recycler.scrollToPosition(boundedTarget);") - && focusBody.contains("postOnAnimation(() -> focusTmdbRecyclerItem(recycler, boundedTarget, attempt + 1)")); + && rowKeyBody.contains("RecyclerView.ViewHolder visibleHolder = recycler.findViewHolderForAdapterPosition(target);") + && rowKeyBody.contains("visibleHolder.itemView.requestFocus();") + && rowKeyBody.contains("recycler.scrollToPosition(target);") + && rowKeyBody.contains("holder.itemView.requestFocus();")); } @Test @@ -2925,9 +2814,8 @@ public class TmdbDetailActivityLayoutTest { pipBody.contains("restoreInlinePlayerPanelAfterOverlay();") && !pipBody.contains("inlinePiPParent.addView(binding.playerPanel")); assertTrue("detail-player fullscreen Back must close playback back to the detail page on TV and mobile, while fusion keeps embedded exit", - backFromFullscreenBody.contains("modeController.onExitFullscreen()") - && !backFromFullscreenBody.contains("if (isPlayerMode())") - && backFromFullscreenBody.indexOf("exitInlineFullscreen();") < backFromFullscreenBody.indexOf("modeController.onExitFullscreen()") + backFromFullscreenBody.contains("if (isPlayerMode())") + && backFromFullscreenBody.indexOf("exitInlineFullscreen();") < backFromFullscreenBody.indexOf("closeDetailFullscreenPlayer();") && backFromFullscreenBody.contains("return;") && !backFromFullscreenBody.contains("Util.isLeanback() && isPlayerMode()") && !backFromFullscreenBody.contains("finishPlaybackToHome();") @@ -3129,16 +3017,11 @@ public class TmdbDetailActivityLayoutTest { String stopBody = source.substring(stop, start); String startBody = source.substring(start, source.indexOf("private void searchInlineDanmaku", start)); - int reuseCurrentPlayback = onPlayBody.indexOf("enterInlineFullscreenIfCurrentInlinePlayback(selectedEpisode)"); - int delegatedPlay = onPlayBody.indexOf("modeController.play();"); - assertTrue("current inline episode clicks must reuse playback before delegated playback", - reuseCurrentPlayback >= 0 && delegatedPlay >= 0 && reuseCurrentPlayback < delegatedPlay); - int enterFullscreen = detailBody.indexOf("enterInlineFullscreen();"); - int startPlayback = detailBody.indexOf("if (!current) playInline();"); - assertTrue("detail-player fullscreen entry must be immediate and must not reload the already playing episode", + assertTrue("current inline episode clicks must reuse playback before fusion reloads", + onPlayBody.indexOf("enterInlineFullscreenIfCurrentInlinePlayback(selectedEpisode)") < onPlayBody.indexOf("if (isFusionMode()) playInline();")); + assertTrue("detail-player fullscreen entry must not reload the already playing episode", detailBody.contains("boolean current = isCurrentInlinePlayback(selectedEpisode);") - && enterFullscreen >= 0 - && startPlayback > enterFullscreen); + && detailBody.contains("if (!current) playInline();")); assertTrue("current inline playback identity must include episode, site key, and line flag", source.contains("private Episode inlinePlaybackEpisode;") && source.contains("private String inlinePlaybackKey = \"\";") @@ -3474,37 +3357,12 @@ public class TmdbDetailActivityLayoutTest { assertTrue("empty auto grouping must reuse the resolver's unique season for episode data", dataSeason.contains("tmdbSeasonChoiceResolution().getSelectedSeason()")); - assertTrue("episode detail must use the card mapping and retain the same resolved fallback season as episode data", - episodeDetail.contains("int detailSeasonNumber = tmdbEpisodeDataSeason(detailEpisodes);") - && episodeDetail.contains("if (boundTmdbEpisode != null)") - && episodeDetail.contains("boundTmdbEpisode.getSeasonNumber()") + assertTrue("episode detail must use the same resolved fallback season as episode data", + episodeDetail.contains("int detailSeasonNumber = tmdbEpisodeDataSeason(") && episodeDetail.contains("int displaySeasonNumber = detailSeasonNumber;") && episodeDetail.contains("int seasonNumber = detailSeasonNumber;")); } - @Test - public void episodeDetailUsesLongPressedCardMappingForManualSeason() throws Exception { - String source = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - String adapter = readJava("com", "fongmi", "android", "tv", "ui", "adapter", "TmdbEpisodeAdapter.java"); - String detail = source.substring(source.indexOf("private void showTmdbEpisodeDetail"), - source.indexOf("private EpisodePosition historyEpisodePosition")); - - assertTrue("long press must pass the validated TMDB episode bound to the visible card", - adapter.contains("void onItemLongClick(View anchor, Episode item, int episodeNumber, TmdbEpisode tmdbEpisode)") - && adapter.contains("TmdbEpisode boundTmdbEpisode = tmdbEpisode;") - && adapter.contains("listener.onItemLongClick(view, episode, episodeNumber, boundTmdbEpisode);")); - assertTrue("episode detail must use the bound card season and episode number", - detail.contains("TmdbEpisode boundTmdbEpisode") - && detail.contains("boundTmdbEpisode.getSeasonNumber()") - && detail.contains("boundTmdbEpisode.getNumber()") - && detail.contains("tmdbService.episode(item, seasonNumber, requestEpisodeNumber")); - assertTrue("unmapped cards and API failures must still open a source detail dialog", - detail.contains("if (boundTmdbEpisode == null)") - && detail.contains("EpisodeDetailDialog.show(this, episode, getSite(), null, null, dismissListener);") - && detail.contains("EpisodeDetailDialog.show(this, episode, boundTmdbEpisode, getSite(), null, null, dismissListener);") - && detail.contains("if (!isTmdbEpisodeDetailSeasonCurrent(displaySeasonNumber)) return;")); - } - @Test public void manualSeasonBindingClearsOnlySelectedFlagMetadata() throws Exception { String source = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); @@ -3706,22 +3564,4 @@ public class TmdbDetailActivityLayoutTest { // 子串匹配 "methodName(args)" 天然忽略它 —— 无需正则、无需截断。 return source.contains(methodName + argsAndRest); } - - @Test - public void playbackStartDelegatesToModeController() throws Exception { - String source = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - String body = javaBlockAt(source, "private void startInlinePlayer(Result result, long resumePosition)"); - assertTrue("successful inline playback must notify the active mode controller", - body.contains("modeController.onPlaybackStarted();")); - } - - @Test - public void inlineBackStopsPlaybackBeforeFinishingDetail() throws Exception { - String source = readJava("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - String body = javaBlockAt(source, "private void onInlineBack()"); - int stop = body.indexOf("stopPlayback();"); - int finish = body.indexOf("finish();"); - assertTrue("leaving inline detail must stop playback before finish", stop >= 0 && finish > stop); - } - } diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectFullscreenSourceTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectFullscreenSourceTest.java deleted file mode 100644 index ff97c0f2e4..0000000000 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectFullscreenSourceTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbDetailDirectFullscreenSourceTest { - - @Test - public void detailDirectPlayUsesCurrentPageFullscreenPlayer() throws Exception { - String source = source(); - String onPlay = method(source, "private void onPlay()"); - String fullscreen = method(source, "private void playDetailFullscreen()"); - String reveal = method(source, "private void revealDetailPlayerFullscreen()"); - - assertTrue("play action must delegate to the active detail-mode controller", - onPlay.contains("modeController.play();")); - assertFalse("detail direct play must not switch to VideoActivity", - onPlay.contains("playDefaultPlayback();") - || onPlay.contains("VideoActivity.startDirectTmdb(")); - int enterFullscreen = fullscreen.indexOf("enterInlineFullscreen();"); - int startPlayback = fullscreen.indexOf("if (!current) playInline();"); - assertTrue("detail direct play must enter fullscreen immediately before playback starts", - enterFullscreen >= 0 && startPlayback > enterFullscreen); - assertTrue("detail direct play must not wait for the first frame before entering fullscreen", - fullscreen.contains("detailPlayerFullscreenPending = false;") - && !fullscreen.contains("detailPlayerFullscreenPending = !current;") - && !fullscreen.contains("revealDetailPlayerFullscreen();")); - assertTrue("the retained reveal helper must still enter fullscreen for other pending paths", - reveal.contains("if (!inlineFullscreen) enterInlineFullscreen();")); - } - - private static String source() throws Exception { - Path path = Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - if (!Files.isRegularFile(path)) path = Path.of("app").resolve(path); - return Files.readString(path, StandardCharsets.UTF_8); - } - - private static String method(String source, String signature) { - int start = source.indexOf(signature); - int end = start < 0 ? -1 : source.indexOf("\n private void ", start + signature.length()); - assertTrue("missing method: " + signature, start >= 0 && end > start); - return source.substring(start, end); - } -} diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityDetailShellSourceTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityDetailShellSourceTest.java deleted file mode 100644 index 3a2d7512e7..0000000000 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityDetailShellSourceTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** Source-contract checks for the TV detail shell; these do not replace device rendering tests. */ -public class VideoActivityDetailShellSourceTest { - - @Test - public void initialDetailModeIsPreparedBeforePreviewCanBeRevealed() throws Exception { - String init = method(source(), "protected void initView(Bundle savedInstanceState)"); - int prepare = init.indexOf("prepareInitialDetailShell();"); - int preview = init.indexOf("checkCast();"); - int request = init.indexOf("checkId();"); - - assertTrue("prepare detail actions before checkCast reveals the preview", - prepare >= 0 && preview > prepare); - assertTrue("prepare detail actions before starting asynchronous detail loading", request > prepare); - - String reset = method(source(), "private void resetDetailForNewIntent()"); - assertTrue("singleTop title changes must prepare the new detail shell before loading", - reset.contains("prepareInitialDetailShell();") - && reset.indexOf("prepareInitialDetailShell();") < reset.indexOf("mBinding.progressLayout.showProgress();")); - } - - @Test - public void initialEnhancedShellKeepsTheTmdbConfigurationAndLayoutGuards() throws Exception { - String prepare = method(source(), "private void prepareInitialDetailShell()"); - - assertTrue("unconfigured TMDB must retain ordinary detail actions", - prepare.contains("boolean tmdbDetail = shouldLoadTmdbDetail();")); - assertTrue("enhanced actions require both available TMDB and an enhanced/direct entry", - prepare.contains("tmdbDetail && (isRuntimeOriginalEnhancedMode() || isIntentTmdbPlayback())")); - assertTrue("initial actions must use the same visibility policy as later detail binding", - prepare.contains("setOriginalEnhancedActionVisibility(enhancedDetail);")); - assertTrue("source text must only be suppressed for an enhanced TMDB layout", - prepare.contains("if (enhancedDetail && shouldUseTmdbLayout()) suppressTmdbNativeTextFields();")); - } - - @Test - public void orderAndTitleRebindsPreserveEpisodeToolbarVisibility() throws Exception { - String source = source(); - String reverse = method(source, "private void reverseEpisode(boolean scroll)"); - String title = method(source, "private void toggleEpisodeFileName()"); - String bind = method(source, - "private void setEpisodeAdapter(List items, boolean scrollToCurrent, boolean updateEpisodeChrome)"); - int start = bind.indexOf("if (updateEpisodeChrome) {"); - int end = start < 0 ? -1 : bind.indexOf("\n }", start); - - assertTrue("reverse must not recompute visibility during a presentation-only rebind", - reverse.contains("setEpisodeAdapter(getFlag().getEpisodes(), scroll, false);")); - assertTrue("title toggle must not recompute visibility during a presentation-only rebind", - title.contains("setEpisodeAdapter(getFlag().getEpisodes(), true, false);")); - assertTrue("episode toolbar updates need an explicit guard", start >= 0 && end > start); - String guarded = bind.substring(start, end); - String outsideGuard = bind.substring(0, start) + bind.substring(end); - for (String view : new String[]{"episodeHeader", "episodeReverse", "episodeViewMode", "episodeFileName"}) { - String setter = "mBinding." + view + ".setVisibility("; - assertTrue(view + " must be inside the visibility guard", guarded.contains(setter)); - assertFalse(view + " must not be updated outside the visibility guard", outsideGuard.contains(setter)); - } - } - - @Test - public void ordinaryEpisodeBindingStillRefreshesToolbarVisibility() throws Exception { - String source = source(); - assertTrue(method(source, "private void setEpisodeAdapter(List items)") - .contains("setEpisodeAdapter(items, true, true);")); - assertTrue(method(source, "private void setEpisodeAdapter(List items, boolean scrollToCurrent)") - .contains("setEpisodeAdapter(items, scrollToCurrent, true);")); - } - - private static String source() throws Exception { - Path path = Path.of("src", "leanback", "java", "com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"); - if (!Files.isRegularFile(path)) path = Path.of("app").resolve(path); - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - private static String method(String source, String signature) { - int start = source.indexOf(signature + " {"); - int end = start < 0 ? -1 : source.indexOf("\n }", start); - assertTrue("missing method: " + signature, start >= 0 && end > start); - return source.substring(start, end); - } -} diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityHistoryTitleTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityHistoryTitleTest.java index 6909805c24..3ecff03cec 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityHistoryTitleTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityHistoryTitleTest.java @@ -90,10 +90,9 @@ public class VideoActivityHistoryTitleTest { initHistory.contains("vod.getFlags(), matchedTmdbItem")); assertTrue("fusion/player/colorful modes must update the TMDB-formatted history title before branching", onPlay.contains("updateInlineHistory(selectedEpisode);")); - assertTrue("detail playback presentation must be delegated to the active mode controller", - onPlay.contains("modeController.play();") - && !onPlay.contains("isFusionMode()") - && !onPlay.contains("isPlayerMode()")); + assertTrue("fusion mode must keep inline playback", onPlay.contains("if (isFusionMode()) playInline();")); + assertTrue("player-detail mode must keep fullscreen inline playback", onPlay.contains("else if (isPlayerMode()) playDetailFullscreen();")); + assertTrue("colorful detail mode must keep the external VideoActivity path", onPlay.contains("else playDefaultPlayback();")); assertTrue("inline modes must store the formatted scraped episode title", inlineHistory.contains("history.setVodRemarks(historyEpisodeTitle(item));")); assertTrue("inline modes must persist the canonical TMDB episode position", diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityLayoutTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityLayoutTest.java index a360cb1816..c927af34b1 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityLayoutTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityLayoutTest.java @@ -150,11 +150,6 @@ public class VideoActivityLayoutTest { for (String requiredId : REQUIRED_FULLSCREEN_CONTROL_IDS) { assertTrue(controlLayout + " is missing @+id/" + requiredId, ids.contains(requiredId)); } - assertFalse("the fullscreen overlay must not expose a duplicate danmaku toggle", - ids.contains("danmaku")); - Path actionLayout = findMobileResPath().resolve(Path.of("layout", "view_control_vod_action.xml")); - assertTrue(actionLayout + " must keep the danmaku action-bar button", - collectAndroidIds(actionLayout.toFile()).contains("danmaku")); } @Test @@ -176,7 +171,7 @@ public class VideoActivityLayoutTest { Path sourcePath = findMobileJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); - // 悬浮/图标按钮(中间上下集、进度条旁全屏、顶部投屏)只受集数、锁定、功能可用性控制, + // 悬浮/图标按钮(中间上下集、进度条旁全屏、顶部弹幕/投屏)只受集数、锁定、功能可用性控制, // 不受「播放器按钮设置」影响——那是仅面向底部横向动作栏的偏好。锁定这些可见性表达式, // 防止有人再次把 PlayerButtonSetting 判断加回悬浮按钮(历史回归点)。 assertTrue("middle overlay next button must depend only on episode count", @@ -187,8 +182,10 @@ public class VideoActivityLayoutTest { source.contains("mBinding.control.fullscreen.setVisibility(isLock() || shortDrama ? View.GONE : View.VISIBLE);")); assertTrue("top cast button must depend only on fullscreen and playback state", source.contains("mBinding.control.cast.setVisibility(isFullscreen() && mHistory != null && !player().isEmpty() ? View.VISIBLE : View.GONE);")); + assertTrue("top danmaku button must depend only on lock and danmaku availability", + source.contains("mBinding.control.danmaku.setVisibility(isLock() || !player().haveDanmaku() ? View.GONE : View.VISIBLE);")); - for (String id : List.of("next", "prev", "fullscreen", "cast")) { + for (String id : List.of("next", "prev", "fullscreen", "cast", "danmaku")) { int line = source.indexOf("mBinding.control." + id + ".setVisibility("); assertTrue("missing overlay visibility line for mBinding.control." + id, line >= 0); String stmt = source.substring(line, source.indexOf(';', line)); @@ -236,13 +233,15 @@ public class VideoActivityLayoutTest { String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); int method = source.indexOf("private void refreshDanmakuControls()"); int action = source.indexOf("mBinding.control.action.danmaku.setVisibility", method); + int quick = source.indexOf("mBinding.control.danmaku.setVisibility", method); int apiSearch = source.indexOf("DanmakuApi.search"); int apiRefresh = source.indexOf("refreshDanmakuControls();", apiSearch); int event = source.indexOf("RefreshEvent.Type.DANMAKU"); int eventRefresh = source.indexOf("refreshDanmakuControls();", event); assertTrue(sourcePath + " is missing refreshDanmakuControls", method >= 0); - assertTrue("late danmaku refresh must update the action-bar button", action > method); + assertTrue("late danmaku refresh must update the fullscreen action button", action > method); + assertTrue("late danmaku refresh must update the quick toggle button", quick > method); assertTrue("auto danmaku search must refresh controls after loading", apiRefresh > apiSearch); assertTrue("manual danmaku refresh event must refresh controls after loading", eventRefresh > event); } @@ -754,6 +753,28 @@ public class VideoActivityLayoutTest { && episodeClick.indexOf("syncCurrentAudioPlaylistMetadata();") < episodeClick.indexOf("mFlagAdapter.toggle(item);")); } + @Test + public void autoFfmpegFallbackResetRebuildsExoBeforeNextItem() throws Exception { + Path sourcePath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "player", "PlayerManager.java")); + String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); + String resetFallback = methodBody(source, "private void resetFfmpegModeFallback()", "static boolean shouldStopOnManualSwitchFailure"); + String start = methodBody(source, "public void start(PlaySpec spec, long timeout, boolean playWhenReady)", "public void parse(String key, Result result, boolean useParse, MediaMetadata metadata)"); + String parse = methodBody(source, "public void parse(String key, Result result, boolean useParse, MediaMetadata metadata, boolean playWhenReady)", "private void stopParse()"); + String release = methodBody(source, "public void release()", "private void resetLutRuntimeState"); + + assertTrue("clearing an AUTO override must remember that the current EXO engine was built with a stale renderer mode", + resetFallback.contains("ffmpegModeEngineRefreshPending =") + && resetFallback.contains("PlayerSetting.clearFFmpegModeOverride();")); + assertTrue("direct playback must refresh a stale AUTO-mode EXO engine before preparing the next item", + start.contains("refreshFfmpegModeEngineIfNeeded();") + && start.indexOf("refreshFfmpegModeEngineIfNeeded();") < start.indexOf("setMediaItem(timeout);")); + assertTrue("parsed playback must also refresh a stale AUTO-mode EXO engine before starting parse work", + parse.contains("refreshFfmpegModeEngineIfNeeded();") + && parse.indexOf("refreshFfmpegModeEngineIfNeeded();") < parse.indexOf("ParseJob.create(this).start(result, useParse);")); + assertTrue("destroying the manager must clear the process-wide AUTO override without scheduling another rebuild", + release.contains("clearFfmpegModeFallbackState();")); + } + @Test public void playbackSpeedInitializationUsesPersonalDefaultSpeed() throws Exception { String mobile = new String(Files.readAllBytes(findMobileJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"))), StandardCharsets.UTF_8); @@ -804,19 +825,6 @@ public class VideoActivityLayoutTest { assertTrue("speed toggle must resolve the target through the stable session state", toggleSpeed.contains("speedToggleState.next(getSpeed(), normalSpeed, PlayerSetting.getSpeed(), fallbackSpeed)")); } - @Test - public void leanbackSpeedBoostReleaseIsGuarded() throws Exception { - String leanback = new String(Files.readAllBytes(findLeanbackJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"))), StandardCharsets.UTF_8); - String keyDown = new String(Files.readAllBytes(findLeanbackJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "custom", "CustomKeyDownVod.java"))), StandardCharsets.UTF_8); - String releaseBody = methodBody(keyDown, "public void releaseSpeed()", "public void setFull(boolean full)"); - - assertTrue("CustomKeyDownVod must expose the guarded speed release state", keyDown.contains("public boolean isChangingSpeed()") && keyDown.contains("public void releaseSpeed()")); - assertTrue("releaseSpeed must clear the flag and end the boost", releaseBody.contains("changeSpeed = false;") && releaseBody.contains("listener.onSpeedEnd();")); - assertTrue("dispatchKeyEvent must release the boost when a key is released outside the state machine", methodBody(leanback, "public boolean dispatchKeyEvent(KeyEvent event)", "private boolean dispatchLutQuickKey(KeyEvent event)").contains("mKeyDown.releaseSpeed()")); - assertTrue("onWindowFocusChanged must release the boost when window focus is lost", methodBody(leanback, "public void onWindowFocusChanged(boolean hasFocus)", "private boolean isInitAuto()").contains("mKeyDown.releaseSpeed()")); - assertTrue("onStop must release the boost", methodBody(leanback, "protected void onStop()", "protected void onBackInvoked()").contains("mKeyDown.releaseSpeed()")); - } - @Test public void refreshedPlayerKernelSwitchKeepsManualFailureSemantics() throws Exception { Path sourcePath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "player", "PlayerManager.java")); @@ -1305,24 +1313,6 @@ public class VideoActivityLayoutTest { assertTrue("TV auto intro skip should wait for the deferred seek to settle", autoSkipGuard > introSkip); } - @Test - public void introSkipCallbackWaitsForReadyBeforeSeeking() throws Exception { - Path mobilePath = findMobileJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); - Path leanbackPath = findLeanbackJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); - assertIntroSkipCallbackWaitsForReady(mobilePath); - assertIntroSkipCallbackWaitsForReady(leanbackPath); - } - - private static void assertIntroSkipCallbackWaitsForReady(Path sourcePath) throws Exception { - String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); - int method = source.indexOf("private void onIntroSkipPlanLoaded()"); - int readyGuard = source.indexOf("player().getPlaybackState() != Player.STATE_READY", method); - int apply = source.indexOf("applyAutoIntroSkip();", method); - assertTrue(sourcePath + " is missing onIntroSkipPlanLoaded", method >= 0); - assertTrue("intro-skip callback must not seek while EXO is still preparing", readyGuard > method); - assertTrue("intro-skip callback must apply only after the READY guard", apply > readyGuard); - } - @Test public void mobileTmdbImageReadyRebindsDeferredSummaryAndRevealsDetailContent() throws Exception { Path sourcePath = findMobileJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); @@ -1519,7 +1509,7 @@ public class VideoActivityLayoutTest { assertTrue(sourcePath + " is missing direct native episode predicate", predicate >= 0); assertTrue("direct native episode mode must be scoped to the 影视原生 setting", - source.indexOf("return isRuntimeDirectMode() && !isTmdbMode();", predicate) > predicate); + source.indexOf("return Setting.isDirectDetailPage() && !isTmdbMode();", predicate) > predicate); assertTrue("direct native playback must bypass enhanced episode binding", setEpisode >= 0 && source.indexOf("if (shouldUseUpstreamNativeEpisodeModule())", setEpisode) > setEpisode); assertTrue("direct native playback should keep upstream grouping while honoring the saved list/grid mode", @@ -1593,7 +1583,7 @@ public class VideoActivityLayoutTest { assertTrue(sourcePath + " is missing leanback direct native episode predicate", predicate >= 0); assertTrue("leanback direct native episode mode must be scoped to the 影视原生 setting", - source.indexOf("return isRuntimeDirectMode() && !isTmdbMode();", predicate) > predicate); + source.indexOf("return Setting.isDirectDetailPage() && !isTmdbMode();", predicate) > predicate); assertTrue("leanback direct native playback must bypass enhanced episode chrome", setEpisode >= 0 && source.indexOf("if (shouldUseUpstreamNativeEpisodeModule())", setEpisode) > setEpisode); assertTrue("leanback direct native playback should keep upstream grouping and vertical episode grid", @@ -2183,13 +2173,12 @@ public class VideoActivityLayoutTest { assertTrue(sourcePath + " is missing checkCast", method >= 0); assertFalse("original enhanced entry must not blank the whole page before the player window loading layer", - body.contains("shouldLoadTmdbDetail() && isRuntimeOriginalEnhancedMode()")); + body.contains("shouldLoadTmdbDetail() && Setting.isOriginalEnhancedDetailPage()")); assertTrue("original enhanced entry must reveal the initial preview shell", body.contains("hasInitialPreview()) showInitialPreview();")); assertTrue("the full-screen TMDB loading overlay must be suppressed while the shell is revealed", overlay.contains("!shouldRevealShellWhileLoading()")); - assertTrue("shell reveal must be scoped to the original enhanced or direct-native detail page", - shell.contains("isRuntimeOriginalEnhancedMode()") - && shell.contains("isRuntimeDirectMode()")); + assertTrue("shell reveal must be scoped to the original enhanced detail page", + shell.contains("Setting.isOriginalEnhancedDetailPage()")); assertTrue("shell reveal must show content instead of leaving the page on progress", reveal.contains("mBinding.progressLayout.showContent();")); assertTrue("shell reveal must pre-suppress the source text that TMDB later overwrites", @@ -2225,9 +2214,8 @@ public class VideoActivityLayoutTest { assertTrue("the detail area must stop waiting for TMDB before revealing in original enhanced mode", waitReveal.contains("isTmdbDetailEnrichmentPending() && !shouldRevealShellWhileLoading()")); - assertTrue("shell reveal must be scoped to the original enhanced or direct-native detail page", - shell.contains("isRuntimeOriginalEnhancedMode()") - && shell.contains("isRuntimeDirectMode()")); + assertTrue("shell reveal must be scoped to the original enhanced detail page", + shell.contains("Setting.isOriginalEnhancedDetailPage()")); assertTrue("source text must still wait for TMDB enrichment so the revealed shell does not swap text", text.contains("if (isTmdbDetailEnrichmentPending()) {")); } @@ -2907,7 +2895,7 @@ public class VideoActivityLayoutTest { assertFalse("TmdbHeaderView must not force the theme toggle visible; VideoActivity owns that mode decision", styleFusionBody.contains("themeToggle.setVisibility(View.VISIBLE)")); assertTrue("VideoActivity must still show the header theme toggle for the real fusion detail mode", - videoSource.contains("DetailThemeVisibility.showFusionThemeButton(isRuntimeFusionMode(), isFullscreen(), isInPictureInPictureMode())")); + videoSource.contains("DetailThemeVisibility.showFusionThemeButton(Setting.isFusionDetailPage(), isFullscreen(), isInPictureInPictureMode())")); } @Test @@ -2966,9 +2954,9 @@ public class VideoActivityLayoutTest { assertTrue("fusion playback control retint must cover reverse icon", source.indexOf("mBinding.reverse", method) > method); assertTrue("fusion playback control retint must cover grid/list icon", source.indexOf("mBinding.episodeViewMode", method) > method); assertTrue("tmdb playback control retint must not depend solely on the global fusion setting", - source.indexOf("if (!isRuntimeFusionMode()) return;", method) < 0); + source.indexOf("if (!Setting.isFusionDetailPage()) return;", method) < 0); assertTrue("TMDB playback controls must not force non-fusion cinema pages into the light palette", - source.indexOf("if (!isRuntimeFusionMode()) return true;", method) < 0); + source.indexOf("if (!Setting.isFusionDetailPage()) return true;", method) < 0); assertTrue("TMDB playback labels and icons must use the header's current detail theme", source.indexOf("mTmdbHeaderView.isCurrentDetailLightTheme()", method) > method); assertTrue("dynamic backdrop playback labels must force white text instead of profile dark text", @@ -2987,76 +2975,22 @@ public class VideoActivityLayoutTest { } @Test - public void mobileVideoEpisodeViewportKeepsItsCapUnlessThePageOwnsTmdbEpisodeScroll() throws Exception { + public void mobileVideoEpisodeViewportUsesStableCapInsideScrollPage() throws Exception { Path sourcePath = findMobileJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); int method = source.indexOf("private void updateEpisodeViewportHeight()"); - int nextMethod = source.indexOf("private boolean usesOuterEpisodePageScroll()", method); + int nextMethod = source.indexOf("private boolean isTmdbEpisodeCardMode()", method); String methodBody = nextMethod > method ? source.substring(method, nextMethod) : source.substring(method); - int outerScroll = source.indexOf("private boolean usesOuterEpisodePageScroll()"); - int outerScrollEnd = source.indexOf("private boolean isTmdbEpisodeCardMode()", outerScroll); - String outerScrollBody = outerScrollEnd > outerScroll ? source.substring(outerScroll, outerScrollEnd) : source.substring(outerScroll); assertTrue(sourcePath + " is missing updateEpisodeViewportHeight", method >= 0); - assertTrue("episode viewport must keep a stable dp cap unless the page owns the TMDB episode scroll", - methodBody.contains("int height = usesOuterEpisodePageScroll() ? 0 : limit;") - && methodBody.contains("!usesOuterEpisodePageScroll() && isTmdbEpisodeCardMode()")); - assertTrue("the outer page scroll contract must cover original enhanced pages and reparented backdrop TMDB playback", - outerScrollBody.contains("isRuntimeOriginalEnhancedMode()") - && outerScrollBody.contains("mTmdbControlsMoved && shouldUseTmdbBackdropSurface()")); - assertTrue("a reparented outer-scroll episode grid must release vertical gestures to its page", - source.contains("mBinding.episode.setOnTouchListener((view, event) -> {") - && source.contains("if (!usesOuterEpisodePageScroll()) return false;") - && source.contains("int action = event.getActionMasked();") - && source.contains("action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_MOVE") - && source.contains("view.getParent().requestDisallowInterceptTouchEvent(false);")); + assertTrue("episode viewport must keep a stable dp cap for scrollable detail pages", + methodBody.contains("int height = limit;")); assertTrue("episode viewport must not collapse based on current remaining screen height", !methodBody.contains("available =")); assertTrue("episode viewport must not depend on root height after the method starts", !methodBody.contains("mBinding.getRoot().getHeight()")); } - @Test - public void mobileOriginalEnhancedEpisodeViewportUsesOuterPageScrollWithoutHeightCap() throws Exception { - Path sourcePath = findMobileJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); - String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); - int outerScroll = source.indexOf("private boolean usesOuterEpisodePageScroll()"); - int outerScrollEnd = source.indexOf("private boolean isTmdbEpisodeCardMode()", outerScroll); - String outerScrollBody = outerScrollEnd > outerScroll ? source.substring(outerScroll, outerScrollEnd) : source.substring(outerScroll); - - assertTrue(sourcePath + " is missing usesOuterEpisodePageScroll", outerScroll >= 0); - assertTrue("original enhanced playback must remove the inner episode viewport height cap", - outerScrollBody.contains("isRuntimeOriginalEnhancedMode()")); - assertTrue("the first original-enhanced layout pass must replace the XML max-height default", - source.contains("private int mEpisodeMaxHeight = -1;")); - } - - @Test - public void mobileBackdropTmdbEpisodeGridLetsTheOuterScrollContentGrowAfterReparenting() throws Exception { - Path sourcePath = findMobileJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); - String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); - int move = source.indexOf("private void moveFlagAndEpisodeToTmdb()"); - int restore = source.indexOf("private void restoreFlagAndEpisodeFromTmdb()"); - int helper = source.indexOf("private void updateTmdbPlaybackScrollContentHeight()"); - int helperEnd = source.indexOf("private void moveTmdbSourceToFlagTitle", helper); - String moveBody = restore > move ? source.substring(move, restore) : source.substring(move); - String restoreBody = helper > restore ? source.substring(restore, helper) : source.substring(restore); - String helperBody = helperEnd > helper ? source.substring(helper, helperEnd) : source.substring(helper); - - assertTrue(sourcePath + " is missing TMDB playback scroll content height sync", helper >= 0); - assertTrue("reparenting the episode grid must let the outer scroll child expand to content height", - helperBody.contains("View child = mBinding.scroll.getChildAt(0);") - && helperBody.contains("if (!(child instanceof ViewGroup content)) return;") - && helperBody.contains("int height = mTmdbControlsMoved && usesOuterEpisodePageScroll()") - && helperBody.contains("? ViewGroup.LayoutParams.WRAP_CONTENT : ViewGroup.LayoutParams.MATCH_PARENT;") - && helperBody.contains("content.setLayoutParams(params);") - && helperBody.contains("mBinding.scroll.requestLayout();")); - assertTrue("the expanded content height must be applied after moving the episode grid into TMDB controls", - moveBody.contains("updateTmdbPlaybackScrollContentHeight();")); - assertTrue("the full-height content contract must be restored when TMDB controls move back", - restoreBody.contains("updateTmdbPlaybackScrollContentHeight();")); - } - @Test public void leanbackVideoContextWallIsCoveredByBackdropMask() throws Exception { Path layoutFile = findLeanbackResPath().resolve(Path.of("layout", "activity_video.xml")); @@ -3131,7 +3065,7 @@ public class VideoActivityLayoutTest { assertTrue(sourcePath + " is missing shouldUseTmdbBackdropSurface", predicate >= 0); assertTrue("colorful and native styled TMDB detail must opt into the dynamic backdrop surface", - source.indexOf("runtimeDetailMode() == Setting.DETAIL_OPEN_ENHANCED", predicate) > predicate + source.indexOf("Setting.getDetailOpenMode() == Setting.DETAIL_OPEN_ENHANCED", predicate) > predicate && source.indexOf("Setting.isTmdbNativeStyle()", predicate) > predicate); assertTrue("dynamic backdrop surface must be allowed even when playback artwork wall is disabled", source.indexOf("!shouldUseTmdbBackdropSurface()", setContextWall) > setContextWall); diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/adapter/MobileSiteAdapterStyleTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/adapter/MobileSiteAdapterStyleTest.java index 933e8fbfb5..0c5e5df979 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/adapter/MobileSiteAdapterStyleTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/adapter/MobileSiteAdapterStyleTest.java @@ -15,13 +15,12 @@ public class MobileSiteAdapterStyleTest { public void siteLabelsKeepSelectionDialogContrastAcrossModes() throws Exception { Path moduleRoot = findModuleRoot(); String adapter = read(moduleRoot.resolve(Path.of("src", "mobile", "java", "com", "fongmi", "android", "tv", "ui", "adapter", "SiteAdapter.java"))); - String theme = read(moduleRoot.resolve(Path.of("src", "mobile", "java", "com", "fongmi", "android", "tv", "ui", "helper", "SiteDialogTheme.java"))); + String textColors = read(moduleRoot.resolve(Path.of("src", "main", "res", "color", "site_button_text.xml"))); assertTrue("Site labels should stay fully opaque in block mode", adapter.contains("holder.binding.text.setAlpha(1.0f);")); assertFalse("Disabled search-mode labels should use the same text color as selectable site labels", - theme.contains("state_enabled")); - assertTrue("The adapter must use the palette covered by this test", adapter.contains("theme.apply(binding.text)")); + textColors.contains("android:state_enabled=\"false\"")); } private static String read(Path path) throws Exception { diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapterTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapterTest.java index 6fae470175..ea23c7aa51 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapterTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapterTest.java @@ -32,7 +32,7 @@ public class TmdbEpisodeAdapterTest { assertTrue("native-enhanced grid sizing must use one page-wide TMDB data decision", source.contains("private boolean pageHasTmdbEpisodeData;") && source.contains("pageHasTmdbEpisodeData = hasPageTmdbEpisodeData();") - && source.contains("applyCardSize(holder, position, compact, pageHasTmdbEpisodeData);") + && source.contains("applyCardSize(holder, compact, pageHasTmdbEpisodeData);") && source.contains("private boolean hasPageTmdbEpisodeData()") && !source.contains("applyCardSize(holder, compact, tmdbEpisode != null);")); } @@ -69,37 +69,19 @@ public class TmdbEpisodeAdapterTest { String nativeBody = nativeBranch >= 0 && nextBranch > nativeBranch ? source.substring(nativeBranch, nextBranch) : ""; assertTrue("native-enhanced TMDB episode cards should bind the file-size badge instead of always hiding it", - nativeBody.contains("boolean showMeta = !TextUtils.isEmpty(holder.binding.date.getText());") - && nativeBody.contains("bindFileSize(holder, nativeEnhancedFileSizeBadge(fileSize, cleanTitle), showMeta);") + nativeBody.contains("boolean showDate = !TextUtils.isEmpty(holder.binding.date.getText()) && mode == Mode.GRID;") + && nativeBody.contains("bindFileSize(holder, nativeEnhancedFileSizeBadge(fileSize, cleanTitle), showDate);") && source.contains("extractFileSize(episode.getRawDisplayName())") && source.contains("withSourceFileSize(episode.getRawDisplayName(), title") && !nativeBody.contains("holder.binding.fileSize.setVisibility(View.GONE);")); } - @Test - public void nativeEnhancedListShowsDateRuntimeRatingAndFileSize() throws Exception { - String source = tmdbEpisodeAdapterSource(); - int nativeBranch = source.indexOf("if (isNativeEnhanced())"); - int nextBranch = source.indexOf("} else if (mode == Mode.GRID)", nativeBranch); - String nativeBody = nativeBranch >= 0 && nextBranch > nativeBranch ? source.substring(nativeBranch, nextBranch) : ""; - - assertTrue("native-enhanced list cards should expose all available episode metadata", - nativeBody.contains("boolean showMeta = !TextUtils.isEmpty(holder.binding.date.getText());") - && nativeBody.contains("holder.binding.date.setVisibility(showMeta ? View.VISIBLE : View.GONE);") - && nativeBody.contains("bindFileSize(holder, nativeEnhancedFileSizeBadge(fileSize, cleanTitle), showMeta);") - && nativeBody.contains("holder.binding.badge.setText(nativeEnhancedScore(tmdbEpisode));") - && nativeBody.contains("holder.binding.badge.setVisibility(TextUtils.isEmpty(holder.binding.badge.getText()) ? View.GONE : View.VISIBLE);") - && nativeBody.contains("holder.binding.overview.setVisibility(TextUtils.isEmpty(overview) ? View.GONE : View.VISIBLE);") - && !nativeBody.contains("boolean showMeta = !TextUtils.isEmpty(holder.binding.date.getText()) && mode == Mode.GRID;") - && !nativeBody.contains("mode == Mode.GRID && !TextUtils.isEmpty(overview)")); - } - @Test public void episodeCardStyleChangesRebindWithoutRepeatedLayoutRequests() throws Exception { String source = tmdbEpisodeAdapterSource(); int setMode = source.indexOf("public void setMode(Mode mode)"); int setGridSpan = source.indexOf("public void setGridSpanCount(int gridSpanCount)"); - int applyCardSize = source.indexOf("private void applyCardSize(ViewHolder holder, int position, boolean compact, boolean hasTmdbEpisodeData)"); + int applyCardSize = source.indexOf("private void applyCardSize(ViewHolder holder, boolean compact, boolean hasTmdbEpisodeData)"); assertTrue("TMDB episode adapter must rebind visible cards when grid/list style changes", setMode >= 0 @@ -115,23 +97,17 @@ public class TmdbEpisodeAdapterTest { } @Test - public void gridEpisodeCardsMatchPlaybackGridOuterAndInnerEdges() throws Exception { + public void gridEpisodeCardsUseSymmetricHorizontalMargins() throws Exception { String source = tmdbEpisodeAdapterSource(); - int method = source.indexOf("private void applyCardSize(ViewHolder holder, int position, boolean compact, boolean hasTmdbEpisodeData)"); + int method = source.indexOf("private void applyCardSize(ViewHolder holder, boolean compact, boolean hasTmdbEpisodeData)"); int methodEnd = source.indexOf("private boolean nativeEnhancedMobileGrid", method); String body = method >= 0 && methodEnd > method ? source.substring(method, methodEnd) : ""; - assertTrue("detail grid margins must match the playback grid's column distribution so both outer edges align and cards stay equal width", - source.contains("applyCardSize(holder, position, compact, pageHasTmdbEpisodeData);") - && body.contains("int gridSpacing = dp(holder.itemView, standardGridItem ? 8 : isNativeEnhanced() ? 12 : 8);") - && body.contains("int gridColumn = position >= 0 ? position % gridSpanCount : 0;") - && body.contains("int marginStart = mode == Mode.GRID ? gridSpacing * gridColumn / gridSpanCount : 0;") - && body.contains("int marginEnd = mode == Mode.GRID") - && body.contains("? gridSpacing - gridSpacing * (gridColumn + 1) / gridSpanCount") + assertTrue("grid episode cards must split their spacing between start and end so outer margins stay balanced", + body.contains("int gridSpacing = dp(holder.itemView, standardGridItem ? 8 : isNativeEnhanced() ? 12 : 8);") + && body.contains("int marginStart = mode == Mode.GRID ? gridSpacing / 2 : 0;") && body.contains("marginParams.setMarginStart(marginStart);") && body.contains("marginParams.getMarginStart() != marginStart")); - assertTrue("grid column margins must come from the bind position instead of holder state read mid-bind", - method >= 0 && !body.contains("getBindingAdapterPosition()")); } @Test diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/detail/DetailModeControllerTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/detail/DetailModeControllerTest.java index a9cc93bd3c..32c095070c 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/detail/DetailModeControllerTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/detail/DetailModeControllerTest.java @@ -73,19 +73,6 @@ public class DetailModeControllerTest { source.contains("binding.detailActions.setVisibility(View.VISIBLE)")); } - @Test - public void playerDetailController_bindsPlaybackServiceWithoutAutoPlay() throws Exception { - Path controllerPath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "detail", "PlayerDetailController.java")); - String source = Files.readString(controllerPath, StandardCharsets.UTF_8); - - assertTrue("detail direct-play must bind PlaybackService before the explicit play click", - source.contains("public boolean shouldBindPlaybackService()") - && source.contains("return true;")); - assertTrue("binding PlaybackService must not enable automatic playback", - source.contains("protected boolean autoPlay()") - && source.contains("return false; // 详情直放必须由用户点击播放")); - } - @Test public void tmdbDetailActivity_delegatesToModeController() throws Exception { Path activityPath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); @@ -119,84 +106,4 @@ public class DetailModeControllerTest { assertTrue("initPage should not set detailActions visibility based on mode (delegated to Controller)", !initPageBody.contains("binding.detailActions.setVisibility(isFusionMode()")); } - - @Test - public void playerDetailMode_keepsFullscreenInlinePlayback() throws Exception { - Path activityPath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); - String source = Files.readString(activityPath, StandardCharsets.UTF_8); - String onPlayBody = methodBody(source, "private void onPlay()"); - String detailModeBody = methodBody(source, "private int getDetailMode()"); - - // 详情直放必须进入内嵌全屏播放器,不能因为播放载入界面误判成融合模式。 - assertTrue("detail-player mode must keep fullscreen inline playback", - onPlayBody.contains("modeController.play();") - && !onPlayBody.contains("isFusionMode()") - && !onPlayBody.contains("isPlayerMode()")); - assertTrue("detail-player mode must restore the selected mode when no intent mode marker exists", - detailModeBody.contains("return Setting.getDetailOpenMode();") - && !detailModeBody.contains("getIntent().getBooleanExtra(\"fusion\", false) ? Setting.DETAIL_OPEN_FUSION : Setting.DETAIL_OPEN_ENHANCED")); - } - - @Test - public void playerDetailMode_entersFullscreenBeforeStartingPlayback() throws Exception { - Path activityPath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); - String source = Files.readString(activityPath, StandardCharsets.UTF_8); - String playBody = methodBody(source, "private void playDetailFullscreen()"); - - int enterFullscreen = playBody.indexOf("enterInlineFullscreen();"); - int startPlayback = playBody.indexOf("if (!current) playInline();"); - assertTrue("new detail-player playback must enter fullscreen before playback starts", - enterFullscreen >= 0 && startPlayback > enterFullscreen); - assertTrue("detail-player must not retain a first-frame pending transition", - playBody.contains("detailPlayerFullscreenPending = false;") - && !playBody.contains("detailPlayerFullscreenPending = !current;")); - } - - @Test - public void playerDetailMode_reusesCurrentPlaybackWithoutReloading() throws Exception { - Path activityPath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); - String source = Files.readString(activityPath, StandardCharsets.UTF_8); - String playBody = methodBody(source, "private void playDetailFullscreen()"); - String inlineBody = methodBody(source, "private void playInline(long resumePosition, String failedUrl, String failureMessage)"); - - int enterFullscreen = playBody.indexOf("enterInlineFullscreen();"); - int startPlayback = playBody.indexOf("if (!current) playInline();"); - assertTrue("current playback may enter fullscreen immediately while new playback resolves once", - enterFullscreen >= 0 - && startPlayback > enterFullscreen - && !inlineBody.contains("stopInlinePlayerForReload();")); - } - - @Test - public void playerDetailMode_waitsForExplicitPlaybackAction() throws Exception { - Path activityPath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); - String source = Files.readString(activityPath, StandardCharsets.UTF_8); - String autoPlayBody = methodBody(source, "private void maybeAutoPlayInline()"); - - // 详情直放只定义点击播放后的全屏行为,进入详情页本身不能触发播放。 - assertTrue("auto playback eligibility must be delegated to the active mode controller", - autoPlayBody.contains("modeController.shouldAutoPlay()") - && !autoPlayBody.contains("isFusionMode()") - && !autoPlayBody.contains("isAutoPlayMode()") - && autoPlayBody.contains("binding.playerPanel.post(this::onPlay);")); - } - - @Test - public void playbackHistoryPublishing_isDelegatedToModeController() throws Exception { - Path activityPath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); - String source = Files.readString(activityPath, StandardCharsets.UTF_8); - String body = methodBody(source, "private void updateInlineHistory(Episode item)"); - - assertTrue("history publishing eligibility must be delegated to the active mode controller", - body.contains("modeController.shouldPublishPlaybackHistory()") - && !body.contains("isFusionMode() || isPlayerMode()")); - } - - private String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue(signature + " is missing from TmdbDetailActivity", start >= 0); - int end = source.indexOf("\n private void ", start + signature.length()); - if (end < 0) end = source.length(); - return source.substring(start, end); - } } diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialogLayoutTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialogLayoutTest.java deleted file mode 100644 index a597349f4e..0000000000 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialogLayoutTest.java +++ /dev/null @@ -1,212 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertTrue; - -public class AdBlockStatsDialogLayoutTest { - - @Test - public void statsDialogUsesNearFullScreenTabbedRoot() throws Exception { - for (String flavor : new String[] {"mobile", "leanback"}) { - String layout = read(findRepositoryRoot().resolve(Path.of("app", "src", flavor, "res", "layout", "dialog_ad_block_stats.xml"))); - int rootStart = layout.indexOf("', rootStart) + 1); - - assertTrue(flavor + " statistics dialog should use the available height", - root.contains("android:layout_height=\"match_parent\"")); - assertTrue(layout.contains("android:id=\"@+id/statsTabs\"")); - assertTrue(layout.contains("android:id=\"@+id/overviewPage\"")); - assertTrue(layout.contains("android:id=\"@+id/sitePage\"")); - assertTrue(layout.contains("android:id=\"@+id/rulePage\"")); - assertTrue(layout.contains("android:id=\"@+id/pipelinePage\"")); - } - } - - @Test - public void statsDialogUsesFixedSafetyMarginsInsteadOfScreenPercentages() throws Exception { - Path root = findRepositoryRoot(); - String mobile = read(root.resolve(Path.of("app", "src", "mobile", "java", "com", "fongmi", "android", "tv", "ui", "dialog", "AdBlockStatsDialog.java"))); - String leanback = read(root.resolve(Path.of("app", "src", "leanback", "java", "com", "fongmi", "android", "tv", "ui", "dialog", "AdBlockStatsDialog.java"))); - - assertTrue(mobile.contains("metrics.widthPixels - horizontalMargin * 2")); - assertTrue(mobile.contains("metrics.heightPixels - verticalMargin * 2")); - assertTrue(mobile.contains("window.getDecorView().setPadding(0, 0, 0, 0)")); - assertTrue(mobile.contains("params.height = height")); - assertTrue(mobile.contains("binding.getRoot().setMinimumHeight(height)")); - assertTrue(leanback.contains("ResUtil.getScreenWidth(activity) - horizontalMargin * 2")); - assertTrue(leanback.contains("binding.getRoot().setMinimumHeight(height)")); - assertTrue(leanback.contains("ResUtil.getScreenHeight(activity) - verticalMargin * 2")); - assertTrue(!mobile.contains("metrics.widthPixels * 0.94f")); - assertTrue(!mobile.contains("metrics.heightPixels * 0.92f")); - assertTrue(!leanback.contains("ResUtil.getScreenWidth(activity) * 0.94f")); - assertTrue(!leanback.contains("ResUtil.getScreenHeight(activity) * 0.92f")); - } - - @Test - public void statsDialogKeepsAllThreeCoreMetrics() throws Exception { - for (String flavor : new String[] {"mobile", "leanback"}) { - String layout = read(findRepositoryRoot().resolve(Path.of("app", "src", flavor, "res", "layout", "dialog_ad_block_stats.xml"))); - - assertTrue(layout.contains("android:id=\"@+id/totalBlocked\"")); - assertTrue(layout.contains("android:id=\"@+id/aiFeedbackCount\"")); - assertTrue(layout.contains("android:id=\"@+id/aiSuccessRate\"")); - } - } - - @Test - public void statsDialogDisablesOverscrollAndRemovesGrayCards() throws Exception { - for (String flavor : new String[] {"mobile", "leanback"}) { - String layout = read(findRepositoryRoot().resolve(Path.of("app", "src", flavor, "res", "layout", "dialog_ad_block_stats.xml"))); - - assertTrue(layout.contains("android:overScrollMode=\"never\"")); - assertTrue(!layout.contains("MaterialCardView")); - assertTrue(!layout.contains("app:cardBackgroundColor=\"@color/black_10\"")); - } - } - - @Test - public void statsDialogIncludesSiteDimensionOverview() throws Exception { - for (String flavor : new String[] {"mobile", "leanback"}) { - String layout = read(findRepositoryRoot().resolve(Path.of("app", "src", flavor, "res", "layout", "dialog_ad_block_stats.xml"))); - - assertTrue(layout.contains("android:id=\"@+id/siteCount\"")); - assertTrue(layout.contains("android:id=\"@+id/topSite\"")); - assertTrue(layout.contains("android:id=\"@+id/topSiteShare\"")); - } - } - - @Test - public void statsDialogKeepsAllThreeRankingDimensionsInTheScrollableContent() throws Exception { - for (String flavor : new String[] {"mobile", "leanback"}) { - String layout = read(findRepositoryRoot().resolve(Path.of("app", "src", flavor, "res", "layout", "dialog_ad_block_stats.xml"))); - - assertTrue(layout.contains("android:id=\"@+id/siteRankRecycler\"")); - assertTrue(layout.contains("android:id=\"@+id/ruleRankRecycler\"")); - assertTrue(layout.contains("android:id=\"@+id/pipelineRankRecycler\"")); - } - } - - @Test - public void statsDialogIncludesDetailedLogAndChartTabsForBothFlavors() throws Exception { - Path root = findRepositoryRoot(); - for (String flavor : new String[] {"mobile", "leanback"}) { - String layout = read(root.resolve(Path.of("app", "src", flavor, "res", "layout", "dialog_ad_block_stats.xml"))); - String dialog = read(root.resolve(Path.of("app", "src", flavor, "java", "com", "fongmi", "android", "tv", "ui", "dialog", "AdBlockStatsDialog.java"))); - - assertTrue(layout.contains("android:id=\"@+id/logPage\"")); - assertTrue(layout.contains("android:id=\"@+id/logRecycler\"")); - assertTrue(layout.contains("android:id=\"@+id/chartPage\"")); - assertTrue(layout.contains("android:id=\"@+id/chartView\"")); - assertTrue(layout.contains("com.fongmi.android.tv.widget.AdBlockChartView")); - assertTrue(dialog.contains("binding.chartView.setEntries")); - assertTrue(dialog.contains("R.string.ad_stats_log")); - assertTrue(dialog.contains("R.string.ad_stats_chart")); - assertTrue(dialog.contains("binding.logPage.setVisibility")); - assertTrue(dialog.contains("binding.chartPage.setVisibility")); - } - } - - @Test - public void blockLogUsesARealTableWithEveryRequestedFieldForBothFlavors() throws Exception { - Path root = findRepositoryRoot(); - for (String flavor : new String[] {"mobile", "leanback"}) { - String dialogLayout = read(root.resolve(Path.of("app", "src", flavor, "res", "layout", "dialog_ad_block_stats.xml"))); - String rowLayout = read(root.resolve(Path.of("app", "src", flavor, "res", "layout", "adapter_ad_block_log.xml"))); - String dialogSource = read(root.resolve(Path.of("app", "src", flavor, "java", "com", "fongmi", "android", "tv", "ui", "dialog", "AdBlockStatsDialog.java"))); - - assertTrue(dialogLayout.contains("android:id=\"@+id/logTableScroll\"")); - assertTrue(dialogLayout.contains("@string/ad_log_site_name")); - assertTrue(dialogLayout.contains("@string/ad_log_site_domain")); - assertTrue(dialogLayout.contains("@string/ad_log_rule_domain")); - assertTrue(dialogLayout.contains("@string/ad_log_blocked_at")); - assertTrue(dialogLayout.contains("@string/ad_log_segment_start")); - assertTrue(dialogLayout.contains("@string/ad_log_segment_end")); - assertTrue(dialogLayout.contains("@string/ad_log_segment_duration")); - assertTrue(dialogLayout.contains("android:id=\"@+id/logRecycler\"")); - assertTrue(dialogLayout.contains("android:layout_width=\"1660dp\"")); - assertTrue(rowLayout.contains("android:id=\"@+id/siteName\"") && rowLayout.contains("android:textStyle=\"bold\"")); - assertTrue(rowLayout.contains("android:id=\"@+id/siteDomain\"")); - assertTrue(rowLayout.contains("android:id=\"@+id/ruleDomain\"")); - assertTrue(rowLayout.contains("android:id=\"@+id/blockedAt\"")); - assertTrue(rowLayout.contains("android:id=\"@+id/segmentStart\"")); - assertTrue(rowLayout.contains("android:id=\"@+id/segmentEnd\"")); - assertTrue(rowLayout.contains("android:id=\"@+id/segmentDuration\"")); - assertTrue(dialogSource.contains("AdapterAdBlockLogBinding.inflate")); - assertTrue(dialogSource.contains("getSegmentEndSeconds()")); - assertTrue(dialogSource.contains("segmentDuration.setText(item.hasSegmentTiming()")); - } - } - - @Test - public void rankingTabsSupportExpandingGroupedBlockLogsForBothFlavors() throws Exception { - Path root = findRepositoryRoot(); - for (String flavor : new String[] {"mobile", "leanback"}) { - String dialog = read(root.resolve(Path.of("app", "src", flavor, "java", "com", "fongmi", "android", "tv", "ui", "dialog", "AdBlockStatsDialog.java"))); - - assertTrue(dialog.contains("class ExpandableLogAdapter")); - assertTrue(dialog.contains("getBlockLogsBySource")); - assertTrue(dialog.contains("getBlockLogsBySite")); - assertTrue(dialog.contains("getBlockLogsByRule")); - assertTrue(dialog.contains("getBlockLogsByPipeline")); - assertTrue(dialog.contains("itemView.setOnClickListener")); - } - } - - @Test - public void mobileStatsDialogUsesReadableTextOnWhiteSurface() throws Exception { - Path root = findRepositoryRoot(); - String dialog = read(root.resolve(Path.of("app", "src", "mobile", "res", "layout", "dialog_ad_block_stats.xml"))); - String item = read(root.resolve(Path.of("app", "src", "mobile", "res", "layout", "adapter_ad_stats_item.xml"))); - - assertTrue("White dialog surface must not use white primary text", - !dialog.contains("android:textColor=\"@color/white\"") - && !item.contains("android:textColor=\"@color/white\"")); - assertTrue("White dialog surface must not use translucent white secondary text", - !dialog.contains("android:textColor=\"@color/white_50\"") - && !item.contains("android:textColor=\"@color/white_50\"")); - } - - @Test - public void simplifiedChineseBlockLogUsesTheRequestedFieldLabels() throws Exception { - String strings = read(findRepositoryRoot().resolve(Path.of("app", "src", "main", "res", "values-zh-rCN", "strings.xml"))); - - assertTrue(strings.contains(">切片开始时间")); - assertTrue(strings.contains(">切片结束时间")); - assertTrue(strings.contains(">切片时长")); - } - - @Test - public void simplifiedChineseStatsDialogLocalizesPipelineRankingTitle() throws Exception { - String strings = read(findRepositoryRoot().resolve(Path.of("app", "src", "main", "res", "values-zh-rCN", "strings.xml"))); - - assertTrue("The pipeline ranking title should be localized for the Chinese TV/mobile UI", - strings.contains("播放链路")); - } - - @Test - public void touchFilterOptionsDoNotConsumeTheFirstTapForFocus() throws Exception { - String dialog = read(findRepositoryRoot().resolve(Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "ui", "dialog", "AdBlockLogFilterDialog.java"))); - - assertTrue(dialog.contains("Configuration.TOUCHSCREEN_NOTOUCH")); - assertTrue(dialog.contains("checkBox.setFocusable(!touchDevice)")); - assertTrue(dialog.contains("checkBox.setFocusableInTouchMode(!touchDevice)")); - } - - private static Path findRepositoryRoot() { - Path current = Path.of("").toAbsolutePath(); - while (current != null) { - if (Files.exists(current.resolve(Path.of("app", "src", "mobile", "res", "layout", "dialog_ad_block_stats.xml")))) return current; - current = current.getParent(); - } - throw new IllegalStateException("Repository root not found"); - } - - private static String read(Path path) throws Exception { - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialogLayoutTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialogLayoutTest.java index 52fadd326b..c0f7265d10 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialogLayoutTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialogLayoutTest.java @@ -55,16 +55,6 @@ public class AdRuleManageDialogLayoutTest { && editSource.contains("wireTextDpadFocus(binding.exclude, binding.regex, binding.confirm)")); } - @Test - public void leanbackLegacyFallbackShowsConcreteCriteriaAndRemainsToggleable() throws Exception { - String dialog = read(projectRoot().resolve("app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java")); - String adapter = read(projectRoot().resolve("app/src/leanback/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java")); - - assertTrue(dialog.contains("HlsRuleConfig.LEGACY_FALLBACK_DETAIL")); - assertTrue(adapter.contains("HlsRuleConfig.LEGACY_FALLBACK_SUMMARY")); - assertTrue(adapter.contains("listener.onHlsToggleClick")); - } - @Test public void leanbackRuleEditorAndPreviewUseLightDialogPalette() throws Exception { String edit = read(projectRoot().resolve("app/src/leanback/res/layout/dialog_ad_rule_edit.xml")); @@ -138,7 +128,7 @@ public class AdRuleManageDialogLayoutTest { dialog.contains("rule.getScript()") && dialog.contains("R.string.ad_rule_detail_script")); assertTrue("HLS detail must contain the complete serialized rule", - dialog.contains("current.detail()")); + dialog.contains("item.detail()")); assertTrue("long rule content must be readable with a remote", detail.contains("androidx.core.widget.NestedScrollView") && detail.contains("android:scrollbars=\"vertical\"") @@ -155,32 +145,6 @@ public class AdRuleManageDialogLayoutTest { && row.contains("@string/ad_rule_detail_hint")); } - @Test - public void leanbackImportedRulesSupportBatchActionsAndKeepToggleFocus() throws Exception { - String source = read(projectRoot().resolve("app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java")); - String adapter = read(projectRoot().resolve("app/src/leanback/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java")); - String layout = read(projectRoot().resolve("app/src/leanback/res/layout/dialog_ad_rule_manage.xml")); - String store = read(projectRoot().resolve("app/src/main/java/com/fongmi/android/tv/api/config/ImportedAdRuleCandidateStore.java")); - - assertTrue(source.contains("importSelectedCandidates(candidates, selected, true)") - && source.contains("importSelectedCandidates(candidates, selected, false)") - && source.contains("R.string.ad_rule_select_all") - && source.contains("R.string.ad_rule_invert_selection")); - assertTrue(layout.contains("android:id=\"@+id/importBulkActions\"") - && layout.contains("android:id=\"@+id/enableImported\"") - && layout.contains("android:id=\"@+id/disableImported\"")); - assertTrue(store.contains("importCandidates(List ids, boolean enabled)") - && store.contains("UserAdRuleStore.addAll(additions)")); - assertTrue(source.contains("adapter.refreshUserEnabled(item)") - && source.contains("adapter.refreshDefaultEnabled(ruleId, enabled)") - && source.contains("adapter.refreshHlsEnabled(key, enabled)") - && count(source, "restoreRuleFocus(position)") == 3); - assertTrue(adapter.contains("notifyItemChanged(i, enabled)") - && adapter.contains("List payloads")); - assertTrue("bulk action focus must return to stats when no pending-import button is visible", - source.contains("binding.importCandidates.getVisibility() == View.VISIBLE ? binding.importCandidates : binding.stats")); - } - private static void assertDeleteConfirmation(String source, boolean expectSafeTvFocus) { int start = source.indexOf("public void onDeleteClick(UserAdRule item)"); int end = source.indexOf("\n @Override", start + 1); diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialogLayoutTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialogLayoutTest.java index fe51403a0b..274d40a9e5 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialogLayoutTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialogLayoutTest.java @@ -90,6 +90,20 @@ public class AiRecommendationInfoDialogLayoutTest { && layout.contains("android:nextFocusRight=\"@id/confirm\"") && layout.contains("android:nextFocusLeft=\"@id/notInterested\"") && layout.contains("android:nextFocusRight=\"@id/notInterested\"")); + + assertFalse("Touch-mode focusability baked into this shared layout makes the first mobile tap only move" + + " focus, so users must tap twice; TV-only touch focus belongs in the flavor gate instead", + layout.contains("android:focusableInTouchMode=\"true\"")); + assertTrue("Each widget stripped in the layout must get its touch focus straight from the flavor check, so" + + " the TV remote still reaches it and mobile still clicks on the first tap", + compactSource.contains("detailScroll.setFocusableInTouchMode(Util.isLeanback())") + && compactSource.contains("notInterested.setFocusableInTouchMode(Util.isLeanback())") + && compactSource.contains("confirm.setFocusableInTouchMode(Util.isLeanback())")); + assertTrue("Every gated widget must stay DPAD-focusable in the layout now that the Java side only toggles" + + " touch-mode focus", + singleTag(layout, "detailScroll").contains("android:focusable=\"true\"") + && singleTag(layout, "notInterested").contains("android:focusable=\"true\"") + && singleTag(layout, "confirm").contains("android:focusable=\"true\"")); } @Test diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/ConfigHistoryCurrentSourceGuardTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/ConfigHistoryCurrentSourceGuardTest.java deleted file mode 100644 index c4d04d767c..0000000000 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/ConfigHistoryCurrentSourceGuardTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -public class ConfigHistoryCurrentSourceGuardTest { - - private static final String MOBILE = "src/mobile/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java"; - private static final String LEANBACK = "src/leanback/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java"; - - @Test - public void editableHistoryAlwaysHidesCurrentConfig() throws Exception { - assertCurrentConfigHidden(MOBILE); - assertCurrentConfigHidden(LEANBACK); - } - - @Test - public void deletingAConfigRequiresConfirmation() throws Exception { - assertDeleteConfirmation("src/mobile/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java"); - assertDeleteConfirmation("src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java"); - } - - private static void assertCurrentConfigHidden(String file) throws Exception { - String source = Files.readString(Path.of(file), StandardCharsets.UTF_8); - assertTrue(source.contains("if (!readOnly && !TextUtils.isEmpty(currentUrl))")); - assertTrue(source.contains("mItems.removeIf(item -> TextUtils.equals(item.getUrl(), currentUrl));")); - assertFalse(source.contains("if (type != 0 && !readOnly && !TextUtils.isEmpty(currentUrl))")); - } - - private static void assertDeleteConfirmation(String file) throws Exception { - String source = Files.readString(Path.of(file), StandardCharsets.UTF_8); - assertTrue(source.contains(".setTitle(R.string.config_delete_title)")); - assertTrue(source.contains(".setMessage(getString(R.string.config_delete_message, item.getDesc()))")); - assertFalse(source.contains(".setMessage(getString(R.string.config_delete_message, item.getName()))")); - assertTrue(source.contains(".setNegativeButton(R.string.dialog_negative, null)")); - assertTrue(source.contains(".setPositiveButton(R.string.dialog_positive")); - if (file.contains("leanback")) { - assertTrue(source.contains("dialog.setOnShowListener")); - assertTrue(source.contains("BUTTON_NEGATIVE).requestFocus()")); - } - } -} diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialogThemeTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialogThemeTest.java index 9d5ec9d385..0eb80b7e9c 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialogThemeTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialogThemeTest.java @@ -11,21 +11,6 @@ import static org.junit.Assert.assertTrue; public class EpisodeDetailDialogThemeTest { - @Test - public void episodeDetailDialogPrefersTheBoundCardEpisode() throws Exception { - String mobile = read(findMobileJavaPath().resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "dialog", "EpisodeDetailDialog.java"))); - String leanback = read(findLeanbackJavaPath().resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "dialog", "EpisodeDetailDialog.java"))); - - assertTrue("mobile dialog must accept the card-bound TMDB episode", - mobile.contains("Episode episode, TmdbEpisode boundTmdbEpisode") && - mobile.contains("boundTmdbEpisode != null ? boundTmdbEpisode : episode.getTmdbEpisode()")); - assertTrue("TV dialog must accept the card-bound TMDB episode", - leanback.contains("Episode episode, TmdbEpisode boundTmdbEpisode") && - leanback.contains("boundTmdbEpisode != null ? boundTmdbEpisode : episode.getTmdbEpisode()")); - } - @Test public void tvEpisodeAndMovieDetailsApplyResolvedThemeBeforeShowing() throws Exception { String source = read(findLeanbackJavaPath().resolve(Path.of( @@ -78,53 +63,6 @@ public class EpisodeDetailDialogThemeTest { countOccurrences(source, "guestAdapter.setLight(light);") >= 2); } - @Test - public void cinemaEpisodeGuestCardsReuseOuterHorizontalLayoutWithoutPhotoRounding() throws Exception { - String adapter = read(findMainJavaPath().resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "adapter", "TmdbPersonAdapter.java"))); - String mobileDialog = read(findMobileJavaPath().resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "dialog", "EpisodeDetailDialog.java"))); - String leanbackDialog = read(findLeanbackJavaPath().resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "dialog", "EpisodeDetailDialog.java"))); - String mobileLayout = read(findMainResPath().resolve(Path.of( - "layout", "dialog_episode_detail.xml"))); - - assertTrue("cinema guest cards must reuse the outer actor rail's horizontal card geometry", - adapter.contains("params.width = dp(holder.itemView, cinema ? 250 : 90);") - && adapter.contains("holder.binding.content.setOrientation(cinema ? LinearLayout.HORIZONTAL : LinearLayout.VERTICAL);") - && adapter.contains("photoParams.width = dp(holder.itemView, cinema ? 86 : 90);") - && adapter.contains("photoParams.height = dp(holder.itemView, cinema ? 86 : 118);")); - assertFalse("guest photos must not receive an independent rounded outline", - adapter.contains("setRoundedPhoto") || adapter.contains("ROUNDED_PHOTO_OUTLINE") - || adapter.contains("setOutlineProvider")); - assertTrue("mobile episode guest cards must switch to the outer actor rail layout only in cinema style", - mobileDialog.contains("adapter.setCinema(cinema);") - && mobileDialog.contains("boolean cinema = Setting.isTmdbCinemaStyle();") - && mobileDialog.contains("params.height = ResUtil.dp2px(104);")); - assertTrue("both TV episode-media paths must switch guest cards to the cinema layout", - countOccurrences(leanbackDialog, "guestAdapter.setCinema(cinema);") >= 2 - && leanbackDialog.contains("guestsGrid.setRowHeight(ResUtil.dp2px(cinema ? 90 : 154));") - && leanbackDialog.contains("params.height = ResUtil.dp2px(104);")); - assertFalse("the mobile episode detail must not render a cancel button", - mobileLayout.contains("android:id=\"@+id/close\"") - || mobileDialog.contains("R.id.close")); - } - - @Test - public void mobileEpisodeDetailKeepsBottomGuestCardsInsideStableScrollBounds() throws Exception { - String dialog = read(findMobileJavaPath().resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "dialog", "EpisodeDetailDialog.java"))); - String layout = read(findMainResPath().resolve(Path.of( - "layout", "dialog_episode_detail.xml"))); - - assertTrue("mobile detail must disable nested scrolling for its horizontal rails", - dialog.contains("view.setNestedScrollingEnabled(false);") - && dialog.contains("view.setOverScrollMode(View.OVER_SCROLL_NEVER);")); - assertTrue("mobile detail must disable parent overscroll and leave a bottom safety inset", - layout.contains("android:overScrollMode=\"never\"") - && layout.contains("android:paddingBottom=\"12dp\"")); - } - @Test public void tvEpisodePhotosUseUnifiedYellowFocusStrokeWithoutGrayOverlay() throws Exception { String adapter = read(findLeanbackJavaPath().resolve(Path.of( @@ -159,17 +97,13 @@ public class EpisodeDetailDialogThemeTest { String guestNavigation = source.substring(guestBranch, photoBranch); assertTrue("guest cards should move to photos when the photo grid has data", - guestNavigation.contains("requestGridFocus(photosGrid)")); + guestNavigation.contains("photosGrid.requestFocus();")); assertTrue("guest cards should fall back to the poster when photos are unavailable", guestNavigation.contains("stillCard.requestFocus();")); - - int focusHelper = source.indexOf("private static boolean requestGridFocus"); - int focusHelperEnd = source.indexOf("private static boolean isSameOrDescendantOf", focusHelper); - String focusHelperBody = source.substring(focusHelper, focusHelperEnd); assertTrue("guest-to-photo navigation must check photo visibility", - focusHelperBody.contains("grid.getVisibility() != View.VISIBLE")); + guestNavigation.contains("photosGrid.getVisibility() == View.VISIBLE")); assertTrue("guest-to-photo navigation must check photo data", - focusHelperBody.contains("grid.getAdapter().getItemCount() == 0")); + guestNavigation.contains("photosGrid.getAdapter().getItemCount() > 0")); int guestsGrid = layout.indexOf("android:id=\"@+id/guestsGrid\""); int guestsEnd = layout.indexOf("/>", guestsGrid); @@ -178,20 +112,6 @@ public class EpisodeDetailDialogThemeTest { && layout.substring(guestsGrid, guestsEnd).contains("android:nextFocusUp=\"@id/photosGrid\"")); } - @Test - public void tvEpisodePosterNavigatesDownToTheFirstAvailablePhotoCard() throws Exception { - String source = read(findLeanbackJavaPath().resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "dialog", "EpisodeDetailDialog.java"))); - String layout = read(findLeanbackResPath().resolve(Path.of("layout", "dialog_episode_detail.xml"))); - - assertTrue("poster focus must be recognized even when a nested child owns focus", - source.contains("isSameOrDescendantOf(focus, stillCard)")); - assertTrue("down navigation must focus an actual grid card", - source.contains("grid.setSelectedPosition(position, holder -> holder.itemView.requestFocus());")); - assertTrue("poster must declare the photo grid as its downward focus target", - layout.contains("android:nextFocusDown=\"@id/photosGrid\"")); - } - private static void assertThemeAppliedBeforeShow(String source, int start, int end, String label) { assertTrue("Missing " + label + " layout block", start >= 0 && end > start); String block = source.substring(start, end); @@ -216,24 +136,6 @@ public class EpisodeDetailDialogThemeTest { return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); } - private static Path findMainJavaPath() { - Path moduleRelative = Path.of("src", "main", "java"); - if (Files.exists(moduleRelative)) return moduleRelative; - return Path.of("app", "src", "main", "java"); - } - - private static Path findMobileJavaPath() { - Path moduleRelative = Path.of("src", "mobile", "java"); - if (Files.exists(moduleRelative)) return moduleRelative; - return Path.of("app", "src", "mobile", "java"); - } - - private static Path findMainResPath() { - Path moduleRelative = Path.of("src", "main", "res"); - if (Files.exists(moduleRelative)) return moduleRelative; - return Path.of("app", "src", "main", "res"); - } - private static Path findLeanbackJavaPath() { Path moduleRelative = Path.of("src", "leanback", "java"); if (Files.exists(moduleRelative)) return moduleRelative; diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/MobileAdRuleManageDialogTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/MobileAdRuleManageDialogTest.java index 445b5fceac..f8c32f2130 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/MobileAdRuleManageDialogTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/MobileAdRuleManageDialogTest.java @@ -30,16 +30,6 @@ public class MobileAdRuleManageDialogTest { assertTrue(dialog.contains("playlistHostRegex") && dialog.contains("minDuration") && dialog.contains("minimumSignals")); } - @Test - public void legacyFallbackShowsConcreteCriteriaAndRemainsToggleable() throws Exception { - String dialog = read("app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java"); - String adapter = read("app/src/mobile/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java"); - - assertTrue(dialog.contains("HlsRuleConfig.LEGACY_FALLBACK_DETAIL")); - assertTrue(adapter.contains("HlsRuleConfig.LEGACY_FALLBACK_SUMMARY")); - assertTrue(adapter.contains("listener.onHlsToggleClick")); - } - @Test public void mobileRuleRowsUseReadableTouchFriendlyCards() throws Exception { String layout = read("app/src/mobile/res/layout/adapter_ad_rule.xml"); @@ -51,35 +41,6 @@ public class MobileAdRuleManageDialogTest { assertTrue(layout.contains("android:contentDescription=\"@string/ad_rule_delete_confirm\"")); } - @Test - public void candidateImportOffersBatchSelectionAndExplicitActivation() throws Exception { - String dialog = read("app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java"); - String layout = read("app/src/mobile/res/layout/dialog_ad_rule_manage.xml"); - - assertTrue(dialog.contains("R.string.ad_rule_select_low_risk") - && dialog.contains("R.string.ad_rule_select_all") - && dialog.contains("R.string.ad_rule_invert_selection")); - assertTrue(dialog.contains("importSelectedCandidates(candidates, selected, true)") - && dialog.contains("importSelectedCandidates(candidates, selected, false)")); - assertTrue(dialog.contains("ImportedAdRuleCandidateStore.ignoreCandidates(ids)") - && dialog.contains(".setNegativeButton(android.R.string.cancel, null)")); - assertTrue(layout.contains("android:id=\"@+id/importBulkActions\"") - && layout.contains("android:id=\"@+id/enableImported\"") - && layout.contains("android:id=\"@+id/disableImported\"")); - } - - @Test - public void committedToggleRefreshesOnlyItsCurrentRow() throws Exception { - String dialog = read("app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java"); - String adapter = read("app/src/mobile/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java"); - - assertTrue(dialog.contains("adapter.refreshUserEnabled(item)") - && dialog.contains("adapter.refreshDefaultEnabled(ruleId, enabled)") - && dialog.contains("adapter.refreshHlsEnabled(item.key(), enabled)")); - assertTrue(adapter.contains("notifyItemChanged(i, enabled)") - && adapter.contains("List payloads")); - } - private static String read(String file) throws Exception { Path root = Files.exists(Path.of("app")) ? Path.of("") : Path.of(".."); return Files.readString(root.resolve(file), StandardCharsets.UTF_8).replace("\r\n", "\n"); diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/SiteDialogSelectionScrollSourceTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/SiteDialogSelectionScrollSourceTest.java deleted file mode 100644 index 18e7a3987f..0000000000 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/SiteDialogSelectionScrollSourceTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class SiteDialogSelectionScrollSourceTest { - - @Test - public void groupChangesScrollToTheSelectedSite() throws Exception { - String source = read("app/src/mobile/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java"); - int methodStart = source.indexOf("private void onGroupClick(String group, View view)"); - int methodEnd = source.indexOf("private void updateGroupView()", methodStart); - String method = source.substring(methodStart, methodEnd); - - assertTrue(method.contains("filter();")); - assertTrue(method.contains("binding.recycler.post(() -> binding.recycler.scrollToPosition(adapter.getSelectedPosition()));")); - assertFalse(method.contains("binding.recycler.scrollToPosition(0);")); - } - - private String read(String path) throws Exception { - Path root = Files.exists(Path.of("app")) ? Path.of("") : Path.of(".."); - return new String(Files.readAllBytes(root.resolve(path)), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } -} diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/SiteDialogThemeSourceTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/SiteDialogThemeSourceTest.java deleted file mode 100644 index f665b51027..0000000000 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/SiteDialogThemeSourceTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class SiteDialogThemeSourceTest { - @Test - public void siteDialogSharesOnePaletteAcrossSearchSitesAndGroups() throws Exception { - String dialog = read("app/src/mobile/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java"); - assertFalse("site selection must not force the static light dialog theme", dialog.contains("ThemeOverlay_WebHTV_LightDialog")); - assertTrue("site selection should use the theme-aware dialog base", dialog.contains("return builder().setView(getBinding().getRoot());")); - assertTrue(dialog.contains("SiteDialogTheme.resolve(binding.getRoot().getContext(), Setting.getDynamicColor())")); - assertTrue(dialog.contains("binding.getRoot().setBackgroundResource(R.drawable.shape_site_dialog)")); - assertTrue(dialog.contains("binding.keyword.setTextColor(theme.onSurface())")); - assertTrue(dialog.contains("binding.keyword.setHintTextColor(theme.onSurfaceVariant())")); - assertTrue(dialog.contains("TextViewCompat.setCompoundDrawableTintList(binding.keyword, theme.accent())")); - assertTrue(dialog.contains("theme.tint(binding.block)")); - assertTrue(dialog.contains("theme.tint(binding.search)")); - assertTrue(dialog.contains("new SiteAdapter(this, theme)")); - assertTrue(dialog.contains("new SiteGroupAdapter(this::onGroupClick, theme)")); - assertFalse("a seed is not a readable surface", dialog.contains("binding.getRoot().setBackgroundColor(color)")); - } - - @Test - public void manualAndWallpaperColorsDoNotDependOnPlatformDynamicColorSupport() throws Exception { - String theme = read("app/src/mobile/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java"); - assertTrue(theme.contains("if (seedColor != 0)")); - assertTrue(theme.contains("MaterialColors.getColorRoles(context, seedColor)")); - assertTrue(theme.contains("MaterialColors.getSurfaceContainerFromSeed(context, seedColor)")); - assertFalse(theme.contains("DynamicColors.apply")); - assertFalse(theme.contains("SDK_INT")); - assertTrue("selected foreground/background must be paired", theme.contains("states(onPrimary, onContainer, onSurface)")); - assertTrue(theme.contains("states(primary, container, surface)")); - assertTrue("no seed keeps the current theme", theme.contains("color(context, androidx.appcompat.R.attr.colorPrimary)")); - } - - @Test - public void controlsDoNotUseTheLegacyFixedLightPalette() throws Exception { - String site = read("app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java"); - String group = read("app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteGroupAdapter.java"); - assertTrue(site.contains("theme.apply(binding.text)")); - assertTrue(site.contains("theme.tint(binding.search)")); - assertTrue(site.contains("theme.tint(binding.change)")); - assertTrue(group.contains("theme.apply(button)")); - assertTrue(group.contains("holder.button.setSelected(selected)")); - assertFalse(group.contains("R.color.dialog_outlined_button")); - for (String file : new String[]{"dialog_site.xml", "adapter_site.xml"}) { - String layout = read("app/src/mobile/res/layout/" + file); - assertFalse(layout.contains("@color/dialog_outlined_button")); - assertFalse(layout.contains("@color/site_button")); - assertFalse(layout.contains("android:textColor=\"#")); - assertTrue(layout.contains("?attr/colorOnSurface")); - } - } - - @Test - public void leanbackThemeChoicesAreDpadFocusable() throws Exception { - String layout = read("app/src/leanback/res/layout/adapter_theme.xml"); - assertTrue(layout.contains("android:background=\"@drawable/selector_item\"")); - assertTrue(layout.contains("android:focusable=\"true\"")); - assertTrue(layout.contains("android:focusableInTouchMode=\"true\"")); - } - - @Test - public void everyActivityAppliesThemeChangesImmediately() throws Exception { - String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java"); - assertTrue(mobile.contains("event.getType() == RefreshEvent.Type.LANGUAGE")); - assertTrue(mobile.contains("event.getType() == RefreshEvent.Type.THEME")); - assertTrue(mobile.contains("recreate()")); - - String leanback = read("app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java"); - assertTrue(leanback.contains("event.getType() == RefreshEvent.Type.LANGUAGE")); - assertTrue(leanback.contains("event.getType() == RefreshEvent.Type.UI_SCALE")); - assertTrue(leanback.contains("event.getType() == RefreshEvent.Type.THEME")); - assertTrue(leanback.contains("recreate()")); - } - - private String read(String path) throws Exception { - Path root = Files.exists(Path.of("app")) ? Path.of("") : Path.of(".."); - return new String(Files.readAllBytes(root.resolve(path)), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } -} diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/TrackDialogTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/TrackDialogTest.java index 65b789d154..4506cdeff0 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/TrackDialogTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/TrackDialogTest.java @@ -200,6 +200,25 @@ public class TrackDialogTest { && adapter.contains("mItems.add(0, item)")); } + @Test + public void videoTrackListHighlightsOnlyTheDecodedAdaptiveTrack() throws Exception { + Path root = moduleRoot(); + String dialog = read(root.resolve(Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "ui", "dialog", "TrackDialog.java"))); + + assertTrue("track list must resolve the selected flag through the adaptive-aware helper", + dialog.contains("item.setSelected(isSelected(trackGroup, j, format, active))")); + assertTrue("the decoded video format must narrow a multi-track adaptive selection", + dialog.contains("TrackUtil.uniqueActiveFormat(tracks, type, player.getVideoFormat())")); + assertTrue("字段完全相同的两条轨道用 equals 会同时高亮,必须按引用比对", + dialog.contains("if (active != null) return format == active;") + && !dialog.contains("return format.equals(active);")); + assertTrue("without a resolved decoded format the player flags must still win", + dialog.contains("return trackGroup.isTrackSelected(trackIndex);")); + assertTrue("轨道快照只能读一次,MPV/IJK 的快照由原生线程写入,两次读可能不一致", + dialog.contains("Tracks tracks = player.getCurrentTracks();") + && dialog.contains("Format active = activeVideoFormat(tracks);")); + } + private static String read(Path path) throws Exception { return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); } diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapterTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapterTest.java index ce034536f6..62b74fee3f 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapterTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapterTest.java @@ -628,7 +628,7 @@ public class TmdbUIAdapterTest { int fullBind = source.indexOf("applyFastTmdbPlaybackFullDetailNextFrame(item);", player); int canApply = source.indexOf("private boolean canApplyPlayerResult()"); int fastCanApply = source.indexOf("mFastPlaybackFlag != null && mFastPlaybackEpisode != null && mHistory != null", canApply); - int actionVisibility = source.indexOf("setOriginalEnhancedActionVisibility(loadTmdbDetail && (isRuntimeOriginalEnhancedMode() || isIntentTmdbPlayback()));"); + int actionVisibility = source.indexOf("setOriginalEnhancedActionVisibility(loadTmdbDetail && (Setting.isOriginalEnhancedDetailPage() || isIntentTmdbPlayback()));"); int checkFlag = source.indexOf("private void checkFlag(Vod item)"); int fastCheckFlag = source.indexOf("mFastTmdbPlaybackStarted && mFastPlaybackFlag != null && mFastPlaybackEpisode != null", checkFlag); int bindEpisodes = source.indexOf("setEpisodeAdapter(mFastPlaybackFlag.getEpisodes(), false);", fastCheckFlag); @@ -837,10 +837,10 @@ public class TmdbUIAdapterTest { String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); int loadContent = source.indexOf("private void loadContent(@Nullable TmdbBundle reusableBundle)"); int loadStart = source.indexOf("load start mode=%d", loadContent); - int sourceStart = source.indexOf("long sourceStart = System.currentTimeMillis();", loadStart); - int sourceLog = source.indexOf("source detail cost=%dms", sourceStart); - int singlePass = source.indexOf("shouldLoadInitialStandaloneTmdbDetailInSinglePass", sourceLog); + int singlePass = source.indexOf("shouldLoadInitialStandaloneTmdbDetailInSinglePass", loadStart); int taskLog = source.indexOf("load tasks mode=%d", singlePass); + int sourceStart = source.indexOf("long sourceStart = System.currentTimeMillis();", taskLog); + int sourceLog = source.indexOf("source detail cost=%dms", sourceStart); int waitStart = source.indexOf("long tmdbWaitStart = System.currentTimeMillis();", sourceLog); int waitLog = source.indexOf("tmdb wait cost=%dms", waitStart); int singlePassApply = source.indexOf("if (singlePassStandaloneTmdb)", waitLog); @@ -852,9 +852,9 @@ public class TmdbUIAdapterTest { int bundleMethod = source.indexOf("private TmdbBundle loadTmdbBundle", applyTmdbLog); int bundleLog = source.indexOf("tmdb bundle cost=%dms", bundleMethod); - assertTrue(sourcePath + " is missing standalone detail load profiling", loadContent >= 0 && loadStart > loadContent && sourceStart > loadStart && sourceLog > sourceStart && singlePass > sourceLog && taskLog > singlePass); + assertTrue(sourcePath + " is missing standalone detail load profiling", loadContent >= 0 && loadStart > loadContent && taskLog > singlePass); assertTrue("standalone detail load should measure source detail and TMDB wait separately", - sourceStart > loadStart && sourceLog > sourceStart && singlePass > sourceLog && taskLog > singlePass && waitStart > taskLog && waitLog > waitStart); + sourceStart > taskLog && sourceLog > sourceStart && waitStart > sourceLog && waitLog > waitStart); assertTrue("standalone TMDB modes should still apply source detail and TMDB bundle together in the single-pass branch", singlePassApply > waitLog && applyLoaded > singlePassApply); assertTrue("detail page UI binding and TMDB bundle loading must stay observable for emulator verification", @@ -891,17 +891,18 @@ public class TmdbUIAdapterTest { int contextGuard = source.indexOf("if (selectedFlag == null || selectedEpisode == null) return false;", cancelSwitch); int emptyContextGuard = source.indexOf("if (TextUtils.isEmpty(flag) || TextUtils.isEmpty(episodeUrl)) return false;", contextGuard); int generation = source.indexOf("int generation = ++inlinePlaybackGeneration;", emptyContextGuard); - int position = source.indexOf("long position = player().getPosition();", generation); + int switchLoading = source.indexOf("inlinePlayerSwitchLoading = true;", generation); + int showLoading = source.indexOf("showInlineLoading();", switchLoading); + int position = source.indexOf("long position = player().getPosition();", showLoading); int speed = source.indexOf("float speed = player().getSpeed();", position); int repeat = source.indexOf("boolean repeat = player().isRepeatOne();", speed); int request = source.indexOf("SiteApi.playerContent(key, flag, episodeUrl, playerType)", repeat); int staleGuard = source.indexOf("isInlinePlayerSwitchRequestCurrent(generation, key, flag, episodeUrl)", request); int lifecycleGuard = source.indexOf("private boolean isInlinePlayerSwitchRequestCurrent(int generation, String key, String flag, String episodeUrl)", staleGuard); - int activeInline = source.indexOf("return inlineStarted", lifecycleGuard); - int activeMode = source.indexOf("&& isInlinePlayerMode()", activeInline); + int pendingGuard = source.indexOf("return inlinePlayerSwitchLoading", lifecycleGuard); + int activeMode = source.indexOf("&& isInlinePlayerMode()", pendingGuard); int activeOwner = source.indexOf("&& isOwner()", activeMode); - int activePlayer = source.indexOf("&& player() != null", activeOwner); - int activePlayback = source.indexOf("&& isInlinePlaybackRequestCurrent(generation, key, flag, episodeUrl)", activePlayer); + int activePlayer = source.indexOf("&& !player().isEmpty()", activeOwner); int updateResult = source.indexOf("currentInlineResult = result;", staleGuard); int updateParse = source.indexOf("useParse = result.shouldUseParse();", updateResult); int switchResult = source.indexOf("player().switchPlayer(playerType, result, activePlaybackKey(), metadata, useParse, position, speed, repeat);", updateParse); @@ -910,16 +911,12 @@ public class TmdbUIAdapterTest { assertTrue(sourcePath + " is missing refreshed inline player-kernel switching", switchMethod >= 0 && refreshCall > switchMethod && refreshMethod > refreshCall); assertTrue("choosing the active kernel must cancel only a pending kernel switch, while missing playback context must not invalidate the current playback request", samePlayerGuard > refreshMethod && cancelSwitch > samePlayerGuard && contextGuard > cancelSwitch && emptyContextGuard > contextGuard && generation > emptyContextGuard); - assertTrue("inline kernel switching should preserve playback state before refreshing the selected episode", - position > generation && speed > position && repeat > speed); - assertFalse("removed inline loading state must not be reintroduced solely for kernel switching", - source.substring(refreshMethod, request).contains("inlinePlayerSwitchLoading") - || source.substring(refreshMethod, request).contains("showInlineLoading")); + assertTrue("inline kernel switching should expose a cancellable loading state and preserve playback state before refreshing the selected episode", + switchLoading > generation && showLoading > switchLoading && position > showLoading && speed > position && repeat > speed); assertTrue("inline kernel switching should request a result resolved for the selected target kernel and ignore stale callbacks", request > repeat && staleGuard > request); - assertTrue("inline kernel switch callbacks should require active inline playback and be ignored after leaving inline playback or losing player ownership", - lifecycleGuard > staleGuard && activeInline > lifecycleGuard && activeMode > activeInline && activeOwner > activeMode - && activePlayer > activeOwner && activePlayback > activePlayer); + assertTrue("inline kernel switch callbacks should require a pending switch and be ignored after leaving inline playback or losing player ownership", + lifecycleGuard > staleGuard && pendingGuard > lifecycleGuard && activeMode > pendingGuard && activeOwner > activeMode && activePlayer > activeOwner); assertTrue("inline kernel switching should install the refreshed result before rebuilding the player", updateResult > staleGuard && updateParse > updateResult && switchResult > updateParse); assertTrue("inline kernel switching must not fall back to rebuilding from the stale PlaySpec", oldFallback < 0); diff --git a/catvod/src/main/java/com/github/catvod/crawler/DebugLogStore.java b/catvod/src/main/java/com/github/catvod/crawler/DebugLogStore.java index 1477c542fb..20ce2dffae 100644 --- a/catvod/src/main/java/com/github/catvod/crawler/DebugLogStore.java +++ b/catvod/src/main/java/com/github/catvod/crawler/DebugLogStore.java @@ -1,190 +1,211 @@ package com.github.catvod.crawler; -import android.os.SystemClock; +import android.text.TextUtils; import com.github.catvod.Init; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; -import com.github.catvod.crawler.diagnostics.DiagnosticLogBuffer; -import com.github.catvod.crawler.diagnostics.RollingDiagnosticFile; -import com.github.catvod.crawler.diagnostics.JournaledDiagnosticFile; -import com.github.catvod.crawler.diagnostics.DiagnosticCategories; import com.github.catvod.utils.Prefers; -import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.RandomAccessFile; import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Date; +import java.util.Iterator; import java.util.List; +import java.util.Locale; public class DebugLogStore { + + private static final Object LOCK = new Object(); + private static final ArrayDeque LINES = new ArrayDeque<>(); + private static final ThreadLocal FORMAT = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)); + private static final String FILE_NAME = "webhtv-debug-log.txt"; private static final String PREF_ENABLED = "debug_log"; + private static final int MAX_MESSAGE_CHARS = 12000; + private static final int MAX_LINES = 2000; + private static final int MAX_FILE_BYTES = 4 * 1024 * 1024; + private static long version; private static volatile boolean enabled; - private static volatile DiagnosticLogBuffer buffer; - private static final String PREF_CATEGORIES = "debug_log_categories"; - private static volatile int categories = DiagnosticCategories.ALL; - private static boolean crashHandlerInstalled; - public static boolean isEnabled() { return enabled; } - public static int categories() { return categories; } - public static boolean categoryEnabled(DiagnosticCategories.Category category) { - return enabled && DiagnosticCategories.accepts(categories, category); - } - public static boolean acceptsEvent(String name) { - return enabled && (categories == DiagnosticCategories.ALL || name.startsWith("mpv.") - || DiagnosticCategories.accepts(categories, DiagnosticCategories.event(name))); - } - public static boolean acceptsTag(String tag) { - return enabled && (categories == DiagnosticCategories.ALL || DiagnosticCategories.accepts(categories, DiagnosticCategories.text(tag))); - } - public static synchronized void setCategory(DiagnosticCategories.Category category, boolean value) { - categories = value ? categories | category.bit : categories & ~category.bit; - Prefers.put(PREF_CATEGORIES, categories); - logCategories("category-changed"); - } - private static void logCategories(String reason) { - event(new DiagnosticEvent("diag.session.begin", "none", "process", 0, 0) - .observed("reason", reason).observed("config", DiagnosticCategories.summary(categories)) - .observed("mode", "full-selected-categories").pin("diagnostic-categories")); + public static boolean isEnabled() { + return enabled; } - private static synchronized DiagnosticLogBuffer create() { - if (buffer == null) { - DiagnosticLogBuffer.Limits limits = DiagnosticLogBuffer.Limits.standard(); - buffer = new DiagnosticLogBuffer(limits, new com.github.catvod.crawler.diagnostics.IncidentDiagnosticFile(new JournaledDiagnosticFile( - new RollingDiagnosticFile(Init.context().getCacheDir(), limits), Init.context().getFilesDir()), Init.context().getCacheDir()), - new DiagnosticLogBuffer.Clock() { - @Override public long wallMillis() { return System.currentTimeMillis(); } - @Override public long monotonicNanos() { return SystemClock.elapsedRealtimeNanos(); } - @Override public int processId() { return android.os.Process.myPid(); } - }); - installCrashHandler(); - } - return buffer; + public static void setEnabled(boolean enabled) { + DebugLogStore.enabled = enabled; + Prefers.put(PREF_ENABLED, enabled); + if (enabled) add("debug", "调试日志已开启"); + else clear(); } - private static void installCrashHandler() { - if (crashHandlerInstalled) return; - Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); - if (previous == null) return; - try { - Thread.setDefaultUncaughtExceptionHandler((thread, error) -> { - try { - if (enabled) { - event(new DiagnosticEvent("process.recovery", "none", "process", 0, 0).severity("error") - .observed("reason", "java-uncaught").observed("javaClass", error.getClass().getName()) - .message(error.getMessage()).pin("process-crash")); - add("process-crash", com.github.catvod.crawler.diagnostics.DiagnosticText.throwable(error), true); - DiagnosticLogBuffer current = buffer; - if (current != null) current.flushBestEffort(150); - } - } catch (Throwable ignored) { - // The original crash handler always retains ownership of termination/reporting. - } finally { previous.uncaughtException(thread, error); } - }); - crashHandlerInstalled = true; - } catch (RuntimeException ignored) { collectorFailure(); } - } - - public static synchronized void setEnabled(boolean value) { - categories = Prefers.getInt(PREF_CATEGORIES, DiagnosticCategories.ALL) & DiagnosticCategories.ALL; - if (!value) com.github.catvod.crawler.diagnostics.DiagnosticCapture.stop("diagnostics-disabled"); - if (value) create().start(false); - enabled = value; - Prefers.put(PREF_ENABLED, value); - if (value) beginCollection("enable"); - else if (buffer != null) buffer.disable(); - } - - public static synchronized void restoreEnabled() { - categories = Prefers.getInt(PREF_CATEGORIES, DiagnosticCategories.ALL) & DiagnosticCategories.ALL; + public static void restoreEnabled() { enabled = Prefers.getBoolean(PREF_ENABLED); if (!enabled) return; - create().start(true); - beginCollection("restore"); + synchronized (LOCK) { + loadLocked(); + } + add("debug", "调试日志已恢复"); } - private static void beginCollection(String reason) { - logCategories(reason); - event(new DiagnosticEvent("diag.session.begin", "none", "process", 0, 0) - .observed("mode", "standard").observed("reason", reason) - .unknown("captureStartedLate", DiagnosticEvent.Status.UNKNOWN) - .coverage("session", DiagnosticEvent.Status.KNOWN) - .coverage("health", DiagnosticEvent.Status.KNOWN) - .coverage("decoder", DiagnosticEvent.Status.NOT_COLLECTED) - .coverage("surface", DiagnosticEvent.Status.NOT_COLLECTED) - .coverage("audioOutput", DiagnosticEvent.Status.NOT_COLLECTED) - .pin("process-session")); - } - - public static void add(String tag, String message) { add(tag, message, false); } - - static void add(String tag, String message, boolean critical) { - DiagnosticLogBuffer current = buffer; - if (!acceptsTag(tag) || current == null) return; - current.add(tag, message, critical); - } - - public static void event(DiagnosticEvent event) { - DiagnosticLogBuffer current = buffer; - if (enabled && current != null && DiagnosticCategories.accepts(categories, event.category())) current.event(event); - } - - public static void collectorFailure() { - DiagnosticLogBuffer current = buffer; - if (enabled && current != null) current.collectorFailure(); - } - - /** Producer-published immutable cache; export never calls back into a player. */ - public static void collectorHealth(String id, DiagnosticEvent event, boolean partial, long generation) { - DiagnosticLogBuffer current = buffer; - if (enabled && current != null) current.collectorHealth(id, event, partial, generation); + public static void add(String tag, String msg) { + if (!isEnabled()) return; + if (TextUtils.isEmpty(msg)) return; + String line = FORMAT.get().format(new Date()) + " [" + Thread.currentThread().getName() + "] " + safe(tag) + ": " + limit(msg); + synchronized (LOCK) { + LINES.addLast(line); + trimLines(LINES, MAX_LINES); + version++; + writeLocked(line); + } } public static String text() { - if (!enabled) return "调试日志未开启"; - DiagnosticLogBuffer.Snapshot snapshot = incremental(-1, "", -1); - if (snapshot == null) return "暂无调试日志"; - return DiagnosticLogBuffer.header(snapshot.health()) + snapshot.text(); + if (!isEnabled()) return "调试日志未开启"; + List copy; + synchronized (LOCK) { + if (LINES.isEmpty()) loadLocked(); + copy = new ArrayList<>(LINES); + } + if (copy.isEmpty()) return "暂无调试日志"; + StringBuilder builder = new StringBuilder(); + for (String line : copy) builder.append(line).append('\n'); + return builder.toString(); } public static List snapshot() { - DiagnosticLogBuffer.Snapshot snapshot = incremental(-1, "", -1); - return snapshot == null ? List.of() : snapshot.lines(); - } - - public static List observedOrigins() { - DiagnosticLogBuffer current = buffer; - return !enabled || current == null ? List.of() : current.origins(); - } - - public static DiagnosticLogBuffer.Snapshot incremental(long afterSeq, String runId, long generation) { - DiagnosticLogBuffer current = buffer; - return current == null ? null : current.snapshot(afterSeq, runId, generation); - } - - public static DiagnosticLogBuffer.Export export() { - DiagnosticLogBuffer current = buffer; - if (enabled && current != null) return current.export(750); - byte[] bytes = "调试日志未开启".getBytes(StandardCharsets.UTF_8); - return new DiagnosticLogBuffer.Export(new ByteArrayInputStream(bytes), bytes.length, true, () -> new ByteArrayInputStream(bytes)); + synchronized (LOCK) { + return new ArrayList<>(LINES); + } } public static int size() { - DiagnosticLogBuffer.Snapshot snapshot = incremental(-1, "", -1); - return snapshot == null ? 0 : snapshot.lines().size(); + synchronized (LOCK) { + if (enabled && LINES.isEmpty()) loadLocked(); + return LINES.size(); + } } public static long bytes() { - DiagnosticLogBuffer current = buffer; - return current == null ? 0 : current.health().get("diskBytes").getAsLong(); + try { + File file = file(); + return file != null && file.exists() ? file.length() : 0; + } catch (Throwable e) { + return 0; + } } - public static long version() { return buffer == null ? 0 : buffer.version(); } - public static long captureGeneration() { return buffer == null ? 0 : buffer.generation(); } + public static long version() { + return version; + } - public static synchronized void clear() { - com.github.catvod.crawler.diagnostics.DiagnosticCapture.stop("diagnostics-cleared"); - DiagnosticLogBuffer current = buffer; - if (current == null) return; - current.clear(); - if (enabled) beginCollection("clear"); + public static void clear() { + synchronized (LOCK) { + LINES.clear(); + version++; + delete(); + } + } + + private static String safe(String tag) { + return TextUtils.isEmpty(tag) ? "Debug" : tag; + } + + private static String limit(String msg) { + if (msg.length() <= MAX_MESSAGE_CHARS) return msg; + return msg.substring(0, MAX_MESSAGE_CHARS) + " ...(truncated " + (msg.length() - MAX_MESSAGE_CHARS) + " chars)"; + } + + private static File file() { + try { + return new File(Init.context().getCacheDir(), FILE_NAME); + } catch (Throwable e) { + return null; + } + } + + private static void writeLocked(String line) { + try { + File file = file(); + if (file == null) return; + byte[] data = (line + "\n").getBytes(StandardCharsets.UTF_8); + if (file.length() + data.length > MAX_FILE_BYTES) { + rewriteLocked(file); + return; + } + try (FileOutputStream stream = new FileOutputStream(file, true)) { + stream.write(data); + } + } catch (Throwable ignored) { + } + } + + private static void loadLocked() { + try { + File file = file(); + if (file == null || !file.exists()) return; + String text = readTail(file, MAX_FILE_BYTES); + if (TextUtils.isEmpty(text)) return; + LINES.clear(); + for (String line : text.split("\\r?\\n")) { + if (!TextUtils.isEmpty(line)) LINES.addLast(line); + } + trimLines(LINES, MAX_LINES); + if (file.length() > MAX_FILE_BYTES) rewriteLocked(file); + } catch (Throwable e) { + } + } + + static void trimLines(ArrayDeque lines, int maxLines) { + while (lines.size() > maxLines) lines.removeFirst(); + } + + static String readTail(File file, int maxBytes) throws Exception { + try (RandomAccessFile input = new RandomAccessFile(file, "r")) { + long length = input.length(); + long start = Math.max(0, length - maxBytes); + boolean startsOnLine = start == 0; + if (start > 0) { + input.seek(start - 1); + startsOnLine = input.read() == '\n'; + } + input.seek(start); + byte[] data = new byte[(int) (length - start)]; + input.readFully(data); + int offset = 0; + if (!startsOnLine) { + while (offset < data.length && data[offset] != '\n') offset++; + if (offset < data.length) offset++; + } + return new String(data, offset, data.length - offset, StandardCharsets.UTF_8); + } + } + + private static void rewriteLocked(File file) throws Exception { + ArrayDeque retained = new ArrayDeque<>(); + int bytes = 0; + Iterator iterator = LINES.descendingIterator(); + while (iterator.hasNext()) { + String line = iterator.next(); + int size = line.getBytes(StandardCharsets.UTF_8).length + 1; + if (bytes + size > MAX_FILE_BYTES) break; + retained.addFirst(line); + bytes += size; + } + LINES.clear(); + LINES.addAll(retained); + try (FileOutputStream stream = new FileOutputStream(file, false)) { + for (String line : LINES) stream.write((line + "\n").getBytes(StandardCharsets.UTF_8)); + } + } + + private static void delete() { + try { + File file = file(); + if (file != null && file.exists()) file.delete(); + } catch (Throwable ignored) { + } } } diff --git a/catvod/src/main/java/com/github/catvod/crawler/SpiderDebug.java b/catvod/src/main/java/com/github/catvod/crawler/SpiderDebug.java index ad4eac0145..7ca5f46e8d 100644 --- a/catvod/src/main/java/com/github/catvod/crawler/SpiderDebug.java +++ b/catvod/src/main/java/com/github/catvod/crawler/SpiderDebug.java @@ -3,8 +3,9 @@ package com.github.catvod.crawler; import android.text.TextUtils; import com.orhanobut.logger.Logger; -import com.github.catvod.crawler.diagnostics.DiagnosticText; +import java.io.PrintWriter; +import java.io.StringWriter; import java.util.Locale; public class SpiderDebug { @@ -15,52 +16,31 @@ public class SpiderDebug { return DebugLogStore.isEnabled(); } - /** Capture only: the runtime remains the sole owner of its Logcat/stdout output. */ - public static void console(String tag, String level, String message) { - if (!DebugLogStore.acceptsTag(tag) || message == null || message.isEmpty()) return; - String severity = switch (level == null ? "" : level.toUpperCase(Locale.ROOT)) { - case "DEBUG", "LOG" -> "DEBUG"; - case "WARN", "WARNING" -> "WARN"; - case "ERROR", "FATAL" -> "ERROR"; - case "STDERR" -> "STDERR"; // A stream is not a severity (logging.info can use stderr). - default -> "INFO"; - }; - try { - DebugLogStore.add(tag, "[" + severity + "] " + message, severity.equals("ERROR")); - } catch (RuntimeException ignored) { - DebugLogStore.collectorFailure(); - } - } - public static void log(Throwable th) { log(TAG, th); } public static void log(String tag, Throwable th) { if (th == null) return; - if (!DebugLogStore.acceptsTag(tag)) return; - String message = DiagnosticText.throwable(th); - DebugLogStore.add(tag, message, true); - Logger.t(safeTag(tag)).e(message); + if (!DebugLogStore.isEnabled()) return; + StringWriter writer = new StringWriter(); + th.printStackTrace(new PrintWriter(writer)); + Logger.t(tag).e(writer.toString()); + DebugLogStore.add(tag, writer.toString()); } public static void log(String msg) { if (TextUtils.isEmpty(msg)) return; - if (!DebugLogStore.acceptsTag(TAG)) return; + if (!DebugLogStore.isEnabled()) return; + Logger.t(TAG).d(msg); DebugLogStore.add(TAG, msg); - Logger.t(TAG).d(DiagnosticText.clean(msg).text()); } public static void log(String tag, String msg, Object... args) { if (TextUtils.isEmpty(msg)) return; - if (!DebugLogStore.acceptsTag(tag)) return; - String message = format(msg, args); - DebugLogStore.add(tag, message); - Logger.t(safeTag(tag)).d(DiagnosticText.clean(message).text()); - } - - private static String safeTag(String tag) { - return DiagnosticText.clean(TextUtils.isEmpty(tag) ? TAG : tag).text(); + if (!DebugLogStore.isEnabled()) return; + Logger.t(tag).d(msg, args); + DebugLogStore.add(tag, format(msg, args)); } private static String format(String msg, Object... args) { diff --git a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticAccess.java b/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticAccess.java deleted file mode 100644 index 076b9d3f6f..0000000000 --- a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticAccess.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import java.net.URI; -import java.util.Set; -import java.util.function.LongSupplier; - -/** Same-origin local debug controls with a bounded action rate; no pairing state. */ -public final class DiagnosticAccess { - private final LongSupplier clock; - private long actionWindow; - private int actions; - public DiagnosticAccess() { this(() -> System.nanoTime() / 1_000_000); } - public DiagnosticAccess(LongSupplier clock) { this.clock = clock; } - - public synchronized boolean allowAction() { - long now = clock.getAsLong(); - if (actions == 0 || now < actionWindow || now - actionWindow >= 1000) { - actionWindow = now; - actions = 0; - } - if (actions >= 5) return false; - actions++; - return true; - } - - public static boolean sameOrigin(String origin, String host, Set allowedHosts) { - if (host == null || !allowedHosts.contains(host.toLowerCase(java.util.Locale.ROOT))) return false; - if (origin == null || origin.isEmpty()) return false; // Browser POST must identify its source. - try { - URI uri = URI.create(origin); - return "http".equals(uri.getScheme()) && uri.getRawUserInfo() == null && uri.getRawQuery() == null - && uri.getRawFragment() == null && (uri.getRawPath() == null || uri.getRawPath().isEmpty()) - && host.equalsIgnoreCase(uri.getRawAuthority()); - } catch (IllegalArgumentException ignored) { return false; } - } -} diff --git a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticCapture.java b/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticCapture.java deleted file mode 100644 index 9a0071eeba..0000000000 --- a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticCapture.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import com.github.catvod.crawler.DebugLogStore; - -import java.util.UUID; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -/** Explicit, bounded opt-in. No media data, player callback or disk I/O is owned here. */ -public final class DiagnosticCapture { - public record Session(String id, String trace, String instance, long generation, long attempt, - long captureGeneration, long deadlineNs) { - public long remainingMs() { return Math.max(0, (deadlineNs - System.nanoTime()) / 1_000_000); } - } - private static volatile Session session; - private static final java.util.concurrent.atomic.AtomicInteger pixels = new java.util.concurrent.atomic.AtomicInteger(); - private static ScheduledExecutorService timer; - private static ScheduledFuture expiry; - private DiagnosticCapture() {} - - public static synchronized Session start(String trace, String instance, long generation, long attempt, - int seconds, boolean protectedMedia) { - if (!DebugLogStore.isEnabled()) throw new IllegalStateException("请先开启调试日志"); - if (protectedMedia) throw new IllegalStateException("受保护内容不能进行深度内容统计"); - if (trace == null || "none".equals(trace) || attempt < 1) throw new IllegalStateException("当前没有可关联的播放,请先播放再操作"); - stop("replaced"); - int duration = Math.min(120, Math.max(1, seconds)); - Session value = new Session(UUID.randomUUID().toString(), trace, instance, generation, attempt, - DebugLogStore.captureGeneration(), System.nanoTime() + duration * 1_000_000_000L); - pixels.set(0); session = value; - if (timer == null) timer = Executors.newSingleThreadScheduledExecutor(r -> { - Thread thread = new Thread(r, "diagnostic-expiry"); thread.setDaemon(true); return thread; - }); - expiry = timer.schedule(() -> expire(value), duration, TimeUnit.SECONDS); - event(value, "started", duration * 1000L); - return value; - } - - private static synchronized void expire(Session expected) { if (session == expected) stop("expired"); } - - public static Session current() { - Session value = session; - return value != null && DebugLogStore.isEnabled() && value.captureGeneration == DebugLogStore.captureGeneration() - && System.nanoTime() < value.deadlineNs ? value : null; - } - - public static Session current(String trace, long generation, long attempt) { - Session value = current(); - return value != null && value.trace.equals(trace) && value.generation == generation && value.attempt == attempt ? value : null; - } - - public static int claimPixel(Session expected) { - if (current() != expected) return -1; - int index = pixels.getAndIncrement(); - return index < 3 ? index : -1; - } - - public static synchronized void stop(String reason) { - Session value = session; session = null; - if (expiry != null) { expiry.cancel(false); expiry = null; } - if (value != null) event(value, reason, 0); - } - - private static void event(Session value, String reason, long duration) { - DebugLogStore.event(new DiagnosticEvent("diag.capture", value.trace, value.instance, value.generation, value.attempt) - .observed("captureId", value.id).observed("reason", reason).observed("expiresMs", duration) - .observed("mode", "bounded-depth").observed("protectedMedia", false).pin("depth-capture")); - } -} diff --git a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticCategories.java b/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticCategories.java deleted file mode 100644 index 191f37f96e..0000000000 --- a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticCategories.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import java.util.Locale; - -/** Stable persisted bits. A missing preference enables every category. */ -public final class DiagnosticCategories { - public enum Category { - PLAYBACK(1, "播放流程"), NETWORK(2, "网络请求"), VIDEO(4, "视频画面"), - AUDIO(8, "音频输出"), SUBTITLE(16, "字幕脚本"), SYSTEM(32, "内核设备"); - public final int bit; - public final String title; - Category(int bit, String title) { this.bit = bit; this.title = title; } - } - public static final int ALL = 63; - private DiagnosticCategories() {} - - public static Category event(String name) { - if (name.startsWith("diag.")) return null; // Collection controls and completeness must remain visible. - if (name.startsWith("audio.") || name.equals("mpv.audio.path")) return Category.AUDIO; - if (name.startsWith("video.") || name.startsWith("surface.") || name.contains("display") || name.equals("mpv.video.path")) return Category.VIDEO; - if (name.startsWith("input.") || name.equals("resolve.result")) return Category.NETWORK; - if (name.startsWith("env.") || name.startsWith("process.") || name.startsWith("mpv.") || name.equals("play.resources")) return Category.SYSTEM; - return Category.PLAYBACK; - } - - public static Category nativePrefix(String prefix) { - String value = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT); - if (value.equals("ad") || value.startsWith("ao") || value.startsWith("af") || value.startsWith("ffmpeg/audio")) return Category.AUDIO; - if (value.equals("vd") || value.startsWith("vo") || value.startsWith("vf") || value.startsWith("ffmpeg/video")) return Category.VIDEO; - if (value.startsWith("sub") || value.startsWith("osd") || value.startsWith("lua")) return Category.SUBTITLE; - if (value.startsWith("demux") || value.contains("http") || value.startsWith("stream")) return Category.NETWORK; - return Category.SYSTEM; - } - - public static Category text(String tag) { - String value = tag == null ? "" : tag.toLowerCase(Locale.ROOT); - if (value.equals("quickjs") || value.equals("python-spider")) return Category.NETWORK; - if (value.contains("subtitle") || value.contains("libass") || value.contains("script") || value.equals("ass")) return Category.SUBTITLE; - if (value.contains("audio") || value.contains("audiotrack")) return Category.AUDIO; - if (value.contains("surface") || value.contains("video") || value.contains("render") || value.contains("codec")) return Category.VIDEO; - if (value.contains("http") || value.contains("proxy") || value.contains("network") || value.contains("okhttp") || value.contains("spider") || value.contains("resolve") || value.contains("cache") || value.contains("request")) return Category.NETWORK; - if (value.contains("playback") || value.contains("player") || value.contains("trace") || value.contains("track")) return Category.PLAYBACK; - return Category.SYSTEM; - } - - public static boolean accepts(int mask, Category category) { return category == null || (mask & category.bit) != 0; } - public static String summary(int mask) { - StringBuilder result = new StringBuilder(); - for (Category category : Category.values()) { - if (result.length() > 0) result.append(','); - result.append(category.name().toLowerCase(Locale.ROOT)).append('=').append(accepts(mask, category) ? "on" : "off"); - } - return result.toString(); - } -} diff --git a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticEvent.java b/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticEvent.java deleted file mode 100644 index e08afe2891..0000000000 --- a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticEvent.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import com.google.gson.JsonObject; - -import java.util.Set; - -/** Versioned, allowlisted facts. No arbitrary object serialization or media content. */ -public final class DiagnosticEvent { - public enum Status { - KNOWN("known"), UNKNOWN("unknown"), NOT_COLLECTED("not-collected"), NOT_SUPPORTED("not-supported"), - NOT_APPLICABLE("not-applicable"), PERMISSION_DENIED("permission-denied"), STALE("stale"), - READ_ERROR("read-error"), TIMED_OUT("timed-out"), PENDING_CALLBACK("pending-callback"), - UNAVAILABLE("unavailable"), NOT_OBSERVABLE("not-observable"); - public final String value; - Status(String value) { this.value = value; } - } - - private static final Set EVENTS = Set.of("diag.session.begin", "env.device", "config.snapshot", "play.request", - "resolve.result", "media.tracks", "play.lifecycle", "play.attempt.end", "diag.health", "diag.error", - "input.open", "input.read.summary", "drm.state", "play.clock", "env.display", "display.change", - "video.candidates", "video.decoder.attempt", "video.configure", "video.output.format", "video.sample.summary", - "video.output.summary", "video.first-output", "video.surface-render-callback", "video.flush-reuse-release", "video.error", - "surface.created", "surface.changed", "surface.destroyed", "surface.bind", "surface.resize", "surface.visibility", - "video.presentation.source", "video.effects", "audio.track", "audio.candidates", "audio.decoder.attempt", - "audio.decoder.output", "audio.processing", "audio.output.configure", "audio.output.write", "audio.output.playhead", - "audio.route", "audio.focus", "audio.volume", "audio.output.lifecycle", "audio.error", "audio.sync", - "mpv.init", "mpv.option", "mpv.event", "mpv.tracks", "mpv.video.path", "mpv.audio.path", "mpv.runtime", - "mpv.decoder.attempt", "mpv.output.failure", "mpv.command.result", "mpv.collector.health", "mpv.native.output", - "ijk.event", "ijk.runtime", "process.recovery", "env.native", "config.change", "input.route", "media.container", - "play.resources", "diag.user-mark", "diag.comparison", "diag.capture", "diag.export", "diag.observation", - "video.pixel-probe", "audio.pcm-probe"); - private static final Set FIELDS = Set.of("mode", "captureStartedLate", "reason", "controllerStage", "elapsedMs", - "playerType", "decode", "headersCount", "tracksSummary", "signalSource", "video", "audio", "physicalVideo", "audibility", - "appVersion", "versionCode", "buildTime", "buildTag", "gitRevision", "media3Version", "flavor", "abi", "process64Bit", - "android", "api", "targetSdk", "manufacturer", "model", "device", "hardwareAccelerated", "source", "role", - "session", "deviceInfo", "nativeLibraries", "display", "config", "configChanges", "request", "input", "inputRoute", - "container", "tracks", "drm", "lifecycle", "clock", "resources", "attemptEnd", "health", "decoder", "surface", "audioOutput", - "closed", "outcome", "lastNormalLayer", "missingEvidence", "nextStep", "association", "engineInstance", - "property", "value", "operation", "operationId", "phase", "result", "domain", "stage", "errorId", "errorCode", - "javaClass", "message", "diagnosticInfo", "recoverable", "transient", "chunk", "chunkCount", "truncated", - "decoderId", "decoderName", "surfaceId", "oldSurfaceId", "audioOutputId", "seekEpoch", "epoch", "format", - "mime", "codecs", "profile", "level", "width", "height", "frameRate", "bitrate", "sampleRate", "channels", - "channelMask", "channelIndexMask", "mapping", "encoding", "pcm", "secure", "tunneling", "offload", "softwareOnly", - "vendor", "candidateIndex", "candidateCount", "accepted", "fallbackAllowed", "reuseResult", "discardReasons", - "csdCount", "csdBytes", "csdDigest", "selected", "available", "trackId", "trackType", "support", "flags", "language", - "videoAvailable", "videoSelected", "audioAvailable", "audioSelected", "videoEvidenceLevel", "audioEvidenceLevel", - "videoPartialFailure", "videoOutputObserved", "audioOutputObserved", "failedBoundary", "firstError", "lastError", - "durationMs", "positionMs", "bufferedMs", "windowMs", "count", "bytes", "firstPtsUs", "lastPtsUs", "lastAgeMs", - "inputBuffers", "outputBuffers", "submittedBuffers", "discardedBuffers", "encryptedBuffers", "eosBuffers", - "ptsRegressions", "droppedBuffers", "skippedBuffers", "renderCallbacks", "metricScope", "physicalDisplay", - "writeCalls", "requestedBytes", "acceptedBytes", "shortWrites", "zeroWrites", "writeErrors", "maxWriteUs", - "sessionId", "bufferSize", "bufferUnit", "playState", "state", "headRaw", "headFrames", "headDeltaFrames", - "timestampValid", "timestampFrames", "timestampNs", "timestampAgeMs", "underruns", "routeId", "routeType", - "routeRole", "usage", "contentType", "volume", "systemVolume", "systemMaxVolume", "systemMinVolume", - "muted", "fixedVolume", "focusOwner", "focusRequest", "suppressionReason", "playWhenReady", "isPlaying", - "speed", "skipSilence", "attached", "shown", "visibility", "windowVisibility", "alpha", "ancestorAlpha", - "holderWidth", "holderHeight", "valid", "surfaceType", "presentation", "artworkVisible", "shutterVisible", - "rotation", "displayId", "modeId", "refreshHz", "resizeMode", "requestedWidth", "requestedHeight", - "loadTaskId", "rangeStart", "rangeLength", "contentTypeHeader", "contentLength", "contentRange", "httpCode", - "nativeLevel", "nativePrefix", "sourceFiltered", "nativeOverflow", "javaDropped", "lateEvents", "nodeErrors", - "subscriptionLevel", "msgLevel", "captureGeneration", "sampleSpanMs", "registrationResult", "heartbeatAgeMs", - "nativeHook", "unfinished", "previousRun", "exitReason", "exitStatus", "traceAvailable", "writerFailure", - "firstSeenMs", "lastSeenMs", "sourceStage", "propertyGeneration", "ageMs", "symptom", "captureId", "expiresMs", - "parameter", "oldValue", "newValue", "note", "userReported", "probeIndex", "channel", "frames", "rms", "peak", - "zeroRatio", "clipped", "nonFinite", "luminance", "blackRatio", "changeRatio", "pixelCount", "protectedMedia", - "processorIndex", "processorName", "active", "inputFormat", "outputFormat", "focusChange", "focusGain", - "focusResult", "focusAction", "effectiveGain", "javaBytes", "nativeBytes", "lowRam", "trimLevel", "powerSave", - "thermalStatus", "batteryLevel", "fingerprintDigest", "library", "buildId", "loadResult", "expectedDigest", - "actualDigest", "manifestMatch", "backend", "seekable", "live", "retainedBeforeMs", "retainedAfterMs", - "requestedBeforeMs", "requestedAfterMs", "complete", "operationCount", "keyframes", "firstInputMs", "firstOutputMs"); - - private final JsonObject root = new JsonObject(); - private final JsonObject observed = new JsonObject(); - private final JsonObject requested = new JsonObject(); - private final JsonObject coverage = new JsonObject(); - private String pinKey; - private boolean critical; - private boolean truncated; - - public DiagnosticEvent(String event, String trace, String instance, long generation, long attempt) { - if (!EVENTS.contains(event)) throw new IllegalArgumentException("Unknown diagnostic event"); - root.addProperty("schemaVersion", 1); - root.addProperty("event", event); - root.addProperty("level", "info"); - root.addProperty("trace", label(trace)); - root.addProperty("playerInstanceId", label(instance)); - root.addProperty("association", "controller-context-only"); - root.addProperty("eventMediaIdStatus", Status.NOT_COLLECTED.value); - root.addProperty("engineStatus", Status.NOT_COLLECTED.value); - root.addProperty("mediaGeneration", Math.max(0, generation)); - root.addProperty("attemptId", Math.max(0, attempt)); - root.addProperty("source", "app-controller"); - root.addProperty("evidenceClass", "observed"); - root.addProperty("status", Status.KNOWN.value); - root.addProperty("ageMs", 0); - } - - public DiagnosticEvent observed(String name, Object value) { put(observed, name, value, Status.KNOWN); return this; } - public DiagnosticEvent requested(String name, Object value) { put(requested, name, value, Status.KNOWN); return this; } - public DiagnosticEvent unknown(String name, Status status) { put(observed, name, null, status); return this; } - public DiagnosticEvent coverage(String name, Status status) { put(coverage, name, status == Status.KNOWN ? true : null, status); return this; } - public DiagnosticEvent pin(String key) { pinKey = label(key); return this; } - public String pinKey() { return pinKey; } - public String name() { return root.get("event").getAsString(); } - public DiagnosticCategories.Category category() { - if (observed.has("nativePrefix")) { - JsonObject prefix = observed.getAsJsonObject("nativePrefix"); - if (prefix.has("value")) return DiagnosticCategories.nativePrefix(prefix.get("value").getAsString()); - } - return DiagnosticCategories.event(name()); - } - public boolean critical() { return critical || pinKey != null; } - public boolean truncated() { return truncated; } - - /** A bounded native message, still sanitized, with explicit truncation rather than a silent label cut. */ - public DiagnosticEvent message(String text) { - String safe = DiagnosticText.clean(text).text(); - JsonObject fact = new JsonObject(); - fact.addProperty("status", Status.KNOWN.value); - fact.addProperty("value", safe.substring(0, Math.min(1800, safe.length()))); - observed.add("message", fact); - truncated = safe.length() > 1800; - observed("truncated", truncated); - return this; - } - - public DiagnosticEvent source(String engine, String version, String role, String source, String association, - String eventMediaId, String currentMediaId, long sourceSeq, long capturedAtNs) { - root.addProperty("engine", label(engine)); - root.addProperty("engineVersion", label(version)); - root.addProperty("engineStatus", Status.KNOWN.value); - root.addProperty("role", label(role)); - root.addProperty("source", label(source)); - root.addProperty("association", label(association)); - root.addProperty("eventMediaIdStatus", eventMediaId == null ? Status.UNKNOWN.value : Status.KNOWN.value); - if (eventMediaId != null) root.addProperty("eventMediaId", label(eventMediaId)); - if (currentMediaId != null) root.addProperty("currentMediaId", label(currentMediaId)); - root.addProperty("collectorSourceSeq", sourceSeq); - root.addProperty("sourceCapturedAtNs", capturedAtNs); - return this; - } - - public DiagnosticEvent severity(String level) { - if (!Set.of("fatal", "error", "warn", "info", "debug", "trace").contains(level)) - throw new IllegalArgumentException("Unknown diagnostic severity"); - root.addProperty("level", level); - critical = "fatal".equals(level) || "error".equals(level) || "warn".equals(level); - return this; - } - - public DiagnosticEvent inferred() { root.addProperty("evidenceClass", "inferred"); return this; } - public DiagnosticEvent userReported() { root.addProperty("evidenceClass", "user-reported"); return this; } - - public String json() { - JsonObject result = root.deepCopy(); - result.addProperty("priority", critical() ? "critical" : "normal"); - if (observed.size() > 0) result.add("observed", observed.deepCopy()); - if (requested.size() > 0) result.add("requested", requested.deepCopy()); - if (coverage.size() > 0) result.add("collectors", coverage.deepCopy()); - String json = result.toString(); - if (json.length() > 4_000) throw new IllegalArgumentException("Diagnostic event exceeds schema budget"); - return json; - } - - private static void put(JsonObject target, String name, Object value, Status status) { - if (!FIELDS.contains(name) || status == null) throw new IllegalArgumentException("Unknown diagnostic field/status"); - JsonObject fact = new JsonObject(); - if (status == Status.KNOWN && value == null) status = Status.UNKNOWN; - if (value instanceof Number number && !Double.isFinite(number.doubleValue())) status = Status.UNKNOWN; - fact.addProperty("status", status.value); - if (status == Status.KNOWN) { - if (value instanceof Boolean bool) fact.addProperty("value", bool); - else if (value instanceof Number number) fact.addProperty("value", number); - else if (value instanceof String string) fact.addProperty("value", label(string)); - else throw new IllegalArgumentException("Only scalar diagnostic fields are allowed"); - } - target.add(name, fact); - } - - private static String label(String value) { - if (value == null) return "none"; - String safe = DiagnosticText.clean(value).text(); - return safe.length() > 256 ? safe.substring(0, 240) + " [truncated]" : safe; - } -} diff --git a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticLogBuffer.java b/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticLogBuffer.java deleted file mode 100644 index 1b076b9948..0000000000 --- a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticLogBuffer.java +++ /dev/null @@ -1,468 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.text.SimpleDateFormat; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Date; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.TimeZone; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -/** One disk owner. The producer lock never covers I/O, flush, export or user callbacks. */ -public final class DiagnosticLogBuffer implements AutoCloseable { - public record Limits(int queueBytes, int memoryBytes, int pinnedBytes, int segmentBytes, int segments) { - public static Limits standard() { return new Limits(1 << 20, 1 << 20, 256 << 10, 4 << 20, 4); } - public Limits { - if (queueBytes < 1024 || memoryBytes < 1024 || pinnedBytes < 1024 || segmentBytes < 1024 || segments < 1 || segments > 4) - throw new IllegalArgumentException("Invalid diagnostic budget"); - } - } - - public interface Clock { - long wallMillis(); - long monotonicNanos(); - default int processId() { return -1; } - } - - public interface Persistence { - void clear() throws IOException; - List restore(int maxBytes) throws IOException; - void append(List lines, List pinned) throws IOException; - Export export(String header, List pinned) throws IOException; - long bytes(); - default long rotations() { return 0; } - default long extraDiskBudgetBytes() { return 0; } - /** Constant-time cached flag, no I/O; allows a pending incident to finish in a silent tail. */ - default boolean needsTick() { return false; } - default void tick() throws IOException {} - } - - public static final class Export implements AutoCloseable { - public interface Opener { InputStream open() throws IOException; } - public final InputStream input; - public final long length; - public final boolean partial; - private final Opener opener; - public Export(InputStream input, long length, boolean partial) { - this(input, length, partial, null); - } - public Export(InputStream input, long length, boolean partial, Opener opener) { - this.input = input; this.length = length; this.partial = partial; this.opener = opener; - } - public InputStream openAgain() throws IOException { if (opener == null) throw new IOException("Snapshot cannot be reopened"); return opener.open(); } - @Override public void close() throws IOException { input.close(); } - } - - public record Snapshot(String runId, long generation, long version, long oldestSeq, long newestSeq, - boolean reset, boolean gap, List lines, JsonObject health) { - public String text() { return String.join("\n", lines) + (lines.isEmpty() ? "" : "\n"); } - } - - private record Entry(long seq, long generation, String line, int cost, boolean critical) {} - private record ExportRequest(long generation, long target, CompletableFuture future) {} - private static final ThreadLocal FORMAT = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)); - private final Object lock = new Object(); - private final Limits limits; - private final Persistence persistence; - private final Clock clock; - private final String runId = UUID.randomUUID().toString(); - private final AtomicLong sourceSequence = new AtomicLong(); - private final ArrayDeque queue = new ArrayDeque<>(); - private final ArrayDeque memory = new ArrayDeque<>(); - private final LinkedHashMap pinned = new LinkedHashMap<>(); - private final LinkedHashSet origins = new LinkedHashSet<>(); - private final LinkedHashMap collectorHealth = new LinkedHashMap<>(); - private final LinkedHashSet partialCollectors = new LinkedHashSet<>(); - private Thread writer; - private volatile boolean enabled; - private boolean stopped, clearPending, restorePending, restoredHistory; - private int queueBytes, memoryBytes, pinnedBytes, highWatermark; - private volatile long generation = 1; - private long sequence, version, droppedNormal, droppedCritical, evictedMemory, evictedPinned; - private long truncated, writeFailures, lastFlushedSeq, lastProcessedSeq, persistedBytes, collectorFailures; - private long capturedAtNanos, lastWriterProgressNanos, rotatedSegments, exportFailures; - private String lastWriteError = "none"; - private ExportRequest exportRequest; - - public DiagnosticLogBuffer(Limits limits, Persistence persistence, Clock clock) { - this.limits = limits; this.persistence = persistence; this.clock = clock; - } - - public boolean isEnabled() { return enabled; } - public String runId() { return runId; } - public long version() { synchronized (lock) { return version; } } - public long generation() { return generation; } - - public void start(boolean restore) { - synchronized (lock) { - if (stopped) return; - if (!enabled) { - enabled = true; - capturedAtNanos = clock.monotonicNanos(); - if (restore && writer == null) restorePending = true; - else clearLocked(); - if (writer == null) { - writer = new Thread(this::runWriter, "webhtv-diagnostic-writer"); - writer.setDaemon(true); - writer.start(); - } - } - lock.notifyAll(); - } - } - - public void disable() { synchronized (lock) { enabled = false; clearLocked(); } } - public void clear() { synchronized (lock) { clearLocked(); } } - - private void clearLocked() { - generation++; - queue.clear(); memory.clear(); pinned.clear(); origins.clear(); collectorHealth.clear(); partialCollectors.clear(); - queueBytes = memoryBytes = pinnedBytes = highWatermark = 0; - droppedNormal = droppedCritical = evictedMemory = evictedPinned = truncated = writeFailures = collectorFailures = exportFailures = 0; - lastFlushedSeq = lastProcessedSeq = 0; - capturedAtNanos = clock.monotonicNanos(); - restoredHistory = restorePending = false; - lastWriteError = "none"; - clearPending = true; - if (exportRequest != null) { exportRequest.future.cancel(false); exportRequest = null; } - version++; - lock.notifyAll(); - } - - public void add(String tag, String message, boolean critical) { - if (!enabled || message == null || message.isEmpty()) return; - long captured = clock.monotonicNanos(); - long epoch = generation(); - long sourceSeq = sourceSequence.incrementAndGet(); - DiagnosticText.Clean clean = DiagnosticText.clean(message); - String safeTag = DiagnosticText.clean(tag == null ? "Debug" : tag).text().replace(':', '_').replace('[', '_').replace(']', '_'); - if (safeTag.length() > 64) safeTag = safeTag.substring(0, 64); - // Reserve the structured tag; arbitrary Spider/native text cannot impersonate events. - if ("av-diag".equals(safeTag)) safeTag = "legacy-av-diag"; - offer(safeTag, clean.text(), null, critical, false, captured, sourceSeq, clean.truncated(), DiagnosticText.origins(message), epoch); - } - - public void event(DiagnosticEvent event) { - if (!enabled || event == null) return; - long captured = clock.monotonicNanos(); - long epoch = generation(); - long sourceSeq = sourceSequence.incrementAndGet(); - try { - offer("av-diag", event.json(), event.pinKey(), event.critical(), true, captured, sourceSeq, event.truncated(), List.of(), epoch); - } catch (RuntimeException error) { - collectorFailure(); // Diagnostics cannot throw into a player callback. - } - } - - public void collectorFailure() { synchronized (lock) { collectorFailures++; version++; } } - - /** Crash path only. Releases the producer lock while waiting and never does caller-thread I/O. */ - public void flushBestEffort(long timeoutMs) { - if (Thread.currentThread() == writer) return; - long deadline = System.nanoTime() + Math.min(150, Math.max(0, timeoutMs)) * 1_000_000; - synchronized (lock) { - long target = sequence; - lock.notifyAll(); - while (enabled && lastProcessedSeq < target) { - long remaining = deadline - System.nanoTime(); - if (remaining <= 0) break; - try { lock.wait(Math.max(1, remaining / 1_000_000)); } - catch (InterruptedException ignored) { Thread.currentThread().interrupt(); break; } - } - } - } - - public void collectorHealth(String id, DiagnosticEvent event, boolean partial, long captureGeneration) { - if (!enabled) return; - try { - JsonObject value = com.google.gson.JsonParser.parseString(event.json()).getAsJsonObject(); - synchronized (lock) { - if (!enabled || generation != captureGeneration) return; - collectorHealth.put(id, value); - if (partial) partialCollectors.add(id); else partialCollectors.remove(id); - while (collectorHealth.size() > 16) { - String oldest = collectorHealth.keySet().iterator().next(); - collectorHealth.remove(oldest); partialCollectors.remove(oldest); - } - version++; - } - } catch (RuntimeException ignored) { collectorFailure(); } - } - - private void offer(String tag, String text, String pinKey, boolean critical, boolean structured, - long captured, long sourceSeq, boolean wasTruncated, List foundOrigins, long epoch) { - long wallTime = clock.wallMillis(); - String thread = DiagnosticText.clean(Thread.currentThread().getName()).text(); - if (thread.length() > 64) thread = thread.substring(0, 64); - String jsonThread = new JsonPrimitive(thread).toString(); - String timezone = new JsonPrimitive(TimeZone.getDefault().getID()).toString(); - int pid = clock.processId(); - String prefix = FORMAT.get().format(new Date(wallTime)) + " [" + thread.replace('[', '_').replace(']', '_') + "] " + tag + ": "; - synchronized (lock) { - if (!enabled || stopped || epoch != generation) return; - long seq = ++sequence; - if (structured) { - text = text.substring(0, text.length() - 1) + ",\"processRunId\":\"" + runId + "\",\"processRole\":\"app\",\"seq\":" + seq - + ",\"sourceSeq\":" + sourceSeq + ",\"wallTime\":" + wallTime + ",\"timezone\":" + timezone + ",\"thread\":" + jsonThread - + ",\"elapsedRealtimeNs\":" + captured + ",\"capturedAtNs\":" + captured + ",\"enqueuedAtNs\":" + clock.monotonicNanos() - + ",\"pid\":" + (pid < 0 ? "null" : pid) + ",\"pidStatus\":\"" + (pid < 0 ? "not-collected" : "known") - + "\",\"captureGeneration\":" + generation + "}"; - } - else text += " [logSeq=" + seq + "]"; - String line = prefix + text; - Entry entry = new Entry(seq, generation, line, line.getBytes(StandardCharsets.UTF_8).length + 192, critical); - if (wasTruncated || text.contains("[truncated")) truncated++; - for (String origin : foundOrigins) { - origins.add(origin); - if (origins.size() > 200) origins.remove(origins.iterator().next()); - } - memory.addLast(entry); memoryBytes += entry.cost; - while (memoryBytes > limits.memoryBytes && !memory.isEmpty()) { memoryBytes -= memory.removeFirst().cost; evictedMemory++; } - if (pinKey != null) { - Entry old = pinned.remove(pinKey); - if (old != null) pinnedBytes -= old.cost; - pinned.put(pinKey, entry); pinnedBytes += entry.cost; - while (pinnedBytes > limits.pinnedBytes && !pinned.isEmpty()) { - String first = pinned.keySet().iterator().next(); - pinnedBytes -= pinned.remove(first).cost; evictedPinned++; - } - } - if (critical && queueBytes + entry.cost > limits.queueBytes) { - for (Iterator it = queue.iterator(); it.hasNext() && queueBytes + entry.cost > limits.queueBytes;) { - Entry candidate = it.next(); - if (!candidate.critical) { it.remove(); queueBytes -= candidate.cost; droppedNormal++; } - } - } - if (queueBytes + entry.cost <= limits.queueBytes) { - queue.addLast(entry); queueBytes += entry.cost; - highWatermark = Math.max(highWatermark, queueBytes); - } else if (critical) droppedCritical++; - else droppedNormal++; - version++; - lock.notifyAll(); - } - } - - public List origins() { synchronized (lock) { return new ArrayList<>(origins); } } - - public Snapshot snapshot(long afterSeq, String expectedRun, long expectedGeneration) { - synchronized (lock) { - long oldest = memory.isEmpty() ? sequence + 1 : memory.peekFirst().seq; - boolean reset = !runId.equals(expectedRun) || expectedGeneration != generation || afterSeq > sequence; - boolean gap = !reset && afterSeq >= 0 && afterSeq < oldest - 1; - List lines = new ArrayList<>(); - long start = reset || gap ? -1 : afterSeq; - if (reset || gap) { - for (Entry entry : pinned.values()) if (entry.seq < oldest) lines.add(entry.line); - } - for (Entry entry : memory) if (entry.seq > start) lines.add(entry.line); - return new Snapshot(runId, generation, version, oldest, sequence, reset, gap, List.copyOf(lines), healthLocked("memory-window")); - } - } - - public JsonObject health() { synchronized (lock) { return healthLocked("memory-window"); } } - - private JsonObject healthLocked(String scope) { - JsonObject health = new JsonObject(); - health.addProperty("schemaVersion", 1); health.addProperty("event", "diag.health"); - health.addProperty("processRunId", runId); health.addProperty("captureGeneration", generation); - health.addProperty("scope", scope); health.addProperty("enabled", enabled); - health.addProperty("lastCapturedSeq", sequence); health.addProperty("lastFlushedSeq", lastFlushedSeq); - health.addProperty("lastProcessedSeq", lastProcessedSeq); health.addProperty("queueBytes", queueBytes); - health.addProperty("queueLimitBytes", limits.queueBytes); health.addProperty("queueHighWatermark", highWatermark); - health.addProperty("memoryBytes", memoryBytes); health.addProperty("memoryLimitBytes", limits.memoryBytes); - health.addProperty("pinnedBytes", pinnedBytes); health.addProperty("pinnedLimitBytes", limits.pinnedBytes); - health.addProperty("diskBytes", persistedBytes); health.addProperty("diskLimitBytes", (long) limits.segmentBytes * limits.segments + limits.pinnedBytes + persistence.extraDiskBudgetBytes()); - health.addProperty("droppedNormal", droppedNormal); health.addProperty("droppedCritical", droppedCritical); - health.addProperty("evictedMemory", evictedMemory); health.addProperty("evictedPinned", evictedPinned); - health.addProperty("truncated", truncated); health.addProperty("writeFailures", writeFailures); - health.addProperty("rotatedSegments", rotatedSegments); health.addProperty("exportFailures", exportFailures); - health.addProperty("lastWriteError", lastWriteError); health.addProperty("collectorFailures", collectorFailures); - health.addProperty("clearPending", clearPending); health.addProperty("restorePending", restorePending); - health.addProperty("restoredHistory", restoredHistory); health.addProperty("priorProcessCompleteness", "unknown"); - health.addProperty("captureStartedAtNs", capturedAtNanos); - health.addProperty("writerLastProgressAtNs", lastWriterProgressNanos == 0 ? null : lastWriterProgressNanos); - health.addProperty("writerProgressStatus", lastWriterProgressNanos == 0 ? "not-collected" : "known"); - health.addProperty("nativeOverflow", "not-collected"); - JsonObject collectors = new JsonObject(); - for (java.util.Map.Entry entry : collectorHealth.entrySet()) collectors.add(entry.getKey(), entry.getValue().deepCopy()); - health.add("collectorHealth", collectors); - boolean partial = droppedNormal + droppedCritical + evictedPinned + truncated + writeFailures + collectorFailures > 0 - || sequence > lastFlushedSeq || clearPending || restorePending || restoredHistory || rotatedSegments > 0 - || ("memory-window".equals(scope) && evictedMemory > 0) || !partialCollectors.isEmpty(); - health.addProperty("completeness", partial ? "partial" : "complete-within-declared-window"); - return health; - } - - public Export export(long timeoutMillis) { - CompletableFuture future = new CompletableFuture<>(); - long epoch; - boolean accepted; - synchronized (lock) { - epoch = generation; - accepted = enabled && writer != null && !stopped && exportRequest == null; - if (accepted) { - exportRequest = new ExportRequest(generation, sequence, future); - lock.notifyAll(); - } - } - if (!accepted) return memoryExport(snapshot(-1, "", -1), "disabled-or-export-busy"); - try { - Export export = future.get(Math.max(1, Math.min(timeoutMillis, 1000)), TimeUnit.MILLISECONDS); - boolean current; - synchronized (lock) { current = epoch == generation && enabled; } - if (current) return export; - export.close(); - return memoryExport(snapshot(-1, "", -1), "capture-reset-during-export"); - } catch (Exception error) { - if (!future.cancel(false)) { - // Completion can win the timeout race. The caller still owns cleanup in that case. - try { - Export abandoned = future.getNow(null); - if (abandoned != null) abandoned.close(); - } catch (Exception ignored) { /* The fallback health already marks this export partial. */ } - } - if (error instanceof InterruptedException) Thread.currentThread().interrupt(); - // Use a fresh generation after clear; never return a pre-clear fallback. - return memoryExport(snapshot(-1, "", -1), "writer-timeout-or-error"); - } - } - - private Export memoryExport(Snapshot snapshot, String reason) { - JsonObject health = snapshot.health.deepCopy(); - health.addProperty("completeness", "partial"); health.addProperty("exportFallback", reason); - byte[] content = (header(health) + snapshot.text()).getBytes(StandardCharsets.UTF_8); - String manifest = "# av-diag-manifest {\"schemaVersion\":1,\"hashedBytes\":" + content.length + ",\"sha256\":\"" - + RollingDiagnosticFile.hex(RollingDiagnosticFile.sha256().digest(content)) + "\",\"hashScope\":\"all-preceding-bytes\"}\n"; - byte[] bytes = (new String(content, StandardCharsets.UTF_8) + manifest).getBytes(StandardCharsets.UTF_8); - return new Export(new ByteArrayInputStream(bytes), bytes.length, true, () -> new ByteArrayInputStream(bytes)); - } - - public static String header(JsonObject health) { - return "# WebHTV AV diagnostics schema=1\n# READY / first-frame callback / accepted audio writes do not prove visible video or audible sound.\n" - + "# Completeness covers only the declared retained window and available collectors.\n# av-diag-health " + health + "\n"; - } - - private void runWriter() { - while (true) { - List batch = new ArrayList<>(); - List pins; - boolean clear, restore; - long epoch; - ExportRequest report = null; - synchronized (lock) { - while (!stopped && !clearPending && !restorePending && queue.isEmpty() && exportRequest == null) { - boolean timed = persistence.needsTick(); - try { lock.wait(timed ? 1000 : 0); } catch (InterruptedException ignored) { if (stopped) return; } - if (timed) break; - } - if (stopped) return; - epoch = generation; clear = clearPending; restore = restorePending; - clearPending = restorePending = false; - if (exportRequest != null && exportRequest.future.isCancelled()) exportRequest = null; - if (!clear && !restore && exportRequest != null && (lastProcessedSeq >= exportRequest.target || queue.isEmpty())) { - report = exportRequest; exportRequest = null; - } - int batchBytes = 0; - while (report == null && !queue.isEmpty() && batch.size() < 64 && batchBytes < 128 * 1024) { - Entry entry = queue.removeFirst(); queueBytes -= entry.cost; - batch.add(entry); batchBytes += entry.cost; - } - pins = pinnedLinesLocked(); - } - try { - if (clear) persistence.clear(); - if (restore) { - List history = persistence.restore(limits.memoryBytes); - synchronized (lock) { - if (epoch == generation && enabled) { - restoredHistory = !history.isEmpty(); - // Prior-process records stay explicitly separate from new observations. - ArrayDeque recovered = new ArrayDeque<>(); - int size = 0; - for (String line : history) { - Entry entry = new Entry(0, epoch, line, line.getBytes(StandardCharsets.UTF_8).length + 192, false); - recovered.addLast(entry); size += entry.cost; - } - for (Entry entry : memory) { recovered.addLast(entry); size += entry.cost; } - memory.clear(); memory.addAll(recovered); memoryBytes = size; - while (memoryBytes > limits.memoryBytes && !memory.isEmpty()) memoryBytes -= memory.removeFirst().cost; - version++; - } - } - } - boolean current; - synchronized (lock) { current = epoch == generation; } - if (current && !batch.isEmpty()) { - persistence.append(batch.stream().map(Entry::line).toList(), pins); - synchronized (lock) { if (epoch == generation) lastFlushedSeq = batch.get(batch.size() - 1).seq; } - } - if (report != null && !report.future.isCancelled()) completeExport(report); - if (current) persistence.tick(); - synchronized (lock) { - if (epoch == generation) { - if (!batch.isEmpty()) lastProcessedSeq = batch.get(batch.size() - 1).seq; - persistedBytes = persistence.bytes(); - rotatedSegments = persistence.rotations(); - lastWriterProgressNanos = clock.monotonicNanos(); version++; - lock.notifyAll(); - } - } - } catch (Exception error) { - synchronized (lock) { - if (epoch == generation) { - if (report == null) { writeFailures++; lastWriteError = error.getClass().getSimpleName(); } - else exportFailures++; - if (!batch.isEmpty()) lastProcessedSeq = batch.get(batch.size() - 1).seq; - version++; - } - lock.notifyAll(); - } - if (report != null) report.future.completeExceptionally(error); - } - } - } - - private void completeExport(ExportRequest request) throws IOException { - JsonObject health; - List pins; - synchronized (lock) { - if (request.generation != generation || !enabled) { request.future.cancel(false); return; } - health = healthLocked("retained-disk-segments"); - LinkedHashSet context = new LinkedHashSet<>(pinnedLinesLocked()); - for (Entry entry : memory) context.add(entry.line); - pins = new ArrayList<>(context); - } - Export export = persistence.export(header(health), pins); - boolean discard; - synchronized (lock) { - if (request.generation != generation || !enabled) request.future.cancel(false); - discard = !request.future.complete(export); - } - if (discard) export.close(); - } - - private List pinnedLinesLocked() { return pinned.values().stream().map(Entry::line).toList(); } - - @Override public void close() { - synchronized (lock) { - stopped = true; enabled = false; - if (exportRequest != null) exportRequest.future.cancel(false); - lock.notifyAll(); - } - } -} diff --git a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticReport.java b/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticReport.java deleted file mode 100644 index ebf5a74f04..0000000000 --- a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticReport.java +++ /dev/null @@ -1,221 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.IOException; -import java.io.OutputStream; -import java.io.SequenceInputStream; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Set; -import java.util.regex.Pattern; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -/** Offline evidence reduction over the immutable exported bytes, never over player objects. */ -public final class DiagnosticReport { - private static final Pattern EVENT = Pattern.compile("^(?:# restored-history )?\\d{4}-\\d{2}-\\d{2} [^\\[]+\\[[^\\]]*] av-diag: (\\{.*)$"); - private static final Set FACTS = Set.of("decoderName", "mime", "width", "height", "sampleRate", "channels", "channelMask", - "encoding", "routeType", "routeRole", "volume", "systemVolume", "muted", "focusResult", "focusChange", "effectiveGain", - "stage", "nativeHook", "symptom", "presentation", "videoSelected", "audioSelected", "protectedMedia", "parameter", - "oldValue", "newValue", "note", "source", "backend", "exitReason", "traceAvailable", "lastError"); - private DiagnosticReport() {} - - public static JsonObject parseEvent(String line) { - if (line == null || line.length() > 64_000) return null; - java.util.regex.Matcher matcher = EVENT.matcher(line); - if (!matcher.matches()) return null; - try { - JsonObject event = JsonParser.parseString(matcher.group(1)).getAsJsonObject(); - return event.has("schemaVersion") && event.has("processRunId") && event.has("seq") && event.has("event") ? event : null; - } catch (RuntimeException ignored) { return null; } - } - - public static final class Analysis { - private final LinkedHashMap sessions = new LinkedHashMap<>(); - private JsonObject health = new JsonObject(); - private boolean partial; - private long events; - private Analysis(boolean partial) { this.partial = partial; } - public boolean partial() { return partial; } - public long events() { return events; } - public JsonObject snapshots() { - JsonObject result = new JsonObject(); JsonArray values = new JsonArray(); - for (Session session : sessions.values()) values.add(session.json()); - result.add("sessions", values); result.add("health", health); result.addProperty("reducerBounded", partial); return result; - } - public String text() { - StringBuilder out = new StringBuilder("WebHTV 音视频诊断报告\n"); - out.append("完整性:").append(partial ? "partial(存在丢失、截断或观测缺口)" : "仅声明的采集范围/留存窗口;逐层可用性见下文").append('\n'); - out.append("本报告只使用导出文件内的证据。READY、首帧回调、写入成功均不证明实际屏幕/扬声器正常。\n"); - out.append("结构化事件行数(含固定快照重复项):").append(events).append("\n\n"); - if (sessions.isEmpty()) out.append("没有可关联的播放事件。下一步:开启诊断后原样复现一次并标记症状。\n"); - for (Session session : sessions.values()) { - out.append("播放:").append(session.trace).append(" / ").append(session.instance).append(" / attempt ").append(session.attempt).append('\n'); - out.append("进程:").append(session.run).append(";内核:").append(session.engine).append('\n'); - out.append("视频最后已证实边界:").append(boundary(session.video, true)).append(";证据 ").append(session.videoId).append('\n'); - out.append("音频最后已证实边界:").append(boundary(session.audio, false)).append(";证据 ").append(session.audioId).append('\n'); - for (Map.Entry entry : session.facts.entrySet()) - out.append(entry.getKey()).append(":").append(entry.getValue()).append('\n'); - out.append("失败证据:").append(session.errors.isEmpty() ? "未记录到(不代表无故障)" : session.errors).append('\n'); - out.append("缺少证据:").append(session.video < 0 ? "视频输出未观测;" : "") - .append(session.audio < 0 ? "音频输出未观测;" : "").append("物理显示/实际可闻始终不可直接观测。\n"); - out.append("下一步:").append(session.errors.isEmpty() ? "在原播放参数下标记故障;需要区分内容与输出时主动开启一次限时统计。" - : "围绕上述最早失败操作,仅改变一个相关参数,分别下载修改前后的日志进行比较。").append("\n\n"); - } - return out.toString(); - } - private void accept(JsonObject event) { - events++; - String trace = string(event, "trace", "none"), instance = string(event, "playerInstanceId", "unknown"), run = string(event, "processRunId", "unknown"); - long attempt = number(event, "attemptId", 0), seq = number(event, "seq", 0); - if (attempt <= 0 && "none".equals(trace)) return; - String key = run + "/" + number(event, "captureGeneration", 0) + "/" + trace + "/" + instance + "/" + number(event, "mediaGeneration", 0) + "/" + attempt; - Session session = sessions.get(key); - if (session == null) { - if (sessions.size() >= 16) { sessions.remove(sessions.keySet().iterator().next()); partial = true; } - session = new Session(run, trace, instance, attempt, string(event, "engine", "unknown")); sessions.put(key, session); - } - session.accept(event, seq); - } - } - - private static final class Session { - final String run, trace, instance, engine; - final long attempt; - final LinkedHashMap facts = new LinkedHashMap<>(); - final LinkedHashMap errors = new LinkedHashMap<>(); - int video = -1, audio = -1; - String videoId = "none", audioId = "none"; - Session(String run, String trace, String instance, long attempt, String engine) { - this.run = run; this.trace = trace; this.instance = instance; this.attempt = attempt; this.engine = engine; - } - void accept(JsonObject event, long seq) { - String name = string(event, "event", "unknown"), id = run + ":" + number(event, "captureGeneration", 0) + ":" + seq; - JsonObject observed = object(event, "observed"); - for (String key : FACTS) if (observed.has(key)) { - JsonObject prior = facts.get(key); - if (prior != null && number(prior, "seq", 0) > seq) continue; - JsonObject fact = new JsonObject(); fact.addProperty("seq", seq); fact.addProperty("evidenceClass", string(event, "evidenceClass", "unknown")); - fact.add("fact", observed.get(key).deepCopy()); facts.put(key, fact); - } - int v = (int) factNumber(observed, "videoEvidenceLevel", -1), a = (int) factNumber(observed, "audioEvidenceLevel", -1); - if (name.equals("video.sample.summary") && factNumber(observed, "inputBuffers", 0) > 0) v = Math.max(v, 1); - if (name.equals("video.output.summary")) { - if (factNumber(observed, "outputBuffers", 0) > 0) v = Math.max(v, 2); - if (factNumber(observed, "submittedBuffers", 0) > 0) v = Math.max(v, 3); - } - if (name.equals("video.surface-render-callback") && factNumber(observed, "renderCallbacks", 0) > 0) v = Math.max(v, 4); - if (name.equals("audio.decoder.output") && factNumber(observed, "submittedBuffers", 0) > 0) a = Math.max(a, 2); - if (name.equals("audio.output.write") && factNumber(observed, "acceptedBytes", 0) > 0) a = Math.max(a, 3); - if (name.equals("audio.output.playhead") && factNumber(observed, "headDeltaFrames", 0) > 0) a = Math.max(a, 4); - if (v > video) { video = v; videoId = id; } if (a > audio) { audio = a; audioId = id; } - if (Set.of("error", "fatal").contains(string(event, "level", "info")) || factNumber(observed, "videoPartialFailure", 0) == 1) { - errors.put(id, name + " " + factString(observed, "stage") + " " + factString(observed, "message")); - while (errors.size() > 4) { - java.util.Iterator keys = errors.keySet().iterator(); keys.next(); keys.next(); keys.remove(); - } - } - } - JsonObject json() { - JsonObject result = new JsonObject(); result.addProperty("runId", run); result.addProperty("trace", trace); - result.addProperty("playerInstanceId", instance); result.addProperty("attemptId", attempt); result.addProperty("engine", engine); - result.addProperty("videoEvidenceLevel", video); result.addProperty("audioEvidenceLevel", audio); - JsonObject values = new JsonObject(); facts.forEach(values::add); result.add("facts", values); return result; - } - } - - public static Analysis analyze(InputStream input, boolean partial) throws IOException { - Analysis result = new Analysis(partial); - scan(input, (line, event) -> { - if (event != null) result.accept(event); - else if (line.startsWith("#") && line.contains("\"completeness\"")) { - try { JsonObject value = JsonParser.parseString(line.substring(line.indexOf('{'))).getAsJsonObject(); - if (value.has("completeness")) { result.health = value; if ("partial".equals(string(value, "completeness", "partial"))) result.partial = true; } - } catch (RuntimeException ignored) { result.partial = true; } - } - }); - return result; - } - - public static DiagnosticLogBuffer.Export text(DiagnosticLogBuffer.Export source) { - try (InputStream copy = source.openAgain()) { - Analysis analysis = analyze(copy, source.partial); - // Append: the original manifest's all-preceding-bytes checksum remains valid. - byte[] summary = ("\n# Human-readable report follows the immutable snapshot\n" + analysis.text()).getBytes(StandardCharsets.UTF_8); - return new DiagnosticLogBuffer.Export(new SequenceInputStream(source.input, new ByteArrayInputStream(summary)), - summary.length + source.length, analysis.partial()); - } catch (IOException | RuntimeException ignored) { return source; } - } - - /** Caller owns source until all passes finish; ZIP is streamed, with no second full disk copy. */ - public static void archive(DiagnosticLogBuffer.Export source, OutputStream output) throws IOException { - JsonObject files = new JsonObject(); - try (ZipOutputStream zip = new ZipOutputStream(output)) { - Analysis analysis; - try (InputStream copy = source.openAgain()) { analysis = analyze(copy, source.partial); } - put(zip, files, "report.txt", new ByteArrayInputStream(analysis.text().getBytes(StandardCharsets.UTF_8))); - put(zip, files, "webhtv-debug-log.txt", source.input); - zip.putNextEntry(new ZipEntry("av-events.jsonl")); MessageDigest digest = RollingDiagnosticFile.sha256(); long[] size = {0}; - try (InputStream copy = source.openAgain()) { - scan(copy, (line, event) -> { - if (event == null) return; - byte[] data = (event.toString() + "\n").getBytes(StandardCharsets.UTF_8); zip.write(data); digest.update(data); size[0] += data.length; - }); - } - zip.closeEntry(); metadata(files, "av-events.jsonl", size[0], digest); - put(zip, files, "session-snapshots.json", new ByteArrayInputStream(analysis.snapshots().toString().getBytes(StandardCharsets.UTF_8))); - JsonObject manifest = new JsonObject(); manifest.addProperty("schemaVersion", 1); manifest.addProperty("completeness", analysis.partial() ? "partial" : "declared-window"); - manifest.addProperty("rawMediaIncluded", false); manifest.addProperty("eventsIncludePinnedDuplicates", true); manifest.add("files", files); - zip.putNextEntry(new ZipEntry("manifest.json")); zip.write(manifest.toString().getBytes(StandardCharsets.UTF_8)); zip.closeEntry(); - } - } - - private interface LineConsumer { void accept(String line, JsonObject event) throws IOException; } - private static void scan(InputStream input, LineConsumer consumer) throws IOException { - BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8), 8192); - StringBuilder line = new StringBuilder(); int c; long count = 0; boolean truncated = false; - while ((c = reader.read()) != -1) { - if (++count > 24L * 1024 * 1024) throw new IOException("Diagnostic snapshot exceeds budget"); - if (c == '\n') { String value = line.toString(); consumer.accept(value, truncated ? null : parseEvent(value)); line.setLength(0); truncated = false; } - else if (line.length() < 64_000) line.append((char) c); else truncated = true; - } - if (line.length() > 0) consumer.accept(line.toString(), truncated ? null : parseEvent(line.toString())); - } - - private static void put(ZipOutputStream zip, JsonObject manifest, String name, InputStream input) throws IOException { - zip.putNextEntry(new ZipEntry(name)); MessageDigest digest = RollingDiagnosticFile.sha256(); - byte[] bytes = new byte[8192]; long count = 0; int length; - while ((length = input.read(bytes)) >= 0) { if (length == 0) continue; count += length; - if (count > 24L * 1024 * 1024) throw new IOException("Diagnostic entry exceeds budget"); - zip.write(bytes, 0, length); digest.update(bytes, 0, length); - } - zip.closeEntry(); metadata(manifest, name, count, digest); - } - private static void metadata(JsonObject files, String name, long size, MessageDigest digest) { - JsonObject value = new JsonObject(); value.addProperty("bytes", size); value.addProperty("sha256", RollingDiagnosticFile.hex(digest.digest())); files.add(name, value); - } - private static String boundary(int level, boolean video) { - if (level < 0) return "未观测"; - return switch (Math.min(4, level)) { case 0 -> "轨道/配置"; case 1 -> "输入sample"; case 2 -> "解码输出"; - case 3 -> video ? "提交到输出(不等于实际显示)" : "输出接受payload(不等于实际发声)"; - default -> video ? "系统帧回调(不等于物理显示)" : "音频播放头前进(不等于实际发声)"; }; - } - private static JsonObject object(JsonObject value, String key) { return value.has(key) && value.get(key).isJsonObject() ? value.getAsJsonObject(key) : new JsonObject(); } - private static String string(JsonObject value, String key, String fallback) { try { return value.has(key) ? value.get(key).getAsString() : fallback; } catch (RuntimeException ignored) { return fallback; } } - private static long number(JsonObject value, String key, long fallback) { try { return value.has(key) ? value.get(key).getAsLong() : fallback; } catch (RuntimeException ignored) { return fallback; } } - private static long factNumber(JsonObject observed, String key, long fallback) { - JsonObject fact = object(observed, key); if (!"known".equals(string(fact, "status", "unknown"))) return fallback; - JsonElement value = fact.get("value"); if (value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isBoolean()) return value.getAsBoolean() ? 1 : 0; - return number(fact, "value", fallback); - } - private static String factString(JsonObject observed, String key) { JsonObject fact = object(observed, key); return "known".equals(string(fact, "status", "unknown")) ? string(fact, "value", "unknown") : "unknown"; } -} diff --git a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticText.java b/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticText.java deleted file mode 100644 index dd96f740c3..0000000000 --- a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/DiagnosticText.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import java.net.URI; -import java.util.ArrayList; -import java.util.Collections; -import java.util.IdentityHashMap; -import java.util.List; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** The common boundary for untrusted legacy/native text. Structured fields also use it. */ -public final class DiagnosticText { - public static final int MAX_CHARS = 12_000; - private static final String REDACTED = "[redacted]"; - private static final Pattern URI_TEXT = Pattern.compile("(?i)(?:[a-z][a-z0-9+.-]{1,24}:)?//[^\\s<>\"'\\\\]+|(?:file|content|magnet):[^\\s<>\"']+"); - private static final Pattern HEADER = Pattern.compile("(?im)(?:[\"']?)(?:authorization|proxy-authorization|cookie|set-cookie|referer)(?:[\"']?)\\s*[:=]\\s*[^\\r\\n]*"); - private static final Pattern SECRET = Pattern.compile("(?i)(?]*"); - private static final Pattern ADDRESS = Pattern.compile("(?i)\\b(?:[0-9]{1,3}\\.){3}[0-9]{1,3}\\b|\\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\\b"); - - private DiagnosticText() {} - - public record Clean(String text, boolean truncated) {} - - public static Clean clean(String input) { - if (input == null) return new Clean("", false); - boolean truncated = input.length() > MAX_CHARS; - String value = input.substring(0, Math.min(MAX_CHARS, input.length())).replace("\\/", "/"); - value = HEADER.matcher(value).replaceAll("sensitive-header=" + REDACTED); - value = SECRET.matcher(value).replaceAll("$1" + REDACTED); - value = BEARER.matcher(value).replaceAll(REDACTED); - value = URI_TEXT.matcher(value).replaceAll("uri:" + REDACTED); - value = LOCAL_PATH.matcher(value).replaceAll("path:" + REDACTED); - value = ADDRESS.matcher(value).replaceAll("address:" + REDACTED); - StringBuilder result = new StringBuilder(Math.min(MAX_CHARS, value.length())); - for (int i = 0; i < value.length() && result.length() < MAX_CHARS; i++) { - char c = value.charAt(i); - if (c == '\n') result.append("\\n"); - else if (c == '\r') result.append("\\r"); - else if (c == '\t') result.append("\\t"); - else if (Character.isISOControl(c) || c == '\u2028' || c == '\u2029' || (c >= '\u202a' && c <= '\u202e')) result.append('?'); - else result.append(c); - } - truncated |= result.length() >= MAX_CHARS; - if (truncated) result.append(" [truncated]"); - return new Clean(result.toString(), truncated); - } - - /** Domain suggestions are local memory only, never part of an exported event. */ - public static List origins(String input) { - if (input == null) return List.of(); - String value = input.substring(0, Math.min(MAX_CHARS, input.length())).replace("\\/", "/"); - Matcher matcher = URI_TEXT.matcher(value); - List result = new ArrayList<>(); - while (matcher.find() && result.size() < 16) { - try { - String candidate = matcher.group(); - URI uri = URI.create(candidate.startsWith("//") ? "https:" + candidate : candidate); - String scheme = uri.getScheme(); - String host = uri.getHost(); - if (host != null && ("https".equalsIgnoreCase(scheme) || "http".equalsIgnoreCase(scheme))) { - result.add(scheme.toLowerCase(java.util.Locale.ROOT) + "://" + host + (uri.getPort() < 0 ? "" : ":" + uri.getPort())); - } - } catch (IllegalArgumentException ignored) { - // Malformed input is neither a diagnostic failure nor a proxy rule. - } - } - return result; - } - - public static String throwable(Throwable error) { - StringBuilder result = new StringBuilder(); - Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); - appendThrowable(error, "", 0, visited, result); - return clean(result.toString()).text(); - } - - private static void appendThrowable(Throwable error, String relation, int depth, Set visited, StringBuilder out) { - if (error == null) return; - if (depth >= 8 || visited.size() >= 16 || out.length() >= MAX_CHARS || !visited.add(error)) { - out.append("\n[truncated exception chain/cycle]"); - return; - } - out.append(relation).append(error.getClass().getName()).append(": ").append(clean(error.getMessage()).text()); - StackTraceElement[] stack = error.getStackTrace(); - for (int i = 0; i < Math.min(stack.length, 48) && out.length() < MAX_CHARS; i++) out.append("\n at ").append(stack[i]); - if (stack.length > 48) out.append("\n[truncated stack]"); - Throwable[] suppressed = error.getSuppressed(); - for (int i = 0; i < Math.min(suppressed.length, 4); i++) appendThrowable(suppressed[i], "\nSuppressed: ", depth + 1, visited, out); - if (suppressed.length > 4) out.append("\n[truncated suppressed]"); - appendThrowable(error.getCause(), "\nCaused by: ", depth + 1, visited, out); - } -} diff --git a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/IncidentDiagnosticFile.java b/catvod/src/main/java/com/github/catvod/crawler/diagnostics/IncidentDiagnosticFile.java deleted file mode 100644 index 9429aba116..0000000000 --- a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/IncidentDiagnosticFile.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import com.google.gson.JsonObject; -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.List; - -/** Same log writer owns the bounded pre/post window and three local incident files. */ -public final class IncidentDiagnosticFile implements DiagnosticLogBuffer.Persistence { - private static final int RECENT_LIMIT = 256 << 10, INCIDENT_LIMIT = 768 << 10; - private record Line(String text, long timeMs, int bytes) {} - private final DiagnosticLogBuffer.Persistence delegate; - private final File directory; - private final ArrayDeque recent = new ArrayDeque<>(); - private final ArrayList pending = new ArrayList<>(); - private int recentBytes, pendingBytes; - private long firstMs, markMs, deadlineMs, diskBytes; - private boolean partial; - private volatile boolean active; - private String trigger; - private long checkpointMs; - private final java.util.function.LongSupplier clock; - - public IncidentDiagnosticFile(DiagnosticLogBuffer.Persistence delegate, File directory) { this(delegate, directory, () -> System.nanoTime() / 1_000_000); } - public IncidentDiagnosticFile(DiagnosticLogBuffer.Persistence delegate, File directory, java.util.function.LongSupplier clock) { - this.delegate = delegate; this.directory = directory; this.clock = clock; - } - private File file(int i) { return new File(directory, "webhtv-diagnostic-incident-" + i + ".txt"); } - private File pendingFile() { return new File(file(0).getPath() + ".new"); } - - @Override public void append(List lines, List pinned) throws IOException { - delegate.append(lines, pinned); - for (String text : lines) { - long now = clock.getAsLong(); - JsonObject event = DiagnosticReport.parseEvent(text); - boolean user = event != null && "diag.user-mark".equals(event.get("event").getAsString()); - boolean error = event != null && ("error".equals(event.get("level").getAsString()) || "fatal".equals(event.get("level").getAsString())); - if (user || error && !active && (markMs == 0 || now - markMs >= 30_000)) { - if (active) finish(now, true); - active = true; partial = false; markMs = now; deadlineMs = now + 15_000; checkpointMs = -1; trigger = user ? "user-mark" : "error"; - pending.clear(); pendingBytes = 0; - firstMs = recent.isEmpty() ? now : recent.getFirst().timeMs; - for (String pin : pinned) add(pin); - for (Line line : recent) add(line.text); - partial |= now - firstMs < 30_000; - } - if (active) add(text); - int cost = text.getBytes(StandardCharsets.UTF_8).length + 64; - recent.addLast(new Line(text, now, cost)); recentBytes += cost; - while (!recent.isEmpty() && (recentBytes > RECENT_LIMIT || now - recent.getFirst().timeMs > 30_000)) recentBytes -= recent.removeFirst().bytes; - } - tick(); - } - - private void add(String line) { - int cost = line.getBytes(StandardCharsets.UTF_8).length + 1; - if (pendingBytes + cost > INCIDENT_LIMIT) { partial = true; return; } - pending.add(line); pendingBytes += cost; - } - - @Override public boolean needsTick() { return active || delegate.needsTick(); } - @Override public void tick() throws IOException { - delegate.tick(); long now = clock.getAsLong(); - if (active && now >= deadlineMs) finish(now, false); - else if (active && (checkpointMs < 0 || now - checkpointMs >= 1000)) writePending(now, true); - } - - private void writePending(long now, boolean recording) throws IOException { - if (!directory.isDirectory() && !directory.mkdirs()) throw new IOException("Incident directory unavailable"); - File temp = pendingFile(); - JsonObject meta = new JsonObject(); meta.addProperty("trigger", trigger); meta.addProperty("requestedBeforeMs", 30_000); - meta.addProperty("requestedAfterMs", 15_000); meta.addProperty("retainedBeforeMs", Math.max(0, markMs - firstMs)); - meta.addProperty("retainedAfterMs", Math.max(0, Math.min(now, deadlineMs) - markMs)); - meta.addProperty("completeness", partial || recording ? "partial" : "declared-window"); - meta.addProperty("postWindowPending", recording); - meta.addProperty("windowClock", "writer-arrival-monotonic-ms; original source/enqueue times retained per event"); - try (BufferedWriter writer = new BufferedWriter(new FileWriter(temp))) { - writer.write("# incident-window " + meta + "\n"); - for (String line : pending) { writer.write(line); writer.write('\n'); } - } - checkpointMs = now; updateBytes(); - } - - private void finish(long now, boolean interrupted) throws IOException { - partial |= interrupted; - writePending(now, false); - rotatePending(); active = false; - pending.clear(); pendingBytes = 0; updateBytes(); - } - - private void rotatePending() throws IOException { - delete(file(2)); - for (int i = 1; i >= 0; i--) if (file(i).exists() && !file(i).renameTo(file(i + 1))) throw new IOException("Incident rotation failed"); - if (!pendingFile().renameTo(file(0))) throw new IOException("Incident replacement failed"); - } - - @Override public DiagnosticLogBuffer.Export export(String header, List pinned) throws IOException { - ArrayList context = new ArrayList<>(pinned); int retained = 0; - for (int i = 0; i < 3; i++) if (file(i).isFile()) try (BufferedReader reader = new BufferedReader(new FileReader(file(i)))) { - String line; - while ((line = reader.readLine()) != null) { - retained += line.getBytes(StandardCharsets.UTF_8).length + 1; - if (retained > (512 << 10)) { context.add("# incident-export {\"completeness\":\"partial\",\"reason\":\"incident-export-byte-budget\"}"); break; } - context.add(line); - } - if (retained > (512 << 10)) break; - } - if (active) context.add("# incident-export {\"completeness\":\"partial\",\"reason\":\"post-window-still-recording\"}"); - return delegate.export(header, context); - } - - @Override public List restore(int maxBytes) throws IOException { - // An interrupted post-window already has a partial header and its last checkpoint. - if (pendingFile().isFile()) rotatePending(); - updateBytes(); return delegate.restore(maxBytes); - } - @Override public void clear() throws IOException { - delegate.clear(); active = false; recent.clear(); pending.clear(); recentBytes = pendingBytes = 0; - for (int i = 0; i < 3; i++) delete(file(i)); delete(new File(file(0).getPath() + ".new")); diskBytes = 0; - } - private void updateBytes() { diskBytes = pendingFile().length(); for (int i = 0; i < 3; i++) diskBytes += file(i).length(); } - @Override public long bytes() { return delegate.bytes() + diskBytes; } - @Override public long rotations() { return delegate.rotations(); } - @Override public long extraDiskBudgetBytes() { return delegate.extraDiskBudgetBytes() + (4L << 20); } - private static void delete(File file) throws IOException { if (file.exists() && !file.delete()) throw new IOException("Incident deletion failed"); } -} diff --git a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/JournaledDiagnosticFile.java b/catvod/src/main/java/com/github/catvod/crawler/diagnostics/JournaledDiagnosticFile.java deleted file mode 100644 index c2c87f901f..0000000000 --- a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/JournaledDiagnosticFile.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; - -/** Writer-owned, bounded recovery facts outside cacheDir. No I/O or callbacks on player/HTTP threads. */ -public final class JournaledDiagnosticFile implements DiagnosticLogBuffer.Persistence { - private static final int MAX_BYTES = 64 * 1024; - private static final Set EVENTS = Set.of("diag.session.begin", "play.attempt.end", "diag.error", "mpv.output.failure", - "video.first-output", "video.output.summary", "audio.output.playhead", "play.clock", "process.recovery", "ijk.event"); - private static final Set FACTS = Set.of("videoEvidenceLevel", "audioEvidenceLevel", "headFrames", "headDeltaFrames", - "submittedBuffers", "outputBuffers", "positionMs", "state", "videoPartialFailure", "firstError", "lastError", - "errorId", "domain", "stage", "javaClass", "errorCode", "reason", "missingEvidence"); - private final DiagnosticLogBuffer.Persistence logs; - private final File journal; - private final JsonObject sessions = new JsonObject(); - private String run = "none", recovery = ""; - private long lastWriteNs, evicted; - private long journalBytes; - private boolean dirty; - - public JournaledDiagnosticFile(DiagnosticLogBuffer.Persistence logs, File filesDirectory) { - this.logs = logs; journal = new File(filesDirectory, "webhtv-diagnostics.journal"); - } - - @Override public void clear() throws IOException { - IOException failure = null; - try { logs.clear(); } catch (IOException error) { failure = error; } - sessions.entrySet().clear(); recovery = ""; dirty = false; evicted = 0; journalBytes = 0; - if (journal.exists() && !journal.delete()) failure = new IOException("Diagnostic journal clear failed"); - File temporary = new File(journal.getPath() + ".new"); - if (temporary.exists() && !temporary.delete()) failure = new IOException("Diagnostic journal temporary clear failed"); - if (failure != null) throw failure; - } - - @Override public List restore(int maxBytes) throws IOException { - List result = new ArrayList<>(); - try { result.addAll(logs.restore(maxBytes)); } - catch (IOException error) { result.add("# process.recovery cached timeline read-error; private journal recovery follows"); } - if (!journal.isFile()) return result; - try (FileInputStream input = new FileInputStream(journal)) { - if (journal.length() > MAX_BYTES) throw new IOException("Oversized journal"); - byte[] bytes = new byte[(int) journal.length()]; - journalBytes = bytes.length; - int length = 0, read; - while (length < bytes.length && (read = input.read(bytes, length, bytes.length - length)) > 0) length += read; - JsonObject previous = JsonParser.parseString(new String(bytes, 0, length, StandardCharsets.UTF_8)).getAsJsonObject(); - previous.addProperty("event", "process.recovery"); - previous.addProperty("priorProcessCompleteness", "unknown; last durable facts only"); - sanitize(previous, 0); - recovery = "# recovered-journal " + previous + "\n"; - } catch (IOException | RuntimeException error) { - recovery = "# recovered-journal {\"event\":\"process.recovery\",\"status\":\"read-error\",\"completeness\":\"partial\"}\n"; - } - result.add(recovery.stripTrailing()); - return result; - } - - @Override public void append(List lines, List pinned) throws IOException { - boolean urgent = false; - for (String line : lines) urgent |= capture(line); - IOException failure = null; - try { logs.append(lines, pinned); } catch (IOException error) { failure = error; } - // At most once a second for ordinary progress. Start/end/crash boundaries are durable promptly. - if (dirty && (urgent || System.nanoTime() - lastWriteNs >= 1_000_000_000L)) { - try { save(); } catch (IOException error) { failure = error; } - } - if (failure != null) throw failure; - } - - private boolean capture(String line) { - int start = line.indexOf("av-diag: {"); - if (start < 0) return false; - int eventStart = line.indexOf("\"event\":\"", start); - if (eventStart < 0) return false; - eventStart += 9; - int eventEnd = line.indexOf('"', eventStart); - if (eventEnd < eventStart || !EVENTS.contains(line.substring(eventStart, eventEnd))) return false; - try { - JsonObject event = JsonParser.parseString(line.substring(start + "av-diag: ".length())).getAsJsonObject(); - String name = string(event, "event"); - if (!EVENTS.contains(name)) return false; - String process = string(event, "processRunId"); - if (!process.equals(run)) { run = process; sessions.entrySet().clear(); } - String instance = string(event, "playerInstanceId"); - String trace = string(event, "trace"); - if (("none".equals(trace) || "process".equals(instance)) && !"process.recovery".equals(name)) return false; - String key = instance; - JsonObject state = sessions.has(key) ? sessions.getAsJsonObject(key) : new JsonObject(); - String attempt = string(event, "attemptId"); - if (!attempt.equals(string(state, "attemptId"))) state = new JsonObject(); - for (String field : List.of("trace", "playerInstanceId", "engine", "attemptId", "mediaGeneration", "eventMediaId", "seq", "wallTime")) { - if (event.has(field)) state.add(field, event.get(field).deepCopy()); - } - state.addProperty("lastEvent", name); - if (!state.has("unfinished") || "diag.session.begin".equals(name)) state.addProperty("unfinished", true); - if ("play.attempt.end".equals(name)) state.addProperty("unfinished", false); - if (event.has("observed")) { - JsonObject observed = event.getAsJsonObject("observed"); - for (String field : FACTS) if (observed.has(field)) state.add(field, observed.get(field).deepCopy()); - } - sessions.add(key, state); - while (sessions.size() > 16) { sessions.remove(sessions.keySet().iterator().next()); evicted++; } - dirty = true; - return "diag.session.begin".equals(name) || "play.attempt.end".equals(name) || "process.recovery".equals(name); - } catch (RuntimeException ignored) { return false; } - } - - private void save() throws IOException { - File directory = journal.getParentFile(); - if (!directory.isDirectory() && !directory.mkdirs()) throw new IOException("Diagnostic journal directory unavailable"); - JsonObject root = new JsonObject(); - root.addProperty("schemaVersion", 1); root.addProperty("previousRun", run); root.addProperty("evictedSessions", evicted); - root.addProperty("scope", "last writer-processed facts; unknown unflushed tail"); root.add("sessions", sessions.deepCopy()); - byte[] bytes = root.toString().getBytes(StandardCharsets.UTF_8); - while (bytes.length > MAX_BYTES && sessions.size() > 0) { - sessions.remove(sessions.keySet().iterator().next()); evicted++; - root.add("sessions", sessions.deepCopy()); root.addProperty("evictedSessions", evicted); - bytes = root.toString().getBytes(StandardCharsets.UTF_8); - } - File temporary = new File(journal.getPath() + ".new"); - try (FileOutputStream output = new FileOutputStream(temporary)) { output.write(bytes); } - if (!temporary.renameTo(journal)) throw new IOException("Diagnostic journal replacement failed"); - journalBytes = bytes.length; - dirty = false; lastWriteNs = System.nanoTime(); - } - - private static String string(JsonObject object, String field) { - JsonElement value = object.get(field); return value != null && value.isJsonPrimitive() ? value.getAsString() : "none"; - } - - private static void sanitize(JsonObject object, int depth) { - for (String key : new ArrayList<>(object.keySet())) { - JsonElement value = object.get(key); - if (value.isJsonObject() && depth < 8) sanitize(value.getAsJsonObject(), depth + 1); - else if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { - String safe = DiagnosticText.clean(value.getAsString()).text(); - object.addProperty(key, safe.substring(0, Math.min(256, safe.length()))); - } else if (!value.isJsonPrimitive() && !value.isJsonNull()) object.remove(key); - } - } - - @Override public DiagnosticLogBuffer.Export export(String header, List pinned) throws IOException { - if (dirty) save(); - return logs.export(header + recovery, pinned); - } - @Override public long bytes() { return logs.bytes() + journalBytes; } - @Override public long rotations() { return logs.rotations(); } - @Override public long extraDiskBudgetBytes() { return logs.extraDiskBudgetBytes() + 2L * MAX_BYTES; } -} diff --git a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/RollingDiagnosticFile.java b/catvod/src/main/java/com/github/catvod/crawler/diagnostics/RollingDiagnosticFile.java deleted file mode 100644 index 5885112b98..0000000000 --- a/catvod/src/main/java/com/github/catvod/crawler/diagnostics/RollingDiagnosticFile.java +++ /dev/null @@ -1,208 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import java.io.BufferedOutputStream; -import java.io.BufferedInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.FilterInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.security.DigestOutputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -/** Called exclusively by the log writer, except for closing an immutable export stream. */ -public final class RollingDiagnosticFile implements DiagnosticLogBuffer.Persistence { - public static final String FILE_NAME = "webhtv-debug-log.txt"; - private final File directory; - private final DiagnosticLogBuffer.Limits limits; - private final AtomicBoolean exportLeased = new AtomicBoolean(); - private List savedPins = List.of(); - private long bytes, rotations; - private volatile long exportBytes; - - public RollingDiagnosticFile(File directory, DiagnosticLogBuffer.Limits limits) { this.directory = directory; this.limits = limits; } - private File segment(int index) { return new File(directory, FILE_NAME + (index == 0 ? "" : "." + index)); } - private File pins() { return new File(directory, FILE_NAME + ".pinned"); } - private File exportFile() { return new File(directory, FILE_NAME + ".export"); } - - private void ensureDirectory() throws IOException { - if (directory == null || (!directory.isDirectory() && !directory.mkdirs())) throw new IOException("Diagnostic directory unavailable"); - } - - @Override public void clear() throws IOException { - ensureDirectory(); - for (int i = 0; i < limits.segments(); i++) { delete(segment(i)); delete(new File(segment(i).getPath() + ".new")); } - delete(pins()); delete(new File(pins().getPath() + ".new")); - if (!exportLeased.get()) delete(exportFile()); - savedPins = List.of(); bytes = rotations = 0; - } - - @Override public List restore(int maxBytes) throws IOException { - ensureDirectory(); - if (!exportLeased.get()) delete(exportFile()); - ArrayDeque recent = new ArrayDeque<>(); - int[] cost = {0}; - // Rewrite legacy files before exposing them: old builds may have persisted credentials. - for (int i = limits.segments() - 1; i >= 0; i--) { - List safe = readSanitizedTail(segment(i), limits.segmentBytes()); - if (segment(i).exists()) replace(segment(i), safe); - for (String line : safe) retain(recent, line, maxBytes, cost); - } - List oldPins = readSanitizedTail(pins(), limits.pinnedBytes()); - if (pins().exists()) replace(pins(), oldPins); - for (String line : oldPins) retain(recent, line, maxBytes, cost); - updateBytes(); - return new ArrayList<>(recent); - } - - private List readSanitizedTail(File file, int budget) throws IOException { - if (!file.isFile()) return List.of(); - ArrayDeque result = new ArrayDeque<>(); - int[] cost = {0}; - try (FileInputStream raw = new FileInputStream(file)) { - long offset = Math.max(0, raw.getChannel().size() - budget); - raw.getChannel().position(offset); - InputStream input = new BufferedInputStream(raw); - if (offset > 0) { int c; while ((c = input.read()) != -1 && c != '\n') {} } - ByteArrayOutputStream line = new ByteArrayOutputStream(); - boolean longLine = false; - int c; - while ((c = input.read()) != -1) { - if (c == '\n') { - restoreLine(result, line, longLine, budget, cost); - line.reset(); longLine = false; - } else if (line.size() < 48_000) line.write(c); - else longLine = true; - } - if (line.size() > 0 || longLine) restoreLine(result, line, longLine, budget, cost); - } - return new ArrayList<>(result); - } - - private void restoreLine(ArrayDeque lines, ByteArrayOutputStream bytes, boolean truncated, int budget, int[] cost) { - String line = DiagnosticText.clean(new String(bytes.toByteArray(), StandardCharsets.UTF_8)).text(); - if (line.isEmpty()) return; - if (!line.startsWith("# restored-history ")) line = "# restored-history " + line; - if (truncated) line += " [truncated history]"; - retain(lines, line, budget, cost); - } - - private static void retain(ArrayDeque lines, String line, int budget, int[] cost) { - lines.addLast(line); cost[0] += line.getBytes(StandardCharsets.UTF_8).length + 192; - while (cost[0] > budget && !lines.isEmpty()) cost[0] -= lines.removeFirst().getBytes(StandardCharsets.UTF_8).length + 192; - } - - @Override public void append(List lines, List pinned) throws IOException { - ensureDirectory(); - OutputStream output = null; - try { - long length = segment(0).length(); - for (String line : lines) { - byte[] data = (line + "\n").getBytes(StandardCharsets.UTF_8); - if (data.length > limits.segmentBytes()) throw new IOException("Diagnostic entry exceeds segment budget"); - if (length + data.length > limits.segmentBytes()) { - if (output != null) { output.close(); output = null; } - rotate(); length = 0; - } - if (output == null) output = new BufferedOutputStream(new FileOutputStream(segment(0), true)); - output.write(data); length += data.length; - } - } finally { if (output != null) output.close(); } - if (!savedPins.equals(pinned)) { replace(pins(), pinned); savedPins = List.copyOf(pinned); } - updateBytes(); - } - - private void rotate() throws IOException { - rotations++; - delete(segment(limits.segments() - 1)); - for (int i = limits.segments() - 2; i >= 0; i--) { - File from = segment(i); - if (from.exists() && !from.renameTo(segment(i + 1))) throw new IOException("Diagnostic rotation failed"); - } - } - - private void replace(File target, List lines) throws IOException { - File temp = new File(target.getPath() + ".new"); - try (OutputStream output = new BufferedOutputStream(new FileOutputStream(temp))) { - for (String line : lines) output.write((line + "\n").getBytes(StandardCharsets.UTF_8)); - } - if (!temp.renameTo(target)) throw new IOException("Diagnostic snapshot replacement failed"); - } - - @Override public DiagnosticLogBuffer.Export export(String header, List pinned) throws IOException { - ensureDirectory(); - if (!exportLeased.compareAndSet(false, true)) throw new IOException("Diagnostic export already in use"); - File report = exportFile(); - try { - MessageDigest digest = sha256(); - long count = 0; - try (OutputStream file = new BufferedOutputStream(new FileOutputStream(report))) { - DigestOutputStream hashed = new DigestOutputStream(file, digest); - byte[] preamble = (header + "# Pinned snapshots and cached capture window (may also occur in timeline)\n" + String.join("\n", pinned) + "\n# Retained timeline\n").getBytes(StandardCharsets.UTF_8); - hashed.write(preamble); count += preamble.length; - byte[] buffer = new byte[8192]; - for (int i = limits.segments() - 1; i >= 0; i--) { - if (!segment(i).isFile()) continue; - try (InputStream input = new FileInputStream(segment(i))) { - int read; - long copied = 0; - while ((read = input.read(buffer, 0, (int) Math.min(buffer.length, limits.segmentBytes() - copied))) > 0) { - hashed.write(buffer, 0, read); count += read; copied += read; - } - } - } - hashed.flush(); - file.write(("# av-diag-manifest {\"schemaVersion\":1,\"hashedBytes\":" + count + ",\"sha256\":\"" + hex(digest.digest()) - + "\",\"hashScope\":\"all-preceding-bytes\"}\n").getBytes(StandardCharsets.UTF_8)); - } - InputStream stream = new FilterInputStream(new FileInputStream(report)) { - private boolean closed; - @Override public void close() throws IOException { - if (closed) return; - closed = true; - try { super.close(); } finally { - try { delete(report); } finally { exportBytes = 0; exportLeased.set(false); } - } - } - }; - exportBytes = report.length(); - return new DiagnosticLogBuffer.Export(stream, exportBytes, header.contains("\"completeness\":\"partial\""), () -> new FileInputStream(report)); - } catch (IOException | RuntimeException error) { - try { delete(report); } catch (IOException ignored) {} - exportLeased.set(false); - throw error; - } - } - - private void updateBytes() { - bytes = pins().length(); - for (int i = 0; i < limits.segments(); i++) bytes += segment(i).length(); - } - @Override public long bytes() { return bytes + exportBytes; } - @Override public long extraDiskBudgetBytes() { return (long) limits.segmentBytes() * limits.segments() + limits.memoryBytes() + limits.pinnedBytes() + (640L << 10); } - @Override public long rotations() { return rotations; } - - static MessageDigest sha256() { - try { return MessageDigest.getInstance("SHA-256"); } - catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } - } - - static String hex(byte[] bytes) { - StringBuilder text = new StringBuilder(bytes.length * 2); - for (byte value : bytes) text.append(Character.forDigit((value >>> 4) & 15, 16)).append(Character.forDigit(value & 15, 16)); - return text.toString(); - } - - private static void delete(File file) throws IOException { - if (file.exists() && !file.delete()) throw new IOException("Diagnostic file deletion failed"); - } -} diff --git a/catvod/src/test/java/com/github/catvod/crawler/diagnostics/CrawlerLogCategoryTest.java b/catvod/src/test/java/com/github/catvod/crawler/diagnostics/CrawlerLogCategoryTest.java deleted file mode 100644 index c1926c547c..0000000000 --- a/catvod/src/test/java/com/github/catvod/crawler/diagnostics/CrawlerLogCategoryTest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class CrawlerLogCategoryTest { - @Test public void runtimeOutputUsesTheExistingNetworkSwitch() { - for (String tag : new String[]{"quickjs", "python-spider", "SpiderDebug"}) { - DiagnosticCategories.Category category = DiagnosticCategories.text(tag); - assertEquals(DiagnosticCategories.Category.NETWORK, category); - assertTrue(DiagnosticCategories.accepts(DiagnosticCategories.ALL, category)); - assertFalse(DiagnosticCategories.accepts(DiagnosticCategories.ALL & ~2, category)); - assertTrue(DiagnosticCategories.accepts(2, category)); - } - } -} diff --git a/catvod/src/test/java/com/github/catvod/crawler/diagnostics/DiagnosticExportAccessTest.java b/catvod/src/test/java/com/github/catvod/crawler/diagnostics/DiagnosticExportAccessTest.java deleted file mode 100644 index bb054c2d88..0000000000 --- a/catvod/src/test/java/com/github/catvod/crawler/diagnostics/DiagnosticExportAccessTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Set; -import java.util.concurrent.atomic.AtomicLong; -import java.util.zip.ZipInputStream; -import static org.junit.Assert.*; - -public class DiagnosticExportAccessTest { - @Rule public TemporaryFolder temp = new TemporaryFolder(); - private static final DiagnosticLogBuffer.Limits LIMITS = new DiagnosticLogBuffer.Limits(8192, 8192, 2048, 8192, 4); - - @Test public void actionsNeedNoPairingButRemainRateLimited() { - AtomicLong now = new AtomicLong(10_000); - DiagnosticAccess access = new DiagnosticAccess(now::get); - for (int i = 0; i < 5; i++) assertTrue(access.allowAction()); - for (int i = 0; i < 100; i++) assertFalse(access.allowAction()); - now.addAndGet(999); assertFalse(access.allowAction()); - now.incrementAndGet(); assertTrue(access.allowAction()); - now.set(0); // A changed clock origin must not lock the controls indefinitely. - for (int i = 0; i < 5; i++) assertTrue(access.allowAction()); - assertFalse(access.allowAction()); - } - - @Test public void controlsRequireAnExplicitSameOriginAndKnownDeviceHost() { - String host = "192.168.1.2:9978"; - Set hosts = Set.of(host, "localhost:9978", "[::1]:9978"); - assertTrue(DiagnosticAccess.sameOrigin("http://" + host, host, hosts)); - assertTrue(DiagnosticAccess.sameOrigin("http://LOCALHOST:9978", "localhost:9978", hosts)); - assertTrue(DiagnosticAccess.sameOrigin("http://[::1]:9978", "[::1]:9978", hosts)); - for (String origin : new String[]{null, "", "null", "http://evil.test", "http://192.168.1.2", - "https://" + host, "http://" + host + "/", "http://" + host + "?x=1", "http://" + host + "#x", - "http://user@" + host, "http://" + host + ".evil.test", "http://[broken", "http://" + host + " http://evil.test"}) { - assertFalse("Rejected origin: " + origin, DiagnosticAccess.sameOrigin(origin, host, hosts)); - } - assertFalse(DiagnosticAccess.sameOrigin("http://evil.test", "evil.test", hosts)); - assertFalse(DiagnosticAccess.sameOrigin("http://" + host, null, hosts)); - } - - @Test public void zipUsesOneImmutableSnapshotAndEveryEntryHasMatchingHash() throws Exception { - RollingDiagnosticFile file = new RollingDiagnosticFile(temp.newFolder(), LIMITS); - file.append(List.of(line("video.output.summary", 1), line("audio.output.write", 2)), List.of()); - try (var export = file.export("# fixture\n", List.of())) { - byte[] original; - try (var input = export.openAgain()) { original = input.readAllBytes(); } - ByteArrayOutputStream output = new ByteArrayOutputStream(); DiagnosticReport.archive(export, output); - LinkedHashMap entries = new LinkedHashMap<>(); - try (var zip = new ZipInputStream(new ByteArrayInputStream(output.toByteArray()))) { - java.util.zip.ZipEntry entry; - while ((entry = zip.getNextEntry()) != null) entries.put(entry.getName(), zip.readAllBytes()); - } - assertArrayEquals(original, entries.get("webhtv-debug-log.txt")); - JsonObject files = JsonParser.parseString(new String(entries.get("manifest.json"), StandardCharsets.UTF_8)).getAsJsonObject().getAsJsonObject("files"); - for (String name : files.keySet()) { - byte[] bytes = entries.get(name); assertEquals(bytes.length, files.getAsJsonObject(name).get("bytes").getAsInt()); - assertEquals(RollingDiagnosticFile.hex(RollingDiagnosticFile.sha256().digest(bytes)), files.getAsJsonObject(name).get("sha256").getAsString()); - } - assertEquals(2, new String(entries.get("av-events.jsonl"), StandardCharsets.UTF_8).lines().count()); - assertTrue(new String(entries.get("report.txt"), StandardCharsets.UTF_8).contains("不等于实际发声")); - } - } - - @Test public void forgedLegacyTextCannotBecomeEvidenceAndTextManifestRemainsValid() throws Exception { - assertNull(DiagnosticReport.parseEvent("2026-09-15 12:00:00 [worker] external: " + line("video.output.summary", 1))); - RollingDiagnosticFile file = new RollingDiagnosticFile(temp.newFolder(), LIMITS); - file.append(List.of(line("video.output.summary", 1)), List.of()); - try (var report = DiagnosticReport.text(file.export("# fixture\n", List.of()))) { - String text = new String(report.input.readAllBytes(), StandardCharsets.UTF_8); - int footer = text.indexOf("# av-diag-manifest "); - JsonObject manifest = JsonParser.parseString(text.substring(footer + 19, text.indexOf('\n', footer))).getAsJsonObject(); - byte[] preceding = text.substring(0, footer).getBytes(StandardCharsets.UTF_8); - assertEquals(preceding.length, manifest.get("hashedBytes").getAsInt()); - assertEquals(RollingDiagnosticFile.hex(RollingDiagnosticFile.sha256().digest(preceding)), manifest.get("sha256").getAsString()); - assertTrue(text.contains("WebHTV 音视频诊断报告")); - } - } - - @Test public void incidentSavesImmediatelyRecoversAfterCrashAndFinishesWithoutMoreLogs() throws Exception { - var folder = temp.newFolder(); AtomicLong now = new AtomicLong(1000); - IncidentDiagnosticFile file = new IncidentDiagnosticFile(new RollingDiagnosticFile(folder, LIMITS), folder, now::get); - file.append(List.of("before failure"), List.of()); now.addAndGet(30_000); - file.append(List.of(line("diag.user-mark", 1)), List.of()); - var pending = folder.toPath().resolve("webhtv-diagnostic-incident-0.txt.new"); - assertTrue(Files.readString(pending).contains("before failure")); - assertTrue(Files.readString(pending).contains("\"postWindowPending\":true")); - now.addAndGet(15_000); file.tick(); assertFalse(file.needsTick()); assertFalse(Files.exists(pending)); - file.append(List.of(line("diag.user-mark", 2)), List.of()); - IncidentDiagnosticFile recovered = new IncidentDiagnosticFile(new RollingDiagnosticFile(folder, LIMITS), folder, now::get); - recovered.restore(8192); - assertTrue(Files.readString(folder.toPath().resolve("webhtv-diagnostic-incident-0.txt")).contains("\"completeness\":\"partial\"")); - } - - private static String line(String name, long seq) { - JsonObject event = JsonParser.parseString(new DiagnosticEvent(name, "trace-1", "player-1", 1, 1) - .observed("submittedBuffers", 2).observed("acceptedBytes", 8).json()).getAsJsonObject(); - event.addProperty("processRunId", "run-1"); event.addProperty("captureGeneration", 1); event.addProperty("seq", seq); - return "2026-09-15 12:00:00.000 [fixture] av-diag: " + event; - } -} diff --git a/catvod/src/test/java/com/github/catvod/crawler/diagnostics/DiagnosticLogBufferTest.java b/catvod/src/test/java/com/github/catvod/crawler/diagnostics/DiagnosticLogBufferTest.java deleted file mode 100644 index 6debd328ea..0000000000 --- a/catvod/src/test/java/com/github/catvod/crawler/diagnostics/DiagnosticLogBufferTest.java +++ /dev/null @@ -1,183 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import org.junit.After; -import org.junit.Test; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -import static org.junit.Assert.*; - -public class DiagnosticLogBufferTest { - private final List buffers = new ArrayList<>(); - private static final DiagnosticLogBuffer.Clock CLOCK = new DiagnosticLogBuffer.Clock() { - public long wallMillis() { return System.currentTimeMillis(); } - public long monotonicNanos() { return System.nanoTime(); } - }; - private static final DiagnosticLogBuffer.Limits LIMITS = new DiagnosticLogBuffer.Limits(8192, 8192, 8192, 8192, 4); - - @After public void close() { buffers.forEach(DiagnosticLogBuffer::close); } - - private DiagnosticLogBuffer buffer(Store store) { - DiagnosticLogBuffer buffer = new DiagnosticLogBuffer(LIMITS, store, CLOCK); - buffers.add(buffer); buffer.start(false); return buffer; - } - - @Test public void writerStallDoesNotBlockProducersAndLossIsReported() throws Exception { - Store store = new Store(); store.block = true; - DiagnosticLogBuffer buffer = buffer(store); - buffer.event(new DiagnosticEvent("diag.session.begin", "p-a-1", "controller-1", 1, 0).pin("start")); - assertTrue(store.entered.await(1, TimeUnit.SECONDS)); - try { - for (int i = 0; i < 200; i++) buffer.add("periodic", "sample=" + i + "x".repeat(120), false); - buffer.event(new DiagnosticEvent("play.attempt.end", "p-a-1", "controller-1", 1, 1).observed("reason", "critical-end").pin("end")); - var snapshot = buffer.snapshot(0, buffer.runId(), buffer.generation()); - assertTrue(snapshot.gap()); assertTrue(snapshot.health().get("droppedNormal").getAsLong() > 0); - assertTrue(snapshot.health().get("queueBytes").getAsInt() <= LIMITS.queueBytes()); - assertTrue(snapshot.health().get("memoryBytes").getAsInt() <= LIMITS.memoryBytes()); - assertTrue(snapshot.text().contains("diag.session.begin")); - assertTrue(snapshot.text().contains("critical-end")); - try (var report = buffer.export(20)) { - assertTrue(report.partial); - assertTrue(new String(report.input.readAllBytes(), StandardCharsets.UTF_8).contains("writer-timeout-or-error")); - } - } finally { store.release.countDown(); } - try (var report = buffer.export(1000)) { assertTrue(new String(report.input.readAllBytes(), StandardCharsets.UTF_8).contains("critical-end")); } - } - - @Test public void clearWhileWriteIsInFlightCannotResurrectHistory() throws Exception { - Store store = new Store(); store.block = true; - DiagnosticLogBuffer buffer = buffer(store); - buffer.add("test", "old-record", false); - assertTrue(store.entered.await(1, TimeUnit.SECONDS)); - try { - long oldGeneration = buffer.generation(); - buffer.clear(); buffer.add("test", "new-record", false); - var reset = buffer.snapshot(1, buffer.runId(), oldGeneration); - assertTrue(reset.reset()); assertFalse(reset.text().contains("old-record")); - } finally { store.release.countDown(); } - try (var report = buffer.export(1000)) { - String text = new String(report.input.readAllBytes(), StandardCharsets.UTF_8); - assertFalse(text.contains("old-record")); assertTrue(text.contains("new-record")); - } - assertFalse(String.join("\n", store.lines).contains("old-record")); - } - - @Test public void failingDiskStillExportsCachedEvidenceAndWatermark() throws Exception { - Store store = new Store(); store.fail = true; - DiagnosticLogBuffer buffer = buffer(store); - buffer.add("error", "write-failure-context", true); - try (var report = buffer.export(1000)) { - String text = new String(report.input.readAllBytes(), StandardCharsets.UTF_8); - assertTrue(text.contains("write-failure-context")); - assertTrue(text.contains("\"writeFailures\":1")); - assertTrue(text.contains("\"lastFlushedSeq\":0")); - assertTrue(text.contains("\"completeness\":\"partial\"")); - } - } - - @Test public void cursorReturnsOnlyNewRecordsAndSeparatesProcessAndClearEpoch() throws Exception { - Store store = new Store(); DiagnosticLogBuffer buffer = buffer(store); - buffer.add("test", "first", false); - var initial = buffer.snapshot(-1, "", -1); - buffer.add("test", "second", false); - var next = buffer.snapshot(initial.newestSeq(), initial.runId(), initial.generation()); - assertFalse(next.reset()); assertFalse(next.gap()); - assertFalse(next.text().contains("first")); assertTrue(next.text().contains("second")); - assertTrue(buffer.snapshot(next.newestSeq(), "different-process", next.generation()).reset()); - buffer.disable(); - assertTrue(buffer.snapshot(next.newestSeq(), next.runId(), next.generation()).reset()); - assertTrue(buffer.origins().isEmpty()); - assertTrue(buffer.snapshot(-1, "", -1).lines().isEmpty()); - } - - @Test public void timedOutExportReleasesLateFileAndDoesNotHoldProducerLock() throws Exception { - Store store = new Store(); store.blockExport = true; - DiagnosticLogBuffer buffer = buffer(store); - buffer.add("test", "before-export", false); - try { - try (var report = buffer.export(20)) { assertTrue(report.partial); } - assertTrue(store.exportEntered.await(1, TimeUnit.SECONDS)); - buffer.add("test", "while-export-is-blocked", false); - assertTrue(buffer.snapshot(-1, "", -1).text().contains("while-export-is-blocked")); - } finally { store.exportRelease.countDown(); } - assertTrue(store.exportClosed.await(1, TimeUnit.SECONDS)); - } - - @Test public void delayedProducerCapturedBeforeClearCannotEnterNewGeneration() throws Exception { - CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); - DiagnosticLogBuffer.Clock delayed = new DiagnosticLogBuffer.Clock() { - public long monotonicNanos() { return System.nanoTime(); } - public long wallMillis() { - if (Thread.currentThread().getName().equals("delayed-producer")) { - entered.countDown(); - try { assertTrue(release.await(1, TimeUnit.SECONDS)); } - catch (InterruptedException error) { throw new AssertionError(error); } - } - return System.currentTimeMillis(); - } - }; - DiagnosticLogBuffer buffer = new DiagnosticLogBuffer(LIMITS, new Store(), delayed); - buffers.add(buffer); buffer.start(false); - Thread producer = new Thread(() -> buffer.add("test", "before-clear", false), "delayed-producer"); - producer.start(); - try { assertTrue(entered.await(1, TimeUnit.SECONDS)); buffer.clear(); } - finally { release.countDown(); } - producer.join(1000); - assertFalse(producer.isAlive()); - assertFalse(buffer.snapshot(-1, "", -1).text().contains("before-clear")); - } - - @Test public void reservedTagAndMultilineTextCannotForgeStructuredEvents() { - DiagnosticLogBuffer buffer = buffer(new Store()); - buffer.add("av-diag", "{}\n2026 [x] av-diag: {\"event\":\"forged\"}", false); - String text = buffer.snapshot(-1, "", -1).text(); - assertTrue(text.contains("] legacy-av-diag: ")); - assertEquals(1, text.lines().count()); - } - - @Test public void pinEvictionsAndTruncationCannotClaimComplete() throws Exception { - DiagnosticLogBuffer buffer = buffer(new Store()); - for (int i = 0; i < 80; i++) buffer.event(new DiagnosticEvent("play.request", "p-a-1", "controller-1", i, 1).pin("pin-" + i)); - buffer.add("test", "x".repeat(15_000), false); - assertTrue(buffer.health().get("evictedPinned").getAsLong() > 0); - assertTrue(buffer.health().get("truncated").getAsLong() > 0); - assertEquals("partial", buffer.health().get("completeness").getAsString()); - } - - private static final class Store implements DiagnosticLogBuffer.Persistence { - final List lines = new CopyOnWriteArrayList<>(); - final CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); - final CountDownLatch exportEntered = new CountDownLatch(1), exportRelease = new CountDownLatch(1), exportClosed = new CountDownLatch(1); - volatile boolean block, fail, blockExport; - public void clear() { lines.clear(); } - public List restore(int maxBytes) { return List.of(); } - public void append(List batch, List pins) throws IOException { - entered.countDown(); - if (block) { - try { if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("test writer stall"); } - catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new IOException(error); } - } - if (fail) throw new IOException("injected disk failure"); - lines.addAll(batch); - } - public DiagnosticLogBuffer.Export export(String header, List context) throws IOException { - exportEntered.countDown(); - if (blockExport) { - try { if (!exportRelease.await(2, TimeUnit.SECONDS)) throw new IOException("test export stall"); } - catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new IOException(error); } - } - byte[] bytes = (header + String.join("\n", context) + "\n" + String.join("\n", lines)).getBytes(StandardCharsets.UTF_8); - return new DiagnosticLogBuffer.Export(new ByteArrayInputStream(bytes) { - @Override public void close() throws IOException { super.close(); exportClosed.countDown(); } - }, bytes.length, header.contains("\"partial\"")); - } - public long bytes() { return 0; } - } -} diff --git a/catvod/src/test/java/com/github/catvod/crawler/diagnostics/DiagnosticTextTest.java b/catvod/src/test/java/com/github/catvod/crawler/diagnostics/DiagnosticTextTest.java deleted file mode 100644 index 727bfc37da..0000000000 --- a/catvod/src/test/java/com/github/catvod/crawler/diagnostics/DiagnosticTextTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import com.google.gson.JsonParser; -import org.junit.Test; -import static org.junit.Assert.*; - -public class DiagnosticTextTest { - @Test public void redactsCredentialsUrisAndControlCharactersBeforeStorage() { - String input = "request=https://user:pass@example.test/path/secret?token=URL_SECRET#fragment\n" - + "Cookie: sid=COOKIE_SECRET; more=value with spaces\r\nAuthorization: Bearer AUTH_SECRET\n" - + "{\"access_token\":\"JSON_SECRET\",\"password\":\"PASS_SECRET\"}\n" - + "at /storage/emulated/0/private/movie.mkv 192.168.1.42\nFORGED [thread] av-diag: {}\u0000"; - String safe = DiagnosticText.clean(input).text(); - for (String secret : new String[]{"URL_SECRET", "COOKIE_SECRET", "AUTH_SECRET", "JSON_SECRET", "PASS_SECRET", "movie.mkv", "192.168.1.42", "example.test"}) assertFalse(secret, safe.contains(secret)); - assertFalse(safe.contains("\n")); assertFalse(safe.contains("\r")); assertFalse(safe.contains("\u0000")); - assertTrue(safe.contains("\\n")); - } - - @Test public void localSuggestionsRetainOnlyOrigins() { - assertEquals(java.util.List.of("https://example.test:8443"), DiagnosticText.origins("https://user:pass@example.test:8443/private?token=SECRET")); - assertFalse(DiagnosticText.clean("https://example.test/private").text().contains("example.test")); - } - - @Test public void exceptionsKeepTypesAndCausesWithBoundedText() { - IllegalStateException inner = new IllegalStateException("token=INNER_SECRET"); - RuntimeException outer = new RuntimeException("https://host/private?access_token=OUTER_SECRET", inner); - inner.addSuppressed(outer); - String text = DiagnosticText.throwable(outer); - assertTrue(text.contains("IllegalStateException")); assertTrue(text.contains("Caused by")); - assertTrue(text.contains("truncated exception chain/cycle")); - assertFalse(text.contains("INNER_SECRET")); assertFalse(text.contains("OUTER_SECRET")); - assertTrue(text.length() < 13000); - } - - @Test(timeout = 3000) public void longUnstructuredTextIsBounded() { - DiagnosticText.Clean clean = DiagnosticText.clean("a".repeat(100_000)); - assertTrue(clean.truncated()); assertTrue(clean.text().length() < 13000); - } - - @Test public void structuredFactsDistinguishFalseZeroAndUnknown() { - DiagnosticEvent event = new DiagnosticEvent("play.lifecycle", "p-a-1", "controller-1", 1, 1) - .observed("closed", false).observed("elapsedMs", 0).observed("decode", Double.NaN) - .unknown("audio", DiagnosticEvent.Status.NOT_COLLECTED).unknown("audibility", DiagnosticEvent.Status.NOT_OBSERVABLE); - var observed = JsonParser.parseString(event.json()).getAsJsonObject().getAsJsonObject("observed"); - assertFalse(observed.getAsJsonObject("closed").get("value").getAsBoolean()); - assertEquals(0, observed.getAsJsonObject("elapsedMs").get("value").getAsInt()); - assertFalse(observed.getAsJsonObject("decode").has("value")); - assertFalse(observed.getAsJsonObject("audio").has("value")); - assertEquals("not-observable", observed.getAsJsonObject("audibility").get("status").getAsString()); - } - - @Test(expected = IllegalArgumentException.class) public void schemaRejectsArbitrarySensitiveFields() { - new DiagnosticEvent("env.device", "none", "process", 0, 0).observed("Authorization", "SECRET"); - } - - @Test(expected = IllegalArgumentException.class) public void schemaRejectsUnknownEvents() { - new DiagnosticEvent("untrusted.event", "none", "process", 0, 0); - } -} diff --git a/catvod/src/test/java/com/github/catvod/crawler/diagnostics/RollingDiagnosticFileTest.java b/catvod/src/test/java/com/github/catvod/crawler/diagnostics/RollingDiagnosticFileTest.java deleted file mode 100644 index 929fcc7350..0000000000 --- a/catvod/src/test/java/com/github/catvod/crawler/diagnostics/RollingDiagnosticFileTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.github.catvod.crawler.diagnostics; - -import com.google.gson.JsonParser; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.List; - -import static org.junit.Assert.*; - -public class RollingDiagnosticFileTest { - @Rule public TemporaryFolder temp = new TemporaryFolder(); - private static final DiagnosticLogBuffer.Limits LIMITS = new DiagnosticLogBuffer.Limits(8192, 8192, 2048, 2048, 4); - - @Test public void rotationPinsAndExportChecksumSurviveLongCapture() throws Exception { - File directory = temp.newFolder(); - RollingDiagnosticFile disk = new RollingDiagnosticFile(directory, LIMITS); - List pins = List.of("pinned-original-config"); - for (int i = 0; i < 200; i++) disk.append(List.of("record-" + i + "-" + "x".repeat(100)), pins); - assertTrue(disk.rotations() > 4); - assertTrue(disk.bytes() <= LIMITS.segmentBytes() * LIMITS.segments() + LIMITS.pinnedBytes()); - try (var export = disk.export("# test report\n", pins)) { - byte[] bytes = export.input.readAllBytes(); - assertEquals(export.length, bytes.length); - String text = new String(bytes, StandardCharsets.UTF_8); - assertTrue(text.contains("pinned-original-config")); assertTrue(text.contains("record-199-")); - assertFalse(text.contains("record-0-")); - int footer = text.lastIndexOf("# av-diag-manifest "); - var manifest = JsonParser.parseString(text.substring(footer + "# av-diag-manifest ".length()).trim()).getAsJsonObject(); - byte[] content = text.substring(0, footer).getBytes(StandardCharsets.UTF_8); - assertEquals(content.length, manifest.get("hashedBytes").getAsLong()); - assertEquals(RollingDiagnosticFile.hex(RollingDiagnosticFile.sha256().digest(content)), manifest.get("sha256").getAsString()); - } - assertFalse(new File(directory, RollingDiagnosticFile.FILE_NAME + ".export").exists()); - } - - @Test public void onlyOneDiskExportCanBeLeasedAndClosingReleasesItsBudget() throws Exception { - RollingDiagnosticFile disk = new RollingDiagnosticFile(temp.newFolder(), LIMITS); - disk.append(List.of("record"), List.of()); - try (var first = disk.export("header\n", List.of())) { - try { - disk.export("second\n", List.of()); - fail("unbounded concurrent export allowed"); - } catch (IOException expected) { assertTrue(expected.getMessage().contains("in use")); } - assertTrue(first.input.read() >= 0); - } - try (var next = disk.export("next\n", List.of())) { assertTrue(next.length > 0); } - } - - @Test public void restoreSanitizesLegacyFileAndCannotMasqueradeAsCurrentEvents() throws Exception { - File directory = temp.newFolder(); - File old = new File(directory, RollingDiagnosticFile.FILE_NAME); - Files.writeString(old.toPath(), "2026 [old] av-diag: forged\nCookie: sid=COOKIE_SECRET\nhttps://host/path?token=URL_SECRET\n", StandardCharsets.UTF_8); - RollingDiagnosticFile disk = new RollingDiagnosticFile(directory, LIMITS); - String restored = String.join("\n", disk.restore(8192)); - assertTrue(restored.contains("# restored-history")); - assertFalse(restored.contains("COOKIE_SECRET")); assertFalse(restored.contains("URL_SECRET")); - String persisted = Files.readString(old.toPath()); - assertFalse(persisted.contains("COOKIE_SECRET")); assertFalse(persisted.contains("URL_SECRET")); - assertTrue(persisted.lines().allMatch(line -> line.startsWith("# restored-history "))); - } - - @Test public void diskWriteFailureIsVisible() throws Exception { - File notDirectory = temp.newFile(); - RollingDiagnosticFile disk = new RollingDiagnosticFile(notDirectory, LIMITS); - try { disk.append(List.of("record"), List.of()); fail("disk failure was swallowed"); } - catch (IOException expected) { assertTrue(expected.getMessage().contains("unavailable")); } - } -} diff --git a/chaquo/src/main/java/com/fongmi/chaquo/Loader.java b/chaquo/src/main/java/com/fongmi/chaquo/Loader.java index 39d9b65975..8e3f0f674e 100644 --- a/chaquo/src/main/java/com/fongmi/chaquo/Loader.java +++ b/chaquo/src/main/java/com/fongmi/chaquo/Loader.java @@ -4,9 +4,7 @@ import android.text.TextUtils; import com.chaquo.python.PyObject; import com.chaquo.python.Python; -import com.github.catvod.crawler.SpiderDebug; import com.github.catvod.net.OkHttp; -import com.github.catvod.utils.Asset; import com.github.catvod.utils.Path; import com.github.catvod.utils.Util; @@ -21,11 +19,6 @@ public class Loader { public Loader() { if (!Python.isStarted()) Python.start(Platform.create()); - try { - Python.getInstance().getModule("webhtv_logging").callAttr("install"); - } catch (RuntimeException error) { - SpiderDebug.log("python-spider", error); - } app = Python.getInstance().getModule("app"); } @@ -37,11 +30,6 @@ public class Loader { } private String source(String api, String name) { - if (api.startsWith("assets://")) { - String source = Asset.read(api); - if (TextUtils.isEmpty(source)) throw new IllegalStateException("Empty asset python script: " + api); - return source; - } if (!api.startsWith("http")) return api; File cache = Path.py(name); try (Response response = OkHttp.newCall(OkHttp.client(15000), api).execute()) { diff --git a/chaquo/src/main/java/com/fongmi/chaquo/Spider.java b/chaquo/src/main/java/com/fongmi/chaquo/Spider.java index 708a5a0add..074caf88e0 100644 --- a/chaquo/src/main/java/com/fongmi/chaquo/Spider.java +++ b/chaquo/src/main/java/com/fongmi/chaquo/Spider.java @@ -115,18 +115,6 @@ public class Spider extends com.github.catvod.crawler.Spider { return toStr(app.callAttr("action", obj, action)); } - public String subtitleInit(String config) { - return toStr(app.callAttr("subtitle_init", obj, config == null ? "" : config)); - } - - public String subtitleSearch(String request) { - return toStr(app.callAttr("subtitle_search", obj, request)); - } - - public String subtitleResolve(String request) { - return toStr(app.callAttr("subtitle_resolve", obj, request)); - } - @Override public void destroy() { try { diff --git a/chaquo/src/main/python/app.py b/chaquo/src/main/python/app.py index 3ae8a150f7..78cd521b2f 100644 --- a/chaquo/src/main/python/app.py +++ b/chaquo/src/main/python/app.py @@ -1,18 +1,24 @@ -import requests import os -import textwrap +import requests from importlib.machinery import SourceFileLoader import json -def spider(cache, source, file_name=None): - name = file_name or os.path.basename(source) +def spider(cache, api, file_name=None): + name = file_name or os.path.basename(api) path = cache + '/' + name - writeFile(path, textwrap.dedent(source).encode()) + download(path, api) name = name.split('.')[0] return SourceFileLoader(name, path).load_module().Spider() +def download(path, api): + if api.startswith('http'): + writeFile(path, redirect(api).content) + else: + writeFile(path, str.encode(api)) + + def writeFile(path, content): with open(path, 'wb') as f: f.write(content) @@ -96,21 +102,6 @@ def action(ru, action): return formatJo - -def subtitle_init(ru, config): - result = ru.init(str2json(config) if isinstance(config, str) and config.strip().startswith(('{', '[')) else config) - return json.dumps(result if result is not None else {"code": 0, "data": {}}, ensure_ascii=False) - - -def subtitle_search(ru, request): - result = ru.search(str2json(request)) - return json.dumps(result, ensure_ascii=False) - - -def subtitle_resolve(ru, request): - result = ru.resolve(str2json(request)) - return json.dumps(result, ensure_ascii=False) - def destroy(ru): ru.destroy() diff --git a/chaquo/src/main/python/webhtv_logging.py b/chaquo/src/main/python/webhtv_logging.py deleted file mode 100644 index b99d07b091..0000000000 --- a/chaquo/src/main/python/webhtv_logging.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Tee Python's existing text streams into the bounded App diagnostic sink.""" - -import sys -import threading - -_TAG = "python-spider" -_MAX_LINE = 12_000 -_install_lock = threading.Lock() -_installed = False - - -class _LogStream: - def __init__(self, stream, level, store, logger): - self._stream = stream - self._level = level - self._store = store - self._logger = logger - self._local = threading.local() - - def __getattr__(self, name): - # Keep encoding, errors, fileno, buffer, reconfigure and stream ownership. - return getattr(self._stream, name) - - def write(self, text): - result = self._stream.write(text) - if isinstance(text, str): - text = str.__str__(text) - if isinstance(result, int) and 0 <= result < len(text): - text = text[:result] - self._capture(text) - return result - - def writelines(self, lines): - for line in lines: - self.write(line) - - def flush(self): - result = self._stream.flush() - self._capture("", flush=True) - return result - - def _reset(self, epoch=None): - state = self._local - state.epoch = epoch - state.pending = "" - state.truncated = False - - def _emit(self): - state = self._local - message = state.pending - if state.truncated: - # Never emit arbitrary tail chunks: splitting credentials could evade redaction. - message += " [line truncated]" - self._reset(state.epoch) - if message: - level = self._level - if level == "STDERR": - # Recognize logging's default level prefix, never arbitrary message keywords. - prefix = message.partition(":")[0] - if prefix in ("DEBUG", "INFO", "WARNING", "WARN", "ERROR", "CRITICAL"): - level = "ERROR" if prefix == "CRITICAL" else prefix - elif message.startswith("Traceback (most recent call last):"): - level = "ERROR" - self._logger.console(_TAG, level, message) - - def _capture(self, text, flush=False): - state = self._local - if getattr(state, "busy", False): - return - state.busy = True - try: - if not self._store.acceptsTag(_TAG): - self._reset() - return - epoch = (self._store.captureGeneration(), self._store.categories()) - if getattr(state, "epoch", None) != epoch: - self._reset(epoch) - start = 0 - while start < len(text): - newline = text.find("\n", start) - end = len(text) if newline < 0 else newline - remaining = _MAX_LINE - len(state.pending) - state.pending += text[start:min(end, start + remaining)] - state.truncated |= end - start > remaining - if newline < 0: - break - self._emit() - start = newline + 1 - if flush: - self._emit() - except Exception: - # Diagnostic failures must not alter script results or recursively print. - self._reset() - try: - self._store.collectorFailure() - except Exception: - pass - finally: - state.busy = False - - -def install(): - global _installed - with _install_lock: - if _installed: - return - from com.github.catvod.crawler import DebugLogStore, SpiderDebug - - sys.stdout = _LogStream(sys.stdout, "INFO", DebugLogStore, SpiderDebug) - sys.stderr = _LogStream(sys.stderr, "STDERR", DebugLogStore, SpiderDebug) - _installed = True diff --git a/chaquo/src/test/python/test_webhtv_logging.py b/chaquo/src/test/python/test_webhtv_logging.py deleted file mode 100644 index 89e8b1abe7..0000000000 --- a/chaquo/src/test/python/test_webhtv_logging.py +++ /dev/null @@ -1,165 +0,0 @@ -import ast -import contextlib -import importlib.util -import io -import json -import logging -from pathlib import Path -import sys -import threading -import types -import unittest -from unittest import mock - -SOURCE = Path(__file__).resolve().parents[2] / "main/python" -spec = importlib.util.spec_from_file_location("webhtv_logging", SOURCE / "webhtv_logging.py") -bridge = importlib.util.module_from_spec(spec) -spec.loader.exec_module(bridge) - - -class Store: - def __init__(self): - self.enabled = True - self.generation = 1 - self.mask = 63 - self.failures = 0 - - def acceptsTag(self, tag): - return self.enabled and bool(self.mask & 2) - - def captureGeneration(self): - return self.generation - - def categories(self): - return self.mask - - def collectorFailure(self): - self.failures += 1 - - -class Logger: - def __init__(self): - self.records = [] - - def console(self, tag, level, message): - self.records.append((tag, level, message)) - - -class StreamTest(unittest.TestCase): - def setUp(self): - self.original = io.StringIO() - self.store = Store() - self.logger = Logger() - self.stream = bridge._LogStream(self.original, "INFO", self.store, self.logger) - - def messages(self): - return [r[2] for r in self.logger.records] - - def test_actual_spider_log_print_and_stream_contract(self): - tree = ast.parse((SOURCE / "base/spider.py").read_text()) - spider = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "Spider") - method = next(n for n in spider.body if isinstance(n, ast.FunctionDef) and n.name == "log") - namespace = {"json": json} - exec(compile(ast.Module(body=[method], type_ignores=[]), "base/spider.py", "exec"), namespace) - with contextlib.redirect_stdout(self.stream): - namespace["log"](None, {"中文": ["值", 1]}) - print("plain", "print") - self.assertEqual(self.messages(), ['{"中文": ["值", 1]}', "plain print"]) - self.assertEqual(self.original.getvalue(), "\n".join(self.messages()) + "\n") - self.assertEqual(self.stream.write("tail"), 4) - self.stream.flush() - self.assertEqual(self.messages()[-1], "tail") - self.stream.writelines(["a\n", "b\n"]) - self.assertEqual(self.messages()[-2:], ["a", "b"]) - self.assertEqual(self.stream.encoding, self.original.encoding) - with self.assertRaises(TypeError): - self.stream.write(b"bytes") - - def test_disabled_and_new_generation_discard_partial_text(self): - self.stream.write("old-") - self.store.generation += 1 - self.stream.write("new\n") - self.assertEqual(self.messages(), ["new"]) - self.store.enabled = False - self.stream.write("private-disabled") - self.store.enabled = True - self.stream.write("visible\n") - self.assertEqual(self.messages(), ["new", "visible"]) - self.stream.write("category-old") - self.store.mask &= ~2 - self.stream.write("category-disabled") - self.store.mask |= 2 - self.stream.write("category-visible\n") - self.assertEqual(self.messages()[-1], "category-visible") - self.assertNotIn("private-disabled", str(self.logger.records)) - - def test_interleaved_threads_do_not_join_each_others_lines(self): - barrier = threading.Barrier(2) - - def worker(prefix): - self.stream.write(prefix + "-") - barrier.wait(timeout=2) - self.stream.write("done\n") - - threads = [threading.Thread(target=worker, args=(prefix,)) for prefix in ("A", "B")] - for thread in threads: - thread.start() - for thread in threads: - thread.join(timeout=3) - self.assertFalse(thread.is_alive()) - self.assertCountEqual(self.messages(), ["A-done", "B-done"]) - - def test_huge_line_is_bounded_and_never_emits_secret_tail_chunks(self): - self.stream.write("token=" + "s" * 100_000) - self.assertLessEqual(len(self.stream._local.pending), bridge._MAX_LINE) - self.assertEqual(self.messages(), []) - self.stream.write("UNREDACTABLE_TAIL\nnext\n") - self.assertEqual(len(self.messages()), 2) - self.assertTrue(self.messages()[0].endswith("[line truncated]")) - self.assertLess(len(self.messages()[0]), bridge._MAX_LINE + 30) - self.assertNotIn("UNREDACTABLE_TAIL", self.messages()[0]) - self.assertEqual(self.messages()[1], "next") - - def test_capture_failure_preserves_original_output_and_return(self): - self.logger.console = mock.Mock(side_effect=RuntimeError("sink unavailable")) - self.assertEqual(self.stream.write("still printed\n"), 14) - self.assertEqual(self.original.getvalue(), "still printed\n") - self.assertEqual(self.store.failures, 1) - self.assertEqual(self.stream._local.pending, "") - self.original.close() - with self.assertRaises(ValueError): - self.stream.write("closed") - - def test_stderr_is_not_error_but_standard_logging_retains_level(self): - stream = bridge._LogStream(self.original, "STDERR", self.store, self.logger) - stream.write("ordinary stderr\nINFO:test:no error occurred\nWARNING:test:careful\nERROR:test:broken\n") - stream.write("Traceback (most recent call last):\n") - self.assertEqual([r[1] for r in self.logger.records], ["STDERR", "INFO", "WARNING", "ERROR", "ERROR"]) - - def test_install_once_before_import_and_existing_logging_still_outputs_once(self): - module = types.ModuleType("com.github.catvod.crawler") - module.DebugLogStore, module.SpiderDebug = self.store, self.logger - original_err = io.StringIO() - with mock.patch.dict(sys.modules, {"com.github.catvod.crawler": module}), \ - mock.patch.object(bridge, "_installed", False), \ - mock.patch.object(sys, "stdout", self.original), \ - mock.patch.object(sys, "stderr", original_err): - bridge.install() - stdout, stderr = sys.stdout, sys.stderr - bridge.install() - self.assertIs(sys.stdout, stdout) - self.assertIs(sys.stderr, stderr) - print("module-import-output") - handler = logging.StreamHandler() - handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s")) - handler.handle(logging.LogRecord("test", logging.ERROR, "", 1, "broken", (), None)) - handler.close() - self.assertEqual(self.messages(), ["module-import-output", "ERROR:broken"]) - self.assertEqual(self.original.getvalue(), "module-import-output\n") - self.assertEqual(original_err.getvalue(), "ERROR:broken\n") - self.assertFalse(self.original.closed) - self.assertFalse(original_err.closed) - - -if __name__ == "__main__": - unittest.main() diff --git a/docs/AD-AUDIO-RULES-V2-P4-controlled-release.md b/docs/AD-AUDIO-RULES-V2-P4-controlled-release.md deleted file mode 100644 index 077e15490c..0000000000 --- a/docs/AD-AUDIO-RULES-V2-P4-controlled-release.md +++ /dev/null @@ -1,71 +0,0 @@ -# AD-AUDIO-RULES-V2-P4-controlled-release - -## Phase 4 目标 -在播放出现退化(卡顿、掉帧、音频 underrun、重缓冲等)时,自动抑制语音识别 Provider,以避免加重系统负担,而不影响指纹通道。抑制仅对当前播放会话生效,下次媒体会话重新评估。 - -## 设计方案对比 -### 方案 1:不变(no change) -- 保持当前行为:语音 Provider 按现有逻辑运行,不考虑播放健康。 -- 优点:无实现成本,无风险。 -- 缺点:在资源受限的设备上可能加剧卡顿或 underrun。 - -### 方案 2:原样接入(upstream-as-is) -- 直接采用上游 Sherpa/ONNX Runtime 的线程调度建议(如降低线程数)。 -- 优点:已有最佳实践参考。 -- 缺点:不解决突发的播放退化;仅静态调整,无法动态响应;且 Android 后台优先级不等同于资源隔离。 - -### 方案 3:WebHTV 自适应门控(chosen) -- 在 PlayerManager 处理播放遥测 tick(约 5 秒)时,读取 PlaybackAnalyticsListener 的快照; -- 若检测到退化(掉帧、underrun、重缓冲异常)则通过 AdAudioRuntimeController 新增 API 抑制语音 Provider(仅 speech,不影响指纹); -- 抑制持续到当前播放会话结束;下次会话重新评估; -- 失败时 fail-open:任何错误都不抑制语音,保证主播放优先。 - -## 现有指标和调用链 -- PlaybackAnalyticsListener.Snapshot 提供: - - droppedFrames(累计) - - rebufferCount、rebufferTotalMs - - (无 audio underrun 计数,仅在 SpiderDebug 开启时日志) -- PlayerManager.publishPlaybackTelemetryTick 每 ~5s 调用一次 refreshAdAudioRuntime; -- AdAudioRuntimeController.refreshLocked 中根据 speechConfig.enabled() && speechConfig.hasSpeechRules() 决定是否创建 speech provider。 - -## 抑制触发条件候选 -- droppedFrames 增长速度超过阈值(当前实现采用每秒至少 4 帧)持续两次检测; -- rebufferCount 增长; -- 音频 underrun:目前仅在 SpiderDebug.isEnabled() 时写日志,复用该开关以避免双重开关; -- 模型启动失败(SPEECH_MODEL_UNAVAILABLE)已有诊断,可直接触发抑制; -- 队列溢出(SPEECH_PCM_QUEUE_PEAK)已有诊断。 - -为避免误判,采用低基数计数(delta)和简单阈值:underrun 或 rebuffer 增量、或掉帧速率达到每秒 4 帧,连续两个有效 tick 才触发抑制。阈值是保守的初始门槛,不是 TV 性能验收结论;真实设备矩阵仍必须测量后再调整。 - -## 是否自动恢复 -- 本次实施不自动恢复:抑制仅持续到当前播放会话结束,下次会话重新评估。 -- 这样避免了在同一段媒体内反复开关导致的抖动。 - -## 真实 TV 验收矩阵 -- 低端 Android TV(如盒子)和高端 Android TV(如索尼、TCL 高端机型); -- 相同 VOD、相同音轨、相同离线模型; -- 四组对照: - 1. 语音关闭(baseline) - 2. 模型未就绪(通过禁用 SpeechAdSetting 实现) - 3. 模型就绪 + 线程 1( Sherpa 单线程) - 4. 模型就绪 + 线程 2(默认) -- 测量项:droppedFrames、AudioTrack underrun、rebuffer、视频/音频 backlog、speech/Sherpa CPU、识别队列峰值、首帧时间。 -- 注意:由于当前工作环境缺乏真实 Android TV 设备,本阶段仅通过 JVM 单元测试和 Leanback ARM64 定向测试验证逻辑正确性;真实 TV 验收留给后续专项任务。 - -## Acceptance Criteria -- 纯 JVM 单元测试覆盖门控逻辑:无退化不抑制;任意退化指标增长触发抑制;抑制不影响指纹 Provider;新会话重置抑制状态; -- Leanback ARM64 定向 JVM 测试通过; -- Leanback Java 编译通过; -- 不把手机性能或 JVM 编译结果当作 TV 性能验证。 - -## Rollback Path -- 由于仅新增增量代码且不修改现有行为(除新增抑制外),回滚为删除新增文件和还原修改; -- 在 P4 guard 结束前,若发现任何回归,可直接放弃本次更改;完成后通过 recovery tag 回滚。 - -## 官方资料和当前不可获得资料 -- 已参考:Android thread 最佳实践(https://developer.android.com/topic/performance/threads,2026-09-09 读取)、Media3 AnalyticsListener API(https://developer.android.com/reference/androidx/media3/exoplayer/analytics/AnalyticsListener,2026-09-09 读取;官方定义 onAudioUnderrun 为 audio underrun、onDroppedVideoFrames 为 dropped frames、onPlaybackStateChanged 为状态变化)、设计文档已记录的 ONNX Runtime threading 指南(https://onnxruntime.ai/docs/performance/tune-performance/threading.html); -- 无法获得:真实 Android TV 设备性能基准、 Sherpa 在不同线程数下的 CPU/延迟曲线(需实际设备测量)。 - -## 手机不是 TV 证据 -- 现有 ADB 设备均为手机(SM-N9700、NX627J),不能用于 Android TV 性能验证; -- 因此本文档明确标注:手机性能结果不等同于 TV 表现,不能替代真实 TV 验收。 diff --git a/docs/AD-AUDIO-RULES-V2-design.md b/docs/AD-AUDIO-RULES-V2-design.md deleted file mode 100644 index 4c3cb78cd7..0000000000 --- a/docs/AD-AUDIO-RULES-V2-design.md +++ /dev/null @@ -1,843 +0,0 @@ -# 音频去广告升级设计:频谱指纹社区闭环与语音广告复合规则 - -> 任务:`AD-AUDIO-RULES-V2` -> -> 状态:Phase 1 已提交且通过 34 项 JVM 测试。Phase 2A 执行/生命周期隔离已通过最终 50 项 JVM 回归与 Leanback arm64 Debug 构建。Phase 2B 的复合规则、识别区间和路由代码已接线,当前仅产生带明确近似标记的确认候选;词级时间对齐、真实 TV 性能/精度、Phase 3/4 仍未完成,不代表整项升级完成。当前交付记录见第 16 节。 -> -> 编写日期:2026-09-08(Asia/Shanghai) -> -> 适用版本:WebHTV 当前 `dev2`,基线 `1806e4647bab28f2b257fe87f3b4567b4ff65b36` - -## 1. 目标与结论 - -用户提供的资料实际上包含两条不同的去广告能力,不能合并成一个解析器: - -1. **音频频谱指纹规则**:在线 `rules.json`,由采集器上传、云端校验合并,匹配已知广告的解码后 PCM。 -2. **语音广告规则**:人可读的“广告之后*>马上回来,30”语法,通过 Sherpa-ONNX 识别文本,再按多句顺序和时间窗口命中。 - -本项目当前已经具备第一条能力的本地运行时和 Probe v1 兼容解析器,但第二条仍是“平面关键词包含匹配”。本次升级推荐: - -- 保留当前 `spectral-sequence-v2` 运行时,不复制采集器 APK,不引入第二条 Exo 音频处理器; -- 继续兼容上游 `ad-audio-probe-rules` v1,使用社区 `rules.json` 作为**独立的指纹来源**; -- 新增独立的语音规则文档/解析/匹配层,把用户提供的 `*`、`>`、`,`、`[pre,post]` 语法转换为有界的语音广告候选; -- 把语音识别计算从播放实时性保护中单独治理:不得在音频回调中执行识别,限制 Sherpa 线程数和后台预算,队列过载时丢弃旧识别输入而不是拖慢 EXO; -- 默认继续关闭语音广告识别和自动跳过;指纹通道、语音通道、HLS/URL 规则互不改变策略和生命周期; -- 第一阶段只使用已验证的本地/内置语音规则,暂不假设在线 `rules.json` 能承载语音文本规则。 - -**推荐决策:实施“WebHTV 适配版 V2”,而不是盲目接入上游 SDK 或把两种规则强行拼接。** - -## 2. 证据基线 - -### 2.1 本地 WebHTV 基线 - -| 证据 | 当前事实 | 设计影响 | -|---|---|---| -| `app/src/main/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodec.java` | 严格解析 `ad-audio-probe-rules`、schema 1、`spectral-sequence-v1`,再映射到本地指纹 v2 | 远程指纹规则无需另造格式;必须保持整包拒绝和 revision 单调 | -| `ProbeRuleDownloader.java` | 后台 HTTPS 下载,4 MiB 限制,解析成功后交给 `ProbeRuleStore` 原子替换 | 继续 fail-open;网络更新不能阻塞播放 | -| `ProbeRuleStore.java` | 本地缓存、原子写入、revision 防回滚 | V2 不覆盖现有缓存;新来源必须复用同一安全边界 | -| `PlaybackMediaAudioPipeline.java` / `PlaybackMediaSignalHub.java` | 单一 Exo PCM 管线、多消费者、session/generation 生命周期 | 指纹和语音共享 PCM,不再安装第二个 AudioProcessor | -| `PcmAdAudioSignalProvider.java` | 指纹匹配运行在 Hub consumer 与后台 matcher 中 | 保持指纹通道隔离,规则规模增长前先测量再做索引 | -| `SpeechAdKeywordSet.java` | 逗号/分号分隔的平面关键词,文本包含即命中 | 不能表达顺序句、跨段 wildcard、前后跳转窗口,需要新规则模型 | -| `SpeechAdSignalProvider.java` | Sherpa 会话、PCM 邮箱、recognition callback、候选生成和代际校验已存在 | 新 matcher 应嵌入 Provider,不应绕开现有 Coordinator | -| `RealtimeSubtitleRecognizer.java` | 在线模型最多使用 `min(4, CPU/2)` 个线程,CPU provider;离线模型另有识别线程 | 必须增加 TV 性能预算,否则模型就绪后可能抢占 EXO 资源 | -| `AdAudioRuntimeController.java` | PCM、Probe、Speech 三个 Provider 组合,Coordinator 是唯一 seek authority | 语音候选只能通过现有 policy/coordinator 跳转 | - -### 2.2 外部仓库与在线规则 - -以下版本通过 GitHub API 在 **2026-09-08** 读取,记录完整 revision,避免把网页瞬时内容当作版本基线: - -| 仓库/地址 | 读取到的 revision | 观察到的内容 | 可信度与限制 | -|---|---|---|---| -| `https://github.com/0o755/m3u8-ad-audio-probe` | `61c7d2ec57792f8b439254b35cc0c3a9a31acf20` | Probe SDK;公开规则协议为 `ad-audio-probe-rules` schema 1;MIT | 官方仓库 README/公开文档;不直接作为 WebHTV 运行时依赖 | -| `https://github.com/0o755/m3u8-ad-audio-collector` | `070e4ff6f500386d0cfa779c981ba3cb39bf66e7` | 采集器、规则测试、提交 Worker;只调用 Probe 公共 API | 采集端与播放端职责不同;不复制 APK 内部实现 | -| `https://github.com/0o755/m3u8-ad-audio-rules` | `bb0041673883f949045d272b845394a2b518882d` | 云端规则仓库,提交后经 Worker/GitHub Actions 校验、去重、冲突过滤、合并 | 规则仓库无签名;当前只能依靠 HTTPS、严格 schema、大小限制和 revision | -| `https://raw.githubusercontent.com/0o755/m3u8-ad-audio-rules/main/rules.json` | 文件 SHA-256 `d5ab4d42196186221676727b81ddc7474363f76a3dd1360362cf16fc7bea6b6c` | `revision=3`、`schemaVersion=1`、`algorithm=spectral-sequence-v1`、5 条规则;每条有 4 个相位序列 | 通过 GitHub API 获取同一文件内容;不是固定发布包,后续内容会变 | -| `https://m3u8-ad-audio-rules-sync.ccfork.workers.dev/rules.json` | 2026-09-08 网页读取工具返回 `revision=3` | 实际响应头字段为 `ad-audio-probe-rules`、schema 1、`spectral-sequence-v1` | A:直接响应证据;早先本机 curl 的 TLS 失败仍保留为环境问题。网页工具可读取不代表本机或 Android 下载器已通过联网验收 | -| `https://github.com/0o755/m3u8-ad-audio-collector/releases` | 当前 API 未发现正式 latest release | 用户另提供了采集器 APK/Demo 地址 | 不把未验证 APK 当作生产依赖;优先依赖公开源码、合同和规则文件 | - -用户提供的 `/run/user/1000/gvfs/smb-share:server=192.168.50.3,share=users/Maple/共享/星落6.0.1.apk` 已于 2026-09-08 做只读文件/ZIP 目录检查:81,613,887 字节、1,722 个 ZIP 条目、存在 `AndroidManifest.xml`,原生库目录仅见 `arm64-v8a`。文件名检索未出现 Sherpa/ONNX/Vosk/Whisper/fingerprint 等显式名称;这**不能证明没有这些能力**,也不能推断广告算法、识别精度或性能。未安装、执行、提取或反编译 APK;其交互行为对照仍未完成,不把它作为实现来源或生产依赖。 - -### 2.3 用户提供的语音语法 - -语音规则示例包含以下实际需求: - -```text -广告之后*>马上回来,30 -中场休息*>广告时间,[2,30] -投注*平台*>注册*彩金,30 -``` - -其含义是: - -- `*`:同一句中匹配任意文本,可为空,可跨识别片段; -- `>`:多个句段按顺序出现; -- `,30`:以最后一个句段结束为锚点,向后覆盖 30 秒; -- `[2,30]`:从第一个句段开始前 2 秒,到最后一个句段结束后 30 秒; -- 行尾不需要再写 `*`;空行、`//` 注释和中文标点应被容忍。 - -这不是当前社区 `rules.json` 的字段,不能塞进 `ProbeRuleCodec` 的 `fingerprints` 数组,也不应把全文规则复制到 `SpeechAdKeywordSet` 的逗号关键词中。 - -## 3. 用户可见能力 - -实施后,用户可以得到两条相互独立的能力: - -1. **已知音频广告自动/确认跳过**:开启音频指纹后,WebHTV 在后台更新社区指纹规则;播放 EXO VOD 时从解码 PCM 匹配,命中后按现有“提示确认”或“自动跳过”策略处理。 -2. **语音广告句式识别**:开启语音广告识别并准备好模型后,识别“广告之后……马上回来”“中场休息……广告时间”等顺序句,按规则指定的前后窗口给出候选;支持用户确认或自动跳过。 - -两种命中同时发生时,Coordinator 仍只允许一次有效 seek;不能因指纹和语音重复命中而连续跳转或延长跳转窗口。 - -## 4. 方案比较 - -### 4.1 方案 A:不改现有实现 - -保留现有指纹 v1/v2 兼容和语音平面关键词。 - -- 优点:风险最低、无需新增解析器。 -- 缺点:用户提供的顺序语法无法使用;误报和漏报都只能靠增加孤立关键词;模型就绪后的 EXO 性能风险不解决。 -- 结论:不能满足本次升级目标,拒绝作为最终方案。 - -### 4.2 方案 B:原样接入 Probe/采集器 SDK - -在 WebHTV 中直接引入 `ad-audio-probe`,同时照搬采集器或 APK 的规则/匹配器。 - -- 优点:上游更新快,采集器与播放端名义上统一。 -- 缺点:Probe 默认 Media3 适配器与 WebHTV 现有 Media3/FFmpeg/Exo 链路有版本和所有权边界;新增一套解码或 PCM 处理会增加包体、网络、生命周期和 CPU;上游指纹协议并不包含语音句法;当前规则仓库无签名;还会引入不可审计的二进制/反编译依赖。 -- 结论:拒绝原样接入;只兼容其公开 rules-v1 数据合同和算法黄金样本。 - -### 4.3 方案 C:WebHTV 适配版(采用) - -- 指纹:复用现有 `PlaybackMediaSignalHub`、`PcmAdAudioSignalProvider`、`ProbeRuleCodec/Store/Downloader`;仅增加来源回退、规则统计和规模化前的指标。 -- 语音:新增独立 `SpeechAdRuleCodec`、不可变 `SpeechAdRuleSet`、有界 `SpeechAdMatcher`;`SpeechAdSignalProvider` 只负责 PCM/识别会话/代际,候选仍流向现有 multiplexer/policy/coordinator。 -- 性能:语音计算使用低优先级、受限线程数和有界邮箱;模型未就绪、过载、识别错误均自动降级,不影响主播放。 -- 安全:不把远程文本规则当作可信代码;限制行数、字符数、段数和窗口;禁止任意正则;规则源整包解析,失败保留旧缓存。 -- 结论:满足能力目标,改动范围可逆,兼容当前行为和默认关闭契约。 - -## 5. 规则协议设计 - -### 5.1 语音规则文档 - -首期采用纯文本本地文档,后续可封装成 JSON,但不和 Probe JSON 混用: - -```text -# speech-ad-rules v1 -广告之后*>马上回来,30 -中场休息*>广告时间,[2,30] -``` - -解析规则: - -1. 去除 UTF-8 BOM、首尾空白、空行和 `//` 行尾注释;`#` 仅作为文档头或整行注释。 -2. 只接受 ASCII/中文逗号 `,`/`,` 的一个动作后缀;没有动作后缀的行拒绝,不静默猜测。 -3. 动作 `,post` 映射为 `preRoll=0`、`postRoll=post`;动作 `,[pre,post]` 映射为对应窗口,也接受省略外侧逗号的 `[pre,post]`。规范输出统一使用 ASCII 逗号;多重动作后缀整行拒绝。 -4. 主体按 `>` 分成 1~8 个有序句段;每个句段长度限制 128 个 Unicode code point。 -5. `*` 只表示通配文本,不表示正则;连续 `*` 合并;主体去除不可识别控制/格式字符,空白折叠。128 code point 上限适用于规范化后的整个句段,而不是每个 wildcard 分隔的字面量。 -6. 句段必须包含至少一个字母或数字;全是 `*`、空句段和只有标点的规则拒绝。 -7. `preRoll`/`postRoll` 限制在 0~120 秒;首期默认规则最多 30 秒,超过上限拒绝。 -8. 单文档最多 256 条规则、总输入 64 KiB、每条最多 8 个句段;超限整份拒绝并保留旧快照。 - -编译结果使用字面量片段和通配状态机,不调用用户可控正则,避免灾难性回溯。中文匹配使用 NFKC 规范化;拉丁文本保留大小写无关匹配;不保存完整识别文本到日志。 - -### 5.2 语音匹配状态机 - -`SpeechAdMatcher` 由单一后台 worker 调用,维护当前 timeline 的有界识别窗口;session/generation 的旧 callback 必须由现有 Provider 先行拒绝,不能由一个 timeline token 替代全部代际校验: - -1. 每个识别结果只保留规范化文本、起止时间、timeline token;每次输入最多 4,096 code point,共享窗口最多 128 个结果、8,192 UTF-16 code unit,超限按完整旧结果淘汰,不截断代理字符对; -2. 规则按 `>` 顺序匹配,首字面量即使跨结果也保留原结果的 `firstStartUs`,末字面量保留所属结果的 `lastEndUs`; -3. `*` 可跨增量识别片段,包括空串和换行;窗口从最早结果起点到最新结果终点最长 30 秒,未完成的首句也不得无限保留。Phase 1 只接受已定稿、非重叠、按时间递增的增量结果;重复/重叠结果拒绝,超长或无效输入中断文本连续性。累计 partial/final 去重与修订属于 Phase 2 适配职责,不能把两个累计全文直接拼接; -4. 同一规则在 30 秒冷却窗口内不重复发候选; -5. 命中后输出 `ruleId`、`preRollMs`、`postRollMs`、`firstStartUs`、`lastEndUs` 和 timeline token,不在 Match 中输出原文; -6. seek、切源、音频 flush、引擎重建和规则热更新提升 generation,清空所有状态;旧 callback 丢弃; -7. Provider 的候选保持原始 capture 时间;**只能由现有 `AdSkipCoordinator.targetFor` 使用 `PlaybackMediaClock` 转为媒体时间并做 duration 钳制**。Provider 不能预先加 media anchor,也不能拿媒体总时长钳制 capture 坐标,避免重复转换。无法取得新鲜时钟或目标已过期时不得 seek。 - -候选区间定义: - -```text -captureStart = max(0, firstStartUs / 1000 - preRollMs) -captureEnd = lastEndUs / 1000 + postRollMs -mediaTarget = clamp(clock.mapCaptureToMediaMs(captureEnd), 0, mediaDurationMs) -``` - -若原始区间为空、映射失败、时间轴过期、媒体为直播/不可 seek,或最终 `mediaTarget <= currentPosition`,则丢弃候选并记录固定枚举诊断。Phase 1 的带 duration 辅助方法只在同坐标系下有意义,不能代替既有 Coordinator 的媒体坐标校验。 - -未知 duration 以负值表示,此时仅计算未钳制上界的候选,不构成自动 seek 授权。Phase 1 的时间来源是**整个识别片段边界**,不是词级对齐结果;不能声称精确定位“关键词后第 30 秒”。Phase 2 必须确认识别器能否提供可靠终点/对齐,不能把回调墙钟或过期播放位置冒充关键词时间。 - -`[2,30]` 描述候选广告区间,不代表可以撤销已经播放的前 2 秒。在没有预扫描/前瞻缓冲的实时识别中,只能在命中后向有效终点前跳,不得为了“补跳前段”回 seek;本设计不新增预解码或播放延迟。迟到结果、已越过终点或时间不可信的候选不自动执行。 - -### 5.3 与现有关键词设置的迁移 - -- 保留 `SpeechAdSetting` 的启用、跳过秒数、模式入口,避免备份和 UI 立即失效。 -- 将现有逗号关键词自动转换为单句规则,例如 `赌场` 转为 `赌场,15`;旧用户无需重新配置。 -- 新增“语音规则”文本编辑/导入入口;旧“关键词”作为快捷规则区或兼容输入,不再把复杂规则拆成逗号 token。 -- 规则来源分为 `builtin`、`user`、`imported`,合并时按稳定 ID 去重;用户规则优先,不自动覆盖用户同 ID 内容。 -- 首期不从音频指纹 `rules.json` 推导语音词,也不把识别文本自动写成 URL/HLS/音频指纹规则。 - -旧关键词的 ASCII 单词边界语义必须继续保留;新规则的字面量包含匹配不能直接替代旧 `SpeechAdKeywordSet`,否则 `ad` 可能误命中 `download`。Phase 1 没有迁移旧设置。 - -### 5.4 规则误伤、冲突与默认策略 - -| 用户示例类型 | 设计处置 | -|---|---| -| “广告之后 > 马上回来”等明确过渡句 | 可作为候选模板,但未经误跳样本与时间对齐验收仍默认确认,不直接自动跳 | -| “本片*冠名”“充值*优惠”“品牌*推荐”等单句 | 可能属于正常剧情/讨论,要求用户主动启用 | -| “激情”“私密”“少妇”“美女*主播”“福利*视频”等宽泛词 | 默认不启用;不得把词命中描述成色情内容判断或可靠儿童保护 | -| “下集预告”“精彩花絮” | 属于可选内容跳过,不等同广告,后续 UI 应与广告开关分离 | -| 同一主体配置不同窗口,如 `本片*冠名,25` 与 `本片*冠名,[4,30]` | Phase 1 保留为不同 ID,仅完全相同的规范规则去重;Phase 3 导入需显式展示冲突、由用户选择。不得默默取最长窗口,Provider/Coordinator 接线后仍最多一次有效 seek | - -用户提供的完整示例不是经过精度验证的默认库;语法可解析与适合自动跳过是两项独立验收。 - -## 6. 播放实时性保护 - -重点防范并验证“模型未就绪时正常、就绪后开启识别导致 EXO 卡顿”的风险。本轮没有执行真实模型/设备对照,不能把线程数或某个调用点认定为已证实根因;“功能能识别”不是充分验收条件,播放实时性是硬约束。 - -### 6.1 当前风险点 - -- `RealtimeSubtitleRecognizer.threadCount()` 目前最多配置 4 个 CPU 推理线程;电视盒的可用核心数和调度能力差异很大。 -- 语音 Provider 会持续接收 PCM;模型就绪后每帧都可能触发重采样、VAD/解码或识别队列工作。 -- Hub consumer 的实时入口即使不做识别,也承担 PCM 引用/复制和投递;必须有明确耗时预算。 - -### 6.2 适配策略 - -实施阶段加入以下契约: - -1. **音频入口零推理**:Hub/AudioProcessor 回调只做格式检查、有限复制或入有界邮箱;不得创建 Sherpa recognizer、执行 ONNX decode、规则匹配或日志 I/O。 -2. **低优先级识别线程**:语音 Provider 使用独立的后台 `Executor`,创建线程时设置 Android background 优先级;不与 UI、Exo 控制线程共用执行器。 -3. **线程数上限**:语音广告专用 profile 先使用 1 个推理线程,并完成 1/2 线程 A/B;现有 `threadCount()` 实际已限制为 1~4,问题不是“没有上限”,而是未按播放共存负载验证。实时字幕的原默认配置不随广告 profile 改变;提高广告上限需要 profile 证明无回归。 -4. **有界退压**:PCM 和识别任务都有固定上限;过载时丢弃最旧帧并 reset 当前语音识别段,禁止无界积压和反向阻塞音频生产者。 -5. **分阶段启用**:模型未验证、播放状态不适合、媒体非 VOD、时钟无效或 Provider backlog 超阈值时,保持 `DEGRADED/IDLE`;不为了“凑完整识别”牺牲播放。 -6. **可观测指标**:记录识别耗时桶、队列峰值、丢帧数、recognizer reset 次数和 Provider 状态;禁止记录 PCM、完整文本、URL、Cookie、关键词正文。 -7. **用户保护**:TV 设置说明语音识别可能消耗 CPU;如果连续检测到过载或播放 watchdog 风险,本次会话自动停用语音 Provider,但指纹/HLS 去广告继续工作。 - -### 6.3 性能验收门槛 - -在至少一台低端 TV 盒和一台高性能 Android TV 上,使用相同 VOD、相同音轨和相同模型完成对照: - -- 关闭语音识别为基线,开启“模型未就绪”、开启“模型就绪 + 线程 1”、开启“模型就绪 + 线程 2”四组; -- 比较 `droppedFrames`、video/audio renderer backlog、`ad-audio`/Sherpa 线程 CPU、识别队列峰值和首帧时间; -- 语音开启组不得造成产品允许阈值之外的掉帧或音频 underrun;任一设备不满足时保留“语音默认关闭 + 自动降级”,不得宣称功能完成; -- 先完成性能实测,再决定是否开放语音自动跳过;指纹确认跳过不依赖语音性能门槛。 - -## 7. 指纹规则接入与社区闭环 - -### 7.1 保持现有兼容链 - -当前公开 `rules.json` 已被本地 `ProbeRuleCodec` 兼容: - -```text -format = ad-audio-probe-rules -schemaVersion= 1 -revision = 3 -algorithm = spectral-sequence-v1 -rules = 5 -``` - -因此不实施格式转换、不接入采集器 APK、不安装 Probe 自己的 Media3 播放器。播放侧继续: - -```text -PlaybackMediaAudioProcessor - -> PlaybackMediaSignalHub - -> PcmAdAudioSignalProvider - -> AdAudioDetectionMultiplexer - -> AdSkipPolicyController - -> AdSkipCoordinator -``` - -### 7.2 下载与来源策略 - -短期沿用 Worker 默认地址,增加以下可逆改进: - -- Worker 为主,GitHub Raw 仅作为固定官方仓库的显式回退;不对任意用户自定义 URL 自动跨域回退; -- 两个响应都必须通过完全相同的 `ProbeRuleCodec`、大小限制、revision 单调校验和原子 Store; -- 网络失败、TLS 失败、HTTP 非 2xx、schema/算法错误或 revision 回退时保留旧缓存并继续播放; -- 记录当前 source、revision、规则数和错误码,不记录规则正文和媒体请求信息; -- 在签名规则包发布前,不把“HTTPS + schema 合法”描述成真实性证明;自动跳过默认仍关闭。 - -中期可把远程规则迁移到已有 `SignedRulePackage*`/`SignedProbeRuleSidecar*` 验证链,但必须先完成外部发布者密钥、轮换、撤销和恢复合同;不能只在客户端加一个公钥就宣称闭环安全。 - -### 7.3 规模与性能 - -当前 matcher 仍近似全规则扫描。远端当前只有 5 条规则,暂不为了未来数千条规则提前重构。达到以下任一条件后再开独立阶段: - -- 规则数超过 256; -- 低端设备每 hop matcher 时间或队列 reset 超过预算; -- 线上规则实测显示全扫描造成可复现的资源竞争。 - -届时按首帧/前两帧建立候选桶,仍在后台 matcher 线程完成,并保留旧扫描器作为回退实现。 - -## 8. 分阶段实施计划 - -### Phase 0:设计与基线(本文件) - -- [x] 固定 WebHTV 基线、外部仓库完整 commit ID 和远程规则快照信息。 -- [x] 明确指纹 JSON 与语音文本规则不是同一协议。 -- [x] 记录“不改、原样接入、WebHTV 适配”三种方案和取舍。 -- [x] 记录播放实时性、兼容、安全、回滚和验收门槛。 - -### Phase 1:语音规则纯 JVM 层 - -预计变更范围: - -```text -app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRule.java -app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleCodec.java -app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleSet.java -app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdMatcher.java -app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleCodecTest.java -app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdMatcherTest.java -``` - -验收:覆盖注释、中文标点、通配、顺序句、前后窗口、跨识别片段、非法/超限输入、冷却和 timeline reset;不依赖 Android、JNI 或真实模型。 - -### Phase 2:Provider 接线与性能保护 - -预计变更: - -- `SpeechAdSignalProvider`:使用新 matcher、候选时间窗口和有界退压; -- `SpeechAdConfig/Setting`:兼容旧关键词,加入规则来源/性能 profile; -- `RealtimeSubtitleRecognizer`:线程数配置化、TV 默认 1、低优先级和 reset 合同; -- `AdAudioRuntimeController`:独立语音 executor、状态隔离和过载自动降级; -- 现有端到端测试:验证语音候选、指纹候选不会双重 seek。 - -验收:单测 + JVM 端到端 + 至少一次 Leanback/TV debug 构建;设备性能验证不以编译通过替代。 - -### Phase 3:规则来源与 UI - -- 内置默认语音规则和用户导入/编辑; -- 规则校验错误显示明确原因,不覆盖旧有效规则; -- 音频指纹设置显示来源、revision、规则数量、最后刷新结果; -- 语音设置显示模型状态、规则数量和性能保护状态; -- Mobile/Leanback 入口保持语义一致,TV 焦点导航单独验证。 - -### Phase 4:受控发布 - -- 默认:指纹确认模式关闭自动跳过;语音识别关闭; -- 灰度:先允许指纹确认跳过,再根据设备 profile 开放指纹自动跳过; -- 语音:性能门槛通过后仍默认关闭,用户明确打开;自动跳过后置; -- 监测误跳、漏检、掉帧、队列溢出和模型启动失败;发现主播放退化立即关闭语音 Provider,不回滚指纹缓存。 - -## 9. 明确不做的事情 - -- 不把人类语音规则写进 `rules.json` 的 `fingerprints` 字段; -- 不把完整识别文本、关键词、媒体 URL、Cookie、Authorization 上传到社区服务; -- 不复制采集器 APK、反编译代码或 Probe 内部类; -- 不在音频回调、UI 线程或播放器控制线程执行 ONNX 推理; -- 不让语音 Provider 直接调用 `seekTo`; -- 不因规则下载失败清空已验证缓存或阻塞主播放; -- 不在没有真实设备 profile 的情况下声称“降低线程优先级即可解决 TV 卡顿”; -- 不将当前无签名远程规则描述成防篡改发布物。 - -## 10. 风险、回滚与接受标准 - -### 风险 - -| 风险 | 防护 | -|---|---| -| 语音模型抢占 CPU 导致 EXO 掉帧 | 线程 1、低优先级、独立 executor、有界退压、过载停用、设备对照测试 | -| 语音规则 wildcard 误报 | 复杂规则默认确认;至少 2 句的规则优先;冷却、时间窗、人工撤销 | -| 规则误跳正常内容 | 指纹与语音独立策略;无签名规则不默认自动跳;规则整包校验和可回退 | -| 远程 Worker/Raw 内容不一致 | revision + 内容摘要 + 相同 codec;只切换到完整验证通过的快照 | -| 规则数量增长导致 matcher 变慢 | 当前小库不提前索引;以 profile 触发单独候选桶阶段 | -| 旧用户设置丢失 | 旧关键词转换为单句规则;保留原 Prefs key 和备份字段语义 | - -### 回滚路径 - -1. 语音规则层:关闭新 matcher feature flag,继续使用旧 `SpeechAdKeywordSet`; -2. Provider 层:停止创建新语音 executor,保留指纹 Provider 和旧语音配置; -3. 模型层:恢复原 Sherpa 线程数/工厂实现; -4. 指纹来源层:恢复到最后一个 `ProbeRuleStore` 有效 revision,不删除 current/previous; -5. 规则文件:新格式解析失败不写入主文件,删除临时文件即可恢复; -6. 若修改跨多个阶段,按 Phase 2 → Phase 1 逆序回退,每阶段独立 commit/tag。 - -### 完成标准 - -- 公开指纹 `rules.json` revision 3 的 5 条规则可被现有 codec 解析并进入缓存,网络失败不影响播放; -- 用户示例中的 `*`、`>`、`,30`、`[2,30]` 至少各有正例、负例和跨识别片段测试; -- 语音候选经过 session/generation/timeline、duration 和 seekability 校验; -- 指纹与语音同时命中时最多产生一次有效跳转; -- 模型未就绪、识别异常、队列溢出、规则超限均 fail-open; -- 在目标 TV 设备上,模型就绪开启语音识别不得引入超出门槛的掉帧或 AudioTrack underrun; -- 所有新增规则/设置均可通过单独 feature flag 关闭,旧用户设置和旧缓存可继续读取; -- 设计、实现、验证、提交和恢复 tag 均记录在本文件,不以“编译通过”代替运行时验收。 - -## 11. 用户决策与当前下一步 - -- **建议**:实施 Phase 1(语音规则纯 JVM 层)和 Phase 2(Provider 性能保护),保持指纹远程规则协议不变。 -- **暂缓**:签名远程规则发布、数千条规则索引、语音规则云端同步,直到发布者合同和真实设备数据齐备。 -- **忽略**:直接引入采集器 APK、Probe 默认 Media3 播放器或把语音规则伪装成指纹 JSON。 - -**唯一下一步**:确认 Phase 2 的 Provider 输入/时间对齐合同、变更范围及真实 TV 性能验收方案后,再启动新的 guard 会话实施;不同时改 UI、下载器和原生依赖。 - -## 12. Phase 1 实施与验证记录(2026-09-08) - -### 范围与完成情况 - -- 分支 `dev2`;Phase 1 基线为设计提交 `045ae26ab2374264f72dd4c266eca2f1f2dfc5e9`,设计恢复标签为 `recovery/AD-AUDIO-RULES-V2/20260908130147-045ae26ab237`。 -- 延续原 `AD-AUDIO-RULES-V2`、`standard` guard 会话;初始受保护脏路径为空,交接时四个未跟踪规则类属于该会话,不重新归属其他任务。 -- 新增 `SpeechAdRule`、`SpeechAdRuleCodec`、`SpeechAdRuleSet`、`SpeechAdMatcher` 及两个对应测试类;仅这六个文件与本文档属于本阶段范围。 -- 不执行用户正则;规范文本产生稳定 ID,集合不可变;程序构造同样约束动作秒粒度,序列化输出不得突破文档大小上限。 -- 未修改 Provider、旧关键词设置、UI、指纹下载/缓存、依赖、JNI 或原生库;没有新增运行时默认开关或实际 seek。回滚不需要清理用户配置。 - -### 决定性验证 - -最终使用本机 `javac` 编译全部四个新类与两个测试类,随后运行缓存中的 JUnit 4.13.2: - -```text -org.junit.runner.JUnitCore - com.fongmi.android.tv.ad.audio.SpeechAdRuleCodecTest - com.fongmi.android.tv.ad.audio.SpeechAdMatcherTest -结果:OK (34 tests),0 failures -``` - -- 覆盖 BOM/注释、中英文逗号、两个动作格式、规范化与 round-trip、重复/连续 wildcard、空串/换行与顺序匹配、非法多重后缀、规则数/文档字节/整句长度上限、不可变集合、时间窗/冷却/重叠回调/timeline reset、跨首句时间来源与 duration 钳制。 -- 新增边界回归用例先在旧实现复现 9 个失败,再于修正后全部通过;不是只更改断言使旧实现变绿。 -- 早先 `:app:testDebugUnitTest` 因缺少 flavor 任务失败,随后 Mobile arm64 定向 Gradle 执行完成主/测试源码编译,但发现两处错误的 duration 断言;它们已修正,最终 JVM 测试覆盖了修订实现。**不把先前 Gradle 失败写成成功,不声称最终源码经过 APK/设备验收**。本阶段没有 Android API/依赖改动,直接 JVM 编译和相同 JUnit 用例是本阶段规则合同的最终门槛。 -- 完整最终输出保存在本机 `/tmp/AD-AUDIO-RULES-V2-green.O8b3vf/test.log`;临时日志不是持久验收的唯一来源,测试命令、范围和结论在本文与提交的 Verification 字段中保留。 - -### 尚未完成的验证与设计门槛 - -- 真实 ASR 定稿/累计结果行为、词级或片段终点的可信度及媒体时钟换算;未解决前不得用新语音规则自动 seek。 -- 目标 TV 的 CPU、视频丢帧、AudioTrack underrun、队列丢弃与模型启动对照;低优先级和单线程只是待测方案,不是性能修复证明。 -- 指纹/语音同时命中后的唯一 seek、网络失败缓存保留和所有 UI/迁移交互,仍属于后续接线阶段。 -- 现有源码/规则直接证据与本地 JVM 反例测试足以限定 Phase 1 的语法合同,但**不等于整项最佳实践评审已完成**。Phase 2 前还需补齐其官方识别器/平台调度资料、相关上游 issue/回退讨论、成熟项目实现,以及目标设备测量;论文或外部 benchmark 若不适用于本地设备,必须记录不适用原因而不照搬性能结论。 -- 星落 APK 仅完成只读包目录对照,实际交互、算法和性能均未验证。 - -### 提交与回滚 - -本节与六个新源码/测试文件原子提交,guard 的提交 Verification 字段记录最终测试结果,并立即创建唯一的 `recovery/AD-AUDIO-RULES-V2/-` 注释标签;不以另一次文档提交追写自身 hash。可用 `git log -1 -- app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdMatcher.java` 定位本阶段实现提交,其恢复标签由该提交的本地 annotated tag 标识。 - -回滚本阶段实现提交即可移除未接线的规则层,保留设计基线与现有播放行为;后续若已经接线,必须先回滚接线阶段再回滚本阶段,不重写历史或移动已发布标签。 - -## 13. Phase 2 决策补充:执行隔离、时间合同与验收 - -### 13.1 本轮权限与冻结基线 - -- 本轮仅更新本文件,不修改运行时代码或依赖;guard 单元为 `AD-AUDIO-RULES-V2-P2-DESIGN`。接续时工作区干净,分支 `dev2`,HEAD 为 `fa8f8959b17dd923397776fa9492152da232a819`。 -- 不重新实施 2026-08-18 的旧关键词 Provider 计划;该计划已存在实现。参考 `docs/superpowers/specs/2026-08-18-speech-ad-keyword-provider-design.md` 时,以当前代码和后续修复为准。 -- 必须保留 `532be2d7ed5b1562c36d6d85b824d5072d51b849`(语音链路修复)、`9355ea530e467c27d6fb28413c2cf6cb471ce729`(timeline reset 保留会话)及 `4cf2f76e2dea95f0ba69baeb66258d5b1a04035f`(原 Provider)已形成的行为。它们是本地保护基线,不是本轮 cherry-pick 候选。 -- Java 制品为 `app/libs/sherpa-onnx-v1.13.4.jar`,SHA-256 为 `c529915aa0c56213678065ad47f3d19c39564555c3a6d95bdbb79e8af82b88fe`;`javap` 确认 Result 有 text/tokens/timestamps/ysProbs,ModelConfig Builder 有 `setNumThreads`,没有通用 ORT session/spinning 配置入口。此检查不证明配套原生库的构建来源。 -- Sherpa `v1.13.4` 官方源码固定为 `142807252687d81b40d6315f23470a1512a00de3`;Media3 保持目录中的 `1.11.0-alpha01-fongmi`。**不升级任何 JAR/AAR/SO,不改 JNI,不引入 Vosk**。 - -### 13.2 当前调用链的具体事实 - -以下定位均对应上述本地 HEAD;风险是代码推导,尚未在 TV 上复现根因。 - -| 路径 / 符号 | 已观察事实 | 决策影响 | -|---|---|---| -| `subtitle/RealtimeSubtitleRecognizer.java:147-174`,`acceptStreaming` / `emitStreamingResult` | 只在 endpoint 或 4.8 秒自适应边界发结果;随后 reset。不是每个 PCM 帧都发累计 partial。终点有末 token 时间加 0.32 秒的估算 | 不先写一个复杂的通用 partial 去重框架;透传完整区间,但不得将估算宣称词级精确度 | -| 同文件 `acceptOffline` / `recognizeLoop` / `recognize`(177-230) | VAD 片段按采样数计算起止,后台队列容量 3;队列积压时只保留最新片段,省略较旧识别段 | 被省略的时间不能与后续文本静默拼接成跨句命中;广告 profile 需要连续性标识/重置,不能直接改变字幕现有策略 | -| `subtitle/SpeechRecognitionFactory.java` 与 `RealtimeSubtitleSpeechRecognitionFactory.java:46-80` | 门面和适配器均透传 text/startUs/endUs/token;Session 本身没有并发安全保证 | 复用现有门面,不新建另一套识别 SDK;native 会话必须有单一串行所有者 | -| `ad/audio/SpeechAdSignalProvider.java:308-314`,`recognitionListener` | Provider 转发时丢弃 `endUs` | 2B 恢复终点传递,不能用旧 HostPosition 或回调墙钟补齐 | -| 同文件 `activateLocked`(285-289)、`drainMailbox`(429-445)、`closeRecognitionSessionLocked`(634 起) | 初始化、accept、reset/close 进入 Provider 锁域;Hub 以 `DIRECT_EXECUTOR` 调用 consumer | 慢推理可让音频入队等待 Provider 锁;禁止仅“换成低优先级”后宣布阻塞消失 | -| `player/audio/PlaybackMediaSignalHub.java:247-295` | mailbox 内调用 executor;DIRECT_EXECUTOR 会在调用线程执行 drain,consumer 本身虽不在 drain 的局部锁块内,但外侧 offer/schedule 调用尚未退出 | 不把这个注册方式误认为天然异步。关闭注册与 Provider 锁还可能形成反向获取关系,入口和清理需要解耦 | -| `RealtimeSubtitleRecognizer.release`(123-144)及 `recognize`(219-230) | release 等待 `recognitionFuture.get()`;离线结果回调又可进入 Provider 锁 | 持 Provider 锁等待 native worker 可能产生循环等待;不使用超时后直接释放 native 指针作为“修复” | -| `ad/audio/AdAudioRuntimeController.java:387,390-398,498-504,566-573` | PCM 指纹与 Speech 使用同一 worker,路由白名单只加入固定 `speech-keyword` ID | 2A 分离 worker;2B 同步更新规则 ID 白名单与配置版本,否则新 matcher 命中也会被丢弃 | -| `ad/audio/AdSkipCoordinator.targetFor`(288-307)与 `player/audio/PlaybackMediaClock.Snapshot.mapCaptureToMediaMs` | Coordinator 对 captureEnd 加 media anchor、验证 fresh/generation、钳制 duration,并拒绝已经过去的目标 | 唯一坐标转换与 seek authority 保持不动;修正本文件早先可能导致重复转换的描述 | -| `SpeechAdSignalProviderTest.candidateUsesCaptureTimeAndLeavesDurationClampingToTheCoordinator` | 现有测试明确要求 Provider 不用 media duration 钳制 capture 区间 | 这是保护合同,不把该用例当作旧预期删除 | - -同时保留缓冲期间 park 而不销毁模型、seek/flush 后同会话 reset 并拒绝旧回调、模型未就绪 fail-open、旧 ASCII 单词边界和指纹独立策略。 - -### 13.3 最佳实践证据记录 - -访问日期统一为 **2026-09-08,Asia/Shanghai**。A 为当前接口/源码直接证据;B 为维护者对特定问题的解释;C 为未经本地复现的外部报告。GitHub 内容经 agent-reach 的 `gh api` 路由读取;本机 `agent-reach` 可执行程序不可用。官方网页由网页读取工具取得,没有修改代理配置。 - -| 证据类别 | 来源、revision 与已读位置 | 等级 / 支持的判断 / 局限与影响 | -|---|---|---| -| 精确上游源码 | `https://github.com/k2-fsa/sherpa-onnx/blob/142807252687d81b40d6315f23470a1512a00de3/sherpa-onnx/java-api/src/main/java/com/k2fsa/sherpa/onnx/OnlineRecognizerResult.java` 与 `OnlineModelConfig.java` | A:Java 公开 token 时间戳和线程数,不公开完整词起止或任意 ORT 配置;采用已有 API,不为本阶段新增 JNI 选项 | -| 精确上游实现 | 同 revision 的 `sherpa-onnx/csrc/online-recognizer-transducer-impl.h`,`Convert` / `GetResult` / `Reset`;`online-transducer-greedy-search-decoder.cc` | A:token 时间来自解码帧,另有 segment/start_time;Java Result 没有 start_time 字段;重置后时间对齐仍须多片段样本验证,不能仅按最新 PCM 末尾推断 | -| 官方运行时文档 | `https://onnxruntime.ai/docs/performance/tune-performance/threading.html`,thread management/intra-op/spinning | A:intra-op=1 不创建额外 intra-op worker;旋转等待、线程池和多 Session 有 CPU/延迟权衡。官方建议不能证明 WebHTV 总线程数为 1,也不能通过未暴露的 Java API“关闭全部 spinning”;只设置可用的线程预算并测量 | -| 官方平台文档 | `https://developer.android.com/topic/performance/threads`,thread priority | A:后台线程仍与渲染线程争 CPU;Android Process 优先级作用于调度,Java Thread 优先级不能作为整套 native 线程的资源隔离证明。仅广告执行入口设 background,音频/视频线程不降级 | -| 上游维护者讨论 | `https://github.com/k2-fsa/sherpa-onnx/issues/982#issuecomment-2160185844`,2024-06-11;关联 PR #989 | B:该维护者说明针对 CTC 的 token 时间性质有限。不能推广为所有模型都有完整词区间;只用其支持“模型相关、必须验收”的边界,不移植该历史 PR | -| 上游现场性能报告 | `https://github.com/k2-fsa/sherpa-onnx/issues/2151` 及 2025-12-16 维护者追问 | C:报告多实例/多线程下的性能下降,但维护者仍要求完整代码;没有可移植 TV 结论。拒绝据此做全局线程池/亲和性/native 重构 | -| 成熟 Android 应用源码 | Sherpa 同 revision 的 `android/SherpaOnnxVadAsr/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt` | A:示例有后台录音/识别与流释放流程,可对照生命周期;它是麦克风应用,不证明 Exo PCM 回调可阻塞,也不授权增加麦克风权限 | -| 独立项目对照 | `https://github.com/alphacep/vosk-android-demo/blob/a5e58ec399135f78325bd3e289849fc3fb57ce96/app/src/main/java/org/vosk/demo/VoskActivity.java`,官方 Android demo | A(边界对照):其活动/识别服务关系与 WebHTV 播放音轨旁路不同,只作 API/生命周期比较;不照搬 SDK、录音入口或性能结论。旧星落评估只对应 5.8.5,不冒充 6.0.1 证据 | -| 测试/测量与论文适用性 | 当前本地 `SpeechAdSignalProviderTest` / `SpeechAdRuntimeEndToEndTest`;外部现场报告如上 | 本轮阅读旧回归合同而不重复测试。本阶段不改变模型/识别算法,新的算法论文不决定锁所有权设计;没有同款 TV 上可迁移的公开 benchmark,所以实际性能仍是设备门槛,不用论文/博客替代。本项目定向并发测试与实际设备对照才决定是否放行 | - -以上覆盖了源码、官方文档、issue/维护者、相关应用和现场报告;不声称已完成真实设备性能证明。临时源码/JSON 保存于 `/tmp/AD-AUDIO-RULES-V2-phase2-evidence/`,持久结论和固定 revision 以本节为准。当前证据已能决定 2A 设计,不再为同一问题扩大搜索。 - -### 13.4 方案比较与推荐 - -| 方案 | 正确性/兼容性 | 性能/生命周期 | 结论 | -|---|---|---|---| -| 不改 | 旧关键词可继续使用,但新规则仍无入口 | 共享 worker 与锁等待风险不变 | 保留为回滚基线,不代表满足升级目标 | -| 原样照搬采集器/其他 ASR demo | 引入另一音轨/麦克风、SDK 或模型所有权;不解决 WebHTV capture 坐标与旧修复 | 新增 CPU、内存、包体和 native 生命周期;平台演示不是 TV 播放证明 | 拒绝 | -| 只设 numThreads=1 或只调优先级 | 不解决丢终点/新 ID 路由,也不消除持锁等待 | 可能降低竞争,仍可阻塞或死锁;降线程也可能扩大积压 | 不作为完整修复 | -| WebHTV 适配:先 2A 执行隔离,再 2B 时间与规则接线 | 保留旧配置/字幕路径、capture 合同、单 Coordinator;逐阶段验证 | 单独 native owner、有界消息、不持控制锁推理;可独立回滚,不改二进制 | **推荐** | - -#### Phase 2A:执行与生命周期隔离(本次实现单元) - -1. 创建**语音广告专用串行 owner worker**,不复用 PCM 指纹 worker;所有模型 create、Session accept/reset/close 由该 owner 串行处理。字幕默认创建入口和现有线程策略不变,广告工厂显式传入受限 profile。 -2. Host/Hub 回调只更新可见 token/状态或进入有界输入队列;无模型校验、初始化、推理、文件/日志 I/O、Future 等待。不能只把 `recognitionSession.accept` 移出 synchronized 后允许 close 并发释放指针。 -3. 控制状态锁不跨 native 调用;识别回调只投递带 instance/session/generation/timeline 的有界结果事件,不等待主线程或 Provider 锁。reset/close 先逻辑失效 token,再由 owner 执行物理 reset/release;释放未完成时不启动第二个替代会话。 -4. owner 的唤醒任务合并,PCM/结果均有固定容量;生命周期命令优先于旧 PCM。丢帧/丢识别段标记不连续并 reset 本段,不能跨缺口拼词。对于正在 native decode 的会话,超时只诊断/停用,不并行 free,不伪造“已经释放”。 -5. 广告 profile 从 numThreads=1 开始;Android background priority 仅在新广告 worker 内设置。不启用 native affinity、全局 ORT 线程池或新的 JNI 开关。新增固定枚举/计数/耗时诊断,不记录文本、关键词、PCM 或媒体 URL。 -6. 保留旧关键词规则、模式、时长与 Prefs;暂不启用新复合规则。新增慢 create/accept/close 和迟到回调的并发测试,先证明音频入口/指纹工作不必等待 ASR,再做设备共存测试。 - -拟批准路径(不是本轮实际修改授权): - -```text -app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProvider.java -app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java -app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioDiagnostics.java -app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactory.java -app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizer.java -app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProviderTest.java -app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerTest.java -app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdRuntimeEndToEndTest.java -app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizerTest.java -docs/AD-AUDIO-RULES-V2-design.md -``` - -实现若确需另增 owner/配置类或公共接口,先列出最小路径与合同变动,不能悄悄扩大此集合。既有实时字幕回归不可删除或改成永远不验证;只为广告新增 profile,并维持原调用重载的语义。 - -#### Phase 2B:新规则、时间精度和唯一跳转 - -- 透传真实识别区间并接入 `SpeechAdMatcher`;保留旧关键词集合,不把 ASCII 单词边界降为任意子串。新规则快照与规则 ID 白名单、路由 version 同步更新,旧 callback 不能使用新快照。 -- Phase 1 的整结果起止无法定位结果中间的关键字:后续需加入匹配字符范围和可选 token 对齐数据,保留旧调用重载。NFKC/BPE/字节回退或 tokens/timestamps 长度不匹配时不能逐项硬配;无可靠边界只作明确标识的近似候选,不自动执行。 -- `+0.32 秒` 与 VAD 整段终点都不是词边界真值。自动路径必须在合法标注音频上量化“末关键词结束 + post”误差;建议先冻结 **500 ms 最大额外时间误差**作为待批准门槛,超过或无法校准不启用该模型的自动路径。不能把停用自动路径当作整项任务完成,必须完成至少一个目标模型/设备的自动场景验收。 -- 只产生 capture 坐标候选;沿用 multiplexer → policy → coordinator 处理最终 seek。至少覆盖从非零 media anchor 开始、seek 后重建时间轴、片尾钳制、迟到结果、指纹/语音同时命中和冲突规则最多一次有效跳转。 -- Phase 2B 需要补充批准 matcher/配置/路由的具体范围;UI、导入冲突展示和远程来源仍属于 Phase 3,不并入 2A。 - -### 13.5 验证、资源与发布门槛 - -**决定性 JVM 回归**:使用可阻塞的 fake recognizer 和 latch/barrier,而不是 sleep 猜时序。慢 ASR 时 Hub 发布和独立指纹任务可完成;close/reset 立即使 token 失效且 native 不并发释放;积压丢弃后不跨空洞命中;配置替换只接受当前 ID;保留既有 capture/duration、park、同 Session reset、prompt/auto/undo 测试。真实模型不适合用 JVM stub 证明。 - -**最小构建**:一次 `bash ./gradlew :app:testLeanbackArm64_v8aDebugUnitTest` 的定向相关测试加 Leanback arm64 debug 构建;除相关编辑/不确定失败外不重跑。不为本阶段重建 FFmpeg/Media3/JNI/所有 ABI。共享字幕 factory 的 Android 调用/生命周期由相关测试和这一构建覆盖,不据此声称所有设备均兼容。 - -**真实 TV 对照**(批准后先冻结样本/阈值,再看结果): - -- 同一设备、OS、APK hash、模型 hash、媒体与解码器、输出/倍速、网络和温度条件;每组先预热,再至少 3 次同长度观测,报告中位数、p95 与离散范围。包括关闭语音、模型未就绪、模型就绪线程 1/2,以及语音+实时字幕同时开启。 -- 复用 `PlaybackAnalyticsListener.onDroppedVideoFrames` 的累计计数,以及已有 `onAudioUnderrun` 调试日志;后者当前受 `SpiderDebug` 开关约束,必须两组使用相同开关。不能用 UI FPS 代替视频解码丢帧;不另建一套播放器统计体系。 -- 建议门槛:无新增 ANR/音频 underrun/死锁;音频投递 p99 ≤1 ms 且单次不超过 5 ms;5 分钟样本新增视频丢帧不超过 1 帧;额外启动/seek p95 ≤50 ms;目标设备内 ASR 实时系数 <1、队列不持续增长。以上是**待批准的产品门槛,不是行业标准或已测结果**;方差跨越门槛则继续取证,不只取最快一轮。 -- 降级只能作为运行时安全保护。若正常目标负载下持续停用识别,说明功能/性能验收未通过,不能按“主播放不再卡”宣布完成。 - -**设备事实**:2026-09-08 13:40 左右 `adb devices -l` 有 `192.168.50.3:5555/5557/5559/5561` 四个在线端点,显示型号分别为 LIO_AN00、SM_N9700、V1923A、HD1910;没有指定哪个是目标 TV,也没有测试本轮模型。不能凭“ADB 在线”证明有合格 TV 环境。本轮未安装、改设置或播放这些设备;目标 TV 与合法标注音频需在设备验收前落实,不阻止 2A 的代码/并发验证准备。 - -**资源/包体/回滚**:只新增广告专用 Java worker/profile 与有界队列,不引入第二条 PCM 管线、麦克风、依赖或 native 制品;精确 APK 增量在构建后记录,不能宣称为零。2A、2B 分别原子提交并打恢复标签;回滚先 2B 后 2A,保留用户关键词、缓存、字幕默认行为。功能 flag 默认关闭不等于通过回归;任何 material regression 未解决时不得发布/宣称整项完成。 - -## 14. 设计补充与恢复校准(2026-09-08) - -> 历史说明:14.1/14.3 保留较早恢复时的草稿状态,不是当前交付状态;当前 guard 已覆盖接口及 2A 实现路径,以第 15 节为准。14.2 的安全与兼容约束仍适用。 - -### 14.1 已提交设计与未提交草稿分开记录(历史) - -本轮恢复检查时,分支为 `dev2`,HEAD 为 Phase 2 设计提交 `411171eadfe07906b912210f5a669711f4476c11`。当前请求仍是“先输出更新相关设计文档”,上一版恢复锚点也明确要求先取得实施确认;交接摘要中的“正在实施”只说明存在草稿,不能替代阶段批准。本轮不继续代码实施、不撤销草稿、不扩大 guard 范围、不构建或安装 APK。 - -当前已有 active guard `AD-AUDIO-RULES-V2-P2A`,基线与上述 HEAD 相同,其初始 dirty/protected 清单为空。本轮恢复时发现以下未提交修改,均按既有工作保留: - -| 路径(相对 `app/src/main/java/com/fongmi/android/tv/`) | 草稿内容及当前限制 | -|---|---| -| `ad/audio/AdAudioRuntimeController.java` | 已增加独立 speech worker,但生产关闭路径仍使用 `shutdownNow`;尚未证明异步 Session 释放不会被丢弃 | -| `subtitle/RealtimeSubtitleRecognizer.java` | 已增加广告 profile/单线程预算;离线 worker 的单参数 `Process.setThreadPriority` 位于线程工厂调用体,而非新线程 Runnable 内,不能视为正确的新线程降优先级实现 | -| `subtitle/RealtimeSubtitleSpeechRecognitionFactory.java` | 已增加带 profile 的创建入口;依赖下行接口草稿,尚未编译或验证默认字幕路径兼容性 | -| `subtitle/SpeechRecognitionFactory.java` | 已增加 `ExecutionProfile` 与默认创建重载,但**不在当前 guard scope 内**;不得直接提交,也不得仅修改 guard 内部状态使其放行 | - -`SpeechAdSignalProvider.java` 尚未修改:模型创建及 `accept/reset/close` 的锁内执行问题仍在。新增 worker/profile 不等于已经完成执行隔离。第 13.5 节要求的并发测试、Leanback 构建及 TV 性能验收均未对这组草稿执行。 - -### 14.2 补充到 Phase 2A 的实现与验收约束 - -1. **接口范围先闭合**:优先评估在已声明的具体工厂和 Recognizer 路径内适配广告 profile,保持字幕默认入口与现有 `SpeechRecognitionFactory` 合同不变。确需修改接口时,须先明确批准该路径及兼容策略,再安全隔离并纳入任务;不能把已有越界草稿反推为授权。 -2. **释放任务必须执行,而不只是入队**:关闭先停止新输入并使 token 失效,再由同一串行 owner 完成 Session 释放。关闭线程池的顺序必须保证最终 close 命令仍可执行;不得在排入 close 后直接 `shutdownNow()` 丢弃它,也不得在主线程等待 native 推理结束。正常关闭优先采用能保留已提交清理任务的有序停止;超时只能记录未完成状态,不能并发 free。测试须验证慢 accept 返回后 close 恰好执行一次、旧回调无效、worker 最终终止;配置替换还须验证旧 close 在新 create 之前完成。 -3. **只降低实际广告线程的优先级**:单参数 `Process.setThreadPriority` 应在新 worker 的 Runnable 开始执行时调用,不能写在线程工厂返回 Thread 前的调用体中。离线 ASR 的 Java 子线程须在自己的执行入口设置,不能假定继承父线程的 Android 调度优先级;保留字幕及音视频线程默认策略。静态/profile 单测之外,真实 Android 验收要观察执行线程,而不是仅断言 Java `Thread.getPriority()`。 -4. **验证边界不变**:保留第 13.5 节的慢 create/accept/close、reset、迟到回调、PCM 缺口、指纹并行和 prompt/auto/undo 场景。规则接线、词时间对齐、UI、远程语音规则以及任何新 native 依赖不并入 2A。通过 JVM/构建只能说明相应代码门槛通过,不能替代 TV 播放性能或完整自动跳过验收。 - -### 14.3 本轮增量证据与交付边界(历史) - -仅核对上述两个草稿风险对应的官方 API,不重复第 13.3 节已完成的上游调研: - -| 来源 / revision / 访问日期 | 等级、支持事实与决策影响 | -|---|---| -| `https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/ExecutorService.html`;Java SE 17 API;2026-09-08 | A:`shutdown()` 允许已提交任务继续执行,`shutdownNow()` 阻止等待任务开始并尝试中断执行中任务。支持“有序保留释放任务”的设计,不保证 native 推理响应中断,也不证明当前草稿能够安全终止 | -| `https://developer.android.com/reference/android/os/Process.html`;`setThreadPriority(int)` / `setThreadPriority(int,int)`,API level 1;2026-09-08 | A:单参数版本作用于调用线程,Android 优先级不由 Java 子线程自动继承。支持把设置放入各广告 worker 执行入口;不证明 native 线程预算或 TV 性能已达标 | - -agent-reach 的 Jina Reader 请求遇到 TLS EOF;本轮改由网页读取工具读取上述官方正文。该网络失败不记作源码/测试失败,不据此重跑构建。 - -本次只对本文作增量更新,源码草稿保留。交付检查限于本文 diff/结构及四个草稿的 SHA-256 保全;不重跑 Phase 1 测试。当前 guard 同时包含未验证代码和一个越界文件,不能形成安全的整体完成提交,因此不调用 `finish`、不创建新 commit/tag;不以绕过 guard 的单独文档提交掩盖该状态。 - -## 15. Phase 2A 实现与代码交付(2026-09-08) - -### 15.1 实际范围与实现决策 - -沿用 `AD-AUDIO-RULES-V2-P2A` / `standard` guard,分支 `dev2`,基线 `411171eadfe07906b912210f5a669711f4476c11`。初始 dirty/staged/protected 均为空,11 个允许路径内的代码与本文作为同一逻辑单元收尾。 - -实际改动:`ad/audio/AdAudioDiagnostics.java`、`AdAudioRuntimeController.java`、`SpeechAdSignalProvider.java`;`subtitle/SpeechRecognitionFactory.java`、`RealtimeSubtitleSpeechRecognitionFactory.java`、`RealtimeSubtitleRecognizer.java`;三个对应测试文件与本文。`SpeechAdRuntimeEndToEndTest.java` 在范围内但仅执行、未修改。没有修改 `PlayerManager`、Hub、UI、规则配置、依赖锁或 native 制品。 - -| 合同 / 位置 | 最终实现与取舍 | -|---|---| -| `AdAudioRuntimeController.Workers/close` | 指纹 analysis 与 speech owner 使用独立 executor;生产 speech worker 异步串行、无 caller-runs fallback。先令 Provider 失效并交接清理任务,再 `shutdown()` 保留已接收任务,不在宿主等待 native。 | -| `SpeechAdSignalProvider` 控制锁 | monitor 只保护状态和有界邮箱;模型检查/创建、resample、识别/reset、匹配、Hub 注册/释放和 native close 在锁外。生产模型生命周期由 speech owner 执行,Hub 继续轻量同步校验/有界复制/投递,不增加第二条 PCM 管线。 | -| `dispatchOwner/runOwnerCommand` | 合并 wakeup 循环排空命令,PCM/结果间优先 lifecycle,不依靠关停后重新 submit。独立 `submissionLock` 覆盖 scheduled 标记到 executor 接收;重复 close 也必须完成交接后才能返回。生产该锁不覆盖 native 执行;所有 dispatch 调用须在 Provider monitor 外,不能在提交锁外加 scheduled 快路径。 | -| 输入/结果边界 | 默认 PCM 邮箱 16 帧,单帧最多 192000 samples,采样率 8000–192000 Hz,拒绝空、非法时间或超限输入。结果/错误队列最多 8 项,文本最多 8192 字符。溢出立即更新 timeline token、使在途旧结果失效;合并 reset 后才接收保留的最新有界尾部,不能跨缺口拼词。 | -| seek/park/close | 同媒体 seek 保留实例并 reset;无丢帧的缓冲只 park,实际丢弃 PCM 才断开片段。关闭先失效 token/摘下引用,再由 owner 锁外清理 Hub lease/registration 和 Session。过时创建结果在 owner 关闭;reset 失败停用实例,不继续拼接旧上下文。 | -| factory/profile | 增加兼容的 `create(listener, profile)` 默认重载,旧 factory 可继续实现旧入口。广告在线/离线模型线程预算为 1,字幕默认入口及 `max(1,min(4,CPU/2))` 预算不变。广告 owner 和离线 worker 在各自 Runnable 入口设 Android background priority;不等同于全进程只有一个线程。 | -| `RealtimeSubtitleRecognizer.release` | 停止新任务后等待识别 executor 真正退出,再释放 native;被中断仍等待,完成后恢复 interrupt 标记。广告路径由 owner 等待且不持 Provider monitor。native 永不返回时仍只能保留待关闭状态,未实现强杀/超时后并发 free。 | -| diagnostics/旧行为 | speech 热路径仅内存计数,不写日志、不记录文本/URL。增加丢帧、queue peak、reset、close pending/closed、拒绝和三个 accept 耗时桶;peak 是高水位不是次数。候选仍用旧关键词 ID、capture 起点和 skipSeconds,Coordinator 保持唯一映射/seek 权限及 duration 钳制,prompt/auto/undo 不变。 | - -executor 拒绝时降级并使 token 失效,保留未清理引用,不在调用线程 fallback native free,不声称资源已关闭。生产 Runtime 的有序关停不应触发此拒绝,回归覆盖该提交顺序。 - -### 15.2 审查与修复 - -- **有效并已修复**:早期存在 Hub 清理锁反转、锁内日志 I/O、溢出未立即失效在途结果、递归 submit 在关停后丢清理、中断等待后提前 free 等风险,分别以锁外 owner 清理、内存 diagnostics、timeline token、单 wakeup drain 和真实终止等待修正,并补并发用例。 -- **独立审查发现并修复**:`ownerTaskScheduled=true` 到实际 `execute()` 之间的提交窗口,可能使新 create 越过旧 close,或让 shutdown 后清理被拒绝。追加提交交接锁和重复 close 屏障;`closeFinishesPendingSubmissionBeforeTheRuntimeCanShutdownTheOwner` 固定该窗口。按生产异步 executor 前提复核未发现新的确定反例。未调用跨模型/外部代理 CLI。 -- **保留的取舍**:仅开 speech 时,模型检查前无 capture,首次 `needsPipelineRebuild()` 为 false;创建命令在 native create 前取得 capture,后续宿主 refresh 可发现重建需求。只读核对 `PlayerManager.refreshAdAudioRuntime`、telemetry pump 及约 5 秒调度路径;不是立即唤醒或严格 5 秒保证,不改宿主策略。 -- **覆盖边界**:新增 Runtime/Hub 测试重放首次 refresh、阻塞检查/创建、capture、管线 attach/代际 reset 和 PCM 到达;另覆盖慢 close 先于替换 create、Runtime shutdown 保留最终 cleanup。这是 fake recognizer/Hub 租约测试,不是实际 Android PlayerManager/音频设备端到端验收。 - -早期 35/37/49 项通过均属不同草稿,不代表最终补丁。首次 49 项通过后因提交竞态作了相关修复,才重跑相同门槛,不是对未改变版本重复验证。最后一次增量读取 ExecutorService 页面时 Jina 返回 HTTP 401、网页读取未返回可用正文,不记作新增证据;沿用第 13.3/14.3 节已记录来源,不扩大网络研究。 - -### 15.3 最终验证与产物 - -```bash -bash ./gradlew :app:testLeanbackArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.ad.audio.SpeechAdSignalProviderTest' \ - --tests 'com.fongmi.android.tv.ad.audio.AdAudioRuntimeControllerTest' \ - --tests 'com.fongmi.android.tv.ad.audio.SpeechAdRuntimeEndToEndTest' \ - --tests 'com.fongmi.android.tv.subtitle.RealtimeSubtitleRecognizerTest' \ - --tests 'com.fongmi.android.tv.ad.audio.AdAudioDiagnosticsTest' \ - --console=plain -``` - -| 测试类 | 用例数 | failures / errors | -|---|---:|---:| -| `SpeechAdSignalProviderTest` | 21 | 0 / 0 | -| `AdAudioRuntimeControllerTest` | 20 | 0 / 0 | -| `SpeechAdRuntimeEndToEndTest` | 1 | 0 / 0 | -| `RealtimeSubtitleRecognizerTest` | 5 | 0 / 0 | -| `AdAudioDiagnosticsTest` | 3 | 0 / 0 | -| **总计** | **50** | **0 / 0** | - -最终 JVM:`BUILD SUCCESSFUL in 13s`;日志 `/tmp/ad-audio-p2a-handoff-final-tests.log`;XML 位于 `app/build/test-results/testLeanbackArm64_v8aDebugUnitTest/`。慢 create/accept/close、提交交接、reset storm、PCM 缺口、迟到回调、执行拒绝和 release 中断使用受控 fake/latch/barrier;没有用 JVM stub 代替真实模型证明。 - -随后一次 `bash ./gradlew :app:assembleLeanbackArm64_v8aDebug --console=plain`:`BUILD SUCCESSFUL in 10s`;日志 `/tmp/ad-audio-p2a-handoff-final-assemble.log`。仅执行既有 arm64 构建依赖链,无单独 FFmpeg/Media3 上游重建或全 ABI 矩阵。既有 CXX5202、废弃 API/Gradle 警告未阻止本目标,不在本次修复范围。 - -- APK:`app/build/outputs/apk/leanbackArm64_v8a/debug/app-leanback-arm64_v8a-debug.apk`。 -- 字节数:`188769225`。 -- SHA-256:`434ef9e96edfa9fdec9907e36c2dfd3eb4100f4308dc5a9700035ad450281cfc`。 -- **没有可靠的同配置、实现前 APK 基线,完整 2A 包体增量仍未测量**;只记录绝对大小,不宣称零增量。未新增模型、JAR、AAR 或 `.so` 依赖。 - -日志/XML/APK 仅为本地构建产物,不提交、上传或发布。最终代码/文档 whitespace、文档结构和 scope/commit/tag 检查在收尾执行,证据记录于 guard 归档。 - -### 15.4 交付、回滚与未完成门槛 - -**仅完成 2A 的代码隔离、确定性并发回归和 arm64 构建。** 未安装 APK、改设备设置、push、发布或启动 2B;旧关键词、默认启用开关和自动跳过策略不变。 - -真实 TV/模型的调度、与播放/实时字幕共存的 CPU、视频丢帧、underrun、启动/seek、实时系数和精度仍按第 13.5 节冻结样本后验收;无合格 TV/标注音频结果,不能宣布整项升级或性能验收完成。词时间对齐、复合规则接入、UI/导入和远程语音规则仍属于 2B/3/4。 - -实现与本文由当前 guard 一次原子提交,并创建 `recovery/AD-AUDIO-RULES-V2-P2A/` annotated 本地恢复标签;具体 commit/tag 由 `task_guard.sh finish` 输出和归档记录,不为自引用 ID 再开文档提交。回滚时先隔离其他工作,再 `git revert <2A-commit>` 回到 Phase 2 设计基线的运行时,保留 Phase 1 纯规则组件、用户配置与缓存;不使用 `reset --hard` 覆盖其他工作。 - -### Recovery anchor - -- **目标/验收**:2A 独立 speech owner、控制锁外 native/Hub 操作、有界输入/结果、逻辑失效和物理串行清理已实现;最终 50 项 JVM、Leanback arm64 Debug 构建通过。完整升级及 TV 验收尚未完成。 -- **工作区/定位**:`dev2`;基线 `411171eadfe07906b912210f5a669711f4476c11`;guard `AD-AUDIO-RULES-V2-P2A`,11 个允许路径、初始 protected 为空。本文与 6 个实现文件、3 个测试文件同一任务提交;以 `git log -1 --format=%H -- docs/AD-AUDIO-RULES-V2-design.md` 和该 guard 恢复标签定位,实际 Git/guard 状态优先。 -- **完成证据**:第 15.2 节独立审查和提交竞态修复;第 15.3 节最终 50/0/0 XML、13 秒测试、10 秒构建及 APK hash。旧 35/37/49 项是历史,不重跑 Phase 1。 -- **当前文件/符号**:Provider 的 `dispatchOwner/runOwnerCommand/close`、Runtime 的 `Workers/close`、factory profile 与 Recognizer 的 `awaitRecognitionTermination`。收尾前仅这些已验证代码及本文待提交;收尾后的实际残留以 guard/diff 为准,不丢弃已有修改。 -- **未解决边界**:真实 TV/模型性能精度、实际设备端到端、同配置实现前 APK 增量基线;native 永不返回只能保留待关闭。2B/3/4 未实施,无安装/push/发布。 -- **回滚锚点**:2A annotated recovery tag 保留已验证实现;运行时回退基线 `411171eadfe07906b912210f5a669711f4476c11`,采用可逆 revert,不覆盖用户脏文件。 -- **唯一下一步**:取得 Phase 2B(词时间对齐与复合规则接线)的明确阶段批准;未批准前不修改 matcher/配置/路由,不把代码门槛冒充设备发布门槛。 - -## 16. Phase 2B 复合规则接线(2026-09-08) - -### 16.1 实际实施范围 - -本阶段严格沿用第 13.4 节的 WebHTV 适配路线,只完成 matcher/config/routing 与候选策略接线;不修改 UI、`SpeechAdSetting` 持久化入口、远程语音规则来源、Media3/原生依赖或播放器坐标转换。 - -- `SpeechAdConfig` 增加可选不可变 `SpeechAdRuleSet`,保留旧四参数构造和 `create(enabled, keywords, ...)`,因此旧关键词配置、默认关闭和备份 key 语义不变。 -- `SpeechAdSignalProvider` 在单一 speech owner 上使用 `SpeechAdMatcher`;识别回调完整透传 `startUs/endUs`,复合规则跨结果命中时输出规则 ID、原始 capture 区间和前后窗口。Provider 不读取媒体 duration、不加 media anchor、不调用 `seekTo`。 -- 复合候选的边界仍是整个识别片段边界,`fullMatch=false` 表示其时间边界是近似的;现阶段不把 Sherpa 的 segment 终点冒充词级终点。 -- `AdAudioRuntimeController` 将复合规则 ID 纳入当前路由白名单,并将规则摘要并入有界 routing version;超长既有 source version 会先做固定 SHA-256 前缀收敛,避免破坏 mux/policy 的 128 字符合同。 -- `AdSkipPolicyController` 增加显式 prompt-only rule ID 集合。复合规则即使用户选择语音 AUTO,也只能进入确认路径;确认后仍由既有 `AdSkipCoordinator` 完成唯一的 capture→media 映射、duration/seekability/fresh-clock 校验和 seek。 -- 结果队列溢出或超长识别结果会丢弃当前文本连续性并重置 matcher;timeline reset、seek/source 生命周期和旧 callback 代际校验继续由 Provider/Hub 负责。 - -### 16.2 验收边界与未完成项 - -已覆盖的代码合同: - -- `*`、`>`、ASCII/中文动作窗口、跨识别片段、冷却和 matcher reset 由 Phase 1 测试继续覆盖;本阶段新增 Provider 端点传递、复合候选 capture 区间、timeline 隔离、规则配置摘要和 Runtime 白名单/确认模式测试。 -- 旧平面关键词仍使用 `SpeechAdKeywordSet` 的原有 ASCII 单词边界和 provider 冷却;复合规则使用独立 ID、独立 matcher 状态,不把两种协议合并。 -- 语音与指纹候选仍共享现有 multiplexer/policy/coordinator 状态机;不会绕过 Coordinator 直接 seek。Coordinator 在 prompt/seek/undo 状态中拒绝后续重复操作,因此同一时刻最多一次有效跳转。 -- 模型未就绪、识别异常、输入超限、队列溢出和无效时间继续 fail-open;不记录识别正文、规则正文、PCM、媒体 URL 或请求头。 - -明确未宣称完成: - -- Sherpa 当前公开 Java API 未提供可泛化、已校准的字级起止范围;本阶段没有新增 token/timestamp 硬配,也没有把整个 segment 的近似终点开放给 AUTO。目标模型/设备的合法标注音频误差(建议最大额外误差 500 ms)仍待实测和批准。 -- 未实现 Phase 3 的内置/用户导入 UI、冲突展示、规则持久化和语音/指纹状态页面;未实现 Phase 4 的 TV 灰度、自动模式放量和设备指标验收。 -- 未安装 APK、未连接或修改设备、未 push、未发布;旧指纹远程规则协议和缓存链路未改。 - -### 16.3 最终验证计划 - -收尾验证已按计划各执行一次以下定向 JVM 回归和一次既有 Leanback arm64 Debug 构建;这证明代码/构建门槛,不替代真实 TV/模型验收: - -```bash -bash ./gradlew :app:testLeanbackArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.ad.audio.SpeechAdRuleCodecTest' \ - --tests 'com.fongmi.android.tv.ad.audio.SpeechAdMatcherTest' \ - --tests 'com.fongmi.android.tv.ad.audio.SpeechAdConfigTest' \ - --tests 'com.fongmi.android.tv.ad.audio.SpeechAdSignalProviderTest' \ - --tests 'com.fongmi.android.tv.ad.audio.AdAudioRuntimeControllerTest' \ - --tests 'com.fongmi.android.tv.ad.audio.AdSkipPolicyControllerTest' \ - --tests 'com.fongmi.android.tv.ad.audio.SpeechAdRuntimeEndToEndTest' \ - --tests 'com.fongmi.android.tv.subtitle.RealtimeSubtitleRecognizerTest' \ - --tests 'com.fongmi.android.tv.ad.audio.AdAudioDiagnosticsTest' \ - --console=plain -bash ./gradlew :app:assembleLeanbackArm64_v8aDebug --console=plain -``` - -结果:`BUILD SUCCESSFUL in 10s`;9 个测试类共 **106 tests,0 failures,0 errors,0 skipped**。分类计数为 `AdAudioDiagnosticsTest=3`、`AdAudioRuntimeControllerTest=21`、`AdSkipPolicyControllerTest=9`、`SpeechAdConfigTest=10`、`SpeechAdMatcherTest=16`、`SpeechAdRuleCodecTest=18`、`SpeechAdRuntimeEndToEndTest=1`、`SpeechAdSignalProviderTest=23`、`RealtimeSubtitleRecognizerTest=5`。完整日志:`/tmp/ad-audio-p2b-final-tests.log`;XML:`app/build/test-results/testLeanbackArm64_v8aDebugUnitTest/`。 - -随后一次构建结果:`BUILD SUCCESSFUL in 47s`;日志:`/tmp/ad-audio-p2b-final-assemble.log`。APK 为 `app/build/outputs/apk/leanbackArm64_v8a/debug/app-leanback-arm64_v8a-debug.apk`,字节数 `188769225`,SHA-256 `0fc9c0ca0e315e57fef6c418f6b3e3d8f8a64cb4f4bc9b7890e31f2d9eea6bc4`。`git diff --check` 通过。上述 JVM/构建结果不替代真实 TV/模型验收;未安装 APK、未 push 或发布。 - -### Recovery anchor - -- **目标/验收**:Phase 2B 复合语音规则接线完成;复合候选使用识别 `startUs/endUs`、保持 capture 坐标、进入规则白名单,并由显式 prompt-only 策略阻止未经词级校准的 AUTO。最终定向 JVM/构建尚未执行。 -- **工作区/定位**:分支 `dev2`;基线 `1e5642857f326e907f5164001d31d5ea84d3dacf`;guard `AD-AUDIO-RULES-V2-P2B`;当前任务改动以 guard scope/diff 为准,初始 protected 为空。 -- **当前文件/符号**:`SpeechAdConfig`、`SpeechAdSignalProvider.processRecognitionResult/ResetMatcherCommand`、`AdAudioRuntimeController.routingSnapshotLocked/installModeResolver`、`AdSkipPolicyController.setPromptOnlyRuleIds`,以及对应配置/Provider/Runtime 测试。 -- **已完成证据**:最终定向回归 106/0/0/0(tests/failures/errors/skipped),9 个测试 XML;Leanback arm64 Debug 构建 47 秒通过,APK 188769225 bytes,SHA-256 `0fc9c0ca0e315e57fef6c418f6b3e3d8f8a64cb4f4bc9b7890e31f2d9eea6bc4`;日志路径见第 16.3 节。 -- **未解决边界**:词级时间对齐、500 ms 误差门槛、真实 TV/模型性能、Phase 3/4;无安装/push/发布。 -- **回滚锚点**:Phase 2A annotated tag `recovery/AD-AUDIO-RULES-V2-P2A/20260908204153-1e5642857f32`;本阶段采用单 commit + `recovery/AD-AUDIO-RULES-V2-P2B/`,回滚用可逆 revert,不覆盖其他工作。 -- **唯一下一步**:用 `task_guard.sh finish` 原子提交本阶段代码、测试与设计记录,并创建 `recovery/AD-AUDIO-RULES-V2-P2B/` annotated 本地恢复标签;不把 JVM/构建通过写成 TV/模型验收通过。 - -## 17. Phase 3 规则来源、设置与备份设计(2026-09-08) - -### 17.1 本轮决策问题与本地事实 - -本轮只决定 Phase 3 的**规则持久化、导入/编辑、来源优先级、备份兼容和 Mobile/Leanback 入口语义**,不实施运行时代码。Phase 3 不包含远程语音规则同步、音频指纹协议、Media3/FFmpeg/JNI/native 变更,也不替代 Phase 4 的真实设备性能和识别精度验收。 - -当前代码事实如下: - -- `app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdSetting.java` 只有 - `speech_ad_enabled`、`speech_ad_keywords`、`speech_ad_skip_seconds`、 - `speech_ad_skip_mode` 四个旧 key;`SpeechAdConfig` 已有 `SpeechAdRuleSet`,但 - `snapshot()` 尚未读取持久化的 v2 规则文档。 -- `SpeechAdRuleCodec` 已把输入限制为 UTF-8 64 KiB、最多 256 条规则,并在写入前可以给出行号错误;因此规则文档是有界配置,不应被当作无限增长的日志或数据库。 -- `SpeechAdRuleCodec` 的规则 ID 由规范文本摘要生成;同一规范规则可以稳定去重,但不同窗口不能默默合并为“最长窗口”。旧 `SpeechAdKeywordSet` 的 ASCII 单词边界语义仍必须保留,不能把旧关键词静默改写成字面量规则。 -- `SettingAdActivity` 与 `SettingAdFragment` 已有同语义但分开的设置入口,音频指纹导入已经使用 `ActivityResultContracts.OpenDocument`、`ContentResolver`、后台解析和失败保留旧规则的模式;这应作为语音规则 UI 的最小复用模板。 -- `Backup.create()` 会收集完整 `SharedPreferences`;按设置类别同步时,`Backup.APP_PREFS`/`Backup.include()` 才决定哪些 key 随设置备份。当前 `speech_ad_*` 和 `ad_audio_*` 尚未进入该白名单。 -- 产品 manifest 当前明确 `android:allowBackup="false"`。产品自己的 `Backup`/`AppBackup` 才是跨设备配置迁移合同;`AppBackup` 对 `filesDir` 只打包显式允许的子树,不能假定新文件会自动进入产品备份。 -- `PlayerManager.configureAdAudioRuntime()` 在刷新运行时时调用 `SpeechAdSetting.snapshot()`;新的设置写入成功后仍须沿用 `reloadAdAudioSettings()`,由现有 Runtime/Coordinator 重新装载,不能由 UI 直接 seek 或改动播放器坐标。 - -### 17.2 最佳实践证据与决策影响 - -本轮使用 IPv4/HTTP/1.1 直读官方页面;`agent-reach` 命令本机未安装,Jina 读取因 TLS EOF 不稳定,故不把路由器的失败当作证据。以下来源均在 **2026-09-08(Asia/Shanghai)** 访问,网页的 `Last-Modified` 作为页面 revision 记录;本地源码/测试以当前 HEAD `db8e98432ecd26029def37ff6e2bf01dc68cd793` 为准。 - -| 来源 / revision | 等级与事实 | WebHTV 适用性及决策影响 | -|---|---|---| -| Android Developers,`https://developer.android.com/training/data-storage/shared-preferences`;页面 `Last-Modified: 2026-03-05` | A:`SharedPreferences` 面向相对较小的 key-value 集合;`apply()` 立即更新内存并异步落盘,`commit()` 同步落盘且不应在主线程调用;页面同时提示新项目优先评估 DataStore | 保留现有 `Prefers` 作为四个标量设置和一个受 64 KiB 限制的规则文本槽位,写入前先完整解析/规范化;不在 UI 线程调用同步 commit,不为本阶段引入 DataStore 迁移。若规则未来超出当前硬上限,另开存储迁移阶段。 | -| Android Developers,`https://developer.android.com/training/data-storage/shared/documents-files`;页面 `Last-Modified: 2026-09-01` | A:Storage Access Framework 的 `ACTION_OPEN_DOCUMENT` 让用户从文档提供者选择文件;应用通过返回的 URI/`ContentResolver` 读取,不需要自行申请共享存储读权限 | 语音规则使用现有 `OpenDocument` launcher,接受 `text/plain`/文本扩展名并严格限制 UTF-8 字节数;读取成功后立即复制为应用自己的规范文本,不保存外部 URI 权限,避免源文件被删或权限变化影响播放。 | -| AndroidX API reference,`https://developer.android.com/reference/androidx/activity/result/contract/ActivityResultContracts.OpenDocument`;`Added in androidx.activity 1.2.0` | A:`OpenDocument` 是 Activity Result contract,可按 MIME 类型创建文档选择 Intent;当前项目已有相同 API 调用,不需要新增依赖或回退到旧的 request-code API | Mobile/Leanback 各保留一个 launcher,但把解析/保存委托给同一设置语义;两端不能各自实现不同的规则格式。 | -| Android Developers,`https://developer.android.com/identity/data/autobackup`;页面 `Last-Modified: 2026-02-26` | A:系统备份通过 include/exclude 控制 `sharedpref`/`file` 等 domain,并有传输类型和配额约束;系统备份配置不等于产品自定义导入导出 | 因为 WebHTV 关闭 `allowBackup` 且已有自定义 Backup JSON/ZIP,Phase 3 显式把新偏好 key 加入 `Backup.APP_PREFS`;不依赖系统 Auto Backup 迁移规则文档,也不在本阶段修改 manifest。 | -| Android Developers,`https://developer.android.com/develop/ui/views/touch-and-input/keyboard-input/navigation`;页面 `Last-Modified: 2026-05-28` | A:交互控件应可获得焦点;方向键/D-pad 默认按布局猜测,错误时可用 `nextFocusUp/Down/Left/Right` 明确指定;必须逐控件测试方向导航 | Leanback 新增的每一行继续使用现有可聚焦 row 样式;若新增管理对话框改变焦点路径,补显式焦点属性和源码测试,并把真实 D-pad 测试列为设备验收,不以 XML 静态检查冒充设备通过。 | -| 本地 `SpeechAdRuleCodec`、`AdAudioRuleStore`、`SettingAdActivity`、`SettingAdFragment`、`Backup` 及其测试 | A(项目源码):已有有界解析、规范化、临时文件/原子替换、后台导入、旧快照保留、双入口和自定义备份框架 | 采用已有 WebHTV 约定,避免引入新依赖、新 JSON 协议或第二套播放器设置架构;新增行为必须有规则 codec、设置、备份和两端入口的定向测试。 | - -### 17.3 方案比较与推荐 - -| 方案 | 正确性、兼容性与维护 | 性能、备份与可回滚性 | 决策 | -|---|---|---|---| -| 不变更:继续只有旧关键词四个 key | 风险最低,但用户提供的 `*`/`>` 规则没有可用入口;无法展示规则来源、数量和错误 | 不增加解析或 UI 成本,但 Phase 3 目标不成立 | 作为回滚基线,不满足目标 | -| 原样采用平台“现代”路线:DataStore + 新 Preference 页面 + 系统 Auto Backup | 与当前 Java/`Prefers`/自定义 Backup 架构不一致;引入异步状态迁移、依赖和两套恢复合同;系统备份还受 manifest 当前关闭状态影响 | 可能更适合未来大量结构化设置,但本阶段范围大、回滚面大,不能解决既有两端设置复用 | 拒绝本阶段原样采用,保留为未来独立迁移候选 | -| 直接把规则文本塞进现有 `speech_ad_keywords` | 会破坏旧 ASCII 单词边界、旧备份值和 UI 的关键词语义;非法复杂规则也难以区分 | 失败时容易覆盖旧设置,恢复和诊断不清晰 | 拒绝 | -| **WebHTV 适配:标量沿用 Prefers,规则文档使用独立 bounded key,SAF 导入后规范化,产品 Backup 显式收录** | 保留旧四 key 和旧关键词 matcher;新增 `speech_ad_rules_v1` 不改变旧用户语义;用户编辑/导入共享同一 codec;全部失败都保留旧有效文档 | 64 KiB/256 条硬上限控制内存与解析成本;`apply()` 不阻塞 UI;自定义 Backup 能随 key 清除/恢复;默认 feature flag 关闭,可单 commit/revert | **推荐实施** | - -这里的“独立 bounded key”不是把复杂规则伪装成关键词:它是新 key `speech_ad_rules_v1`,只存 `SpeechAdRuleCodec.serialize()` 的规范纯文本;旧 `speech_ad_keywords` 永不迁移、覆盖或改变边界语义。选择 key 而不是新增内部文件,是因为产品 `Backup.create()`/`restorePrefers()` 已经对偏好有完整恢复合同,而 `AppBackup` 当前会跳过任意未列入允许子树的 `filesDir` 文件;不为一份最多 64 KiB 的设置文本新增文件归档协议。 - -### 17.4 推荐设计的具体合同 - -#### 持久化与来源 - -新增的稳定 key 及默认值: - -```text -speech_ad_rules_v1 = "" # 规范纯文本;空表示无用户文档 -speech_ad_rules_source = "" # "user" 或 "imported",仅记录当前文档来源 -speech_ad_builtin_enabled = false # 内置规则包默认不参与运行时匹配 -``` - -- 内置规则随 APK 放在 `app/src/main/res/raw/speech_ad_rules_v1.txt`,只读、可版本化,不写入用户 key。首期只放保守的多片段过渡句模板;宽泛单词、色情/儿童保护含义和“下集预告”不进入默认启用库。 -- `speech_ad_builtin_enabled=false`、`speech_ad_enabled=false` 共同保持当前默认关闭行为。用户打开语音能力后,旧关键词仍按旧 matcher 工作;只有用户显式打开内置规则后,内置规则才加入 `SpeechAdConfig.rules()`。 -- 用户编辑或导入都替换当前自定义文档,分别把 `speech_ad_rules_source` 记为 `user` 或 `imported`;两者不是两个并行规则池,避免用户不知道哪一份生效。内置规则与自定义文档合并时按 `custom > builtin` 的稳定 ID 优先;规范文本相同只保留一条,ID 相同但规范文本不同则整次合并失败并保留旧有效快照,不取最长窗口。 -- `SpeechAdSetting.snapshot()` 只返回已验证的 `SpeechAdConfig`。写入入口先执行完整 parse、canonicalize 和上限检查,再一次性写 key;解析失败不得清空或部分写入旧文档。外部旧备份若带有非法值,运行时 fail-open、显示配置错误,并继续保留旧关键词路径。 -- 旧关键词**不自动转换**为 v2 规则。这是对第 5.3 节早期“自动转换”草案的明确修正:两种匹配边界不同,静默转换会把 `ad` 等旧边界语义改变为字面量命中,造成兼容回归。 - -#### UI 与交互 - -- 保留现有四个语音设置行;新增一个“语音规则”管理行和一个“内置规则”开关/状态行。摘要至少显示:当前来源(无、内置、用户编辑、用户导入)、有效规则数、内置开关、最近一次校验错误。规则正文不写日志。 -- 管理入口提供“编辑当前自定义规则、导入文本规则、清空自定义规则、恢复/查看内置规则”四个动作。编辑器使用多行 `EditText`;确定时先在后台或确认回调边界完成 codec 校验,错误必须带行号且保留旧快照。取消不产生写入。 -- 导入使用独立的 `ActivityResultContracts.OpenDocument` launcher,优先 MIME `text/plain`,同时按文件名接受 `.txt`;通过 `ContentResolver` 限制读取字节、严格 UTF-8 解码和解析,成功后只保存规范文本,不保留 URI。JSON 指纹导入入口和语音规则入口保持分离。 -- Mobile 与 Leanback 只共享设置语义/`SpeechAdSetting`,不强行共享现有 Activity/Fragment 基类;两端资源、按钮结果、错误文案和来源摘要保持一致。Leanback 每个新 row 必须 `focusable`,必要时添加 `nextFocus*`;源码测试覆盖入口、资源 ID、launcher 和 reload 调用,目标 TV 再执行 Up/Down/Left/Right 走查。 -- 每次成功编辑、导入、清空或切换内置规则后调用现有 `reloadAdAudioSettings()`。UI 不创建 Provider、不持有 native Session、不做 `seekTo`。 - -#### 备份、恢复与迁移 - -- 将 `speech_ad_rules_v1`、`speech_ad_rules_source`、`speech_ad_builtin_enabled` 加入 `Backup.APP_PREFS`,并用 `BackupPreferenceFilterTest` 验证:完整备份和 settings-only 同步包含三者,config/spider-only 不包含;不修改旧 key 的归属。 -- `Backup.create()` 已收集完整偏好,因此新 key 会进入完整产品备份;`Backup.restore()` 的 `restorePrefers(clear=true)` 会对新备份按 key 恢复,缺少这些新 key 的旧备份按默认值回退,不需要解压额外文件。系统 Auto Backup 仍不作为产品恢复合同。 -- 任何写入前都以规范文本为唯一持久化形式;不备份外部 URI、原始非法文本、解析错误、识别正文、媒体 URL 或请求头。恢复后沿用现有 `reload()`/播放器刷新路径,第一次运行仍保持语音默认关闭。 -- 旧用户升级不迁移关键词、不丢失关键词、不自动打开内置规则。回滚本阶段后,新 key 可被忽略,旧四 key 和旧 matcher 仍可读取;若已产生新 key,下一次设置恢复不会覆盖旧 key 的合法值。 - -### 17.5 Phase 3 最小实施单元、验收与回滚 - -获得明确的“开始实施”后,按以下一个可逆阶段执行;本设计阶段不修改这些路径: - -```text -app/src/main/java/com/fongmi/android/tv/ad/audio/SpeechAdSetting.java -app/src/main/java/com/fongmi/android/tv/bean/Backup.java -app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java -app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAdFragment.java -app/src/leanback/res/layout/activity_setting_ad.xml -app/src/mobile/res/layout/fragment_setting_ad.xml -app/src/main/res/raw/speech_ad_rules_v1.txt -app/src/main/res/values/strings.xml -app/src/main/res/values-zh-rCN/strings.xml -app/src/main/res/values-zh-rTW/strings.xml -app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdConfigTest.java -app/src/test/java/com/fongmi/android/tv/bean/BackupPreferenceFilterTest.java -app/src/test/java/com/fongmi/android/tv/ui/activity/SpeechAdSettingSourceTest.java -``` - -验收至少包括: - -1. codec 正例/负例、64 KiB/256 条上限、重复 ID/不同窗口冲突、BOM/UTF-8、导入失败保留旧值;旧关键词 ASCII 边界回归不变。 -2. `SpeechAdSetting` 的默认关闭、旧 key 兼容、内置开关、自定义来源、规范 round-trip、坏恢复值 fail-open 和 `reloadAdAudioSettings()` 接线。 -3. 完整 Backup、settings-only、旧备份缺 key、三种新 key 的 include/filter 回归;不重复运行与本阶段无关的全量矩阵。 -4. Mobile/Leanback 的入口、资源、焦点 row、导入 launcher 和错误摘要源码测试;一次受影响的 Leanback arm64 JVM 定向测试及一次 Debug 构建。若有目标设备,再单独记录 D-pad 方向走查,不能用构建结果替代。 - -回滚采用本阶段单 commit 的可逆 `git revert`:先关闭/移除内置规则参与和新 UI,再由旧四 key/旧关键词路径继续运行;不删除用户偏好或改动指纹缓存。若某个规则文档恢复失败,保留上一份规范文档并让语音 Provider fail-open,绝不清空指纹规则或阻塞主播放。 - -### 17.6 决策状态与恢复锚点 - -- **建议**:按第 17.3/17.4 节实施 Phase 3;这是当前最小、兼容现有 Java/Prefers/Backup、无新依赖且可独立回滚的路线。 -- **仍需用户批准**:本记录是设计研究和实施计划,不授权修改第 17.5 节代码/资源路径;收到明确“开始实施”前不得启动 Phase 3 实现 guard。 -- **当前未完成**:内置规则内容的最终文案审查、真实 TV D-pad 体验、模型/标注音频精度、Phase 4 灰度和设备指标;本节不声称这些门槛已通过。 -- **当前工作区/定位**:分支 `dev2`;HEAD `db8e98432ecd26029def37ff6e2bf01dc68cd793`;当前 guard `AD-AUDIO-RULES-V2-P3-DESIGN`,仅允许 `docs/AD-AUDIO-RULES-V2-design.md`,初始 protected dirty 路径为空。 -- **已完成证据**:本节完成本地代码事实审查和官方 Android 存储/SAF/备份/TV 导航证据记录;未运行 Phase 3 代码测试或构建,也未修改 APK/设备。 -- **回滚锚点**:P2B recovery tag `recovery/AD-AUDIO-RULES-V2-P2B/20260908220006-db8e98432ecd`;本设计提交可单独 revert,不影响 P2B 已验证运行时。 -- **唯一下一步**:等待用户批准“按第 17 节开始实施 Phase 3”;若批准,先重新核对工作区并启动 `AD-AUDIO-RULES-V2-P3` 实现 guard,不重复 P2B 的 106 项测试或 47 秒构建。 - -## 18. Phase 3 实施记录(2026-09-09) - -用户继续执行“按计划实施”的目标后,沿用 `dev2`、设计提交 -`da8d4d245f0866dd0ec879d4e076b8858e3027df` 启动 `AD-AUDIO-RULES-V2-P3`。 -初始工作区干净;本单元只修改第 17.5 节的 13 个实现/测试/资源路径及本文。 -此前第 17.6 节的“等待批准”是设计阶段的历史状态,不再是本阶段下一步。 - -### 18.1 实现时的兼容与安全约束 - -- 使用同一个 `SharedPreferences.Editor.apply()` 发布规范文本和来源,不分别写两个 key。 - 官方 `SharedPreferences.Editor` 的 `apply()` 合同(2026-09-09 复核)只保证内存更新和异步写盘,不提供写盘失败回执;不把 UI 的“已保存设置”描述成已通过断电持久性验收。来源: - `https://developer.android.com/reference/android/content/SharedPreferences.Editor`。 -- 同一主体的不同窗口生成不同 ID,所以只检查 ID 不足以满足第 5.4 节。 - 编辑、导入、打开内置库和恢复均在发布前检查主体/窗口冲突;明确指出冲突位置, - 用户通过编辑保留一个窗口,或关闭内置库后使用自定义窗口。不得自动选最长窗口或同时启用两者。 -- 合并后的总量同样受 256 条/64 KiB 限制;超限整次拒绝,原设置不变。 - 原文为空的编辑是显式清空;空文件或只有注释的导入不是清空请求,应拒绝。 -- 缓存同配置的不可变规则快照,防止设置摘要反复解析/散列,或因新对象导致 Runtime 将相同配置当成替换配置。 - 内置资源只读一次;不改变 Provider、模型线程、唯一 seek authority 或复合规则 prompt-only 策略。 -- 从产品备份恢复的三项新配置按一个验证单元处理;错误类型、语法、超限和冲突不覆盖旧有效配置。 - 没有新 key 的旧完整备份仍按第 17.4 节恢复为新功能默认值。只清理这三项新配置,不修改无关恢复行为。 -- 外部提供者异常只转成固定错误,不把 URI、路径、正文或任意异常消息写到摘要/日志。 - 导入完成后成功才刷新 Runtime;页面退出不应阻止已经成功写入的配置通知现有播放器。 - -### Recovery anchor(验证完成,待提交) - -- **目标**:第 17 节的本地语音规则设置、双端 UI、备份、定向回归和构建;全局 V2 目标仍包含未完成的真实模型/TV 验收,不缩减为本阶段。 -- **文件/状态**:`SpeechAdSetting`、`Backup`、双端 `SettingAd*` 与 layout、raw 规则、三组字符串和三份 P3 测试已完成;保留旧四个关键词 key,不自动迁移;新规则采用 64 KiB/256 条上限、严格 UTF-8、规范化存储、来源记录、内置开关、冲突拒绝和坏恢复值保护。导入失败不刷新 Runtime,成功导入即使页面退出也通知现有播放器;管理菜单包含编辑、导入、清空和查看内置规则。 -- **证据**:定向 `:app:testLeanbackArm64_v8aDebugUnitTest` 选择 `SpeechAdConfigTest`(13)、`SpeechAdRuleCodecTest`(18)、`SpeechAdKeywordSetTest`(9)、`BackupPreferenceFilterTest`(11)、`SpeechAdSettingSourceTest`(2),合计 **53 tests,0 failures,0 errors,0 skipped**;日志 `/tmp/ad-audio-p3-focused-tests-final.log`,XML 位于 `app/build/test-results/testLeanbackArm64_v8aDebugUnitTest/`。`bash ./gradlew :app:compileLeanbackArm64_v8aDebugJavaWithJavac --no-daemon --console=plain` 成功(19s,47 tasks up-to-date);日志 `/tmp/ad-audio-p3-compile-final.log`。P2B 历史 106 项结果不作为 P3 证明。 -- **设备**:只读 `adb devices -l` 可见五个连接,尚未确认各自测试用途/TV 代表性;未安装、启动或改变设备应用。 -- **回滚**:基线 recovery tag `recovery/AD-AUDIO-RULES-V2-P3-DESIGN/20260908233846-da8d4d245f08`,本阶段最终采用原子 commit/tag,不 push。 -- **边界**:本阶段没有生成或安装 APK,没有执行真实 TV/D-pad 走查、模型/标注音频精度、启动/seek 性能或包大小/hash 验收;这些仍属于后续设备/Phase 4 工作。未修改 native、Media3、FFmpeg、JNI、远程规则协议或播放器核心。 -- **唯一下一步**:运行 `bash .codex/scripts/task_guard.sh finish --verified ... --commit-message ...` 原子提交本阶段并立即创建唯一 annotated recovery tag;不 push。 diff --git a/docs/AV-DIAG-01-playback-diagnostics.md b/docs/AV-DIAG-01-playback-diagnostics.md deleted file mode 100644 index 2b2b2efa00..0000000000 --- a/docs/AV-DIAG-01-playback-diagnostics.md +++ /dev/null @@ -1,1042 +0,0 @@ -# AV-DIAG-01:音视频全链路调试日志改造方案 - -## 2026-09-17 直播播放参数入口补齐 - -- 用户授权优先补手机直播播放参数按钮,并在同样简单时同步电视端;当前基线 `8dd32cb9aab018a9d2c74437d235bc0e1912eded`,guard `AV-DIAG-01-live-params` / quick-fix,保护104个原有 `app/.cxx/` 文件。 -- 两端直播已经持有 `PlayerOsdController`,缺少与点播 `VideoActivity.onPlayParams` 对等的入口。复用其全局设置、实时数据及采样生命周期,补手机顶部图标、直播控制行/控制面板和电视控制行;开关后隐藏控制层以显示参数,按钮选中状态及重新进入时的状态同步。手机图标48dp,电视沿用原横向滚动及遥控焦点顺序。 -- 本单元只是已有能力的入口补齐,不改变解码/自动回退、音频输出、网络、native或采样频率;采用已经存在的本地设计,按局部修复处理,无需扩展上游研究。验证为Mobile ARM64打包、Leanback ARM64 Java/资源编译和手机一次入口检查。 -- 同时只读监听用户的AV3A/AVS3直播问题。已保留 `/private/tmp/webhtv-live-avs-20260917/` 日志;当前安装的是release包,使用Web增量日志和ADB logcat,不依赖run-as。普通直播源已观察到 `video/avc` 1920×1080与 `audio/av3a` / `libarcdav3a`,不能将其误称为AVS3视频;MPV部分尝试尚未到file-loaded,须另行核对加载链路。 -- 最小编译已通过:同一次Gradle完成 `compileLeanbackArm64_v8aDebugJavaWithJavac` 和 `assembleMobileArm64_v8aDebug`,耗时2分1秒,日志 `build/avdiag-live-params/gradle.log`。APK SHA-256 `d1927aa145edcd719f47626d737f67b49092cbb842a145a933c2917a0aaba916`,181893921字节,50个原生条目与AVS3 MediaCodec基线相同;身份记录 `build/avdiag-live-params/apk-identity.json`。UI接线复用已有实现,不新增镜像单测。 -- 日志已进一步定位MPV加载失败:21:45尝试出现HTTP 400、首个HLS分片加载失败和 `avformat_open_input()` 失败,另一次代理请求30秒超时;不能归因于AVS3解码器不支持。21:37本地4K50测试片实际为 `video/avs3` / `avs3.32.55` / 3840×2160 / 50fps / 10bit + `audio/av3a` / 48kHz / 9声道;这不能代替随后4K直播源尚缺的实际轨道证据。 -- 当前状态:按钮实现和两端编译完成,用户随后确认“打个tag,播放参数面板有了”,据此验收手机入口并关闭可选验证。期间USB多次断开,安装辅助脚本超时,自动UI检查未形成有效结果;最终设备APK哈希为 `d125250477df01ec518201eaecfffaa68c89f77f29b18aaacc32878b119ba8a1`,不同于上述本机构建,不把它记为本候选安装校验通过。手机入口以用户确认作为运行证据,电视端仅有编译验证。直播解码/请求问题仍待后续处理,不包含在本次按钮验收中。回滚为撤销本单元提交,解码库不变;唯一下一动作:guard原子提交并生成本地恢复tag,不推送。 - -> 状态:2026-09-15 **D0–D5实现与对应产物已补齐**,覆盖索引见14.13,软件验证及交付记录见14.14。设备实播、T01–T25和性能A/B由用户实测,不记作已通过;物理屏幕/扬声器等平台不可观察边界保留。 -> -> 2026-09-14夜间评审的持续记录与查名操作边界(9.2.1、9.3.1)已合入当前实施文档;原隔离工作区仅作输入。 -> -> 目标:没有 ADB 的手机、电视、盒子、模拟器,仅通过 App「调试日志」及其导出文件,重建一次播放的输入、选轨、解码、视频输出、音频输出、策略变化和故障恢复链路,得到有证据的根因或明确的剩余观测边界。 - -## 0. 范围、基线与交付位置 - -- 编写日期:2026-09-13,Asia/Shanghai,UTC+08:00。 -- 代码审阅基线:WebHTV `feature/mpv-dv7-fel`,HEAD `0a82dc13e255524d7c0e4e04c2f51ec9119aec88`;文中本仓库位置均相对此工作区。 -- 本地 Media3 源码基线:`e3e922d5c01bc0b564849940fe589daf37360d15`,来自 `third_party/media-lock.json`;该锁另有补丁和 artifact overrides,**不能把基线源码等同于每个最终 AAR**。 -- 原设计时 FEL guard 为 `P2-4-fel-vo-handoff`;2026-09-14 实施前核实最新 `P2-4-fel-warmup-push` 已 finished。D0 基线为 `feature/mpv-dv7-fel` / `2ec5afd8cc3f21bf1693b198f87018488775c660`,独立 guard `AV-DIAG-01-D0`,保护已有 `app/.cxx/` 全部 70 个未跟踪文件。 -- 原设计交付:`/private/tmp/webhtv-av-diagnostics-2026-09-13/AV-DIAG-01-音视频全链路调试日志改造方案.md`。相关已读取资料保存于同目录 `evidence/`,实施验证也保存在该临时目录;当前任务文档位于仓库 `docs/AV-DIAG-01-playback-diagnostics.md`。 -- 已归档为 `docs/AV-DIAG-01-playback-diagnostics.md`,原临时文档及证据快照保留。`AV-DIAG-01` 是用户插入需求标识,不占用、改写既有上游 E/P/C 任务编号;D0–D5 的后续工作均续写本文件。 -- 范围:Exo/Media3、其 FFmpeg/nextlib 扩展、MPV、仍可用的 IJK,以及公共播放/Surface/AudioManager/日志导出层。网络、代理、DRM、字幕/LUT、生命周期作为解码和输出故障的必要上下文,不扩展成全量抓包或系统监控。 -- 原评审非目标:修复模拟器黑屏、改默认硬解/软解策略、自动切播放器、自动更改直通、升级依赖、读取其他 App 日志或要求用户打开 ADB。14.10的全文实施授权包含必要的native诊断hook和同锁重编;不改变原播放策略。 -- 夜间复核基线:`5cde3c015258f620f264d5f3ffe0a437c2ea3d48`。原工作区有活动 guard `E4-LIBASS-stage1` 及 ASS 相关既有改动,本轮全部保护;在 `/private/tmp/webhtv-avdiag-review-20260914`、分支 `assessment/av-diag-01-log-review-20260914` 中仅修改本文件,guard 为 `AV-DIAG-01-review-20260914`。不改变原工作区的 HEAD、索引、文件或任务状态。 -- 持续输出补充:用户随后要求明确持续记录方式;在同一隔离分支、基线 `2ac420f8c038e5a6016cc0169cd80b8b4c8608d3` 上,仅细化9.2.1和T21,guard `AV-DIAG-01-continuous-log`。这是后续实施的设计约束,未修改现有日志代码。 - -### 0.1 验收承诺的正确边界 - -**不能诚实承诺“任何视频不显示/任何无声,仅凭 App 日志一定定位到最终根因”。** Android 不向普通 App 开放全部厂商解码器、SurfaceFlinger、AudioFlinger、HDMI/功放及物理屏幕/扬声器内部状态。硬解回调、AudioTrack 写入成功甚至时间戳前进,都不等于用户一定看见/听见。 - -本方案承诺的可验收目标是: - -1. App 能观测的各层都有输入、输出、失败、生命周期和关联标识;不是只有最终一行错误。 -2. 明确区分事实、配置、推断、能力声明和用户观察;未采集/不支持/过期/权限受限不伪装为正常。 -3. 可定位的故障提供具体失败操作、实际参数、原始错误与前后状态;不可定位的故障提供最后一个已证实正常的边界、缺失证据,以及**一个**最小区分步骤。 -4. 单一音频或视频失败不会被另一条正常链路、`READY`、进度前进或封面图片掩盖。 -5. 导出包自身能够说明信息是否完整。日志被截断、限流、丢失、尚未落盘时,不输出“已排除某层故障”的假结论。 - -## 1. 问题样本与需要避免的误判 - -### 1.1 已有用户样本 - -2026-09-12,两份 `webhtv-debug-log` 对应同一模拟器、同一网盘原画 MP4。媒体为 HEVC Main10、1920×1080、24fps、SDR、AC3 5.1。敏感网盘链接和 Token 不复制到本文。 - -| 已观察到的事实 | 能说明什么 | 不能说明什么 | -| --- | --- | --- | -| Exo 使用 `OMX.qcom.video.decoder.hevc`,收到首帧回调,`READY`、进度持续前进 | 解码/播放接口至少进入了对应状态 | 屏幕真实画面正确;名字含 qcom 不证明模拟器使用真实高通硬件 | -| Exo `renderedFps=24` 来自呈现时间戳间隔估算 | 媒体/提交帧的时间戳节奏 | 实际屏幕刷新了 24 张不同的正确画面 | -| 首帧附近有 `syncVideoSurfaceSize fixed=1280x720` | App 实际请求过固定 Surface 尺寸 | 黑屏一定由该操作导致;请求值也不等于下一次 callback 的实际尺寸 | -| MPV `Failed to initialize a decoder for codec 'hevc'`,之后 `vid=no aid=1` | 视频初始化失败,音频仍选中 | 设备根本不能硬解;也不能只因音频继续而认为整次播放正常 | -| MPV 存在视频轨元数据,仍打印 `source=mpv-playback-restart` 的 `first-frame` | 当前上层把就绪信号当作首帧标记 | 原生层已经输出有效视频帧 | -| 画面是“夸父”静态标识 | 可能是无视频时的 artwork/封面 | 不能称为“视频卡在第一帧”,必须记录画面来源 | -| 用户补充原版影视 TV 同设备同视频能硬解 | 应优先做 App/内核/配置差异对照 | 尚未拿到原版准确版本、内核、实际 decoder/VO/AO,不能假定两条执行链完全相同 | - -此案例必须成为后续诊断验收 fixture;**它不是本文已经完成的修复或可复现设备测试**。 - -### 1.2 要覆盖的问题族 - -- 完全黑屏、有声无画、封面占位、首帧后冻结、seek/切集/横竖屏后黑屏、绿屏/紫屏/花屏、HDR/DV 输出错误。 -- 无声、有画无声、音轨未选中、音量/焦点导致静音、PCM 全零、声道映射错、爆音/断续/缓冲不足、直通和 offload 失败、蓝牙/HDMI/eARC/USB 路由切换后无声。 -- 硬解创建/配置/启动失败、运行中失效、资源耗尽、厂商能力误报、软解扩展未加载、ABI/库组合不匹配、用户选项未生效。 -- 资源不是预期视频、清晰度/转码变化、代理/Range 错误、音视频某一轨无数据、DRM/key 不可用、PTS/DTS/同步问题。 -- 日志开启过晚、应用卡主线程、native 卡住/崩溃、进程被杀、日志洪泛、导出丢上下文。 - -### 1.3 2026-09-14 新日志复核与设计增量 - -用户再次报告模拟器上 Exo、MPV 硬解均无影片画面,提供 `IMAGE 2026-09-14 23:16:43.jpg` 和 `webhtv-debug-log (3) (1).txt`。截图显示 MPV/硬解设置及静态网盘标识;新 TXT 的播放 trace 是 `p-6i9b-3`,只覆盖 MPV,不能据此补出 Exo 的失败链。App 为 5.6.0(560)、buildTime `202609141846`、Android 14/API34、mobile arm64_v8a;用户 APK 的 Git/native revision 仍未采集,不能把本地源码直接当作该 APK 的精确来源。 - -| 新证据(TXT 的 logSeq) | 可作出的判断与边界 | -| --- | --- | -| 1040:`hevc_mediacodec: Failed to getCodecNameByType(video/hevc, 2)`,后续多次出现 | HEVC Main10 对应的查名调用未返回 decoder 名称。尚缺本次候选、过滤原因、JNI/能力查询结果;不能等同于设备不存在 HEVC 硬解 | -| 1042:`MediaCodec 0x0 failed to start` | 未获得可用 codec;本地 FFmpeg 的通用失败出口也打印此句,不能仅凭文字把失败操作定为 `MediaCodec.start()`。Java 查名失败可在创建前返回,NDK 分支还可能按 MIME 创建,实际分支必须另取证 | -| 1055、1077:`Software decoding fallback is disabled`;1228:`vid=no aid=1` | 视频失败后没有软解兜底,音轨仍选中。不能以有视频元数据、进度前进或 `mpv-playback-restart` 的兼容首帧标记证明视频输出;aid 有效本身也不证明物理有声 | -| 导出含 64 行 `mpv-native`,按事件身份去重后为 32 条,最后为1090;未见 native 限流汇总 | 与当前32条/5秒窗口吻合,提示尾部证据可能未导出;不能把相同文字的不同尝试合并,也不能凭条数断言具体丢失数量 | -| manifest:`droppedNormal=0`、`droppedCritical=0`、`nativeOverflow=not-collected`、`complete-within-declared-window` | 只说明已进入 D0 sink 的声明窗口,不能证明进入 sink 之前的 native 过滤、限流或队列没有损失 | - -上述去重身份为 `processRunId=4cffb428-81fc-4286-9239-ed8a73e587f6`、`captureGeneration=7` 加 `logSeq`;pinned 与时间线中的同一事件只算一次,不能按消息正文去重。附件不复制进仓库,不记录网盘凭证。 - -**原方案已覆盖的内容不重列为缺口:** V01/V02 的 Exo 候选和尝试链、S01–S05 的 Surface/封面、M04/M09 的视频局部失败、9.2 的分级限流、T24 的原版对照,都属于待实施项。此次补充的是两项原来不够具体、会影响验收的契约:① M08 的 native 查名失败必须区分候选剔除、查询失败及实际后续操作(9.3.1);② 关键证据必须在上游过滤前取得,且限流后即使没有下一条日志也能导出健康统计(9.2.1)。这次仍未证明 Exo 与 MPV 同因,也未证明32位兼容、设备能力或 Surface 是根因。 - -## 2. 现有实现盘点:复用什么、补什么 - -下表保留原设计时的实现盘点,D0 后的实际交付以14.4为准;“缺口”指代码尚不能形成完整可导出证据链,不意味着方案漏写或整个仓库没有任何同类零散日志。 - -| 现有落点 | 已有能力 | 本次设计确认的缺口 | -| --- | --- | --- | -| `catvod/src/main/java/com/github/catvod/crawler/SpiderDebug.java:15` | 开关门控;字符串/Throwable 进入 DebugLogStore;同时可进入平台日志 | 没有公共结构化 envelope、字段级隐私白名单和会话完整性协议 | -| `catvod/src/main/java/com/github/catvod/crawler/DebugLogStore.java:20` | 内存列表、cache 文件、恢复、下载文本;单消息限制 12000 字符 | 当前列表和文件没有总量上限;持锁同步逐条开文件写入;失败被吞;关日志即 clear;长日志全文读回 | -| `app/src/main/java/com/fongmi/android/tv/server/process/DebugLogs.java:24` | `/debug/logs`、`/debug/logs.txt`、`/debug/stream`、启停和清空 | stream 在 version 变化时返回全文;缺游标/缺口标识、会话快照、诊断包完整性、故障标记操作 | -| `app/src/main/java/com/fongmi/android/tv/setting/Setting.java:587` | App/Android/机型/ABI 环境日志 | 缺实际加载库集合与构建标识、GPU/音频路由、配置来源、诊断能力清单 | -| `app/src/main/java/com/fongmi/android/tv/player/PlaybackTrace.java:13` | trace 和 request/prepare/tracks/ready/first-frame 阶段 | 跨进程/播放器实例、重试、预载和新资源 generation 的关联不充分;缺独立音视频终态 | -| `app/src/main/java/com/fongmi/android/tv/player/PlaybackTelemetryCoordinator.java`、`PlaybackTelemetryMapper.java` | 聚合播放/内存/网络等状态,部分字段已有来源和置信度 | 不能把估算 FPS/未知值当最终渲染证据;需与新证据事件统一语义,而非再建冲突仪表盘 | -| `app/src/main/java/com/fongmi/android/tv/player/exo/PlaybackAnalyticsListener.java:390` | decoder 名称、输入 Format、VideoSize、AudioTrackConfig、掉帧、sink error、seek、最终 error | 缺候选及剔除原因、配置/输出格式、完整 fallback 链、实际写入/呈现分层;`onAudioPositionAdvancing` 目前主要服务 seek 跟踪 | -| `app/src/main/java/com/fongmi/android/tv/player/exo/PlaybackAnalyticsListener.java:963` | 主要 decoder init 的 diagnosticInfo、最多 8 层异常类型名 | 没有同等完整的异常 message/stack/suppressed/fallback 证据;记录全部候选不能反向增加黑名单计数 | -| `app/src/main/java/com/fongmi/android/tv/player/exo/ObservedVideoFrameRateEstimator.java:11` | 由 PTS 估算源帧节奏 | 必须改进日志字段命名或标注,不能宣称物理显示 FPS | -| `app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java:420` | attach、render 切换、尺寸、Surface valid;固定尺寸请求 | 缺 Surface 生命周期 generation、actual holder 回读、窗口可见性/遮罩及解码输出绑定关系 | -| `app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java:4244` | 设置默认 artwork | 缺“当前展示的是封面/视频/遮罩”的明确日志,TV 也需等价采集 | -| `app/src/main/java/com/fongmi/android/tv/player/AudioPlaybackDiagnostics.java:98` | 原轨/当前轨、decode/output mode、降级原因、失败阶段模型 | 需自动输出到调试日志,不依赖用户打开某个 OSD/能力面板 | -| `app/src/main/java/com/fongmi/android/tv/player/engine/ExoPlayerEngine.java:631` | 从 decoder 和 AudioTrack config 区分 PCM/直通/offload | 缺具体 AudioTrack 的 route/write/head/timestamp 与有效音量链 | -| `app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java:5335` | native 音频焦点请求失败、noisy、焦点变化的部分日志 | 需贯通 Exo/native 两种 owner,记录成功、duck/resume、音量和发生顺序 | -| `app/src/main/java/com/fongmi/android/tv/player/codec/CodecCapabilityInspector.java:42` | 设备与当前媒体能力报告 | 需自动保存本次媒体相关能力摘要;面板能力声明不替代实际创建/configure 结果 | -| `app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java:1276` | native warn/error 等可直接进日志;generation 检查;主线程队列延迟;错误窗口 | 现有 5 秒 32 条窗口可能压制关键上下文;需结构化尝试链、分级保留和明确丢失统计,不全开逐帧 verbose | -| `app/src/main/java/androidx/media3/mpvplayer/MpvPropertySnapshot.java`、`MpvDiagnosticsPolicy.java` | observer 缓存、部分过期/新文件处理、限制同步查询、部分敏感信息脱敏 | 缺统一字段 age/status 与跨属性快照一致性;不能恢复为 UI 线程大量同步 JNI 查询 | -| `third_party/mpv-player-jni/src/event.cpp`、`app/src/main/java/is/xyz/mpv/MPVLib.java` | native event/property/log 回调及库加载 | 需 event queue overflow、command reply/error、native attempt/generation 和库实际来源的明确证据 | -| `app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java:7176` | 根据 MPV ready/轨道存在标记启动完成 | 诊断需独立记录 `videoAvailable` 与 `videoSelected/videoOutputActive`,不能继续只认该 FIRST_FRAME | -| `app/src/main/java/com/fongmi/android/tv/player/engine/IjkSimplePlayer.java:532`、`IjkPlayerEngine.java:219` | onPrepared/onError/onInfo、音视频快照 | 不把 IJK 当盲区;能取的属性写出,取不到的原生输出信息明确 unknown | -| `app/src/main/java/com/fongmi/android/tv/player/mpv/PlaybackRecoveryMonitor.java:145` | 卡死恢复请求、上次用户退出结果持久记录 | 不等于系统崩溃/ANR 完整记录;需公共会话 journal 与按 API 获取的退出原因 | - -## 3. 设计决策与最佳实践选择 - -### 3.1 三种方案比较 - -| 方案 | 优点 | 无法满足的部分/风险 | 决策 | -| --- | --- | --- | --- | -| 不改,只让用户换软解/抓 ADB | 无代码成本 | 无 ADB 设备无法定位;丢失真正硬解差异;只缓解不解释 | 不满足需求 | -| 原样启用 Media3 EventLogger、MPV `all=v`、FFmpeg debug | 上游现成;起步快 | EventLogger 默认面向 Logcat;缺 UI/音频实际路由;可能泄露 URL/请求头;大量日志可拖慢 native/UI,仍可能把 READY 当成功 | 只借鉴事件覆盖,不整套照搬 | -| 扩展现有日志通路,分层结构化事件+定时聚合+异常前后快照+可选深度探针 | 用户不需 ADB;保持现有入口;可判定“失败发生在哪层”;能控制开销 | 需统一 schema、owner/thread、隐私与完整性;部分底层采集需独立依赖阶段 | **推荐:WebHTV 适配方案** | - -### 3.2 不改变播放决策的原则 - -- 采集器默认只读;不能为了诊断主动 flush/recreate decoder、seek、改格式/直通/音量/硬解或重启播放器。 -- 记录“发生了 fallback”和“为什么发生”,不增加 fallback、不修改黑名单、不可让诊断错误触发恢复策略。 -- 保留已有首帧/启动行为接口;先新增语义正确的证据字段并将旧 `firstFrameMs` 标为兼容/就绪信号。修改 UI 或启动状态机属于另外的经审批行为改动。 -- Media3/MPV/IJK、前台播放/预载、音频/视频、主 Surface/OSD Surface 不能共用不带 owner 的计数器。 -- 最大化复用上游 public/UnstableApi 扩展点。当前 Media3 有 `ForwardingAudioSink`、`ForwardingAudioOutputProvider`、`ForwardingAudioOutput`;`AudioTrackAudioOutput.getAudioTrack()` 可取得其实际对象。先验证最终 AAR 的签名再接入,**不反射私有字段、不替换底层默认实现来凑日志**。 -- 只有 public hooks 无法提供必要字段时,才设计窄的 Media3/native 诊断扩展;它是单独审批、单独构建验证的阶段,不混入公共日志改造。 - -## 4. 统一事件模型与关联规则 - -### 4.1 同一个日志通路 - -```text -用户/遥控器动作、站源解析、媒体和策略 - │ - ├─ Exo Analytics + codec adapter + AudioSink/AudioOutput - ├─ MPV event/property/log + JNI + 必要 native 诊断点 - ├─ IJK info/error/property - └─ Surface/窗口 + AudioManager/route + 系统/生命周期 - │ - ▼ -结构化事件 → 隐私白名单/长度限制 → 有界优先级队列 - │ │ - ├─ 按会话快照/环形前序窗口 └─ 独立日志写线程/落盘进度 - ▼ -现有 SpiderDebug / DebugLogStore 的兼容日志视图 - ├─ App 调试日志/电视诊断入口 - ├─ /debug/logs 与 /debug/logs.txt(仍可直接下载完整可读文本) - └─ 诊断包:timeline + snapshots + manifest + summary -``` - -**不能只新增 Logcat、OSD、内部对象或一张截图。** 每个验收事件必须出现在用户能导出的调试日志中。native 尚未 bridge 的错误必须写明 unavailable,不得靠用户去 adb 找补。 - -### 4.2 每条事件的公共字段 - -文本兼容外壳建议:`日期 [线程] av-diag: {单行 JSON}`。保留现有分类,网页另增加「音视频诊断」过滤。JSON 转义换行,禁止把外部文本伪造成下一条日志。 - -| 字段 | 必须满足的语义 | -| --- | --- | -| `schemaVersion`, `event`, `level` | 版本化字段定义;事件名稳定;severity 不从自由文本猜 | -| `wallTime`, `timezone`, `elapsedRealtimeNs` | wall 用于用户对照;耗时用单调时钟;不混合 wall 与 media time | -| `processRunId`, `processRole`, `pid`, `thread` | 每次进程启动随机 run id;主进程/native恢复辅助进程分开;不使用设备永久标识 | -| `seq`, `sourceSeq`, `capturedAtNs`, `enqueuedAtNs` | 全局写入顺序与源头捕获顺序分开;晚到事件保留原时间;不能把导出顺序冒充因果 | -| `trace`, `playerInstanceId`, `engine`, `engineVersion` | 复用现有 trace,补播放器实例;切内核明确 parentTrace/parentAttempt | -| `mediaGeneration`, `attemptId`, `operationId`, `parentEventId` | 新文件、重试、seek、flush、track switch 可分辨;旧 generation 的事件记 late/orphan,不能污染新会话 | -| `role`, `eventMediaId`, `currentMediaId` | foreground/preload;Media3 EventTime 的事件媒体与当前媒体可能不同;不使用单个可变全局 trace 给回调补身份 | -| `videoTrackId`, `audioTrackId`, `decoderId`, `surfaceId`, `audioOutputId` | 只在适用时填写;同名 codec 重新创建仍是新的 decoderId;对象 ID 为本进程不透明序号,不导出地址 | -| `positionUs`, `durationUs`, `seekEpoch`, `clockDomain` | 不把未知时长、TIME_UNSET、无效浮点当 0;写清 media/system/audio clock | -| `requested`, `effective`, `observed`, `origin` | 用户值/策略结果/底层读回;origin 为 default/user/profile/mpv.conf/per-file/runtime-fallback 等 | -| `status`, `source`, `evidenceClass`, `ageMs` | evidenceClass为observed/configured/inferred/user-reported;status固定为known/unknown/not-collected/not-supported/not-applicable/permission-denied/stale/read-error/timed-out/pending-callback/unavailable/not-observable | -| `snapshotId`, `fieldsChanged`, `suppressedCount`, `truncated` | 周期输出增量;完整基线/终态另保留;任何省略可追踪 | - -规则:数值 `0`、布尔 `false`、空列表只有真实查询/事件支持时才写;没有 `hwdec-current` 的 MPV 不能回退到 `config.hwdec` 再宣称“实际硬解”。`available`、`selected`、`initialized`、`producingOutput` 独立建模。`unavailable`表示接口存在但当前没有值,`not-supported`表示该API/构建不支持,`not-observable`表示App无合法观测手段,`unknown`表示证据尚不足;禁止混用。 - -### 4.3 音视频成功证据分层 - -| 视频层 | 音频层 | 证据强度与边界 | -| --- | --- | --- | -| V0 媒体声明视频轨 | A0 媒体声明音频轨 | 只有元数据 | -| V1 轨道已选且收到 sample | A1 轨道已选且收到 sample | 不证明 decoder 可工作 | -| V2 decoder 输出 buffer/frame | A2 decoder 输出 PCM,或已选择直通的压缩帧 | 直通不应强求软件/硬件 audio decoder;压缩帧不是 PCM | -| V3 frame release/render submission | A3 sink/AudioOutput 实际接收/写入 | 提交不等于呈现;写入不等于声压 | -| V4 MediaCodec output-surface callback / GPU present 成功等 | A4 播放头/有效时间戳前进、实际路由存在 | 命名注明具体 API;不能跨内核把 V4 定义成同一强度 | -| V5 可选像素统计 + 用户确认 | A5 可选 PCM 统计 + 用户确认/外部链路检查 | PixelCopy 仍不是面板出光;PCM 非零仍不是扬声器发声 | - -每次 report 同时输出 `videoEvidenceLevel`、`audioEvidenceLevel`、`lastGoodBoundary`、`failedBoundary`、`missingEvidence`。`READY` 仅是状态,不出现在“最终音画正常”的判定条件中。 - -## 5. 触发、留存与用户操作 - -### 5.1 无 ADB 的最短使用流程 - -1. App 设置或局域网调试页开启「音视频诊断」,展示隐私、时间/空间上限和实际采集能力。 -2. 用户原样复现,不先切软解/直通,避免破坏原故障现场。 -3. 遥控器可选择「黑屏」「画面不动」「无声」「断音」「音画不同步」「其他」,或网页点「标记此刻故障」。先保存快照,再允许深度捕获。 -4. 一次点击下载 `webhtv-debug-log.txt`;可选下载完整诊断包。下载不能依赖播放线程响应,也不能触发重新查询所有 native 属性。 -5. 如需 A/B,保持原内核/硬解,仅改变一个相关参数,分别保存修改前后的日志;实际生效结果以播放事件为准。按14.16用户决定,移除只记录手填备注的“记录单参数对照”入口。 - -未提前开启时无法恢复此前没有采集的事实;此时输出 `captureStartedLate=true`、现有基线和能得到的历史记录,再请求一次复现。不开日志时不暗中常驻采集媒体/设备隐私。 - -### 5.2 必须触发的快照 - -- 打开调试、process start、player create、play request、解析返回、prepare、轨道变化、decoder configured/output-format changed。 -- 首个 sample、首个解码输出、首个提交/回调、首个音频写入、audio-position advancing;这些分别触发而不是共用 `first-frame`。 -- 用户故障标记、明确 decoder/sink/native 错误、长时间无输出的观察告警、route/focus/volume 变化。 -- seek/flush/reinit、切音轨/视频轨、切源/切内核、Surface 重建/尺寸变更、进后台/前台/PiP/投屏。 -- stop/release、end-file、进程恢复、导出开始/完成;记录会话终态或为何没有终态。 - -启动无帧观察建议默认 5 秒、持续无进展观察建议 3 秒/连续 3 个采样,**仅为候选阈值**,必须与 ready/playWhenReady、pause、buffering、seek、suppression、音频-only、封面轨、低帧率、静止图等门控一起判断。超时只生成“未观测到输出”事件,不直接认定 decoder 故障或切换播放器。 - -### 5.3 模式和预算(设计目标,尚未测量) - -| 模式 | 捕获内容 | 限制 | -| --- | --- | --- | -| 关闭 | 不做新增详细诊断,不保留新的媒体内容统计 | 既有业务错误机制不因诊断关闭而失效 | -| 标准诊断 | 状态事件即时记录;计数器每 1 秒内聚合、通常每 5 秒输出;异常时输出完整快照 | 不逐包/逐帧字符串化,不强制获取 GPU/PCM 样本 | -| 深度复现 | 指定 trace/attempt 的 decoder/native 部分日志、有限时间戳/像素/PCM统计 | 用户确认;默认 60 秒,最多 120 秒;自动恢复原日志级别 | -| 异常冻结 | 最少目标前 30 秒、后 15 秒的有界上下文+当前/起始快照 | 留存窗口受预算影响时显式 partial,不承诺所有洪泛情况下都不丢 | - -初始预算建议:内存诊断队列约 1 MiB、每事件通常 ≤4 KiB(长异常分片总计 ≤16 KiB)、单 trace pinned 基线 ≤256 KiB;运行日志 16 MiB、4 个轮转段,异常包最多 3 份/合计 24 MiB,两者总磁盘上限 40 MiB,临时导出也计入预算。低内存设备预算可减半并记录配置。它们是容量设计值,实施测试后再固定,不能随意无上限扩张。 - -## 6. 公共链路:必须增加的事件与字段 - -每行“字段”均叠加第 4 节 envelope;下表中的 ID 同时用于验收和阶段交付,不代表现有类已经实现。 - -| ID / 事件 | 字段与触发点 | 要排除/定位的问题 | -| --- | --- | --- | -| C01 `diag.session.begin` | 诊断模式、开关来源、captureStartedLate、用户问题标记、所有 collector 版本和 available/status;开日志/起播各一次 | 为什么某类字段缺失 | -| C02 `env.device` | App/version/buildId/git(构建注入)、product/flavor/ABI、进程位数、OS/API/targetSDK/build fingerprint摘要、厂商/型号、低 RAM/内存、设备类别;声明的 ABI 与实际库 ABI分开 | 同名版本不同包、ARM 转译/系统差异;模拟器仅是线索 | -| C03 `env.native` | 每条播放器链实际加载的库名、相对来源、ABI、build-id/version、加载成功/失败、期望 manifest 与读取值是否一致;编译期开关 | “库在包里但没加载”、分离 FFmpeg 命名空间、用户 APK 与测试 APK 不同 | -| C04 `env.display` | 当前 displayId/mode/尺寸/刷新率/HDR types、preferred 与 applied display mode;GPU vendor/renderer/version/backend capability已有值 | HDR/display/GPU 不匹配;不要为采集另建 GL/Vulkan context | -| C05 `config.snapshot` | 当前内核、视频/音频 decode 独立策略、fallback、渲染、fixed-size、队列、tunneling、LUT/shader、音效/倍率/offset、passthrough/offload及来源 | “菜单硬解”不等于实际 decoder 或音频直通 | -| C06 `config.change` | 用户/自动原因、old/requested/effective/observed、apply返回码、需不需要重建及实际重建 attempt;mpv.conf各层覆盖次序 | 用户改了设置但旧播放器没生效 | -| C07 `play.request` / `resolve.result` | 本地匿名 mediaId、站源/解析组件版本标识、资源类型、scheme、route owner、解析耗时、返回container hints、headers名称(非值)、候选数量 | 网盘链接解析和播放器失败分界 | -| C08 `input.open` / `input.read.summary` | requestId、trace/attempt、range起止、响应码、Content-Type/Length/Content-Range、首字节/读取字节/速率、超时/重试、cache命中/读取来源 | 200/206错配、缓存/代理返回HTML、单轨数据不足 | -| C09 `input.route` | direct/App proxy/外部loopback、可见网络段、owner、upstream visibility、native/App cache归属 | 6677等外部代理不能假装看见其网盘上游状态 | -| C10 `media.container` | 实际 extractor/demuxer、容器/时长/seekable/live、variant/rendition/edition、ID映射;来源与 declared MIME分开 | 播放的不是预期原画/同名不同转码 | -| C11 `media.tracks` | 各轨 id/type/flags/language/role/default/forced/attached-picture、available/support/selected、Format、禁选/超能力/手选原因 | 没视频轨、误选封面、静音音轨、未选轨却READY | -| C12 `drm.state` | scheme UUID、session状态、secure-required、key状态/过期、license结果码/耗时、平台错误;仅结构摘要 | 等key/secure surface被误诊为硬解;不得记录key/license正文 | -| C13 `play.lifecycle` | Activity/service/controller owner、绑定/解绑定、playWhenReady/isPlaying/state/suppression及reason、前后台/窗口、播放重建原因 | 控制器暂停、旧回调、后台限制导致无音画 | -| C14 `play.clock` | master clock、position来源、audio/video PTS摘要、seekEpoch、speed/offset/sync correction、sample窗口 | 进度是音频/估算时钟而视频停滞;时钟不同步 | -| C15 `play.resources` | Java/native内存摘要、低内存/trim、温控/省电、电量状态、线程队列延迟、已有buffer压力 | 资源耗尽/线程饿死;不高频读取全进程/proc | -| C16 `play.attempt.end` | video/audio各自终态、成功证据层级、首个错误/最后错误、fallback链、release结果和耗时 | 不能用一条“播放成功”遮盖半失败 | -| C17 `diag.health` | 采集器注册结果、queue size/high watermark/dropped、写盘error/lastFlushedSeq、限流/截断数量、native event overflow、时间窗口缺口 | 区分“没有错误”与“错误日志没采到” | - -对端不受控的网盘/Spider仅可传播不含凭证的诊断 requestId(协议明确允许时);不能为了串 trace 向第三方任意加头、更改缓存 key 或泄露内部 trace。 - -## 7. 视频链路:Exo、Surface 与画面来源 - -### 7.1 Exo 解码输入与选择 - -| ID / 事件 | 必填证据 | 采集落点 | -| --- | --- | --- | -| V01 `video.candidates` | mime/codec string/profile/level/tier、bitdepth/chroma、尺寸/fps/bitrate、secure/tunnel;候选名/canonicalName/vendor/hw/sw/alias;系统支持与策略剔除逐项reason;最终候选顺序 | `ExoUtil` factory/selector、`ExoRuntimeAwareVideoRenderer.getDecoderInfos`、能力 inspector;仅本次相关MIME | -| V02 `video.decoder.attempt` | 每次 create/configure/start begin/end、decoderId、耗时、当前候选序号、返回或异常、fallbackAllowed及下一步;不只最后成功的decoder | MediaCodecAdapter.Factory装饰器/现有renderer路径;仅诊断,不改变排序 | -| V03 `video.configure` | 配置MediaFormat白名单:mime、width/height/max dims、profile/level、color keys、rotation、max-input-size、operating-rate/priority、低延迟/tunnel/secure相关有效参数;CSD条数/长度/摘要;绑定surfaceId/generation | adapter configure入口,记录输入实际值;不整包 `MediaFormat.toString()` | -| V04 `video.output.format` | codec实际output format、crop/stride/slice-height/color/pixelFormat/HDR摘要;input-vs-output变化;DecoderReuseEvaluation结果/丢弃原因 | renderer/adapter output-format-changed、AnalyticsListener input format | -| V05 `video.sample.summary` | 选中轨的sample/bytes/keyframe/EOS/encrypted数量、首末PTS、PTS回退/断裂、input feed age | renderer已有计数/adapter queue聚合;不复制数据,不额外demux | -| V06 `video.output.summary` | dequeued/decoded/output-skipped、release-to-surface、release-without-render、rendered/drop counters;每项定义、scope及reset epoch | DecoderCounters和adapter;tunneling与软件renderer单独注明可观测项 | -| V07 `video.first-output` | first sample、first output、first release、Media3 first-frame callback各自时间/PTS/surfaceId;不能合并 | 当前AnalyticsListener/renderer已有回调 | -| V08 `video.surface-render-callback` | 如果合法可用:MediaCodec回调PTS/nanoTime、收集时间/批量延迟、callback count、对应codec/surface generation | API保护+复用现有adapter/renderer listener;禁止替换tunneling已有listener | -| V09 `video.flush-reuse-release` | flush/stop/reset/reinit/release起止、触发原因、最后input/output时间、在途量、结果和复用判断 | codec生命周期;release前保存允许取得的metrics,失败不再无界查询 | -| V10 `video.error` | error域/stage/codec、errorCode/diagnosticInfo/recoverable/transient、原始异常链和有限堆栈、fallback候选链 | 所有错误回调,不只onPlayerError;错误证据与策略计数隔离 | - -特别规则: - -- `sourceCadenceFps`(PTS间隔)、`decodedBuffersPerSecond`、`submittedFramesPerSecond`、`codecRenderCallbacksPerSecond`、`displayRefreshHz` 必须分开;不统一命名为 `renderedFps`。 -- MediaCodec frame callback 在旧 Android 可延迟/批量/缺失;Android 14 起也有 executing-state条件,缺回调不独自构成无帧证据。Surface回调不证明物理显示正确。[R04] -- 通过adapter统计的“output buffer”不是逐张完整画面的通用等价物;对secure/tunnel/软件/特殊codec写明计量范围。 -- `getMetrics()` 按支持情况白名单采集,不假定错误后仍能读取、不调用已release对象。build/API变化统一记采集状态。 -- 原版同设备对照必须取实际 decoder、renderer、queue、Surface、库版本;名字“硬解”只作为 requested 配置。 - -### 7.2 Surface/窗口/封面链路 - -| ID / 事件 | 字段与要求 | -| --- | --- | -| S01 `surface.created/changed/destroyed` | SurfaceId/generation、类型SurfaceView/TextureView、valid、format、holder尺寸、view尺寸、创建/销毁时间;主视频/OSD用途分开 | -| S02 `surface.bind` | playerInstance/decoderId ↔ surfaceId,old/new、attach/detach结果、调用线程、是否已release;native ANativeWindow acquire/release在其owner层记录 | -| S03 `surface.resize` | media原尺寸→能力profile来源→requested fixed/layout→surfaceChanged后actual holder尺寸;带operationId;不可用同一调用栈立即回读当成完成 | -| S04 `surface.visibility` | attachedToWindow/isShown/windowVisibility/view及祖先alpha/visibility、view/content bounds、clip/rotation/transform、Z-order、当前Activity/window owner | -| S05 `video.presentation.source` | `video/artwork/shutter/loading/error/unknown`、设置来源和原因、对应selectedVideo状态;artwork只记匿名id;手机和TV同时覆盖 | -| S06 `video.effects` | 输入色彩/HDR→效果/LUT/shader/tone mapping→输出色彩/渲染后端;程序/滤镜配置摘要和apply/error结果 | -| S07 `display.change` | requested vs actual mode/refresh、frame-rate hint、HDR/色彩空间、时间和是否发生surface重建 | -| S08 `video.pixel-probe` | 用户主动触发;source明确指向视频Surface或窗口;有限3次低分辨率统计、返回码、亮度/变化率/blackRatio、间隔/耗时;不默认保存图片 | - -S08只是可选证据:PixelCopy取的是最近排队的buffer,不是外部屏幕;Window截图可能只有UI,必须区分主视频Surface和封面。[R06] 保护内容、secure surface、FLAG_SECURE或API不支持时记 `permission-denied/not-supported`,**不绕过保护**。黑场、静止片段、字幕、UI遮罩都会造成假阳性。默认不采集完整截图或帧;如用户另行同意附图,进入单独的受控附件与留存流程。 - -## 8. 音频链路:必须从“格式”补到“实际输出” - -### 8.1 分层路径 - -```text -选中音轨/压缩sample - ├─ decoder → PCM → 声道映射/重采样/音效/倍速/增益 → AudioOutput - └─ passthrough/IEC61937/编码直通(可能无audio decoder) → AudioOutput - │ - AudioTrack / MPV AO / IJK AudioTrack/OpenSL ES - │ - Android路由/焦点/系统音量 → 扬声器/蓝牙/HDMI/AVR -``` - -offload、直通和软件/硬件解码是不同维度;日志不能用“硬解开关=音频也硬解”或者“没有decoder=无声”替代它们。 - -### 8.2 必须增加的音频事件 - -| ID / 事件 | 必填字段 | 解释目标 | -| --- | --- | --- | -| A01 `audio.track` | 原始和当前id/codec/profile、sampleRate、channelCount及layout/mask、bitrate、language/role/selection、encoderDelay/padding、DRM | 误选/禁用音轨、2声道与多声道差异 | -| A02 `audio.candidates` | decoder/audio-output支持结果、hardware/software/FFmpeg可用性、passthrough/offload能力及查询来源、候选剔除reason | 平台支持声明、App策略和实际路径分界 | -| A03 `audio.decoder.attempt` | 每次create/configure/start/flush/release及耗时、actual decoder、异常原始码/恢复性质、fallback顺序 | codec失败与音频设备失败分开 | -| A04 `audio.decoder.output` | PCM/encoded标记、采样格式/位宽/浮点、sampleRate、channel layout、输出帧/sample数、首末PTS | decoder是否真有输出;位深/布局转换 | -| A05 `audio.processing` | 按顺序列出active processor/AF:mix/resample/map/tempo/skip-silence/EQ/增益/limiter等;输入/输出格式,配置来源,生效结果 | 滤镜把音量乘零、中心声道丢失、采样率错误 | -| A06 `audio.output.configure` | 实际outputId/sessionId、AO/AudioTrack backend、encoding与PCM/直通/offload、channelMask/indexMask/layout、sampleRate、buffer大小/单位、attributes usage/contentType/flags、tunnel/session关联 | “请求直通”与实际PCM/失败;mask仅bitCount不足以解释映射 | -| A07 `audio.output.write` | 同一outputId的调用/请求/接受字节、pending、短写/0写/错误数、首末PTS、lastWriteAge、耗时分布;异常保存raw code、阶段和原始异常 | CPU解码有输出但sink阻塞/拒写/失效 | -| A08 `audio.output.playhead` | playState/state、播放头raw uint32和扩展计数、epoch、单位;timestampValid/framePosition/nanoTime/age、position来源和速度;underrun增量 | 数据接受与音频时钟前进分开 | -| A09 `audio.route` | discovered/advertised/preferred/actual routed device分开;device类型和会话内匿名id、encodings/channel counts/masks/rates、变更reason/time;HDMI接入状态 | 蓝牙/HDMI/扬声器实际路线;不能由“连接设备列表”断定实际路由 | -| A10 `audio.focus` | owner=Media3/native/App、请求类型/attributes、结果granted/failed/delayed、gain/loss/transient/duck、pause/resume动作、effective gain来源 | 未取得焦点、被duck/mute、恢复遗漏 | -| A11 `audio.volume` | player requested/effective、native volume/mute、AudioOutput setVolume实参、已知duck倍率/音效增益、STREAM_MUSIC current/max/min、mute/fixed-volume、外部设备音量可观测性 | 多级静音;App音量1.0不代表外部AVR有声 | -| A12 `audio.output.lifecycle` | initialize/play/pause/flush/stop/release/route-recreate的起止、错误、pending bytes、旧/新outputId、offload end及延迟填充 | 多声道→立体声、seek后、路由切换后失声 | -| A13 `audio.error` | AudioSink configuration/init/write、AudioCodec error、MediaCodec diagnostic、FFmpeg/AO错误、AudioTrack raw返回、异常链、事件时route/config | 原始错误不能只剩一个Exception类型 | -| A14 `audio.pcm-probe` | 用户明确开启,有限窗口,输入/后处理/输出检查点、frameCount、per-channel RMS/peak/zeroRatio/clipping/NaN;PCM格式与归一化 | 区分源静音、解码全零、滤镜输出静音;不是声音录制 | -| A15 `audio.sync` | clock来源/epoch、audioPTS/videoPTS差、音频延迟/用户offset/tempo/drop补偿、时间戳有效性 | 断续、音画漂移与真正无声分界 | - -### 8.3 实施时不能踩的坑 - -- 在现有 `ExoUtil.buildAudioSink` 的最终 provider 路径接入 forwarding,保留直通/offload、多声道、audio processors、设备能力动态更新等行为。不要为了日志换成“简化 AudioTrack”。 -- `ForwardingAudioSink.handleBuffer` 的消耗量仅说明 sink接收,不等于底层每次 `AudioTrack.write`。利用 `ForwardingAudioOutput` 观察真正输出层;确需底层原始写返回码/头部写入等时再加窄 hook,并标明哪个计数是 payload、哪个是内部头部。 -- 当前基线 `AudioTrackAudioOutput` 暴露实际 `AudioTrack`,可在合法owner线程/API保护下读状态、route、metrics;不能在HTTP/UI线程持锁跨层读、不能调用对象的play/flush/写入来探测。 -- buffer bytes→frames 的换算仅适用于已知线性PCM和正确的每sample字节数;编码直通/offload按其API定义记录,不用 `channels×2` 硬算。 -- `getPlaybackHeadPosition()` 是uint32,会wrap且flush/stop会reset;按outputId/epoch计算,不把回零当倒播或卡死。[R05] -- `getTimestamp()` 返回false时保留availability状态,不读取旧结构当新样本。route/模式切换后允许短期不稳定;稳定时直接调用约10–60秒一次,复用Media3已有tracker数据,禁止为日志再每帧调用。[R05] -- 时间戳可代表已呈现或已承诺将呈现的数据;不证明外部AVR实际发声。`getRoutedDevice()` 非播放期可返回null,此时不直接判为路由失效。 -- 系统自动duck、来电静音可能没有完整App焦点回调;只记录App能看到的结果,不能因“没loss日志”排除系统静音。[R03] -- A14不得申请麦克风权限、录制环境声音或保存原始PCM。只在非保护PCM可观察点算低频聚合;直通流标not-applicable,不能解释为zero amplitude。 -- 声道日志必须有layout/mask和mapping;`channels=6` 无法区分侧环绕/后环绕/中心声道映射。外部扬声器物理接线、AVR模式要由用户观察补充。 - -## 9. MPV、FFmpeg、JNI 和 IJK - -### 9.1 MPV事件与属性 - -| ID / 事件 | 必须输出到调试日志的内容 | -| --- | --- | -| M01 `mpv.init` | libmpv/FFmpeg/libplacebo/JNI版本/ABI/buildId、load/init结果;源revision来自构建manifest,不从App版本猜 | -| M02 `mpv.option` | 白名单的mpv.conf/per-file/App策略/命令覆盖、requested/effective/readback/status;设置失败的原始返回码与`mpv_error_string` | -| M03 `mpv.event` | start-file/file-loaded/video-reconfig/audio-reconfig/playback-restart/end-file/shutdown;end reason/error;client/source sequence、generation | -| M04 `mpv.tracks` | `track-list` 的available/selected/default/attached-picture、实际 `vid/aid/sid` 和current-tracks id、input metadata;区分不存在/暂时未选/失败后禁用 | -| M05 `mpv.video.path` | `hwdec`配置 vs `hwdec-current`实际、hwdec-interop、video-codec、decoder/FFmpeg具体实现;`current-vo`、gpu API/context/backend;video-dec/params/out-params分层 | -| M06 `mpv.audio.path` | audio-codec/decoder、`audio-params`、`audio-out-params`、`current-ao`、audio-device、volume/mute、AF配置、delay/speed、SPDIF实际格式/降级reason | -| M07 `mpv.runtime` | time-pos、video-pts等可用PTS、cache/buffering、pause/idle、avsync、frame-drop-count、decoder-frame-drop-count、mistimed/vo-delayed及各自有效性;不能把estimated-vf-fps当显示FPS | -| M08 `mpv.decoder.attempt` | 来自native初始化/失败回调或日志解析:codec名/具体hwdec、GL/Surface/native_window关联是否建立、顺序/阶段/错误码、copy/direct/software候选;查名与实际create/configure/start按9.3.1区分;事实来源是结构化native还是文本推断必须明确 | -| M09 `mpv.output.failure` | 有video轨但初始化失败后vid=no,audio仍活跃时:保存videoPartialFailure=true、原始错误、最后成功层;不只生成整机READY | -| M10 `mpv.command.result` | loadfile/seek/track/option/attach等commandId、提交/返回/完成、错误码/耗时、generation;readback缺失不冒充成功 | -| M11 `mpv.collector.health` | property注册format/result、MPV_FORMAT_NONE/unavailable、freshness、node解析失败、event queue overflow、native→Java→writer各段延迟、drop计数 | -| M12 `mpv.native.output` | 必要的ANativeWindow/Surface、EGL/Vulkan、hwdec interop/VO present失败,以及AO实际AudioTrack/OpenSL状态;拿不到的字段明确native-hook-required | - -`hwdec-current` 的 `no` 在活跃解码时代表软解;**未加载decoder时属性不可用,不等于no**。`audio-params` 是decoder输出、`audio-out-params` 是写给音频API的格式。[R08] - -### 9.2 MPV采集的线程与可靠性 - -- 复用现有 `MpvPropertySnapshot` 和观察器,给每个值增加更新时刻/generation/status;快照不保证跨属性原子一致,记录采样跨度,丢弃跨generation拼接。 -- 优先在事件线程把必要字段复制为有界不可变消息,再交给日志writer;必须及时清空mpv event queue。[R07] -- native log callback不能同步反查MPV属性、等待主线程、做文件写入/HTTP/复杂JSON;现有直接SpiderDebug写入最终也要进入无阻塞队列,不让持锁文件I/O反压native。 -- 少量需读回的属性仅在安全线程用受控异步请求;总在途数有界,记录request/replyId、generation、超时和unsupported;timeout不调用强杀/销毁decoder来“解除诊断阻塞”。 -- node/字符串必须在mpv event内存失效前复制,限制大小/深度,明确截断;handler/release卸载observer,忽略晚到事件但增加统计。 -- 不开启全局每帧 `all=trace`。标准模式保留错误/警告与初始化/选轨/实际VO/AO关键信息;深度模式限定组件如vd/vo/ao/demux和指定trace,超时恢复。 -- 取消当前32条/5秒的统一丢弃窗口;标准诊断开启期间,纳入采集级别的事件持续输出到异步writer。容量充足时不得仅因条数/时间配额丢弃事件;实际队列拥塞时按关键证据优先保留、重复消息汇总及损失报告处理,具体契约见9.2.1。 -- MPV `PLAYBACK_RESTART` 是seek/播放重新初始化信号,不是视频frame-present信号。[R07] 新日志必须纠正解释,但不顺手改既有播放状态机。 - -### 9.2.1 过滤前取证与没有后续日志时的收尾(本轮补充) - -**持续输出契约:** 标准诊断从开启一直采集到关闭或进程结束,包括播放器没有报错、用户没有打开日志面板的期间。文件由后台writer批量追加,达到单段大小后继续写新段,按11.1的总字节预算淘汰最旧片段;报告声明实际保留时间范围并固定起播/故障上下文。文件轮转不暂停采集,也不以“每几秒最多几条”代替容量管理。持续采集的范围由声明的日志级别/collector决定,不等于无限保存历史;5.3的逐帧/逐包深度复现限时独立适用。 - -1. **覆盖源端级别。** M02/M11记录 `mpv_request_log_messages` 订阅级别、有效 `msg-level` 及组件覆盖。当前 App 标准路径可能使用 `all=warn`,而 FFmpeg 的 `Failed to getCodecNameByType` 是 INFO;只扩大 Java 队列不能找回源端未发出的信息。标准诊断须通过有界的组件级初始化消息或结构化 hook 取得必要查名/失败摘要,不要求用户另开全局 verbose。源端低级别消息未生成、无法计数时,明确过滤范围与不可观测计数,不编造0。 -2. **持续记录与拥塞处理分开。** native/JNI/Java 不沿用统一条数配额;普通负载下持续传递所选级别的事件。周期指标按预先声明的schema聚合;重复消息若合并,必须带count/first/last,不能合并不同attempt/操作的事件身份。新错误、尝试边界和最终失败形成有界固定摘要,实际队列容量不足时优先保留。原生 severity/domain/stage 要传至结构化事件及关键队列;只经旧文本入口成为 normal 优先级,或仅豁免 `WebHTV FEL fatal`,不满足普通 HEVC 初始化失败的验收。文本解析必须标为 inferred,日志分类不触发新的播放恢复动作。 -3. **各段分别核算。** M11/C17区分源端级别过滤、native event overflow、Java限流、应用队列丢弃和writer失败,保留可得的累计count/firstSeen/lastSeen/sourceStage/attempt。sink 的零丢弃不能代表前段零损失;某段不可观测则报覆盖缺项,必要错误摘要丢失时 report 必须 partial。 -4. **不能等下一条获准日志才报告。** 限流计数和最后失败摘要保存在有界缓存;attempt结束、诊断模式切换及TXT导出均可读取,周期收尾也不能依赖再次收到native消息。HTTP导出只读缓存,不同步反查MPV或等待UI;重复导出使用带generation/窗口的累计统计,不能一次取走计数让下一份报告假装零损失。关闭/清空仍遵守现有D0删除语义,不要求关闭后恢复已删除日志。 -5. **持续记录及静默尾部验收。** 在真实native→Java入口或等价契约fixture,队列/磁盘容量充足时,于5秒内发送超过32条不同的已纳入采集级别事件,再发送新的codec查名/初始化失败,随后完全停止native日志;保持播放未结束直接导出,再结束attempt导出。两份TXT都须按事件身份完整保留这批事件和最后错误,不能仅因旧窗口配额发生丢失。另以实际队列耗尽/磁盘失败注入验证损失的来源/计数/时间范围及partial,无后续事件也能导出;持续记录跨文件轮转仍有连贯seq及明确的历史淘汰边界。还须覆盖标准日志级别下INFO查名失败及sink零丢弃但上游有缺口;仅向D0 sink人工注入日志不算验证了上游链路。 - -### 9.3 FFmpeg/nextlib与native扩展 - -需要记录 `avcodec` implementation、hwdevice/hwframes创建、AVCodecContext输入/实际pix_fmt、sample_fmt/channel layout、thread配置、extradata长度/摘要、open/send/receive失败的数值与可读错误、EAGAIN/EOF统计和flush epoch。EAGAIN是正常背压,不当作解码失败刷屏。 - -- 保留Exo扩展FFmpeg、MPV FFmpeg、IJK FFmpeg各自的库身份与owner,不共享/替换 `.so`。 -- `av_log_set_callback` 要求线程安全,默认日志到stderr不保证进App调试日志。[R09] 在各播放器已有日志owner处扩展桥接,不随意替换全局callback、级别或其他模块handler。 -- 不将日志中的native对象地址作为公共ID,不解引用外部未知指针获取ctx信息;未能绑定attempt的日志写 `association=unresolved`。 -- 新FFmpeg/MPV/JNI插桩若确有必要,进入独立native阶段,保留源锁/补丁/ABI/SONAME/DT_NEEDED/导出兼容与精确artifact记录;不能用Java日志完成冒称该阶段已完成。 - -### 9.3.1 MediaCodec查名失败的操作边界(本轮补充) - -M08不能只收集“初始化失败”和另一次Java能力枚举:必须能解释**本次实际native选择器**为什么未返回名字,并区分查询失败与确实没有合格候选。记录下列证据,按decoder attempt/lookup operation关联;同样的失败文字可属于不同direct/copy/重试,不能据正文合并身份。 - -| 取证边界 | 必填内容与语义 | -| --- | --- | -| 请求与分支 | 实际mime、AVCodecContext profile与Android profile的数值/名称/命名空间、MediaFormat覆盖来源、hardware_only/allow-profile-mismatch等有效标志、`use_ndk_codec`;与菜单“硬解”分开 | -| 实际枚举 | 枚举API/路径、已知总数、本MIME候选、实际检查数/中止位置;本次访问到的候选名或枚举序号、hw/sw/encoder声明、profile列表及可用状态;不另建解码器做能力试探 | -| 每个候选的处理 | 原生实际顺序及accept/reject/not-visited,具体规则:encoder、hw/sw声明、名称过滤、MIME/alias、profile匹配等;原始能力为空、查询未执行、查询失败要分开。API异常不能伪装成“不支持该profile” | -| 查名结果与重试 | selected/no-eligible-codec/query-error及对应证据;JNI/JVM、方法映射或能力API失败保存操作、原始错误/有限异常链;profile-mismatch重查单独标识请求变化和结果,不悄悄覆盖第一次失败 | -| 后续真实操作 | 每次create-by-name/create-by-type/configure/start的attempted、begin/end/result;Java查名失败提前返回与NDK按MIME继续创建明确分开。通用`failed to start`只保留为原文,实际stage从操作证据得出;未采集时为unknown,不能自动填`createAttempted=false` | - -本地 `mediacodec_wrapper.c:ff_AMediaCodecList_getCodecNameByType` 把多种返回NULL路径合并;`mediacodecdec_common.c:mediacodec_dec_get_video_codec` 在未返回名字时按Java/NDK分支采取不同后续动作,外围失败出口仍可打印 `MediaCodec %p failed to start`。[R18] 因而 Exo V01 的候选快照只能作对照,不能冒充 MPV 选择器已访问/拒绝了同一候选。Surface为空也须绑定具体direct/copy和操作阶段,不能跨尝试归因为同一个Surface错误。 - -实施归属:D3先交付public API和现有native日志可得部分;若本次选择器的原因仍不可得,M08明确 `native-hook-required`,由D5中单独批准的窄hook补齐。D3可以按其声明范围交付,但不能据此宣称本样本的查名原因已闭环。此条只增加取证要求,不授权修改profile过滤、启用软件fallback或改变解码器顺序。 - -### 9.4 IJK最小同等覆盖 - -从 `IjkSimplePlayer.onPrepared/onInfo/onError/onVideoSizeChanged`、`IjkPlayerEngine`现有快照获取:what/extra原始值、decoder类别与名字、选中轨、fps/cache/delay等可用属性、first-video-render/first-audio-render info、buffering、Surface和生命周期;与公共route/focus/volume串联。 - -IJK走OpenSL ES时不能声称有Java AudioTrack对象。没有原生write/head/route证据时明确unknown并分配后续hook;不得伪造与Exo完全相同的字段覆盖率。 - -## 10. 异常、卡死、进程退出和证据完整性 - -### 10.1 错误结构 - -`errorId/domain/stage/nativeCode/javaClass/message/diagnosticInfo/recoverable/transient/operationId/attemptId/firstSeen/lastSeen/count`;一次新错误保留经过脱敏的cause、suppressed、decoder fallback chain和有限堆栈。设置最大深度、循环检测、总长度及chunk编号,长异常不能被旧12000字符上限截掉而无提示。 - -错误打印不改变策略状态;特别是“遍历fallback异常链做诊断”不能被现有runtime profile当成多次独立故障,导致额外黑名单。 - -### 10.2 线程卡死时仍可交付 - -- 诊断/HTTP导出读缓存和已落盘快照,不依赖主线程调度或`getProperty`成功。记录collector last heartbeat/queue delay。 -- 用户标记黑屏/无声时立即固定已有上下文;完整snapshot超时则输出partial和超时component,不整次导出等待native。 -- Java崩溃捕获与既有handler兼容,只做有界最小落盘;不吞异常、不执行网络上报、不阻塞任意线程等待日志锁。 -- native signal handler不能调用JNI/Java/普通锁/复杂格式化;使用已有安全崩溃路径或下一次启动恢复,不为本需求贸然建立新崩溃框架。 -- journal保存last active trace/attempt、各层最后进展、write seq;next boot合并上次unfinished标记、已有PlaybackRecoveryMonitor记录和公开可得的系统退出信息。 -- Android API30+按权限读取自身 `ApplicationExitInfo` 摘要;ANR trace可能存在,API31+native crash tombstone可能存在,但系统环形缓存会丢失。[R10] 返回null/不可读均应记录,不能保证一定获得native stack。 -- `cacheDir`可能被系统清理;故障journal/固定异常包规划在App私有持久目录,容量有界,并有清空/删除策略。移除App/清除数据/突然断电仍存在不可恢复边界。 - -### 10.3 完整性状态 - -导出manifest至少包含:`schema/build/processRun/trace范围、collector覆盖、start/end seq、gaps、droppedByPriority、truncatedChunks、nativeOverflow、writeFailures、lastCapturedSeq/lastFlushedSeq、closed/unclosed、captureStartTime、logMode、文件长度与SHA-256`。 - -`completeness=complete` 只指**声明模式/时间窗/可用collector内的证据完整**,不表示物理世界全部可观测。异常包缺起始配置/轨道/实际decoder/output或存在关键缺口时为partial,并列出具体缺项;不能只报一个绿色“日志导出成功”。 - -## 11. 存储、UI和隐私安全 - -### 11.1 日志存储必须先改,不能先无上限加字段 - -1. 保留SpiderDebug公共兼容入口;新结构化事件和旧文本统一进入非阻塞有界队列,单writer批量写盘,不在codec/native/音频线程逐条打开文件。 -2. 两级优先队列:状态/新错误/配置/终态优先,周期样本与重复文本可合并;“关键永不丢”在有限内存下不可绝对承诺,必须有保底摘要及丢失记录。 -3. ring只放运行窗口,起始环境/配置/选轨/尝试链另做有界pinned快照,避免几分钟后问题只剩末尾几行。 -4. 日志文件轮转,active trace元数据可重建;写盘失败记录独立计数/内存告警,避免记录写盘错误时无限递归写同一失败sink。 -5. 普通flush按批次,异常可请求有界快刷;不在实时线程fsync每条日志。导出记录flush watermark和超时;不保证系统强杀前最后一微秒落盘。 -6. 区分“停止采集”和“清空记录”。目前关闭会clear,后续若改为保留历史,UI必须明确提示、提供删除和到期清理;这是需审批的用户可见语义变化,不能偷偷实施。 -7. HTTP增量改为有界cursor/afterSeq,报告oldest/newest/gap;保留旧TXT入口的一致有界快照,后台流式输出,不在每次网页刷新拼接全文件。 - -### 11.2 导出内容与界面 - -建议包结构(示意,非本轮已生成产品功能): - -```text -webhtv-av-report-.zip - report.txt 可直接读:症状、最后正常层、错误链、缺项、下一步 - webhtv-debug-log.txt 兼容现有调试日志,包含AV结构化事件和必要旧文本 - av-events.jsonl 与TXT一致的结构化事件 - session-snapshots.json 环境/初始配置/能力/轨道/错误前后/终态 - manifest.json 完整性、schema、版本、脱敏级别、各文件校验 - optional/ 用户另行同意且允许的附件,不默认截图/音频 -``` - -摘要和结构化附件的核心信息也必须进入TXT,不能要求用户必须解压JSON才知道当前decoder、音频route和失败阶段。面板字段与导出使用同一数据源;不开面板也自动收集。 - -TV界面支持焦点移动、一次按键标记症状、查看/复制局域网地址或二维码、下载失败提示。网页不只提供关键词过滤,还可按trace/attempt/音视频/优先级查看;过滤不能导致下载丢掉未显示的必要上下文。 - -### 11.3 隐私与访问控制 - -- 默认只记URI的scheme/路由类型/会话内匿名标识;URL path/query/fragment、网盘sToken/share token、Cookie、Authorization、Referer可能含凭证,不能原样落盘。 -- config、header、异常/native自由文本都可能含密码;使用字段白名单+统一敏感值脱敏,不依靠单个“遇到URL替换”的正则当作完整安全边界。 -- 不记录DRM key/license/session私密数据、媒体原始字节、音频/视频原始sample、设备serial/MAC/账号/局域网完整拓扑。必要设备名称/地址用report内盐化标识。 -- CSD/extradata只记录类型/长度及适当摘要;保护内容默认不做内容指纹/PCM/像素probe。可比对的媒体样本hash仅限用户明确提供的测试样本,不自动下载全片求hash。 -- 白名单过的错误字符串仍需长度上限、CR/LF/控制字符转义、HTML/JSON正确编码;不能让站源或媒体metadata伪造结构化event。[R11] -- 深度模式/附件/远程导出单独提示用户;不自动上传用户日志,不因诊断而扩大现有远程托管授权。 -- 2026-09-16用户明确要求去掉调试日志配对,按14.16取消配对码/token身份认证,保留POST、设备已知Host和严格同源Origin、操作限频、导出预算与脱敏;同局域网可访问设备者可直接操作。Origin是浏览器跨站请求防护,不冒充用户身份认证,不改变其他远程托管授权。 -- 启停/清空维持POST,GET不产生修改;缺失/null/异常Origin的修改请求拒绝,TXT/stream/status只读接口保持原用法。 - -## 12. 根因判定矩阵 - -“证实”是具体层内事实;跨层推论用likely/unknown。自动摘要必须输出命中的证据ID,而不是一句“设备不支持”。 - -| 症状与组合证据 | 可作出的判断 | 仍需的最小区分步骤 | -| --- | --- | --- | -| open/read失败,没有可识别tracks | 输入/网络/代理/容器层失败,不是已证实的decoder故障 | 对照实际响应与代理owner,不盲目切解码 | -| videoAvailable=true、selected=false、有禁选reason | 轨道选择/策略未启用视频 | 检查一次手选/限制参数的requested/effective | -| native查名未返回codec名,后有通用`failed to start` | 查名结果已知;创建/启动是否执行仍须分支与操作证据,不能只按错误文案定stage | 取9.3.1本次候选处理/JNI查询/Java或NDK后续链,区分无合格候选与查询失败 | -| create/configure/start明确异常,含候选和错误码 | 当前decoder尝试在该操作失败 | 对照下一个候选/原版实际decoder;能力声明不能推翻错误 | -| 有input无output,未pause/seek/等待key且超过窗口 | decoder/输入数据之间停滞候选 | 看keyframe/CSD/DRM/错误;不能仅超时定厂商bug | -| 有output/release,surface invalid或绑定到旧generation | 绑定/生命周期异常证据明确 | 对照surface重建操作和decoder绑定事件 | -| output/release持续、UI shutter/artwork盖住 | UI展示路径候选很强 | 记录遮罩来源/可见性;保持硬解做UI/渲染单变量对照 | -| fixed-size请求紧邻失画,无后续实际尺寸/回调证据 | Surface尺寸调整是候选,不是根因结论 | 保持硬解,只关固定尺寸并重入;保留A/B report | -| videoSelected=false但audio active且MPV HEVC init failed | 明确视频局部失败;静态图不是影片首帧 | 检查硬解尝试链,不把audio READY当全成功 | -| renderer/Surface回调正常、有效像素统计也有变化但用户仍黑屏 | 已到可观测显示边界;可能窗口合成/外部显示/观察差异 | 用户实拍或系统/厂商诊断;不能自动判“用户看错” | -| 没有选中音轨/aid=no | 选轨问题或音频初始化失败后的结果 | 看禁选原因与audio error先后 | -| audio decoder明确失败 | 解码层失败 | 逐个candidate错误/扩展库加载状态 | -| PCM输出有、processor之后全零 | 滤镜/增益或源内容问题候选 | 比对pre/post统计与配置,只改一个滤镜/增益 | -| source PCM本来全零 | 源轨当前区间静音候选 | 同一PTS另一音轨/已知测试片;静音片段不算故障 | -| 有音频输出但write错误/0写持续、playhead停 | AudioOutput/设备交互异常候选 | raw返回、route/epoch/重建前后;区分pause/背压 | -| write/head正常、有效volume为0或明确focus loss/duck | 可证实的App/系统策略静音因素 | 恢复单个已知静音因素再确认,记录请求结果 | -| write/head正常但路由是蓝牙/HDMI非预期 | 路由差异证据 | 用户确认实际输出设备;preferred不能替代actual | -| 直通active,PCM probe不存在 | 正常采集边界,不是“无PCM导致无声” | 在用户同意下单独关直通做A/B,检查外部设备支持 | -| 单次播放正常、换声道/route后无声 | 重配置/设备残留状态候选 | 对照old/new outputId、release、encoding/mask、设备能力变化 | -| write/head非零、各增益和route正常,用户仍无声 | App证据到音频呈现/路由边界;不能排除系统自动mute或AVR/扬声器 | 用户确认AVR/系统输出或厂商信息;不声称唯一根因 | -| report有关键seq gap/collector unavailable | 证据不足 | 开启指定缺失collector做一次复现;不能结论性排除未观测层 | - -## 13. API与设备兼容策略 - -| 能力组 | 版本/限制 | 设计要求 | -| --- | --- | --- | -| 普通播放器事件/Java异常/日志导出 | App支持的全部Android | 作为基础层,TV/手机同覆盖 | -| MediaCodec错误详情/Output format | 依具体API与codec模式 | 新字段逐项SDK保护;异常不会因读diagnostic再次崩溃 | -| MediaCodec frame rendered listener | API23+;旧系统不保证逐帧回调;与tunneling现有listener冲突风险 | 不可默认抢占;缺失标可用性边界 | -| AudioTrack timestamp/playhead | timestamp为公开API;具体route可能不支持,单位/reset不同 | 正确validity/epoch;不调用hidden getLatency | -| actual routed device | API23+;播放状态影响null;现代AudioRouting监听按SDK适配 | 已连接与实际路由分开;listener卸载 | -| underrun/buffer capacity | 多项API24+,buffer相关API需逐项核对 | 旧设备缺字段非0值 | -| MediaCodec/AudioTrack metrics | API26+相关公开接口,字段因实现/版本而异 | 白名单,release前快照;不依赖vendor键稳定 | -| codec hw/sw/vendor/alias、performance point | 主要API29+,低版本只可有限推断 | low confidence/claimed与实测结果分开 | -| PixelCopy | 按具体重载API24+/26+保护,secure/没有buffer可能失败 | 用户主动、有限频率、失败码不是黑屏根因 | -| ApplicationExitInfo | API30+;native tombstone API31+且不保证保留 | 只自身公开可读信息,缺失明确 | -| 音频焦点 | API26+请求模式、API31+系统fade/duck、target35+请求前台限制等 | 同时记录runtimeAPI、targetSDK、前台服务状态;不扩大权限 | -| MPV properties/native能力 | 按实际加载版本/构建探测,不假定同名App相同lib | 注册失败/属性不可用保留;可用列表写manifest | -| IJK/OpenSL ES | 不一定有Java AudioTrack | 不伪造route/write等字段 | - -实施时以项目最终SDK与AAR确认精确方法签名和minAPI,补测试。本文是设计门禁,不能用“源码里有该方法”跳过最终artifact可达性检查。 - -## 14. 改造位置、分期及回滚 - -### 14.1 拟修改位置(不是本轮授权修改清单) - -| 层 | 现有落点/拟新增小组件 | 边界 | -| --- | --- | --- | -| schema/collector | `player/PlaybackTrace.java`、`PlaybackTelemetry*`;拟增`PlaybackDiagnosticEvent/Session/Collector`等小组件 | 一套语义,兼容原日志;不替代播放控制器 | -| sink/留存 | `catvod/.../SpiderDebug.java`、`DebugLogStore.java` | 支持旧调用,新增结构化事件/有界队列/轮转/健康状态 | -| 无ADB入口 | `server/process/DebugLogs.java`、`ui/dialog/DebugLogDialog.java`,手机/TV入口 | 单一数据源、遥控器可用、导出不依赖playback线程 | -| 环境/能力 | `setting/Setting.java`、`player/codec/CodecCapabilityInspector.java`、实际native loader | 只查本次媒体相关项,不每秒枚举所有codec | -| Exo | `PlaybackAnalyticsListener.java`、`ExoUtil.java`、`ExoRuntimeAwareVideoRenderer.java`、`ExoPlayerEngine.java` | public hooks优先;不重写decoder策略 | -| 音频公共 | `AudioPlaybackDiagnostics.java`、`PlayerManager.java`、现有AudioOutput/provider路径 | 保持音效/直通/offload/焦点owner,不注册竞争控制器 | -| UI/Surface | `ui/activity/PlaybackActivity.java`、手机/TV `VideoActivity.java`、PlayerView适配 | 使用生命周期回调采集实际尺寸;不为采集额外resize | -| MPV | `androidx/media3/mpvplayer/MpvPlayer.java`、`MpvDiagnosticsPolicy.java`、`MpvPropertySnapshot.java`、`is/xyz/mpv/MPVLib.java` | 缓存/异步,不回退到主线程同步大批JNI读取 | -| JNI/native(有缺口才动) | `third_party/mpv-player-jni/src/event.cpp`及经批准的MPV/FFmpeg/Media3诊断hook | 独立native阶段、独立原子回滚 | -| IJK | `player/engine/IjkSimplePlayer.java`、`IjkPlayerEngine.java` | 已有属性优先,无能力明确unknown | -| 崩溃/恢复 | 现有`PlaybackRecoveryMonitor`及App启动路径 | 不改变退出/恢复策略;仅串联上次会话证据 | - -### 14.2 分期顺序 - -| 阶段 | 交付范围 | 退出条件 | 回滚 | -| --- | --- | --- | --- | -| D0 公共最小底座 | envelope/隐私/有界sink/关键快照/导出健康、C01–C17必要上下文 | 开/关/洪泛/磁盘失败不阻塞播放;TXT能恢复会话;未知不伪装0 | 独立功能开关与原子commit,保持原导出兼容 | -| D1 Exo视频与公共Surface | V01–V10、S01–S07,纠正首帧/FPS日志含义 | 黑屏/封面/硬解失败/Surface重建fixture可区分;不改播放策略 | Exo collector独立关闭,撤回对应commit | -| D2 Exo音频与系统路由 | A01–A13/A15、PCM可用路径、AudioOutput桥接 | 解码/写入/路由/音量/焦点/直通各失败可区分;旧行为无回归 | 音频collector独立关闭;不改变输出默认值 | -| D3 MPV | M01–M12中现有public API可得部分、native log关联、局部失败、queue健康 | HEVC init失败+音频继续必须报视频局部失败;9.2.1源端级别和静默尾部可验收;9.3.1缺项明确归属D5;无主线程同步查询回归 | Java/JNI边界分清,若改JNI需整组兼容回滚 | -| D4 IJK与恢复闭环 | IJK最小覆盖、journal/exit-info、跨进程完整性 | 旧内核/旧API缺项被正确表达;卡住/杀进程后可导出已有证据 | 独立开关、保持既有恢复行为 | -| D5 经批准的深度探针/native缺口 | S08/A14、必要codec/VO/AO低层hook、A/B报告 | 有明确用户同意、限时、成本/安全/ABI门禁;观测边界不虚假消失 | 深度默认关,native阶段独立锁/产物/commit/tag | - -公共底座为Exo必需的陪同项;播放器实施顺序仍为 **Exo → MPV → IJK/公共闭环**。D5不是默认必须全量native重构:每项先证明现有public hook缺口。待批准实施时按真实工作树、设备与构建缓存给逐阶段当前agent墙钟估计,不在本文虚构未来构建耗时。 - -所有阶段都需单独明确scope、现有脏文件保护、测试证据和本仓库规定的原子commit/annotated recovery tag。用户已批准从 D0 开始;完成 D0 不代表整个需求完成。 - -### 14.3 D0 实施决定(2026-09-14) - -- 授权:用户在明确的“开始分阶段实施 / 先归档并评审”问题中选择前者;本轮执行恢复锚点指定的 D0,不同时开工 D1–D5。 -- 完成目标:复用 SpiderDebug/DebugLogStore,建立有界异步写盘、结构化会话与固定上下文、统一脱敏、可判断缺口的 TXT 导出及增量网页读取;诊断失败不改变播放器策略。 -- 当前代码核对:原设计至当前 HEAD 的相关提交均为 FEL 修复,D0 sink/HTTP/PlaybackTrace 尚未实现本方案。`DebugLogStore.add/writeLocked/loadLocked` 仍持锁同步 I/O 且无总量上限;`DebugLogs.stream` 仍反复返回全文;`ProxySetting.collectDebugUrls` 消费日志中的 URL,必须在脱敏时保存其域名建议功能。 -- 范围:`catvod/.../crawler/{DebugLogStore,SpiderDebug}.java`、新增 `crawler/diagnostics/**` 及对应单测;App `PlaybackTrace`、新 `PlaybackDiagnosticSession` 及对应测试;`Setting`、`ProxySetting`、`server/process/DebugLogs`;本文件和任务索引。保护已有 `app/.cxx/`;不改 AAR、native、锁、解码/选轨/输出/恢复策略。 -- 设计选择:保留同步原 sink 会继续阻塞播放且无法限制资源;全开 EventLogger/native verbose 仍缺完整性并泄露外部文本;采用原入口后接单 writer、有界队列/轮转、固定快照与独立健康统计。队列拥塞优先舍弃周期/旧文本,但关键事件也可能丢失,必须报告;写盘失败不能递归写自身。 -- 身份与事实:沿用每个 PlaybackTrace 的身份并补进程 run、实例和 generation;只有明确回调产生的阶段是 observed。READY、兼容 first-frame/audio-playable 只记控制器信号,视频可见/声音可闻均为 not-observable;D1–D5 尚无 collector 的层列为 not-collected,不声称覆盖完整 62 类事件。 -- 留存与并发:标准队列 1 MiB,内存读取窗口 1 MiB,pinned 总量 256 KiB,磁盘 4×4 MiB;单 writer 批处理/轮转,采集线程不做文件 I/O。TXT 导出使用有界等待与独立快照;writer 不响应时返回内存 partial 和明确 watermark。临时导出纳入 40 MiB 总预算,不保留无限份文件。 -- 隐私与兼容:结构化字段白名单,旧文本在进入内存/磁盘/平台日志前统一脱敏、限制长度、转义换行及控制字符;异常限制深度、帧数和循环。代理建议仅保存有界、本机内存中的 origin/域名,不导出凭证或 URL path。保留“关闭即清空”和现有 TXT 下载地址。本阶段不增加 mark/deep-capture/ZIP 远程操作;原 LAN 读取/启停权限模型保持现状,鉴权迁移是新增敏感操作上线前的后续门禁,不能据此宣称 T22 全部完成。 -- 基础上下文:D0 交付 session/device/config/request/resolve/controller lifecycle/end/health 与各 collector 的覆盖状态。codec/sample/AudioTrack/Surface/native 实际输出由 D1–D4 接入;未取到不补零、不复用 requested 冒充 observed。开日志过晚、日志清空、进程重启和截断均列入完整性。 -- 证据复用:第17节 R01/R02/R11/R12/R13/R14 的原始快照仍在原 `evidence/`,2026-09-14 按 D0 问题复核其相关正文与当前源码;不合入任何上游提交。官方事件媒体关联、OWASP 敏感字段/注入/资源耗尽与写失败、成熟项目桥接以及维护者观测边界支持本决定。无新算法,论文/通用 benchmark 不适用;并发和性能以真实 sink 故障注入及三次可比测量裁决。 -- 验收:有界/关键优先/轮转、写盘失败与超时导出、清空与并发写竞态、截断/seq gap、水位、字段/异常脱敏、旧文本和结构化 JSON 防注入、会话隔离/晚开启/非物理成功语义的定向单测;相关 Java 编译和 App 导出检查。构建只用已有二进制,不重建 native;设备不可用时明确记录,不能把构建代替设备性能验收。 -- 预算:2026-09-14 12:44 Asia/Shanghai 起,约45分钟,预计13:29;设计归档6分钟、实现24分钟、验证提交15分钟。基线性能与设备信息以实际取得的证据记录,不虚构。 -- 回滚:独立 D0 原子 commit/tag;撤回该提交整体恢复旧 Java sink/读取通路,native 产物不受影响。关闭诊断立即停止新增采集并按既有语义清空。 - -### 14.4 D0 实施与验证记录 - -- 已实现:`DiagnosticLogBuffer` 单 writer、有界内存/队列/pinned、优先保留关键事件、generation 清空隔离、游标缺口和健康统计;`RollingDiagnosticFile` 4 段轮转、历史脱敏、单份临时 TXT 导出、前置内容 SHA-256 与超时缓存回退;`DiagnosticText` 统一旧文本/异常脱敏,`DiagnosticEvent` 版本化白名单与固定未知状态。 -- 已接入:SpiderDebug/DebugLogStore 原入口、PlaybackTrace 控制器会话与 prepare attempt、Setting 设备环境/晚开启基线、网页增量日志/音视频过滤/TXT 下载。`ProxySetting` 改用仅保存在本机内存的最多200个 origin;`ShellProxyDialog.suggestRules` 已核实只消费 `suggestion.hosts()`,不依赖凭证或资源路径。 -- D0 事件覆盖:C01、C02、C05 的请求配置、C07 请求/解析、C11 的控制器轨道摘要、C13 控制器阶段、C16 未知音视频终态、C17 健康统计。C03/C04/C06/C08–C10/C12/C14/C15 及实际 codec/Surface/AudioTrack 仍明确 not-collected;C11 不声称逐轨完整选轨覆盖。控制器当前 trace 不冒充来源回调的媒体身份,`association=controller-context-only` / `eventMediaIdStatus=not-collected` 保留边界,D1/D3 才能补来源关联。 -- 首轮验证:JDK21 / 现有 Android37.0 SDK,Gradle 定向35项测试通过(新日志17项、新会话4项、原 Trace7项、PlaySpec4项、Exo trace3项),Mobile/Leanback arm64 Java 编译通过,耗时2分26秒。仅出现原有资源命名空间、Room查询字段和废弃 API 警告。完整输出:原证据目录 `d0-gradle-2.log`;首次沙箱缓存锁拒绝为环境错误,获准访问已有缓存后完成。 -- 网页验证:从实际 Java 字符串提取的脚本通过 Node 语法与执行检查,覆盖增量追加、run/clear generation 重置、gap 替换、结构化 severity 不靠关键词猜测、HTML 转义。脚本和输出位于原证据目录 `d0-web-check.py` / `d0-web-check.cjs` / `d0-debug-page.js`。 -- 并发审查补充:超时与 future 完成同时发生时主动释放未交付导出;内存 fallback 编码/哈希和文件关闭均放在 producer lock 外。新增旧 producer 跨 clear 丢弃、超时后文件释放两项用例;8项日志核心测试及双端 Java 编译通过,45秒,见 `d0-gradle-final.log`。合计37项不同用例。公开开关/恢复/清空再按同一控制锁串行化,防止并发 HTTP 启停造成 facade 与 writer 开关不一致;producer 不取该锁,最后 `:catvod:compileDebugJavaWithJavac` 通过。 -- 实际导出 fixture:`d0-fixtures/d0-export-fixture-1.txt`、`-2.txt`、`-3.txt` 由生产 `DiagnosticLogBuffer/RollingDiagnosticFile` 写入并导出,输入是人工构造的控制器信号和敏感字段,**不是设备实播日志**。三轮每轮1204条采集/落盘一致、零丢弃/写失败,样例内 URL/Cookie 值已移除、物理音画为 not-observable。生产文件校验测试验证前置内容 SHA-256,fixture 也保留 manifest。 -- 主机开销:同一 JDK21/机器、每轮200条预热后1000条标准文本采集,三轮均值0.148/0.118/0.095 ms,P95 0.277/0.202/0.157 ms,最大2.610/0.421/0.330 ms;内存窗口约347728字节、pinned约2322字节、队列高水位2628/1566/3927字节。原始输出 `d0-host-smoke.log` 与 `D0ExportSmoke.java`;这只验证主机 producer/预算,不证明设备 CPU、起播、seek 或播放性能达标。 -- 时长偏差:原目标13:29,导出并发收尾、缓存权限等待及证据生成超出估计;13:40后停止扩展,只完成公开开关时序、最小编译、文档校验与原子收尾。 -- 本地提交/恢复:guard `AV-DIAG-01-D0` 将本节、代码和测试一并原子提交并立即创建 `recovery/AV-DIAG-01-D0/-`;精确 commit/tag 使用 guard 回执和 Git 的 `Task-Guard: AV-DIAG-01-D0` 提交记录恢复,未授权推送。回滚只撤回该 D0 提交,基线 `2ec5afd8cc3f21bf1693b198f87018488775c660`。 -- 设备状态:本轮 `adb devices -l` 无已连接设备。未安装 APK、未进行手机/TV 实播或设备性能 A/B,T25 用户端闭环及产品 CPU/起播/seek 阈值保持未验收;不得用主机测试代替。 - -### 14.5 2026-09-15 D1–D4 实施授权与边界 - -- 用户授权:按 `/private/tmp/webhtv-avdiag-review-20260914/docs/AV-DIAG-01-playback-diagnostics.md` 实施;复用第17节研究,不合入上游提交、不升级或重建native。 -- 基线:`feature/mpv-dv7-fel` / `684f6066393fa503b2a0573c25a2aa25d01294fc`,恢复tag `recovery/MPV-REBUFFER-PANEL/20260915110914-684f6066393f`。预存 `app/.cxx/` 70文件全部保护。 -- 单元一 `AV-DIAG-01-EXO`:D1/D2共用每播放器来源上下文;装饰现有renderer factory/codec adapter/audio provider,增加Analytics、公共Surface及系统音频观察。不改变候选排序、fallback、ASS/DV、处理链或启动状态机。后续D3 MPV、D4 IJK/恢复分别原子提交。 -- 单元一范围:`app/src/main/java/com/fongmi/android/tv/player/`、公共 `PlaybackActivity.java`、`DiagnosticEvent.java`、本文件及索引。后续单元按实际文件另开guard,不含二进制、锁或预存脏路径。 -- 最终接口证据:已读取本项目 `media3-exoplayer/1.11.0-alpha01-fongmi` AAR classes.jar;javap确认四类forwarding接口及 `AudioTrackAudioOutput.getAudioTrack()` 可用。`createAdapter`合并内部创建/配置/启动,公开边界只报告整体尝试,子操作不可伪造。 -- 关联:Analytics按EventTime分别匿名映射事件/当前媒体;adapter和sink保存来源上下文,无法确认复用媒体时显式记录边界。HTTP只读缓存,不反查native或等待UI。 -- 验证:遵循用户自己实测偏好,只核对接口及受影响Mobile/Leanback arm64 Java编译;不运行自动化测试或设备用例。真实导出、播放、性能A/B仍待用户验收,不声称模拟器故障根因闭环。 -- 时间:北京时间2026-09-15 11:34(Asia/Shanghai)起,Exo20–30分钟、MPV10–15分钟、IJK/恢复10–15分钟、编译收尾5–10分钟,预计12:20–12:45。 -- 回滚:各单元guard原子commit/annotated recovery tag;关闭诊断停止详细采集,撤回对应提交恢复接线,不推送。 - -### 14.6 D1/D2 公开接口交付(2026-09-15) - -- `PlaybackDiagnosticCollector` / `ExoDiagnosticCollector`:每实例匿名身份和不可变media tag随EventTime走,晚到回调不借用全局可变trace;独立音视频证据层、错误cause/suppressed有界分片,清空/重新开启后重置窗口。 -- V01–V10公开部分:选择器原结果/硬件声明/profile-level及runtime profile过滤原判定;原factory装饰器记录create/configure/start整体尝试和错误、MediaFormat白名单、CSD长度、输入/output/release/flush聚合、原有frame listener计数;Analytics逐轨、复用、first-frame、错误、DRM、load范围/字节/有限响应头。未改变排序、黑名单计数、fallback、ASS或杜比路径。 -- S01–S07公开部分:共享PlaybackActivity绑定SurfaceHolder附加观察器;fixed/layout请求与actual callback分别记录,view/祖先alpha、shutter/artwork、选中视频、display请求/当前值及effects请求有明确来源。TextureView不替换播放器listener;其native Surface身份不可见时标not-observable。 -- A01–A13公开部分:复用原DefaultAudioSink及直通provider,实际AudioOutput(包括vendor-direct)被装饰;分别统计sink接收和output payload接收、短/零写及原始写异常。实际AudioTrack head uint32扩展/epoch、合法timestamp有效性(不快于10秒)、underrun、actual/preferred/discovered route、系统/播放器/AudioOutput音量分别记录;所有播放/写入/flush/stop调用仍只转发原行为。A15当前用clock/seekEpoch/speed及原播放器同步指标,不推算不可见的物理延迟。 -- 明确缺项:factory没有内部子操作hook,不能区分其内部create/configure/start;没有抢占普通codec frame listener;实际processor内部数据、完整焦点请求结果、物理呈现/声压、CSD内容、native metrics/低层阶段均不伪造,深度字段属于D5或明确not-collected边界。没有像素/PCM探针、额外解码器探测或native重建。 -- 验证:首次接线双端Java编译42秒通过;修正窗口/Surface关联后最终双端Java编译36秒通过,记录 `/private/tmp/avdiag-exo-final-compile.log`。字段白名单唯一性、collector字面字段匹配及diff格式检查通过;没有运行自动化测试、设备场景或性能A/B。 -- 时间:11:34–12:05完成本单元代码/编译;继续MPV,保持整体12:45执行目标。guard `AV-DIAG-01-EXO` 原子提交/tag后转下一单元;实际设备/导出验收未完成。 -- 提交:`57656c06bdb4e13de3cbaf8f573dc76212e7d718`,tag `recovery/AV-DIAG-01-EXO/20260915120613-57656c06bdb4`,未推送。 - -### 14.7 D3 MPV 公开接口交付 - -- 12:06起,预计10–15分钟;guard `AV-DIAG-01-MPV`,基线为上述Exo提交。范围是MPV Java wrapper/现有JNI Java入口、诊断schema/缓存/sink、本文件及索引;不修改JNI源码或native产物。 -- 实际JNI已将QUEUE_OVERFLOW转给通用event;日志订阅是 `terminal-default`,因此以可观察的 `msg-level` 组件覆盖取得INFO初始化消息。保留原播放恢复逻辑;诊断日志不触发额外fallback。 -- 缓存健康与最后错误由后台心跳和现有事件维护,HTTP直接读sink内已发布副本;无native反查/UI等待。属性事实与acceptedWrite请求缓存分开,保留generation/status/age及注册结果。 -- 已实现:所有订阅native消息在旧业务过滤之前持续进入统一结构化sink,移除播放器路径对32条及FEL测量条数窗口的使用;错误/边界关键优先,普通/周期事件按容量处理。sink新增最多16个不可变collector健康缓存,导出读取缓存;队列、native overflow、Java无条数丢弃、过期/坏NODE、源端不可观测过滤计数分别报告。 -- 已实现:INFO组件覆盖只提高不足的级别,保留已有verbose;观察实际msg-level,区分App请求/原生读回。新增INFO不流入原有恢复分类器。关闭诊断恢复保存的日志配置;初始mpv.conf回调及后续用户修改分别作为恢复基线,不把自己的设置回执当成用户配置。 -- 已实现:新增版本、hwdec实际/请求、视频/音频格式、输出/音量/同步、逐轨观测和command返回/reply;属性值只用observer缓存,不使用acceptedWrite冒充实际值,跨文件保留过程级选项并清理文件数据。静态字段只输出变化,周期时钟最多5秒一次。 -- M08/M09:保留查名失败原severity/prefix/原文和独立事件身份,查名结果与实际create/configure/start分开;缺选择器内部原因明确native-hook-required。有video轨、vid=no及audio选中并伴视频init错误时报告videoPartialFailure,保留音频是否实际发声的未知边界。 -- 验证:MPV接线及日志级别恢复/跨文件缓存补齐后的Mobile/Leanback arm64 Java编译通过,日志 `/private/tmp/avdiag-mpv-final-compile.log`;schema、live路径不再引用nativeLogWindow及diff检查通过。未运行用户保留自行执行的native入口洪泛/静默尾部fixture、设备导出或性能A/B;不能把编译称作T03/T05/T21已验收。 -- 用时超过10–15分钟估计:原恢复策略输入隔离与原生回调代际/关闭恢复需补齐;后续仅推进D4必要实现,不增加native研究或测试范围。 -- 提交:`88780d4dc8a197fc9bb5e767f450e1847e50a35d`,tag `recovery/AV-DIAG-01-MPV/20260915123353-88780d4dc8a1`,未推送。 - -### 14.8 D4 IJK 与恢复公开接口交付 - -- 12:34起,预计15–20分钟;guard `AV-DIAG-01-RECOVERY`,基线为上述D3提交。范围:IjkSimplePlayer/IjkPlayerEngine、PreviousProcessExitLogger/App、诊断持久化及本文件/索引。 -- IJK只接入现有原始info/error和状态刷新,原生AudioTrack/OpenSL内部状态保持不可观察;实际stream选择与旧UI默认首轨表示分开。 -- journal由原日志writer写入App私有持久目录,大小有界,普通进展限频;保存已记录的run/trace/attempt、终态/错误/输出进展。next boot读取旧journal并随TXT导出,保留旧run身份;关闭/清空同时删除。 -- Java崩溃仅排队有界摘要并短时best-effort flush,随后交给原handler;系统退出信息在后台读取自身公开API,trace缺失明确未知,不更改已有恢复/重启策略。 -- 已实现:IJK原始info/error、实际stream选轨、视频/音频render-start、解码名称/类型、输出FPS(明确计量口径)、缓存字节、系统音量/可发现设备及生命周期。普通快照复用现有状态刷新,每5秒最多一轮;OpenSL/AudioTrack实际write/head/route缺项保持native-hook-required。 -- 已实现:`JournaledDiagnosticFile`包装原RollingDiagnosticFile;原writer在私有files目录维护最多16个实例、64KiB journal及临时替换文件,普通进展最多每秒一次、开始/结束/崩溃优先。cache清理后仍可从journal恢复旧run/trace/attempt及unfinished状态,旧记录经字段脱敏并进入TXT;磁盘预算包含journal。 -- 已实现:DebugLogStore与原Java uncaught handler串联,最多150ms best-effort排队刷写后必定交还原handler;从后台线程读取自身ApplicationExitInfo摘要及最多8KiB ANR文本前缀,缺少trace/API明确unavailable/not-supported。native平台二进制trace仅报告可用性及字节边界,不伪造Java堆栈。 -- 验证:Mobile/Leanback arm64最终Java编译32秒通过(`/private/tmp/avdiag-recovery-compile.log`);schema静态检查、diff格式和上游checkpoint检查均通过(0错误/0警告)。未进行设备、崩溃注入、真实用户端导出或性能A/B;这些验收仍归用户执行。 -- 提交:`ac02ce84ae14332fa8bf2aab840ee4ab5a7fac2b`,tag `recovery/AV-DIAG-01-RECOVERY/20260915125623-ac02ce84ae14`,未推送。 - -### 14.9 D3 原生回调尝试归属收尾 - -- guard `AV-DIAG-01-MPV-OWNER`,基线为上述D4提交;仅修改MpvDiagnosticCollector/MpvPlayer及本文件/索引,保护原`app/.cxx/`70文件。复用R07/R13及既定不可变Context设计,不改变播放控制、恢复策略或native。 -- 证据:openCurrent会先创建新controller attempt,旧end-file在stop timeout后仍可能到达。原collector取当前Context并关闭它,会把旧文件终态写入新尝试。 -- 修正:在loadfile调用前保存请求Context,START_FILE在native事件线程固定该次owner;后续原生日志、事件、属性/轨道快照、局部失败与health使用该owner和property generation。新controller请求不重绑旧回调;只有native owner仍等于当前attempt时,原生end-file才关闭当前记录。controller stop/release另外记录请求来源。 -- 无法归属时明确未知:START_FILE没有公开playlist entry ID,重叠的不同attempt、无App加载请求的START_FILE和queue overflow不猜媒体;START_FILE之前的初始化日志也不使用新controller媒体冒充来源。原生日志本身仍标`log-media-unconfirmed`,async reply只凭operationId关联请求,不借用当前媒体。诊断锁内不调用MPV、文件IO或等待UI。 -- 验证:本次修改后Mobile/Leanback arm64 Java编译一次通过,24秒(`/private/tmp/avdiag-mpv-owner-compile.log`);collector字面字段/事件schema静态校验通过(`/private/tmp/avdiag-mpv-owner-schema.log`)。diff格式与文档checkpoint在本单元收尾验证,不追加自动化测试、设备操作或性能测试。 -- 回滚:撤回本guard对应提交即可恢复上述D4状态;精确提交/tag由guard回执及`Task-Guard: AV-DIAG-01-MPV-OWNER`提交记录恢复。 - -### 14.10 全量补齐(2026-09-15) - -- 授权:用户明确要求“全部实施完毕,而且要快”。包含前次遗漏的采集字段、症状/对照操作、报告/结构化筛选、受保护的限时深度探针和必要native hook;不是再次等待授权的评审。 -- 基线:`8d46751d7fd0f61bd2cb2557bb8fe99a0d33d494`,`feature/mpv-dv7-fel`;保护`app/.cxx/`70文件。App单元guard `AV-DIAG-01-COMPLETE-APP`,底层补丁/对应产物后续独立原子单元。 -- 研究与选择:复用R01–R19已读源码/平台资料;不变方案不能满足用户需求,直接替换播放器/focus owner会改变行为,因此继续采用原owner内只读hook。PixelCopy严格沿R06只做3次低分辨率统计;PCM沿R05/R12只读duplicate buffer,保护/编码流禁用,不保存媒体内容。新远程操作按R11使用本机显示的短期配对码、header token、POST、Origin和限频;保留旧TXT读取。 -- App范围:现有player/诊断、DebugLogs、DebugLogDialog、公共PlaybackActivity、Setting/App、strings、catvod日志及直接相关定向测试;唯一文档及索引。不得修改播放器选择、fallback、音量、处理顺序或输出参数以获得诊断。 -- 待补齐清单:C03/C06/C09/C10/C15上下文与资源;A05/A10实际处理链和焦点;V02/A03操作分步;S08/A14限时统计;M08/M12及native库身份;故障标记/冻结/对照、TXT可读报告与ZIP、trace/attempt/音视频/优先级筛选、远程操作访问控制;逐项覆盖表与最小验证/产物。 -- 验收:探针关闭时不扫描像素/PCM、不提升native日志;会话/代际/保护条件正确;新增远程写操作不匿名、不接受跨站请求;导出只读缓存/磁盘快照,不回调播放线程。使用定向编译/安全与预算契约校验、受影响ABI和APK打包校验;实际设备/物理输出结果只按真实证据记录。 -- 回滚:各单元独立commit/tag;默认标准诊断,深度需产品内用户主动启动,最长120秒自动恢复;必要底层补丁与其二进制同单元撤回。不升级依赖,不推送。 -- 时间目标:13:26 Asia/Shanghai起55–75分钟,14:20–14:40;既有缓存、窄hook/增量重编、验证输出只保存一次。任何真实不可控阻塞须具体报告,不用追加研究消耗时间。 -- 用户追加(13:50–13:55):移除MPV性能页重复的“详细日志”;全局调试弹窗按播放流程/网络请求/视频画面/音频输出/字幕脚本/内核设备提供子开关,缺省全部开启、持久保存用户选择。关闭分类在采集入口生效;控制和完整性事件保留,并记录选择,避免将用户关闭误判为无错误。限时像素/PCM探针服从视频/音频分类及原主动确认、120秒上限。 - -### 14.11 App操作、报告、受控采样交付 - -- 已接入:默认全开的六类采集开关;本机/TV故障标记;短期配对和有界远程操作;单变量对照记录;同writer持有的前30秒/后15秒incident,标记立即落checkpoint、静默到期收尾、进程恢复保留partial;TXT尾部可读报告及流式ZIP/逐文件SHA256,单并发60秒导出预算;trace/attempt/音视频/priority网页筛选。 -- 深度:默认60秒、上限120秒、绑定具体player/trace/generation/attempt;最多3次64×36像素统计,每个PCM检查点最多3×4096 frame;不保存原图/PCM。切播放/关闭/清空/到期停止,MPV在原owner恢复日志等级并保留用户更高verbosity。受保护内容和编码直通不采样。普通日志记录完整的所选分类,旧性能verbose值已从构建MPV配置路径解除,菜单清理随下一单元。 -- 补齐公共上下文:真实native/App focus请求返回、callback/abandon;复用既有memory/system monitor记录资源,不增加proc/PSS轮询;输入route所有者及可见边界、Exo容器timeline、controller配置old/requested与重建attempt。具体底层create/configure/start、active processors及原始AudioTrack write仍在下一单元。 -- 验证:双端arm64 Java编译92秒通过;catvod诊断23项用例20秒通过(含4项新的配对失效/限频/Origin、ZIP校验/不可变快照、TXT既有manifest与伪造事件、incident即时保存/静默完成/崩溃恢复)。嵌入网页JS语法通过;新增事件字段白名单通过;`git diff --check`通过。编译首次仅遇Gradle缓存沙箱权限,授权后使用原缓存完成。 -- 证据:`/private/tmp/avdiag-complete-app-compile.log`、`/private/tmp/avdiag-complete-contracts.log`,catvod测试XML;不等同于设备实播或性能A/B。已有Room查询警告未改动。 -- 下一单元:基于当前已补丁AAR重编窄hook类并保持其他字节;MPV/FFmpeg只读事件补丁、同锁双ABI产物;完成C03库身份、设置菜单清理和APK。 - -### 14.12 底层观察点与产物 - -- 基线:`180811f16073271ba1cb6a4f2f889008966facd3`,恢复tag `recovery/AV-DIAG-01-COMPLETE-APP/20260915141054-180811f16073`。guard `AV-DIAG-01-COMPLETE-NATIVE`;14:12 Asia/Shanghai;继续保护`app/.cxx/`70文件。 -- 精确源码:读取当前已提交common/exoplayer sources.jar的AudioFocusManager、AudioProcessingPipeline、AudioTrackAudioOutput、同步/异步codec adapter、ExoPlayerImpl/Internal及extractor adapter。与R12相同API所有者,focus已在common并由ExoPlayerImplInternal管理;不新增焦点请求者。codec只包装原API调用并原样抛回异常;原始AudioTrack写统计与payload/header单位分开,处理链在configure/flush时采集。 -- 采用方案:common中的可关闭只读observer,App负责固定owner/attempt关联、有界聚合/脱敏;当前AAR选择性重编类,不改变其他class字节,保留ASS和现有所有扩展。对比不改无法获必要内部字段,替换整个sink/focus owner会改变行为,故拒绝。FFmpeg/MPV在当前已打补丁缓存上追加独立补丁;保留原查名排序、fallback、AO/VO参数及所有本地DV/字幕补丁。 -- 范围:guard内App设置/诊断接线、common/exoplayer两个AAR/source/metadata、构建脚本和独立补丁、MPV两ARM ABI的受影响产物、唯一任务文档/索引。构建缓存为临时编译输入,不提交、不reset其已有修改。 -- 验收/回滚:新增hook关闭时不做字符串/媒体扫描;错误码/生命周期不被改写;两ABI同输入、ELF依赖不变;App双端打包;这一提交连同补丁/二进制可整体回滚到本节基线。继续复用R01–R19,不扩展研究。 -- 已完成源码和接线:Media3 `PlaybackDiagnostics`/`Media3DiagnosticBridge`记录原owner内的焦点请求、duck倍率、实际active处理链、codec分步和AudioTrack原始返回;`ForwardingExtractor.init`把真实output绑定到创建MediaItem时的不可变Context,保留底层implementation和全部既有ASS/DV extractor行为。关闭音频分类后跳过新增逐buffer统计。 -- Media3产物:`scripts/build_media3_diagnostics.py`从上述完整已提交基线选择性重编common/exoplayer,两模块未修改的class字节逐一保持;新补丁、AAR、sources、module/checksum和media-lock一起更新。编译临时副本仅去除AAR未携带的package-private SOURCE-retention注解,发布sources保留。构建输出见`/private/tmp/avdiag-media3-build.log`,已通过,不重复重编。 -- MPV/FFmpeg:保留同一source lock及所有本地补丁;查名实际访问/拒绝原因/profile比较、查询失败具体stage、Java提前返回/NDK by-MIME和create/configure/start结果均进入原av_log桥。App兼容AVClass文本前缀,新增hook不进入旧恢复分类器。AudioTrack记录真实写入返回及bytes/int16/float单位、配置/路由/已有head和timestamp缓存;outputId使用进程内递增编号,避免重建后复用。两ABI FFmpeg、MPV首轮成功;最后outputId/JNI异常清理修改只需增量重链MPV。 -- C03:后台只读自身可执行映射,支持APK内未提取SO;解析实际ELF ABI/GNU build-id/SHA256、对照MPV asset或APK条目,不把“包里存在”当成已加载。不创建native context。构建注入Git完整revision+dirty/clean状态、MPV/FFmpeg/libplacebo/framework源锁,避免提交前包伪装成提交后精确版本。 -- 观察告警:复用既有5秒样本,连续3次且至少15秒无进展才生成只读`diag.observation`;暂停/缓冲/seek/suppression、无选中motion video、未知或低帧率、无有效输出指标均门控。AudioTrack只观察PLAYING且有已接受payload的playhead;不据此断言物理无声、不触发恢复。MPV新增file-format/seekable/current-edition缓存;A/V clock口径明确。 -- 用户设置:六类默认全开和持久选择沿用14.11;已删除MPV“详细日志”的菜单、value/action、getter/setter和preset残留。正常启用即收集所选分类完整的标准诊断;像素/PCM主动限时捕获仍独立。 -- 最后覆盖核对:非保护、当前owner的CSD按index/length/bytes计算SHA-256,总读取上限64 KiB,只读duplicate;保护内容、超预算、旧owner或读取失败保留明确状态。adapter输出PTS与输入PTS分别记录,flush/capture重置;新增统计服从对应分类。 - -### 14.13 已实现覆盖索引 - -同一事件内的具体字段仍以其`status/source/association`为准;此表表示采集能力已接入,不表示每台设备、每个后端都能返回所有字段,更不表示物理输出已验证。 - -| ID | 实现落点及证据 | -| --- | --- | -| C01 | `PlaybackDiagnosticSession/Collector`:开关、迟开、版本、身份、分类选择及可用边界 | -| C02 | `Setting.logDebugEnvironment`:构建Git/revision状态、设备/API/ABI、fingerprint摘要;资源补充见C15 | -| C03 | `NativeLibraryDiagnostics`:实际可执行映射、ELF ABI/build-id/SHA256、APK/asset对照、加载失败;未加载库不伪装成功 | -| C04 | `SurfaceDiagnosticCollector`及既有MPV输出日志:display/mode/HDR与已有后端信息;不创建额外GPU context | -| C05 | `PlaybackDiagnosticSession`及MPV配置入口:当前播放配置及来源 | -| C06 | `PlayerManager`配置变化与`MpvDiagnosticCollector.option`:请求、old/new、原始返回和重建attempt | -| C07 | `PlaybackTrace/Session`:请求/解析结果、资源分类、匿名媒体身份及输入路由 | -| C08 | Exo load事件、既有网络日志与MPV observer:Range/响应头摘要、字节/耗时及缓存;不可见上游不补造数据 | -| C09 | `PlaybackDiagnosticSession`:direct/App/外部loopback owner及可见网络边界 | -| C10 | Media3 extractor owner hook/timeline及MPV `file-format/seekable/current-edition`:实际容器实现与媒体身份 | -| C11 | Exo tracks、MPV track-list、IJK选轨快照:available/support/selected与格式 | -| C12 | Exo DRM analytics与媒体保护标记:会话/keys/error阶段;不采license正文或key | -| C13 | 公共PlaybackActivity/PlayerManager与各内核collector:生命周期、暂停、抑制和来源 | -| C14 | Exo player/adapter及MPV observer:播放时钟、独立decoder输出PTS、seek epoch和speed | -| C15 | 复用既有memory/system/recovery monitor:资源、trim/温控/省电与线程延迟;不增加proc/PSS轮询 | -| C16 | `PlaybackDiagnosticCollector.end`:音视频分别记录最后证据层及错误;保留物理输出未知 | -| C17 | `DiagnosticLogBuffer/RollingDiagnosticFile`及collector health:队列、seq、轮转、flush、丢失、写失败、迟到与未结束 | -| V01 | `ExoDiagnosticCodecAdapter.selector`:本次selector返回的候选顺序与能力/策略来源;不把独立枚举当实际尝试 | -| V02 | Media3同步/异步codec原API hook:create/configure/start分步及原异常,绑定factory operation owner | -| V03 | `ExoDiagnosticCodecAdapter.mediaFormat`:配置白名单、surface关联与受保护/有界CSD摘要 | -| V04 | adapter output-format及analytics input/reuse:输入输出格式分层、复用结果 | -| V05 | adapter queue聚合:bytes/input/加密/EOS/PTS范围与回退;不复制sample | -| V06 | adapter dequeue/release及DecoderCounters:解码输出、提交、丢弃、drop各自口径 | -| V07 | first-input、first-output、release及Media3 first-frame回调分别标记 | -| V08 | 包装原有Media3 frame-render listener;没有该listener时明确未采集,不抢占tunneling回调 | -| V09 | adapter flush/release/reuse及epoch;生命周期错误保留原异常 | -| V10 | 独立`video.error`关联`diag.error`的有界cause/stack链,不累加播放策略故障次数 | -| S01 | 公共SurfaceHolder附加观察器:create/change/destroy与尺寸/有效性 | -| S02 | 公共UI与codec adapter绑定/解绑、old/new surface;native侧保留原owner输出日志 | -| S03 | fixed/layout请求、operation与之后actual holder callback分别记录 | -| S04 | view/祖先可见性、alpha、窗口/附着状态;TextureView不替换播放器listener | -| S05 | `SurfaceDiagnosticCollector`:video/artwork/shutter等来源与selectedVideo状态 | -| S06 | Exo effects请求及MPV原后端/滤镜日志;requested不冒充applied或色彩正确 | -| S07 | display/frame-rate请求与当前实际显示mode分别记录 | -| S08 | `PixelDiagnosticProbe`:主动限时、最多3次64×36统计、PixelCopy原始返回和保护门控 | -| A01 | Exo输入/轨道Format、MPV current-track、IJK选轨:原始/当前音频路径 | -| A02 | codec selector与实际AudioOutputProvider支持查询:解码与输出能力分开 | -| A03 | Media3/FFmpeg codec原操作:create/configure/start及原错误;音频不套用视频成功状态 | -| A04 | adapter与AudioOutput输入:编码/PCM格式和独立decoder输出PTS;不把AudioOutput接受量当decoder帧数 | -| A05 | `AudioProcessingPipeline` configure/flush owner hook:active处理器顺序、输入输出格式;MPV AF配置由observer取得 | -| A06 | 实际provider返回对象与MPV AO配置:outputId、encoding/mask/buffer、offload/tunnel及来源 | -| A07 | AudioOutput payload统计与AudioTrack原始API返回分层;MPV保留bytes/int16/float单位、短写/0/错误与耗时 | -| A08 | Exo真实AudioTrack head/timestamp/epoch;MPV复用AO现有head/timestamp缓存,validity不伪造 | -| A09 | `SystemAudioDiagnosticCollector`与实际AudioTrack路由:discovered/preferred/actual角色分开,MPV在原AO内低频取得actual | -| A10 | Media3原AudioFocusManager与App/native原focus owner:请求返回、callback、duck gain及player command,不新增请求者 | -| A11 | player、AudioOutput、MPV与系统音量/静音分别采集;系统不可见mute/外部AVR音量保留未知 | -| A12 | 实际AudioOutput生命周期及MPV recreate;新AudioTrack使用新的进程内outputId | -| A13 | `audio.error/diag.error`:初始化、write、codec异常与raw错误码分离,保留当时owner | -| A14 | `PcmDiagnosticProbe`:主动限时、每检查点最多3×4096 frames、逐声道数值,保留原buffer position;保护/编码流禁用 | -| A15 | `audio.sync`:Exo独立decoder输出PTS/player clock/epoch,MPV实际avsync/delay;不把decoder PTS差当物理同步误差 | -| M01 | 实际加载映射/库摘要、源锁构建注入与mpv/FFmpeg版本observer | -| M02 | 白名单option原始set返回、requested与readback;分类/限时日志覆盖到期恢复,保留用户更高verbosity | -| M03 | 原native事件入口、source seq、start-file owner与end reason/error | -| M04 | 有界track-list NODE解析,available/selected/default/albumart及实际轨选择 | -| M05 | hwdec requested/current、interop、codec/VO/GPU与分层video参数缓存 | -| M06 | decoder/输出音频参数、AO、volume/mute、AF和SPDIF配置;native实际配置补充A06–A08 | -| M07 | position/PTS/cache/pause/idle及各类drop独立字段,不混为重缓冲计数或物理FPS | -| M08 | FFmpeg实际选择器访问/拒绝/profile比较、查询失败stage及真实create/configure/start;识别AVClass日志前缀 | -| M09 | 有视频但初始化失败后vid=no且audio仍选中,报告videoPartialFailure及原错误;audio selected不冒充有声 | -| M10 | commandId、提交/返回/完成、耗时与generation;无法确认媒体归属时明确unresolved | -| M11 | observer注册、NONE/unavailable、age、NODE/late/native overflow与源端过滤边界;静默时可从缓存导出 | -| M12 | 原native VO/interop失败进入结构化sink;新增AudioTrack owner hook,非AudioTrack后端不伪造write/head | - -- D4 IJK最小覆盖已接入原info/error/decoder/track/FPS/cache及公共focus/route/volume;OpenSL ES内部write/head按9.4明确不可见,不建立新的JNI/音频owner。 -- 5/10/11/12节的产品闭环已接入:症状标记、前30秒/后15秒incident、崩溃恢复partial、有界短期配对/POST/Origin/限频、对照步骤、TXT可读报告、流式ZIP/校验和、网页结构筛选、队列及轮转损失状态。真实症状判读仍需用户提供同次复现导出,不能用fixture声称实机闭环。 -- 无输出观察默认采用三个5秒采样、至少15秒,复用现有定时器;文中5秒/3秒为候选阈值,并非已确认的设备故障期限。合法暂停、缓冲、seek、抑制、封面/音频only、未知cadence均门控,观察不触发任何恢复动作。 - -### 14.14 软件验证、产物与验收边界 - -- App前一原子单元:`180811f16073271ba1cb6a4f2f889008966facd3`;tag `recovery/AV-DIAG-01-COMPLETE-APP/20260915141054-180811f16073`。14.11的23项契约验证不重复运行。 -- Media3选择性重编common/exoplayer成功,保留全部无关class字节;`/private/tmp/avdiag-media3-build.log`保存完整输入hash和结果。APK包含新`PlaybackDiagnostics/Media3DiagnosticBridge`定义,确保新AAR可达。 -- 两ABI FFmpeg及MPV构建成功,最后outputId修改仅增量重链MPV;`scripts/verify_mpv_native_assets.sh --require-elf`通过,日志`/private/tmp/avdiag-final-elf-check.log`。保留同锁、命名空间、DT_NEEDED及JNI contract;未重编`libplayer.so`。 -- 双端arm64 Debug首轮构建6分22秒通过,显式`-PexoAssPrototype=true`保留已经验收的ASS字体能力,使用既有CMake隔离init-script保护`app/.cxx/`。随后仅因补齐CSD/输出PTS这个相关Java改动增量打包,最终双端构建28秒通过;未构建全ABI/App矩阵。 -- 包内核验:两APK的10个MPV asset逐个与最终工作树SHA256一致;libass存在且构建flag开启;五个新增诊断类的DEX **class definition**存在,不仅搜索类名引用。原生库身份生产解析器以两ABI的真实ELF对照llvm-readelf,验证GNU build-id存在/缺失、ABI、SHA256;STORED APK fixture验证真实映射条目和未映射排除。当前libmpv没有GNU build-id,使用实际SHA256并保留build-id缺失;首次验证脚本错误地假设必有build-id,已修正参考预期并增加有build-id的NDK C++库对照,未修改生产解析器或重跑APK断言。 -- 最后Java增量之后,仅针对新APK重新核对包内六个诊断class definitions、更新的PTS代码、libass和10个MPV asset,均通过;不重复原生ELF、解析器或23项契约检查。验证文件:`/private/tmp/avdiag-final-apk-build.log`、`/private/tmp/avdiag-final-apk-content.log`、`/private/tmp/avdiag-final-artifacts.log`、`/private/tmp/avdiag-final-parser-check.log`、`/private/tmp/avdiag-final-artifacts.json`;不包含伪造的设备实播结果。 -- Media3 AAR SHA256:common `81339bd78814f83987c2d3dd07dfbfd16b32ae34aabd23616abf4938a77bc696`;exoplayer `47f90bd4fc52567738847ceb59c435ddeb6d35b987866d6899de87de3170bdf6`。source/metadata/checksum与media-lock同单元交付。 -- 原生关键SHA256:arm64 libmpv `8213150b467bc2dd9501bbd8e484ac8db04f537ebc8276133a845c18fda1b01a`、libmvcodec `3720c16422139d874f1954e6851330530b6767056f527062c37d4d197cb44c1d`;armv7 libmpv `2a0e1f749b8d57377da086a5c150db588b90cf65b656eddce36e6a3ba194709d`、libmvcodec `fd283848a22160964511e819992fe76aca4e37cbb4ac69e3817a63951a805e25`。 -- 仍然明确的验收边界:用户自行进行设备实播、TV遥控器操作、T01–T25和性能A/B;本轮未用ADB安装/操作。编译与ELF检查不能替代这些结果,不宣称“零开销”或物理音画正常。常规缺回调/未知后端、旧owner、保护内容、日志关闭/迟开等状态持续保留在导出中。 -- 原子回滚:当前guard `AV-DIAG-01-COMPLETE-NATIVE`将App适配、三份窄补丁、两个AAR及两ABI原生产物一起提交并生成唯一`recovery/AV-DIAG-01-COMPLETE-NATIVE/…`本地annotated tag。不push;撤销该单元可恢复到14.12基线且保留14.11分类/导出等App功能。 - -| 最终安装包(arm64 Debug) | 字节数 | SHA256 | -| --- | ---: | --- | -| `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk` | 188388733 | `d35d0a46d648b66f716cb76eea378cfadf3f6c0bdf916553d1b3d186b188d88d` | -| `app/build/outputs/apk/leanbackArm64_v8a/debug/app-leanback-arm64_v8a-debug.apk` | 176524458 | `e19d8fb0f4feefe45ca9361cace6da333507b50ca0d82cc01eea2c9cb05d0885` | - -安装包标记构建时的完整基线Git revision及`dirty`状态,产物hash如上;不为写入提交后hash再做一次整包构建。当前代码提交/恢复tag可通过guard回执与本单元提交记录唯一定位。 - -### 14.15 PR #107 爬虫日志与 Web 日志页(2026-09-15) - -用户已授权参考 [PR #107](https://github.com/fish2018/webhtv/pull/107) 实现 QuickJS/Python 爬虫日志,不直接采用 PR 代码;同时修复手机/电脑日志页顶部空间、滚动后操作不可达和控件重叠。这是本任务的追加单元,不重开已完成的播放器/native 阶段。 - -基线:`feature/mpv-dv7-fel` / `5acbb05afff34235d66bc1a6f3d7f67427e2a239`;guard `AV-DIAG-01-CRAWLER-WEB`,保护预存 `app/.cxx/` 70 文件。16:42 CST 声明预计 25 分钟(研究 5、实现 13、验证/构建/收尾 7),目标 17:07 CST。范围只含日志桥接、Web 页、相关验证和本文/索引;不改爬虫执行方式、播放器、依赖、签名或 CNB 发布。 - -#### 决策证据(访问日 2026-09-15) - -| 来源 / 等级 | revision / 实际读取 | 结论、适用性与决策影响 | -| --- | --- | --- | -| PR #107 / A(代码) | base `784b90420d646eb6c7ddcc63ad622a92c65b02b4`,head `3e383c6ed5537d008024f223bc670794aeb4dcf8`;API正文、diff/patch及相关方法;API无评论/代码评审评论 | PR把QuickJS四回调和Python self.log转给SpiderDebug.log、Web按tag归Console。当前facade本身写Logcat,照搬会重复输出/丢失级别;普通print不经过self.log。 | -| 本地调用链 / A | 上述基线的 `quickjs/.../Spider.createCtx` → `Console`;`chaquo/Loader` → `app.spider`/`SourceFileLoader` → `base.Spider.log`;`SpiderDebug` → `DebugLogStore` → `DiagnosticLogBuffer`;`DebugLogs` | Console在evaluate前已注册;Python app导入前适合一次性安装桥接。复用现有异步sink、脱敏、generation和分类,不另建writer。 | -| Chaquopy官方文档/源码 / A | [sys/Android](https://chaquo.com/chaquopy/doc/current/android.html#sys),当日正文;[16.1.0 stream.py](https://github.com/chaquo/chaquopy/blob/16.1.0/product/runtime/src/main/python/java/android/stream.py)、同版 `java/android/__init__.py` | stdout/stderr已有行缓冲Logcat owner,stderr为WARN;native fd不在sys流可见范围。用公开TextIO委托保留原输出,不依赖或修改私有类;本地Chaquopy17.0.0不升级。 | -| CPython / A | [v3.10.18 logging.StreamHandler](https://github.com/python/cpython/blob/v3.10.18/Lib/logging/__init__.py),构造/emit/flush/setStream | 默认写sys.stderr,logging仍拥有formatter/异常;额外root handler会影响basicConfig/重复日志,因此不添加handler、不改logging配置。 | -| pytest成熟实现/测试 / B、A | [8.4.2 TeeCaptureIO](https://github.com/pytest-dev/pytest/blob/8.4.2/src/_pytest/capture.py)、[test_capture.py](https://github.com/pytest-dev/pytest/blob/8.4.2/testing/test_capture.py) 的tee-sys、Unicode、原输出检查 | 借鉴tee保留原流write返回值/异常语义;测试用无限缓冲不适合App,采用每线程有界行缓冲、明确截断、关闭时不保留文本。不复制代码。 | -| MDN技术文档 / B | [position](https://developer.mozilla.org/en-US/docs/Web/CSS/position),sticky/scrolling ancestor正文 | 现有main的overflow-x:hidden会影响sticky滚动祖先;顶部须正确固定,tabs独立横滚,输入框规则排除checkbox。 | - -相关提交处置(仅用户指定日志功能进入候选范围): - -| 完整 commit | 内容 | 处置 | -| --- | --- | --- | -| `cd30288078162c6266fe62303032eb144554336c` | QuickJS Console桥接 | 手工重实现:只新增sink通道、保留级别,避免第二次Logcat输出 | -| `636ff64b2ced0433c67bd1faf689a55393585b96` | Python self.log桥接 | 补充/替代:一次性TextIO tee覆盖self.log、print及标准流logging/traceback | -| `7ac206819f6e60be04f26e8e6312014fa87f88b8` | Console tab包含爬虫 | 手工重实现:来源与显式级别、错误筛选和安全转义 | -| `e452bdbdefb74ff8891057b2ea02ede2f612c8d1`、`83b61ba3ee9d0ad8a8b5fa5f319c7e1e2735c3b2` | XBPQ地址及变量修复 | 不适用此次需求;其余PR签名/站源/构建改动不在授权单元内 | - -已检查相关patch的后续同文件变更,无本需求的维护者讨论/revert证据(评论接口为空)。算法论文/编码器benchmark与此次TextIO和布局决策无关,不扩大搜索。 - -#### 方案、验收与回滚 - -- 不改动:继续缺少爬虫输出和可达操作区,不能满足需求。原样PR:改动少但重复Logcat、无级别、仅self.log,也未解决布局,因此拒绝直接合并。 -- 采用窄适配:SpiderDebug新增仅写调试sink的Console入口,先检查总开关/分类,再保留显式级别;QuickJS保持原Logcat owner。Python在app/爬虫导入前安装一次tee,原流仍负责输出;每线程缓存上限、flush和generation隔离;采集异常不打断爬虫。不添加logging handler,不把stderr等同error,不用当前影片伪造异步爬虫归属。 -- 爬虫收集跟随“网络请求”分类(默认全开);Console tab区分QuickJS/Python。Web/TXT/ZIP仍用已有脱敏/轮转/完整性机制。超长单行保留有界前缀、显式截断,不能任意拆散凭据而绕过脱敏。 -- 顶部合并为一个固定操作区,分类单行横滑;“地址说明”位于开关后,诊断操作/结构筛选按需展开;搜索限宽,解释checkbox和暂停独立布局。展开面板在小屏限制高度并自行滚动,保留全部配对/导出/诊断操作。 -- Java/Python/HTML变更不改ABI/依赖ownership/播放器;启用日志有新增有界开销,不宣称零开销。Python native fd、主动替换sys.stdout的脚本、安装前已持有原流的第三方handler不冒充已覆盖。 -- 决定性验证:Python原流/分段/线程/禁用/generation/洪泛/异常契约;Java编译及分类/错误保留;真实生成网页在手机/桌面宽度的顶部固定、无重叠、tabs横滚、Console/错误/搜索/暂停交互。只构建mobile/leanback arm64 debug,保留 `-PexoAssPrototype=true` 并隔离CMake,设备体验由用户验收。 -- 回滚:整体revert本单元commit至上述基线;已有tag `recovery/AV-DIAG-01-COMPLETE-NATIVE/20260915162159-5acbb05afff3`。不push。 - -#### 17:29 用户 UI/UX 反馈后的布局决策 - -用户截图中诊断表单、配对和类别换行挤占正文,要求使用折叠/tab/抽屉/弹窗,按UI/UX最佳实践重排。首轮本地浮层的浏览器检查也发现 `popup outside viewport`,不能交付;改用独立于页面高度的视口级面板。原17:07目标因用户反馈与布局调整失效,17:30重新估计15分钟、目标17:45;不重跑已经通过的Python/分类检查。 - -新增证据均于2026-09-15经代理读取正文/实际源码: - -- [Chrome DevTools ConsoleView.ts](https://github.com/ChromeDevTools/devtools-frontend/blob/main/front_end/panels/console/ConsoleView.ts) 当日main快照,B:`console-main-toolbar`仅保留主要操作;`console-show-settings-toolbar=false`和默认隐藏sidebar体现按需揭示设置。采用这种职责分层,不引入其组件依赖。 -- [Carbon Data table usage](https://carbondesignsystem.com/components/data-table/usage/),B:toolbar承载主要按钮/搜索/筛选,展开用于小空间中的大量信息;依操作频率整理,而非把所有控件缩小后平铺。 -- [WAI-ARIA APG modal dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/),A:背景不可交互、焦点进入/圈定面板、Escape/可见关闭按钮、关闭返回触发控件。优先HTML dialog,保留不支持showModal时的焦点/遮罩降级。 -- [APG Tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/),A:一次展示一个panel,选中态、左右/Home/End键和对应标签关系;适用于面板内筛选/诊断/工具三个同级区域。 -- [WCAG 2.2 Target size](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html),A:最小24×24及间距;本任务手机主操作采用44px目标,不靠缩小点击区域节省空间。 - -最终结构:默认顶部两行(状态与搜索/暂停/工具入口;单行横滚分类),手机搜索按需展开,桌面搜索限宽;筛选/诊断/工具放入一个带三页签的面板,手机底部sheet、桌面右侧drawer。地址说明按钮紧跟关闭采集,展开在工具页内;解释选项放筛选页,避免与暂停争夺主工具栏宽度。面板独立滚动、关闭后回到原日志位置,分类/关键词/结构筛选生效时显示状态提示,不嵌套弹窗或重排原日志内容。 - -#### 最终实现和验证(2026-09-15 17:56 CST) - -本单元实现完成:QuickJS四级Console和Python标准流接入统一sink;默认全开的网络分类生效,错误保留、原Logcat唯一输出、脱敏/轮转沿用现有实现。Python在app/爬虫导入前一次性安装,真实self.log、普通print及标准流logging/traceback可见。现有 `app/proguard-rules.pro:62` 保留 `com.github.catvod.crawler.**`,Java反射入口不需要新增规则。 - -Web默认两行固定顶部;搜索在手机按需展开、桌面限宽260px;Console靠前显示。一个三页签工具面板承载结构筛选、解释显示、配对/诊断、下载/采集管理、地址说明;手机底部sheet、桌面右侧drawer。关键词和高级筛选数量可见,提供重置。面板内滚动、点击遮罩/关闭/Escape、Tab焦点循环/恢复、页签方向键及旧浏览器降级均已实现。 - -| 验证 | 结果 / 证据 | -| --- | --- | -| Python实际方法/流契约 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s chaquo/src/test/python -v`,7项通过,0.006秒;覆盖真实base.Spider.log、print/writelines/flush、原输出/异常/返回值、分线程、禁用/generation/分类、100k长行、采集异常、一次安装与logging级别;`/private/tmp/pr107-python-tests.log` | -| Java分类契约 | `:catvod:testDebugUnitTest --tests com.github.catvod.crawler.diagnostics.CrawlerLogCategoryTest`,1项通过;QuickJS/Python/Java爬虫统一网络类别且默认全开 | -| 真实Java生成页 + Chrome | `scripts/verify_crawler_web_logs.py` 生成实际HTML;用已安装Chrome运行产出的browser.cjs,320×640、390×844、844×390、1440×900全部通过;手机顶部93px、桌面83px。单行分类、触控区域、正文宽度、头部固定、Console/错误/关键词/暂停、安全转义、面板视口边界、焦点/返回/Escape、页签、配对、结构筛选、无showModal降级通过;`/private/tmp/pr107-web/browser-result.json`、`/private/tmp/pr107-web-check.log` | -| 可视核对 | 已检查 `logs-390.png`、`drawer-390.png`、`drawer-1440.png`,默认正文可见,面板按区域展示;预览目录 `/private/tmp/pr107-web/`。宿主JVM输出编码修正为UTF-8,避免测试夹具把中文变成问号;不是Android产品字体问题 | -| 最终双端APK | 保留 `-PexoAssPrototype=true` 与CMake隔离init-script;最后一次相关焦点修正后增量构建57秒成功,未重跑已通过且未变的Python/分类检查;`/private/tmp/pr107-final-build.log` | -| 包内与治理 | 两APK均包含 `webhtv_logging.pyc`、新drawer的DEX内容、`lib/arm64-v8a/libexo_ass.so`;BuildConfig的Exo ASS仍开启。checkpoint verifier 0错误/0警告;预存app/.cxx由guard保护。最终收尾使用本guard原子提交和唯一annotated tag,不push | - -最终产物(未安装设备,用户实测/性能A/B不冒充通过): - -| 变体 | 路径 | SHA-256 | -| --- | --- | --- | -| 手机arm64 debug | `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk` | `ec6f44be454ea45dede31ca2f9e508015c0cddffcd27f2e551699983f5027e79` | -| 电视arm64 debug | `app/build/outputs/apk/leanbackArm64_v8a/debug/app-leanback-arm64_v8a-debug.apk` | `00a23b8285b81e853d56bcecf63d279b57611ba7502abb21471df948e955c3cb` | - -前一轮浮层越界已由视口级dialog替代,Chrome在末尾Tab移出焦点的问题已加显式循环修正;最终浏览器检查全部通过。没有进一步更改播放器/native、业务爬虫执行或签名/CNB发布。 - -### 14.16 无配对日志操作与固定顶部按钮(2026-09-16) - -用户插入需求:删除调试日志配对,把下载/清空放顶部,评估“记录单参数对照”。基线`feature/mpv-dv7-fel` / `ed3d710ef551210278920ba4cd25e8dda6e19ad6`,FEL内容复用候选已独立提交/tag;本轮不重编native。guard `AV-DIAG-01-WEB-ACTIONS` / quick-fix,保护104个既有 `app/.cxx/` 文件。06:59 Asia/Shanghai开始,修改/定向验证约10–18分钟、TV64打包及收尾约5–7分钟,目标07:15–07:25。 - -#### 依据、选择与批准 - -- 实际调用链:`DebugLogs.doResponse/diagnosticAction`使用Bearer和`DiagnosticControls.ACCESS`;`DiagnosticAccess`生成6位码、15分钟token并同时做5次/秒限频;App `DebugLogDialog`显示配对码,网页将token放sessionStorage。用户要求的是整个调试操作流程取消配对,仅删除页面输入框会留下403,不能作为实现。 -- `DiagnosticControls.compare`只发布`diag.comparison`的手填old/new/note,result仍为unknown;`DiagnosticReport`未使用它做对照计算,只提示使用该入口。日常使用价值不足,删除页面/接口/该专用发布方法,并将报告指引改为保存前后日志;正常参数事件、故障标记、限时统计、历史日志解析保留。报告这一处必要文案纳入同一任务scope,无新增行为或模块边界。 -- UI复用14.15已读Chrome DevTools toolbar、Carbon、APG dialog/tabs及WCAG触控目标证据:高频动作在固定顶部,手机仍两行,44px点击区域;下载/清空/暂停/工具同排,搜索移到单行横滑分类旁;结构筛选及低频ZIP/采集管理继续在原抽屉,不叠加新的面板体系。 - -| 新访问证据(2026-09-16) | 事实/等级及本地适用性 | -| --- | --- | -| [Go net/http csrf.go](https://github.com/golang/go/blob/56ebf80e57db9f61981fc0636fc6419dc6f68eda/src/net/http/csrf.go)与[测试](https://github.com/golang/go/blob/56ebf80e57db9f61981fc0636fc6419dc6f68eda/src/net/http/csrf_test.go),go1.25.1=`56ebf80e57db9f61981fc0636fc6419dc6f68eda` | A;不安全方法校验Origin/Host或Fetch Metadata,GET不能做状态修改;测试覆盖跨站/null/方法及bypass误匹配。借鉴同源防护边界,不引入Go/新依赖;其无Origin默认放行不直接采用 | -| [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html),当日完整正文 | B;强匹配源/目标Origin,目标地址应由服务端确定,缺失来源推荐拒绝;该机制也适用于无认证请求。沿用设备实际地址allowlist,去token后拒绝缺失/null/异常Origin,HTTP页面的fetch POST自动携带Origin;不信任任意Host/转发头 | -| [Go #75054](https://github.com/golang/go/issues/75054)与[#79766](https://github.com/golang/go/pull/79766)实际问题/提议正文 | B/C;bypass匹配/Origin userinfo接受边界需要明确。本地不增加bypass,已有userinfo/path/query/fragment拒绝保留;两项均只读参考,不移植patch,也不把closed状态当作已合并证据 | - -原始正文、源码/测试、API元数据保存在`/private/tmp/webhtv-debug-web-actions.ALJuwC/`。这是已有产品流程与HTTP/UI契约调整,无新的渲染/密码学/媒体算法;论文和GPU benchmark不能决定此取舍,不新增此类检索。网页布局继续复用昨日固定快照及实际浏览器验证,不重复泛搜。 - -比较:不改违背用户需求;只去UI但保留Bearer会使按钮失败;完全删掉来源/方法/资源预算并无必要。采用窄适配:完整删除配对/token链,严格同源POST和原5次/秒限频独立保留;删除手工对照入口及无效报告指引;把高频动作放顶部。用户本次指令已明确批准产品访问行为变化,无需重新请求相同批准。 - -验收:无配对即可标记、停止统计、导出ZIP及启停/清空;跨站/未知Host/缺失Origin/GET修改继续拒绝、过快返回429;TXT读取兼容。320/390/844/1440视口无重叠/横向页面溢出,下载/清空滚动后仍可用,抽屉/键盘/筛选/暂停保留;手机/TV共用弹窗不再显示配对码。先跑修改后的访问/报告JUnit和实际Java页面浏览器检查,再一次TV64构建、包内MPV库身份/签名验证。回滚到本节基线恢复这组App源码/测试;FEL native提交保持独立。APK与性能验收不混同。 - -#### 实现及软件验证结果 - -- `DiagnosticAccess`删除配对码、token及其过期状态,`allowAction()`独立保留每秒5次有界限频;`sameOrigin()`拒绝缺失/空/null来源,并保留设备已知Host与URI严格比较。`DebugLogs.controlRequestError()`统一限制所有修改/诊断操作,方法、来源、频率失败分别返回405/403/429。网页不再读写sessionStorage或发送Authorization;App共用弹窗也删除配对提示。 -- 顶部第一行依次为下载TXT、清空、暂停、工具;第二行分类横滑,手机搜索入口位于分类右侧。ZIP及低频工具保留在原抽屉。删除手填对照表单、接口及专用发布方法,报告改为提示保存修改前后日志;历史事件格式保持可解析。 -- `DiagnosticExportAccessTest`共5项通过:无需配对即可操作、5次/秒及窗口恢复/时钟回退;同源IPv4/IPv6/localhost、跨站/未知Host/缺失与异常Origin;既有ZIP快照/哈希、TXT防伪及incident恢复检查保留。结果见证据目录`access-tests.xml`。 -- `scripts/verify_crawler_web_logs.py`使用实际Java生成的开启/关闭页面、已安装Chrome和本地模拟HTTP响应通过320×640、390×844、844×390、1440×900。顶部手机93px、桌面83px,下载/清空滚动后命中有效,按钮尺寸/重叠、分类、搜索/暂停、Console、安全转义、抽屉焦点/Escape、页签及旧dialog降级通过;标记/深度统计确认/停止/ZIP/清空/采集启停均发送不含Authorization的同源POST。浏览器检查验证页面发出的请求,真实服务器访问判定由上述JUnit及编译验证覆盖,未冒充电视实测。证据:`browser-run.log`、`browser/browser-result.json`及截图。 -- JDK21下定向JUnit和`:app:assembleLeanbackArm64_v8aDebug`一次实际构建61秒通过,108任务中14执行、94复用缓存,隔离CXX目录且不重编FEL。最初仅因沙箱拒绝Gradle缓存锁和Chrome启动而重试权限;页面生成成功项没有重跑。证据:`gradle-build.log`,原始环境拒绝见`gradle.log`/`browser.log`。 -- TV64 buildTime=`202609160721`,APK为`app/build/outputs/apk/leanbackArm64_v8a/debug/app-leanback-arm64_v8a-debug.apk`;164143897字节,SHA-256=`f5f13571b3a210ab689d8aed3963620399bbcd75f191ecdf7e49debc98938eab`。10个MPV包内库全部与已提交资产一致,v2签名通过,ZIP开销802588字节。libmpv仍为FEL候选`240935cf90a8ff660bc11cf8f3d20ef2be228ee559317db952af1ee1d1a1a62d`。固定副本为证据目录`webhtv-tv64-debug-web-actions.apk`;旧候选另存`prior-fel-candidate-tv64.apk`,未安装或推送。 -- 本轮证据统一位于`/private/tmp/webhtv-debug-web-actions.ALJuwC/`。FEL电视实际像素、bind/map收益及p95验收继续归属P2-4,不能把本轮软件/打包通过作为卡顿已解决的证据。 - -### 14.17 音频直通的面板结论误报(2026-09-18) - -- 用户确认音频行已显示实际直通,但结论仍报“decoder 未初始化”。根因位于共用 `PlayerOsdController.getDiagnosis`:只检查 Exo analytics 的 decoder 名称为空,没有使用音频行的实际输出快照;名称缺失不能证明解码失败。本文既有音频诊断约定明确直通无需 App 音频解码器,本轮是落实既有诊断设计的局部修复,无上游/播放策略变更。 -- 实现:每次面板刷新只读取一次 `AudioPlaybackDiagnostics.Snapshot`,音频行与结论复用它。实际 `ACTIVE` 直通说明“无需 App 解码器”,压缩直出/卸载说明由音频设备解码;PCM 名称缺失说明“名称未上报”,尚未观测到输出说明待确认,不据此断言无声。 -- 优先级:实际音频 `FAILED` 仍提示音频链路失败,播放器错误、轨道不支持/未选中、网络和掉帧提示继续优先于正常输出说明。音频失败不会再被当前视频硬解选项误解释成视频硬解失败。手机/电视、点播/直播共用此控制器。 -- 范围:仅上述控制器与本文;guard `AV-DIAG-01-AUDIO-HINT` / `quick-fix`,基线 `623b069261bf8f9d559969e9c99a6775e547443c`,保护既有 `app/.cxx/` 104个文件。采样频率、音频输出、解码选择及计数逻辑保持原行为。 -- 验证:JDK21 下 `:app:compileMobileArm64_v8aDebugJavaWithJavac` 一次通过,耗时38秒,39项任务中2项执行、37项复用;使用既有隔离 CMake init-script。`git diff --check` 通过,编译日志位于 `build/avdiag-audio-hint/compile.log`。无新增测试、native重编或设备操作;未打包/安装 APK,不以编译代替 HDMI/AVR 的真实出声验证。 -- 回滚:将本单元两个文件恢复至基线提交;不回退其他播放器修复。 - -### 14.18 单视频轨无效重选与重复能力日志优化(2026-09-18) - -用户在核对日志35后明确批准两项优化:只有可用的替代视频规格才执行自动约束;解码能力首次/变化时完整记录,重复查询复用快照。作为本诊断任务暴露的 Exo 性能修复,继续使用本文,不另建平行方案。基线 `4818057cd64c2c62c94e7208d9121719b4d11fe0`,guard `AV-DIAG-01-EXO-RESELECT` / standard;原有 `app/.cxx/` 104文件保护。06:35开始,06:55–07:00为代码、定向验证、TV32打包和原子提交/tag的执行目标。 - -#### 证据与最佳实践决定 - -| 来源 / 日期 / 等级 | 实际阅读内容、适用性与决定 | -| --- | --- | -| 用户 `webhtv-debug-log (35).txt`,2026-09-18 / A(记录事实) | `dvhe.05.09/3840×2160` 使用 `OMX.MS.DOLBY_VISION.DVHE.STN.Decoder`;单视频轨仍恢复/收紧约束。三轮播放线程能力日志各92条,跨度641/530/531ms,`sourceCapturedAtNs→enqueuedAtNs`累计461/365/358ms,含调度/锁等待,不能当纯CPU耗时。两段集中新增166/144掉帧;4条underrun是回调,feed age不是实际断音时长。导出标记partial,不能由此声称完整因果或最终收益 | -| 当前 `ExoUtil.AutomaticVideoConstraintController`、`ExoDiagnosticCodecAdapter`、`PlaybackDiagnosticCollector`、`DiagnosticLogBuffer.event/offer`,基线同上 / A | 轨道形状只参与日志;`setParameters`仍执行。候选包装器在同步选轨路径逐项生成JSON,再入队;异步落盘并未移走生产端序列化。与用户APK `206a57e0e337304a8b78712c487ef245d7cb0fa0` 比较,相关逻辑仍在当前代码中 | -| Media3锁 `e3e922d5c01bc0b564849940fe589daf37360d15`及实际本地sources.jar SHA-256 `4d158d63ab0a99688880d6acfdc73ed340f09fa9ac9dd1934fbaa0babfcad086`,2026-09-18 / A | 已读 `DefaultTrackSelector.setParametersInternal`、`MappingTrackSelector.selectTracks`、`ExoPlayerImplInternal.reselectTracksInternal`、`MediaCodecUtil.getDecoderInfos`、`MediaCodecVideoRenderer.maybeDropBuffersToKeyframe`。参数改变会重选并重新调用supportsFormat;列表本身有缓存,相同选轨结果不会必然重建renderer,flush也可能来自丢帧追赶。补读同revision的 `DefaultTrackSelectorTest` 视频约束/override测试,保留Media3本身的ABR和手选语义 | -| [Media3 Track selection](https://developer.android.com/media/media3/exoplayer/track-selection),2026-09-18 / A | 准备后以实际Tracks判断可用性,同一group代表同内容的不同格式,override表达用户选择。因此门控使用已选group内受支持的可自适应规格;不靠URL、协议名或仅manifest数量推断。固定轨、手选视频覆盖及尚无轨道信息时保留当前选择 | -| [Media3 #2316](https://github.com/androidx/media/issues/2316)、[#824](https://github.com/androidx/media/issues/824),2026-09-18 / D | 已读issue正文:轨道启用可能伴随停顿、ABR行为依赖真实可选轨与输入。未将用户报告当本设备因果或维护者方案;检索中的#2521预览缩略图无关,排除。不移植上游issue补丁 | -| [spdlog v1.15.3 dup_filter_sink.h](https://github.com/gabime/spdlog/blob/v1.15.3/include/spdlog/sinks/dup_filter_sink.h),2026-09-18 / B | 已读完整实现,借鉴重复信息计数;拒绝照搬字符串生成之后才过滤、按时间抑制任意事件的做法。WebHTV在逐项JSON生成之前比较结构化能力快照,每次重复查询仍发一条引用/累计查询次数,不抑制真实错误 | -| [Log4j Performance](https://logging.apache.org/log4j/2.x/manual/performance.html),2026-09-18 / A(官方实现建议) | 已读生产端避免昂贵参数/消息提前构造的说明;将判重放在候选事件展开之前,不在本修复中重构全局日志writer。此处不是新的调度/解码算法,学术论文和GPU benchmark不适用;目标电视性能仍须同片实测 | - -在线原文与哈希保留于 `build/avdiag-exo-reselect/research/`;使用仓库配置的127.0.0.1:7897代理。一次architecture旧URL返回404,线程语义由实际Media3源码确认,不依赖该页面。没有依赖升级、上游提交合入、native或ABI改变。 - -| 方案 | 取舍 | -| --- | --- | -| 不修改 | 单轨无收益的选轨和重复JSON开销继续存在,不采用 | -| 只恢复为原生Media3策略或关闭调试日志 | 前者移除项目已有多档位保护,后者失去正常诊断能力,不采用 | -| 窄App适配(采用) | 轨道适用性门控保留初始化基线、实际多档位调整/恢复与手选;每个collector独立、有界、按媒体owner/capture generation隔离的能力快照记录首次/变化与重复引用。保留候选顺序、secure/tunneling/policy区分、完整错误和诊断分类 | - -实现范围为 `ExoUtil`、新增轨道适用性策略及测试、Exo诊断适配器/collector/快照缓存及测试、本文/索引。快照覆盖候选顺序、硬软属性、profile/level;读取失败不被当成可复用的成功能力。缓存不改变delegate查询结果,不保存播放器/Surface/native资源,不增加线程或周期采样。 - -验收:固定单轨/未知/非自适应/不支持替代轨/手选不发自动约束;真实多规格仍使用现有冷却与恢复策略;重复能力不再展开整套候选事件,变化/清空/重新采集/切源重新完整记录,缓存有界且并发安全。一次定向JUnit与TV32快速Release构建;按同一日志三轮候选查询fixture核对输出事件量。电视无法ADB,目标电视DV5掉帧和听感收益不冒充已实测;构建产物供用户用同片段、同设置在线日志对照。 - -回滚:本guard所有App源、测试、文档作为一个提交回退至上述基线;现有native、手动视频解码和音频直通策略保持。 - -#### 实现与验证结果 - -- `ExoVideoConstraintApplicability` 以当前已选video group、adaptive support、受支持格式的尺寸/帧率/码率差异和手选override决定适用性;未知与固定单轨返回不可调整。`AutomaticVideoConstraintController.buildInput` 在普通刷新和掉帧/codec故障两条路径共同门控,并撤销固定轨上的定时重评;切会话仍按原流程恢复初始化基线。多规格的原冷却/恢复策略不改,最低档被选中后也保留恢复资格。 -- `ExoDiagnosticCodecSnapshotCache` 每collector最多32个查询键,每键至多128个候选;以owner和capture generation隔离,准备新媒体/释放时清空。结构化快照包含候选次序、硬软/vendor属性及原有最多64条profile/level展开信息,保留truncated标记;MIME、secure、tunneling、policy分别建键。相同数据只输出带snapshotId/queryCount的单条引用;读取失败不缓存为成功,delegate的真实查询、排序及异常路径原样保留。全部JSON仍走现有脱敏/有界队列。 -- 32项不同的定向用例通过:原自动约束冷却/恢复16项、快照变化/会话/清空/有界/并发计数8项、单轨/手选/最低档恢复/未知规格等门控8项。第一轮24秒完成编译,门控测试因宿主JVM的 `Android TextUtils not mocked` 在构造Media3轨道对象时有7项环境失败;将平台对象读取与纯策略判断分开后,只重跑该8项,未放宽Android mock或断言。其余24项成功证据保留在 `first-test-results/`。 -- 单次最终Gradle执行在3分12秒内通过定向重验及 `:app:assembleLeanbackArmeabi_v7aRelease`,192项任务中33执行、4 from-cache、155 up-to-date;使用JDK21、`-PfastRelease=true` 和既有隔离CMake init-script。因上述测试夹具适配和TV32打包,收尾晚于最初06:55–07:00目标;未重复已通过的缓存/恢复用例,未增加设备矩阵。 -- 将日志35的18次实际codec-selector查询转换为fixture并调用编译后的生产缓存:4个完整快照、14次引用;原222条候选/profile事件变为56条,减少约74.8%。这是同一输入的诊断事件量结果,不是CPU或掉帧收益测量;独立的动态音频能力/错误事件继续记录。原始fixture、Java回放与输出分别为 `codec-query-fixture.json`、`CodecSnapshotReplay.java`、`replay-result.txt`。 -- TV32 APK:`app/build/outputs/apk/leanbackArmeabi_v7a/release/leanback-armeabi_v7a.apk`,125767037字节,SHA-256 `8df919f2188530913f8a3adaf833ab27133afc22ef6339a4da2e8baea4fd4525`。ZIP完整性、29个native条目均为armeabi-v7a及APK签名校验通过;未安装或发布。Java代码由手机/电视共用,构建证据不代替用户电视DV5的同片实测。 -- 所有本轮软件证据集中于 `build/avdiag-exo-reselect/`;源码和产物在本guard内按一个逻辑单元提交/tag。用户电视上的最终掉帧、重缓冲及听感变化仍需新版在线日志确认。 - -## 15. 验收矩阵:如何证明日志真的够用 - -### 15.1 无ADB原则 - -验收执行者可以在研发机用测试工具制造故障,但**判读者只能收到与普通TV用户相同的TXT/诊断包**。不能偷用ADB/logcat/IDE观察来补漏后宣称“无需ADB已覆盖”。结果必须能引用包内事件ID还原链路。 - -### 15.2 具体场景 - -| 用例 | 故障/对照 | 日志最低验收结果 | -| --- | --- | --- | -| T01 正常AVC/AAC | Exo硬解、Surface、PCM | C/V/S/A各层身份连贯,实际decode/output路径明确,音视频终态独立 | -| T02 HEVC Main10黑屏样本 | 本文用户样本或等价fixture | 记录实际codec、fixed-size requested/actual、frame层级、surface/shutter;不能直接断言模拟器不支持 | -| T03 MPV视频失败音频继续 | mock/native故障使hevc init fail、vid=no、aid有效 | `videoPartialFailure=true`,封面来源明确;不输出“首帧成功=视频正常” | -| T04 codec候选首个失败 | create/configure/start失败后第二个成功 | 完整顺序/错误码/实际成功codec;诊断不多计黑名单 | -| T05 所有codec失败/扩展缺库 | hw-only、名称/profile过滤、JNI/能力查询失败、Java提前返回、NDK按MIME创建及soft扩展缺失分别测试 | 按9.3.1区分无合格候选、查询失败、加载失败及实际create/configure/start失败;未知操作不能填未尝试 | -| T06 Surface生命周期 | 旋转/切全屏/PiP/重建/seek | old/new surface和decoder绑定明确;过期事件不能写入新attempt | -| T07 封面/遮罩/静态视频/合法黑场 | 分别触发 | 展示来源与selectedVideo明确;合法静态或黑场不能直接判decoder挂死 | -| T08 输入不是媒体/错误Range | HTML/401/403/206不匹配/外部loopback不可见 | 输入层证据和不可见边界;不误标解码器坏 | -| T09 DRM/secure | key不可用/secure surface | 等key与codec失败分开;不泄露key或进行像素捕获 | -| T10 音频解码失败 | AAC/AC3等decoder init/runtime故障 | 原/实际轨、异常链和最后正常层;其他video进度不掩盖 | -| T11 音量与焦点 | player0/系统mute/focus loss/duck | 每一层设置来源、有效值和变化时间;平台不可见mute明确未知 | -| T12 AudioOutput短写/失效 | 0写、部分写、dead-object/负返回、underrun | 单位/接受量/错误/播放头/epoch可还原;正常短写非故障洪泛 | -| T13 路由切换 | speaker→Bluetooth/HDMI/USB→speaker | discovered/preferred/actual分开;切换后output重建和格式快照完整 | -| T14 直通/offload | AC3/EAC3/DTS/TrueHD、可用和不支持各一项 | actual模式、降级链;不存在PCM统计不判无声;不自动改直通 | -| T15 多声道→立体声 | 5.1/7.1与2.0切轨/切文件 | mask/layout/mapping、release/reinit、路由能力前后;定位残留状态候选 | -| T16 处理链静音 | 源静音、后处理gain0、正常低音量 | 可选pre/post统计区分;不录制原PCM,不认定物理声压 | -| T17 时间戳/seek/reset | audio uint32 wrap、flush回零、timestamp false、PTS跳变 | epoch/validity正确,不用陈旧timestamp/0伪造正常或卡死 | -| T18 切源/预载/切内核 | 快速切3个媒体并延迟回调 | trace/player/mediaGeneration正确;preload不能被统计成前台帧 | -| T19 主线程/MPV卡死 | 阻塞模拟或既有hang fixture | 缓存快照和native此前错误仍可导出;export timeout有partial;诊断不制造额外deadlock | -| T20 崩溃/被杀后启动 | Java异常/native crash/系统kill/API旧版本 | 上次unfinished trace、journal、exit-info状态;未获stack不宣称完整 | -| T21 持续日志/洪泛/磁盘不足 | 容量充足下5秒内超过32条不同事件→最后新错误→静默;跨文件轮转;实际队列耗尽/writer失败另测;标准级别INFO错误 | 正常容量下所选级别事件完整导出,无固定条数丢弃;轮转持续采集并声明历史边界;实际损失在结束前/后导出均可见,sink零丢弃不冒充全链完整;不阻塞播放 | -| T22 安全输入 | URL query token、Cookie空格、堆栈多行、恶意标题HTML/CRLF、超长node | TXT/JSON/网页都脱敏并正确转义;日志不可伪造;修改操作仅接受同源POST并限频(14.16已取消配对) | -| T23 旧API/不支持属性 | API支持下限、MPV属性unavailable/IJK无AudioTrack | not-supported/unavailable与实际false分开;没有崩溃或同步轮询风暴 | -| T24 原版对照 | 同设备/同sample/同PTS、原版与WebHTV硬解 | 对照包记录确知和unknown:APK/内核/decoder/renderer/配置/output;不靠“硬解”标签断言同路径 | -| T25 TV用户端闭环 | 不接ADB,仅遥控器+局域网网页 | 开启→复现→标记→下载→判读全程可完成,面板不开也有日志 | - -### 15.3 单元、契约和性能测试 - -- schema兼容、脱敏、JSON/HTML、异常cause循环/分片、ring/pinned/rotation、writer失败、seq gap、flush watermark、开关/停止/清空及崩溃恢复的确定性单元测试。 -- Exo基于已有Media3 audio sink测试/forwarding contracts覆盖buffer position不被采集破坏、短写、格式变化、flush后epoch、listener完整转发;禁止把错误“修成继续播放”来通过诊断测试。[R12] -- MPV NODE深度/大小、MPV_FORMAT_NONE、旧generation、late callback、queue overflow、限流计数、observer卸载和command timeout契约测试。 -- “无输出”判定加入合法暂停/seek/缓冲/音频-only/封面/低帧率/静音对照,检验误报。 -- 开关诊断的性能A/B至少3次可比运行:同设备同sample同播放参数,观察起播/seek、音视频drop/underrun、CPU/Java/native内存、分配率、磁盘和queue延迟。目标是不出现新增卡顿/ANR/持锁I/O;若差异超过测试噪声且达到事先约定的产品阈值,必须优化或下调采集,不宣称零开销。 -- 建议实施前明确阈值:标准诊断CPU均值增加≤1个百分点、P95起播/seek额外≤20ms、稳态额外内存不超过声明预算;这些为候选门槛不是已测事实,低端TV基线噪声及CPU核数归一方式需写入测试记录。深度模式单独告知开销并验证限时恢复。 -- 仅改变Java诊断不跑无关全ABI/native矩阵;改JNI/native则验证两受影响ARM ABI/导出/依赖、对应播放器实播。依赖版本对齐和实际APK内容仍是门禁。 - -### 15.4 完成定义 - -每个已批准阶段必须同时交付:对应事件覆盖清单、真实导出样例、测试结果、哪些设备/API字段仍不可得、单变量A/B例子、性能/安全结果及回滚锚点。实现了D0/D1不能声称整个方案已完成;只写日志但仍无法分清“音轨未选”与“AudioTrack无声”不通过。 - -## 16. 结构化日志示例(设计示意,不是真实新测试结果) - -为可读性省略公共字段;实际记录每行均有schema/run/trace/attempt/seq/timestamp。 - -```json -{"event":"video.configure","decoderId":"vd-2","observed":{"decoder":"OMX.qcom.video.decoder.hevc","mime":"video/hevc","size":[1920,1080],"bitDepth":10,"surfaceId":"sv-3"},"status":"known","source":"codec-adapter"} -{"event":"surface.resize","operationId":"op-8","surfaceId":"sv-3","requested":{"mode":"fixed","size":[1280,720]},"observed":{"size":null},"status":"pending-callback","reason":"capability-profile"} -{"event":"surface.changed","parentEventId":"op-8","surfaceId":"sv-3","observed":{"holderSize":[1280,720],"valid":true},"status":"known"} -{"event":"video.output.summary","decoderId":"vd-2","windowMs":5000,"observed":{"submittedFrames":120,"sourceCadenceFps":24},"codecRenderCallbacks":{"value":null,"status":"not-collected"},"physicalDisplay":{"value":null,"status":"not-observable"}} -{"event":"mpv.output.failure","observed":{"videoAvailable":true,"videoSelected":false,"audioSelected":true,"videoPartialFailure":true},"errorId":"err-4","reason":"hevc-decoder-init-failed"} -{"event":"video.presentation.source","observed":{"kind":"artwork","selectedVideo":false},"source":"player-view"} -{"event":"audio.output.configure","audioOutputId":"ao-4","requested":{"passthrough":true},"observed":{"mode":"pcm","sampleRate":48000,"channelMask":"0xFC","channels":6},"reason":"output-policy","source":"audio-output"} -{"event":"audio.route","audioOutputId":"ao-4","observed":{"deviceType":"HDMI","deviceId":"report-local-2"},"status":"known","source":"AudioTrack.getRoutedDevice"} -{"event":"audio.output.playhead","audioOutputId":"ao-4","epoch":2,"observed":{"headDeltaFrames":240000,"windowMs":5000},"timestamp":{"valid":false,"status":"unavailable"},"audibility":{"value":null,"status":"not-observable"}} -{"event":"diag.health","observed":{"droppedPeriodic":80,"lastCapturedSeq":421,"lastFlushedSeq":410,"nativeOverflow":false},"completeness":"partial","missingEvidence":["periodic-window","unflushed-tail"]} -``` - -正式schema采用第4节固定status枚举并拒绝未定义值;不能让同一“未知”在不同collector里随意产生不可解析文案。示例中事件存在不代表失败已被单条解释,report必须组合上下文。 - -## 17. 调研依据、适用性与限制 - -原设计在线资料访问日为2026-09-13;通过系统配置代理 `127.0.0.1:7897` 获取并读取正文/实际源码,而非搜索摘要。2026-09-14夜间增量仅复核本地代码和用户附件,未重复开展上游合并研究。未上传用户日志或请求网盘资源。固定版用于接口语义,不意味着建议升级/降级到该版本。 - -| 编号 / 等级 | 实际来源与revision | 支持的结论 | WebHTV适用性/限制与决策影响 | -| --- | --- | --- | --- | -| R01 / A | [Media3 Debug logging](https://developer.android.com/media/media3/exoplayer/debug-logging),在线文档快照 | EventLogger覆盖状态、轨道support、decoder;默认调试观察面向Logcat | 借鉴事件,不以“加EventLogger”代替写入App调试日志 | -| R02 / A | [Media3 Analytics](https://developer.android.com/media/media3/exoplayer/analytics),在线快照 | AnalyticsListener/EventTime关联事件媒体;预载媒体可能不同于当前媒体;状态需要上下文解释 | 保留event/current media及role,不用全局当前trace回填旧事件 | -| R03 / A | [Android Audio focus](https://developer.android.com/media/optimize/audio-focus),在线快照 | Android版本/target影响焦点请求;系统可自动duck/fade/mute,部分不通知App | 采集有效gain和系统能力,不能用“没focus loss”排除静音 | -| R04 / A | [AOSP MediaCodec.java](https://github.com/aosp-mirror/platform_frameworks_base/blob/android-15.0.0_r1/media/java/android/media/MediaCodec.java),tag `android-15.0.0_r1` | CodecException恢复/暂时性、output-surface rendered callback可延迟批量且旧版本可漏 | 错误链字段和V2/V3/V4分层;不把callback等同物理图像 | -| R05 / A | [AOSP AudioTrack.java](https://github.com/aosp-mirror/platform_frameworks_base/blob/android-15.0.0_r1/media/java/android/media/AudioTrack.java),同tag | head uint32/reset、timestamp可不可用且稳定后应低频查询;actual route;写入错误 | A07–A13计量/采样/未知语义;不用hidden latency接口 | -| R06 / A | [AOSP PixelCopy.java](https://github.com/aosp-mirror/platform_frameworks_base/blob/android-15.0.0_r1/graphics/java/android/view/PixelCopy.java),同tag | 复制最近排队buffer、source无数据/保护/invalid等失败码 | 可选有限探针;不证明物理显示、不绕过保护 | -| R07 / A | [mpv client API](https://github.com/mpv-player/mpv/blob/v0.40.0/include/mpv/client.h),tag `v0.40.0` | event queue有限、overflow;property/async请求;PLAYBACK_RESTART不是present成功 | 有界observer/队列健康、重命名诊断证据而不是照搬ready | -| R08 / A | [mpv properties](https://github.com/mpv-player/mpv/blob/v0.40.0/DOCS/man/input.rst),tag `v0.40.0` | hwdec配置vs实际、decoder/output格式分层、drop/avsync语义和不可用状态 | 每属性可用性/age;平台实际fork需capability probe | -| R09 / A | [FFmpeg libavutil/log.h](https://github.com/FFmpeg/FFmpeg/blob/n8.0/libavutil/log.h),tag `n8.0` | 默认stderr、log callback须线程安全、format_line2截断语义 | 复用各内核日志owner桥接;不能进callback做慢I/O或假定stderr已导出 | -| R10 / A | [AOSP ApplicationExitInfo.java](https://github.com/aosp-mirror/platform_frameworks_base/blob/android-15.0.0_r1/core/java/android/app/ApplicationExitInfo.java),同AOSP tag | ANR/native trace可能存在且环形保留不保证 | journal+下一次启动恢复;不承诺所有crash有stack | -| R11 / B | [OWASP Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html),在线快照 | interaction id、敏感数据排除、日志注入、资源耗尽/写失败和访问控制验证 | 日志基础设施与安全不是可选附录;纳入D0与T21/T22 | -| R12 / A | 本地Media3 `e3e922d5c01bc0b564849940fe589daf37360d15`:`libraries/exoplayer/.../util/EventLogger.java`、`DecoderCounters.java`、`audio/DefaultAudioSink.java`、`AudioTrackAudioOutput.java`、`ForwardingAudioSink.java`、`ForwardingAudioOutput.java`、`mediacodec/ForwardingMediaCodecAdapter.java`、对应`audio/DefaultAudioSinkTest.java` | public forwarding hook、实际AudioTrack、生命周期/格式变化测试;日志可重用而不重写播放 | 注意锁的patch/override与最终AAR一致性;不是已完成二进制验证 | -| R13 / B | [mpv-android MPVLib.kt](https://github.com/mpv-android/mpv-android/blob/751d53205cc3d878c7161d79a97e1b65ed6ed37b/app/src/main/java/is/xyz/mpv/MPVLib.kt),HEAD `751d53205cc3d878c7161d79a97e1b65ed6ed37b` | 现成event/property/log observer桥接模式 | 参考职责,不复制其同步锁行为或以外部项目能力替代WebHTV实际实现 | -| R14 / B | [Media3 #2765 维护者讨论](https://github.com/androidx/media/issues/2765#issuecomment-3258334393),读取issue及comments快照 | READY+音频正常+视频黑屏仍可能是decoder自报正常或UI隐藏;建议对照设备/decoder/media | 支持多层证据、单变量A/B和厂商边界;不是本次模拟器根因证明 | -| R15 / D | [Media3 #2711](https://github.com/androidx/media/issues/2711)、[#2519](https://github.com/androidx/media/issues/2519)、[#3339](https://github.com/androidx/media/issues/3339)、[#3316](https://github.com/androidx/media/issues/3316),issue正文/相关comments快照 | 用户报告无错误黑屏、多声道→立体声无声、AVR侧/后声道映射问题 | 用于选择T02/T15等场景,不把未复现报告当普遍Android事实或直接修复依据 | -| R16 / A(实现事实) | 本文第2节当前WebHTV源码;基线HEAD完整SHA见第0节 | 当前sink同步无上限、部分日志已有、MPV语义/Surface操作等 | 改造必须复用/补齐,不重复建设或改写已有播放策略 | -| R17 / D(用户现场) | 2026-09-12用户截图、两份日志及“原版可硬解”补充 | 存在实际用户症状与部分日志链 | 缺设备现场、原版准确版本和可控A/B;只作为诊断需求与fixture素材 | -| R18 / A(本地实现事实) | 2026-09-14读取 WebHTV `5cde3c015258f620f264d5f3ffe0a437c2ea3d48` 的 `MpvDiagnosticsPolicy.NativeLogWindow`、`MpvPlayer` native log回调、`MpvPlayerEngine.buildConfig`相关配置;本地 `build/mpv-native/mpv-android/buildscripts/deps/ffmpeg/libavcodec/{mediacodec_wrapper,mediacodecdec_common,mediacodecdec}.c`,源码HEAD `177f090e0503b7e013922ca903bde14b1c375f18`,前两文件含本地修改 | 32条限流及仅下一条日志带出汇总;标准`all=warn`与INFO查名错误;查名NULL的多义性、Java/NDK分支和通用失败出口 | 证明9.2.1/9.3.1需要的本地取证边界,不证明用户APK包含完全相同源码/补丁,不把基线HEAD冒充最终artifact身份 | -| R19 / D(新用户现场) | 2026-09-14接收的 `webhtv-debug-log (3) (1).txt` 与 `IMAGE 2026-09-14 23:16:43.jpg`;run/generation/trace及logSeq见1.3 | MPV HEVC Main10查名失败、音视频状态分离、32条native事件与缺失限流统计 | 只含MPV本次复现;未获得新的Exo或原版对照链,不证明两内核同因或设备/ABI不兼容 | - -补充读取了 [Media3 Troubleshooting](https://developer.android.com/media/media3/exoplayer/troubleshooting) 的seek/媒体容器/平台限制说明,帮助限定输入/时间戳边界。本文未提出新编解码算法或通用性能优化,因此学术论文/算法benchmark类证据不直接适用;性能验证采用本项目同设备同sample开关诊断A/B,而非搬用无关论文分数。上游revert/完整commit ledger不适用:本轮没有选择/合入任何上游提交。 - -### 17.1 调研结论与停止条件 - -官方接口语义、实际代码、成熟项目事件桥接及维护者讨论已能决定:**建立分层证据、输出路径观测、非阻塞有界日志、隐私和完整性,不能把更多verbose等同于足够诊断。** 继续扩大搜索不会解决无法访问的电视/模拟器/AVR状态;剩余不确定性应转入上面的验收和最小设备对照,而不是继续罗列资料。 - -## 18. Recovery anchor / 后续唯一动作 - -- Objective:完成获批的Exo单轨无效自动约束门控和能力日志快照复用,减少选轨时对播放线程的干扰,见14.18。 -- Plan:两项优化、32项定向用例、真实查询fixture回放和TV32快速Release均已完成;按本guard原子提交/tag收尾。 -- Current unit:`feature/mpv-dv7-fel` / 基线 `4818057cd64c2c62c94e7208d9121719b4d11fe0`;guard `AV-DIAG-01-EXO-RESELECT`;保护 `app/.cxx/` 104文件。 -- Files:14.18列出的Exo App源码、两个定向测试与本文/索引。 -- Evidence:32项用例通过;18次真实查询的候选事件222→56;TV32构建、ZIP/ABI条目与签名校验通过。产物SHA及详细证据见14.18与 `build/avdiag-exo-reselect/`。 -- Unverified:未安装/发布新APK;用户电视无法ADB,同片DV5最终掉帧收益需新包在线日志确认。 -- Rollback:本guard单元原子回退至基线;不推送。 -- Exactly one next action:以本节软件验证证据执行guard finish,原子提交并创建本地恢复tag。 diff --git a/docs/C-AVS3-video-decoding.md b/docs/C-AVS3-video-decoding.md deleted file mode 100644 index 89bf32bca1..0000000000 --- a/docs/C-AVS3-video-decoding.md +++ /dev/null @@ -1,380 +0,0 @@ -# C-AVS3:AVS3 视频解码 - -## Recovery anchor - -- **MPV MediaCodec 接入交付(2026-09-17 19:22 Asia/Shanghai)**:本轮代码、双ARM codec、Mobile ARM64 APK及定向验证已完成;guard `C-AVS3-mpv-mediacodec`,回滚基线仍为 `13053755eaea00aa9c6449e8ad55c2ccf1fbc68a`。最终ASan/UBSan、原始FFmpeg+7补丁对照、双ABI/16KB/178导出符号、9项Java测试通过;最终codec在原手机的JNI/NDK查询均返回“无AVS3硬件”,并拒绝无显式硬件上下文的wrapper重试。APK `build/avs3-native/deliverables/WebHTV-5.6.0-AVS3-MediaCodec-mobile-arm64-debug.apk`,SHA-256 `eba62c78bb1aeef6ff6f6643358e1003f8c0e4e503270987a6b85ade378d45cc`。50个APK native条目只有目标MPV codec变化;Exo及其他MPV库保持。此APK未安装到用户App;命令检查仅使用临时native库和dex,没有安装探针APK。**未实测:具备AVS3硬件的电视/盒子的真实出帧、profile兼容和性能。唯一下一动作:执行guard finish原子提交并生成本地恢复tag。** 本段取代下方构建中状态。 - -- **MPV AVS3 MediaCodec 接入(2026-09-17 18:47 Asia/Shanghai)**:用户在询问可行性后明确“实施”。本轮为 `C-AVS3-mpv-mediacodec` / upstream;基线 `13053755eaea00aa9c6449e8ad55c2ccf1fbc68a`,恢复 tag `recovery/C-AVS3-resume-seek/20260917180827-13053755eaea`。保护既有 `app/.cxx/` 104 文件。完成目标:注册 FFmpeg AVS3 MediaCodec、接通 App 硬解列表、交付双 ARM codec 和 Mobile ARM64 APK;硬解失败禁止自动软解,现有软件后端保留。方案、来源和验证边界见文末“MPV AVS3 MediaCodec”。预计研究/修改15分钟、增量构建/定向验证15–20分钟、关闭3分钟,目标19:25。**唯一下一动作:按已记录方案应用 AVS3 wrapper 与硬件选择补丁。** - -- **重复续播跳转修复(2026-09-17 18:03 Asia/Shanghai)**:用户授权快速修复并明确不运行测试。基线 `edf4324034fe1681a658dd4557dd8451fcfdb792` / `recovery/C-AVS3-hpm15/20260917175103-edf4324034fe`;guard `C-AVS3-resume-seek` / quick-fix,仅 `MpvPlayer.java` 和本文件,保护已有 `app/.cxx/`。原日志已证实同一媒体在FILE_LOADED前后各发送一次seek;原代码仅为raw ISO提前返回,普通媒体先立即跳转、随后又恢复同一待执行位置。现将提前返回统一用于尚未加载完成的媒体,只更新最新 `initialSeekPositionMs`,由既有FILE_LOADED分支执行一次;已随 `loadfile start=` 定位时继续免去额外seek。已加载媒体的人工跳转、光盘导航、HLS去广位置解析、stop/替换清理沿用原逻辑。此为既定方案的局部修正,无native、ABI、依赖或解码策略改动,不重新开展外部研究。代码审读覆盖未加载/已加载、起播参数与清理分支;按用户要求不运行测试,本轮不打包安装,运行时效果尚未经复测。预计修复/审读2分钟、文档及提交tag1分钟,目标18:06。**唯一下一动作:审读最终局部diff并执行guard finish。** 本段关闭下方历史记录中的重复seek待修项,不改变4K50软件吞吐限制。 - -- **用户确认关闭(2026-09-17)**:用户明确接受设备软件解码性能限制并要求“打tag”。据此关闭本轮额外设备检查,以最终APK、双播放器/双ARM原生制品检查、独立参考像素/时间戳/flush/错误路径验证,以及手机两套codec原片128帧一致性和MPV实际首帧作为本次交付证据。停止额外构建、采样和研究,执行 `C-AVS3-hpm15` 原子提交与本地annotated恢复tag,不推送。**保留限制**:原片4K50不实时,续播预解码等待较长;下方记录的重复seek待修点没有在此单元修复,Exo App持续播放/内核切换的完整设备验收未补跑。用户接受性能不等于宣称不存在其他问题,也不扩大0x30或商业分发的支持范围。**唯一下一动作:执行guard finish并报告生成的commit/tag。** 本段取代下方“验收未通过、不提交/tag”的历史关闭状态。 - -- **手机复测后的最新结论(2026-09-17,优先于下方检查点)**:最终 APK 已安装,两个 ARM64 原生 codec 对原片前128帧均得到独立参考 SHA-256 `e3b317e5ea200e571184985c070b5c27d569c6e317ed145e85bab830aca04452`。MPV App 从开头手动软解在4.339秒记录首帧,已看到原片画面,但约35秒墙钟只推进到2.44秒媒体时间;用户明确反馈静止、卡顿及续播黑屏,**不视为播放验收通过,不提交/tag**。`hpm15-app-mpv-zero.{log,png,xml}` 留存画面/时间证据,OSD按墙钟推算的29秒不能替代 native `time-pos`。续播日志 `hpm15-user-stall.log` 的 `p-1ac9fgt-e` 在FILE_LOADED前后分别发送一次seek,且尚无首帧;`MpvPlayer.seekToPosition` 和FILE_LOADED分支存在重复发送的具体待修点,本轮尚未修改其代码。另一条SURFACE_LOST发生在用户退出/切换附近,未证实是稳态黑屏根因,不据此更改Vulkan。 -- **性能证据与限制**:原生MPV检查57.32秒包含双重像素哈希;simpleperf采集5674样本、丢失0,结果 `hpm15-device-mpv-profile.log`、`hpm15-mpv-profile-report.txt`、`hpm15-mpv-hotspots.txt`。按同构建未strip符号映射,ESAO标量亮度滤波占全部样本13.55%,两项8抽头运动补偿约12.87%;校验本身也占CPU。没有证据表明单一局部优化即可达到4K50,禁止把原生正确性或一张截图描述为流畅播放。原17:33收尾目标因设备揭示吞吐缺口而失效,停止重复打包与已通过检查。原guard/范围/保护路径保持,未安装任何新探针APK,尚未改手机全局首选项。 -- **用户关于降级的询问**:0x32→baseline 0x20/0x22的一般可行路线是完整解码并重新编码,不是修改profile或剥离DV增强层那样的转换;0x22可保留10-bit,0x20为8-bit。当前手机公开MediaCodec没有AVS3,baseline只能使用已有uavs3d软解;目标若为本机硬解,应考虑提前转码到已支持的HEVC/H.264。此处仅回答可行性,用户尚未要求实施转码功能,没有改原片、加自动转码或改变视频只能手动切换的合同。**唯一下一动作:继续原片Exo实际输出与续播的最小定位,针对已记录的重复seek决定最小修复;在性能和App验收缺口关闭前不创建完成tag。** - -- **当前状态(2026-09-17 17:13 Asia/Shanghai)**:元数据补全的四个 codec、制品检查和 Mobile ARM64 APK 已完成;最终 APK SHA-256 为 `1538aa22ec3daed088facd91daeb68e75b5af2e3e2dbb2b4e82968d88b9ae37e`,19 项 native/许可证匹配,已安装到重连的 `10CF6H1D2L0009S`。Exo 实际 ARM64 codec 命令验证原片 128 帧 SHA-256 为 `e3b317e5ea200e571184985c070b5c27d569c6e317ed145e85bab830aca04452`,与独立参考一致,55.43 秒包含逐像素双重哈希,不是纯解码 FPS。用户随后报告 App 内两套软解仍无画面,**App 验收未通过,不提交/tag**。已保存 `hpm15-user-failure-{current.log,logcat.txt,summary.txt,native-summary.txt,ui.xml,png}`;Exo 手动软解从 8.858 秒续播,初始化成功,11.6 秒输入 44 包但提交 0 帧;MPV 软解取得 3840×2160 输出参数后仍无首帧记录,停止前只运行约 12 秒。尚不能区分续播所需预解码耗时与输出链故障。沿用 guard `C-AVS3-hpm15` 与全部原范围,保护 `app/.cxx/`;本轮预计定位5分钟、必要修复/增量打包10分钟、设备验证与关闭5分钟,目标17:33。**唯一下一动作:用现有 APK 从原片开头播放,结合实际帧输出与时间戳定位 App 无画面的原因。** 本段优先于下方历史状态,不重复已通过的源/制品验证。 - -- **当前实施(2026-09-17 16:11 Asia/Shanghai)**:用户在审阅 HPM 15.0 真实 `0x32` 解码证据后明确要求“那就直接实现”。guard `C-AVS3-hpm15` / upstream;基线 `80fea0039053ab95c2e38150ecb7af63d1fdb8ae`,分支 `feature/mpv-dv7-fel`,保护原有 `app/.cxx/` 104 文件。完成目标:在手动软件视频模式为 Exo → MPV 接入 HPM 15.0 高级 10-bit 后端,保持 baseline uavs3d、硬解禁止自动软解、AV3A/ASS/DV/FEL;交付双 ARM 原生库与 Mobile ARM64 APK。HPM 有界接口、546 个内部符号隔离、原始 0x32 分派和构建接线已完成;独立像素 oracle、两实例/flush、ASan、FFmpeg MP4 时间戳和原有基础档次像素检查通过。Exo NDK28 与 MPV NDK29 双 ARM codec、nextlib r5、锁与制品检查均完成,其他 AAR/MPV 库保持基线字节。APK 已启动构建,ADB 尚无设备,实际 Android NEON/播放器验证待手机连接;没有提交/tag或安装。目标仍为 16:35–16:55,设备等待另行记录。**唯一下一动作:收取 APK 构建结果,手机连接后安装并验证原片的两套软件链、跳转及硬解禁止回退。** 本段优先于下方历史状态;详细证据见“16:10 构建与接口检查点”。 -- 目标:按用户 2026-09-16 明确要求,让当前手机上的 AVS3 视频实际输出画面;按 Exo → MPV 接入成熟实现,保留现有 AV3A 音频、其他解码、ASS、DV/FEL 与性能行为。用户随后明确要求深读论文、文档、issues 和成熟项目代码。 -- 当前阶段:2026-09-17 已移除Exo硬解模式的AVS3软件后备,恢复视频只能手动切换的合同,音频回退保留;单ARM64 APK及MPV/音频定向测试通过,用户随后确认“可以了,打tag”,按关闭快速路径提交。基础AVS3及Surface修复的前一提交为 `769e53471dbb3e9ad4f9b94842099ba97a92fdbd` / `recovery/C-AVS3-video/20260917111624-769e53471dbb`。High profile 0x30/0x32仍未支持。 -- 工作区:`feature/mpv-dv7-fel`,基线/回滚锚点 `dbff1ffecd973c6d89eef1bf139f7243ac6b3ead`;guard `C-AVS3-video` / upstream;保护既有 `app/.cxx/` 104 文件。前一 P2-4 的电视性能验收继续等待设备证据。 -- 设备:`10CF6H1D2L0009S`,vivo V2453A / PD2453,Android 15/API 35,arm64-v8a,QTI SM8735。`MediaCodecList.ALL_CODECS` 枚举得到 **55 条视频 decoder/MIME 条目,AVS 为 0**(含别名,不代表 55 个不同硬件单元)。本机无公开的 AVS3 MediaCodec 解码路径,需要软件后端。已通过 OEM 安装助手安装候选 APK;未重启手机或清空日志。 -- 样片:手机 `/sdcard/Download/影音测试库/A09_AVS3A/AVS3_4K50_DASH.mp4`,31,308,084 字节,SHA-256 `e780726556974bc84e9b6307336c97bcc5082b9813fe9d9d85a2b225da6df677`;60 秒、3840×2160、50fps、约 4.3Mbps、BT.709,**仅视频轨**。裸 MP4 本身无音轨;配套 DASH 音频另行引用。 -- 关键发现:序列头 `00 00 01 B0 32 55 ...`,profile 为 **0x32**;uavs3d 仅实现 `0x20/0x22`。`parser.c:dec_parse_sqh` 的 `com_check_val2` 在非 debug 构建把非法值写回 0x22,不是安全拒绝;必须在调用前检查原始序列头,不能靠打开 decoder 或放宽 profile 完成。 -- 已完成:核对实际双 ARM Exo/MPV 打包库均含 AV3A decoder、不含 libuavs3d;Exo 当前 MP4 没有选出视频轨;固定 FFmpeg 已有 AVS3 MP4/TS/裸流识别,但 decoder 构建禁用;拉取 uavs3d 固定源码并阅读 FFmpeg wrapper、配置/官方文档和 Media3/nextlib 接线。 -- 新实证:HPM 12.2 固定源码已在本机编译,真实样片的 37 组参考帧列表使其默认 32 组数组越界;启用上游 `RPL_GOP32=1` 的 64 组容量后序列头通过。首图片头 350 字节,参考程序在 bit 971 的填充位断言失败,输出仍为 0 帧。434 次位读取均经独立 Python 读取核对一致;这只确认取值一致,**不确认字段语义、版本兼容或图像正确**。 -- 16:51 补充研究:阅读了 Bevara 的 GPAC/WebAssembly AVS3 filter 和晶晨公开驱动的完整图片头处理函数。前者实际调用 uavs3d 且拒绝非 8-bit 输出;后者通过硬件参数区取得已解析字段,关键软件读位代码不参与驱动构建,不能移植为本机软件 decoder。hysAnalyser 当前公开树没有解析器源码。这些结果限定于已审阅版本,不证明其他 High profile 开源实现不存在。 -- 本次提交范围:两文档、依赖锁、AVS3 三层补丁、共享/Exo/MPV 构建接线、`ExoUtil` 的 AVS3 专用软件回退、`PlayerManager` 跨内核Surface重建和 `MpvPlayer` 加载就绪等待;Exo AAR 与 MPV 双 ABI codec 已替换。nextlib r4 除 4 个 AVS3 相关 class、两 ABI 的 libavcodec 和新增许可证外,所有 AAR 项与 r3 逐字节相同;MPV 其余 18 个库完全保留。最终fixtures的3项提取器测试、包装/ABI/来源及此前APK对应关系均通过;最终App增量构建通过,用户接受手机结果。 -- 正确性证据:`build/avs3-native/exo-device-final-{8,10}.log`;8-bit 重建 SHA-256 `4af75530b1dc15dd41f1ec2e47f6ad64deb0fddf9f6b46bd772dba6f65589ecb`,10-bit `745e6cefa14496131199293c118bbf664ac0a1f07dd8333ffd6d36d9e2e39cc4`。固定 uavs3e `e1ff0f37a8d67814e1d650f6b49d495c49dde946` 已修正 Apple 位深宏,并用单线程编码避免其并行生成流与重建不一致的问题;解码仍用两线程,256×144 保留底边覆盖。最终流和 manifest 已纳入本任务测试路径。 -- 原始证据:`/private/tmp/webhtv-avs3-20260916-1412/`。 -- 外部资料:用户已提供 `/Users/macbookpro/Downloads/AVS3-P2-TAI109.2-2021.pdf`,437 页,SHA-256 `f357b0fd264cd4a34a31fd4ca261e7b80c94899239af61173775b6cbaf48895b`。正文已提取,并阅读序列/图片头及 ESAO/CCSAO 表;正式表 29 的 DBR 参数顺序与 HPM12.2 不同,首次已证实语义差异在原图片头 bit 100。另一目录 `AVS3P10_RM0_V3p1` 的 README 和 API 明确为实时语音 P10(16/32 kHz、WAV/PG),不提供本次 P2 视频后端。原文件只读,不拷入产品。 -- 设备临时状态:11:10手机 `10CF6H1D2L0009S` 已重连,已调用OEM安装助手安装最终APK,随后用户确认“可以了,打tag”。关闭阶段不再查询安装状态或运行探针。此前临时命令探针包 `com.fongmi.android.tv.avs3probe` 尚在手机,未再次执行;此前仅恢复player首选项为原值2,未覆盖整份配置,无ADB forward。 -- 下一动作:按用户确认执行 `C-AVS3-video-hardware-only` guard finish,原子提交及创建本地恢复tag;不追加设备探针或重复已通过检查。 - -## HPM 15.0 高级档次实施(2026-09-17) - -### 16:10 构建与接口检查点 - -- 适配器和独立参考的原片 128 帧逐帧一致;两实例交错、关闭另一实例后的 flush 重放共 256 帧一致。首次 oracle 脚本受 HPM CLI 无换行进度输出影响误判,已去掉库内进度输出并修正 matcher;保存日志的实际图像没有不一致。 -- ASan 对有效原片暴露四抽头色度 SSE 函数读取八个系数、越过表尾的问题;仅把这两处 load 改为读取实际使用的四个 `s16`。修正后 128 帧与原始参考逐帧相同,截断序列、失败后 flush/释放以及完整 decode/close 无 ASan 报错。`hpm15-asan-result.json` 保存该检查的二进制哈希。没有通过关闭 sanitizer 或改变像素算法回避错误。 -- FFmpeg 的实际 MP4 → AVPacket → AVFrame 两轮各 128 帧通过:SHA-256 `e3b317e5ea200e571184985c070b5c27d569c6e317ed145e85bab830aca04452`;原始 PTS 为 5000…132000、time_base `1/50000`,相邻帧 20 ms;seek 回开头后图像和时间戳重复一致。基础 8/10-bit 原有两轮各 16 帧 SHA-256 完全保持。裸流本身没有容器 PTS,不用其解封装器合成的包时间戳做显示顺序判定;MP4 独立执行该检查。未知 0x30/0x7f、截断 0x32 的 raw/av3c/in-band 三条错误/清理路径通过。 -- Exo NDK28、MPV NDK29 各自两 ARM ABI codec 构建完成。MPV 增量构建保留原 builder 的 pkg-config sysroot、`--icf=safe` 与 16 KB 页面对齐;只打包 codec,未用旧 build cache 中的 libmpv 覆盖 FEL/P8 修复。nextlib r5 AAR 为 `ebbf79af2f448b5a4f242c3421591484c251c541c898fbc20f7e5d310a3fa95b`,源码 JAR与 r4 相同。 -- `hpm15-artifact-contract.log`:两 ABI ELF/SONAME、16 KB 对齐、静态输入身份、内部符号隐藏、跨播放器依赖隔离、Maven 哈希/锁、许可证通过;相对基线所有非 codec AAR class/条目及其他 18 个 MPV 库逐字节相同。 -- 尚未完成:单 ARM64 APK、真实 Android NEON 解码/播放器播放与跳转。手机仍未出现在 ADB 列表中;当前结果不等于 Android 实时性能验收。**唯一下一动作:完成已启动的 APK 构建,然后在重新连接的手机验证双软件链和硬解禁止回退。** - -### 决策与来源 - -本轮只解决“已验证可解原片的 HPM 15.0 如何可靠接入现有两套软件解码链”。用户已明确批准实现,不重新询问已经批准的接线、构建和必要验证。沿用下面已读完的 FFmpeg、Android/Media3、uavs3d issues、VLC/GPAC 和 DAVS3/GPU 论文证据;它们决定 packet/frame 接口、后端能力分离、移动平台边界和性能验证方法。此前 HPM 12/14 的失败不再作为 15.0 不可用的依据。 - -| 等级 / 访问日 | 精确来源 / 处置 | 本轮证据及设计影响 | -| --- | --- | --- | -| A / 2026-09-17 | `https://github.com/zzZ2001a/avs`,`0c7ac42edfac6d18b92b58a5ef43bca58526ca7a`,`dependencies/hpm-HPM-15.0`;实施该目录的解码算法和公共依赖,外层 mesh/编码器不接入 | 用户资料清单 180 项哈希/大小全部一致;148 个归档源码只有 `com_recon.c` 的内部 inline 链接适配不同;另取固定 GitHub `dec.c`、`com_typedef.h` 与资料逐字节一致。公开 `dec_decode` 是空壳,实际入口为 `dec_cnk` / `dec_pull_frm`。保留算法,用独立 C 库接口管理生命周期。 | -| A / 2026-09-17 | 用户 `avs3-profile32-result` 与 `/private/tmp/webhtv-avs3-hpm15-review-20260917/` 的独立复验 | 完整 MP4 的 3000 包与无损 ES 逐字节相同,47 个原序列头均 0x32。原始前 128 帧复验实际出帧,退出 0;39.359 秒墙钟、3.716 fps 纯解码、约 521 MiB peak RSS。证明可解该片段,不等于整片一致性认证或 Android 4K50 实时性能。 | -| A / 2026-09-17 | ETSI TS 101 154 V2.8.1,§5.16.2.2.2,第 177 页;用户原版 PDF | DVB AVS3 HDR 规范明确 High 10-bit `profile_id=0x32`。编号是标准档次,不是专用容器;当前 BT.709 样片也使用它,不能把 0x32 一律标为 HDR。 | -| A / 2026-09-17 | 同固定 HPM `dec.c`、`dec_eco.c`、`com_util.c`、`dec_bsr.c`、`com_port.h` | 存在共享扫描表、DOI 状态、裸断言、无界失败后续读取。不得直接链接 CLI;添加实例资源所有权、共享状态同步、读取/分配边界和可返回的错误路径,flush 重建完整解码状态。 | -| A / 2026-09-17 | FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18` 的 `libuavs3d.c`、现有本地 AVS3 补丁;保持该基线,窄适配 | 保留 baseline 的 uavs3d API/线程/图像转换;在原始 profile 判定处分派 0x32。FFmpeg 继续负责容器、packet/frame、缓冲所有权和播放器输出。Exo NDK28 与 MPV NDK29 分别静态链接,不复用 `.so`。 | -| A / 2026-09-17 | HPM 原文件许可证及用户 `HPM-LICENSE.txt` | 用途限定 AVS 标准开发、测试与推广,并未授予专利权。完整许可和源码身份随二进制提供;本次是用户授权的本地兼容性实现/测试,不把它改称 BSD/MIT 或据此授权商业分发。没有发布、推送或对外提交资料的操作。 | - -相关 issue/维护者讨论及独立论文继续引用本文件“研究证据”,没有新发现的 HPM15 Android 维护者实现或官方性能保证;不以缺少该类公开证据声称不存在实现,也不重复广泛搜索。源版本只有上述 HPM 目录新增,现有 FFmpeg/uavs3d/mpv/Media3 均保持原完整 revision。 - -### 方案取舍与实现合同 - -- **不改动**:原片仍无法播放,不满足用户需求。**原样调用 HPM CLI/空壳 API**:不能作为播放器库,并有全局状态、退出进程、无时间戳和内存所有权问题,拒绝。**窄适配**:选择在现有 AVS3 FFmpeg 入口增加静态 HPM 后端;保持 0x20/0x22 的成熟 uavs3d 路径,只将真实 0x32 交给 HPM。不修改 profile,不转码,不降分辨率/位深/特效,不为新后端添加 Debug/Release 开关。 -- HPM 只编译解码器和必要公共源。保留原始语法算法;编译证实 BIO/DMVR 的纯 C 分支不完整,故改为固定 `DLTcollab/sse2neon@3cf69760cc6fdc45a5d70f0d42a8f079489f9867`(v1.8.0,MIT)映射既有 SSE 整数计算到 ARM NEON,ESAO 复用完整 C 函数。已读 README、`_mm_madd_epi16` / `_mm_mulhi_epi16` 的真实映射及独立标量期望测试、issue #622(旧 GCC 编译限制);NDK Clang 满足其版本要求,精确浮点兼容宏显式启用。头文件 SHA-256 `07723c9f9457dd4316f1fde3dd4eb6f31dd67d9955f6c21f4e609ac1698be48a`,使用原整数算法而非继续手工补整个 SIMD 内核。所有 App 硬解/软解选择不变:设备没有 AVS3 硬件时硬解模式报不支持,绝不自动进入 HPM。 -- 包装层负责防竞争字节还原、完整 chunk 边界、有限 padding、输入/尺寸/分配边界;所有断言继续求值,失败转为解码错误,不能用 `NDEBUG` 丢掉断言中的读位。一次失败使该实例停止继续使用损坏状态,flush/close 可释放所有本实例资源。 -- 共享只读表和临时全局工作区同步,流相关 DOI/图片状态归属实例;不得因第二个实例创建/删除而破坏第一个实例。按原始 PTS/DTS 关联实际图片,使用 DPB 显示顺序输出,不用 packet 到达顺序冒充显示顺序。序列结束 drain 与 seek flush 分离。 -- HPM 图像由适配层持有到 FFmpeg 拷贝完成,输出转换保持真实编码位深与颜色元数据。拒绝未提供外部参考的 library stream,不对未知档次误报支持。当前验收目标为 0x32,0x30 的能力不冒称已验收。 -- 静态依赖无新增运行时 SONAME。nextlib 仅替换两 ABI codec 与附加许可证,使用新 r5 坐标;MPV 仅替换各自 codec 及来源清单,其余 native 库必须与 `80fea0039053ab95c2e38150ecb7af63d1fdb8ae` 一致。FFmpeg 配置及现有 FEL/AV3A 补丁完整保留。 - -### 最小验收、交付与回滚 - -1. 适配后原始 0x32 片段实际出帧,图像哈希与未改算法参考输出对照;验证 B 帧 PTS 顺序、序列边界 drain、seek/flush 重放、两实例交错及失败后释放。 -2. 对截断序列/图像/chunk、未知 profile、异常尺寸做有界错误返回检查;用主机 ASan 覆盖实际解析路径。不能以吞错、关闭校验或安装只有占位 API 的包通过。 -3. Exo → MPV,各自双 ARM 构建、ELF/namespace/符号/固定输入/许可证检查;baseline 8/10-bit 输出保留。只构建受影响的 codec 和 Mobile ARM64 APK,避免无关 native 矩阵。 -4. 手机对原 MP4 的两套软件链验证真实画面、时间戳/跳转、停止退出与切换;确认硬解模式不自动软解。测量实际速度/内存,明确报告 4K50 是否实时,不承诺参考解码器已有实时优化。 -5. 源码、补丁、锁、制品、记录作为同一 guard 单元提交并创建 annotated 本地 recovery tag。需要回滚时恢复本单元前 `80fea0039053ab95c2e38150ecb7af63d1fdb8ae` 的对应整组文件,保留其后已验证智能去广、P8 HDR10 和 FEL 修复。绝不覆盖原 `app/.cxx/` 或跨播放器替换库。 - -### 接口阶段记录 - -- 已完成:有界 C 接口、按实例记录的分配/错误边界、全局表指针与 DOI 保存/恢复、序列和图片 metadata/PTS 关联、FFmpeg 0x32 分派、固定源码/NEON adapter、两个构建链的 mandatory 输入接线。主机库编译通过,截断序列和失败后释放检查通过。 -- 新安全实证:固定数组边界检查发现原 HPM `esao_on_block()` 在某 LCU 关闭滤波时先读 `esao_adaptive_param[...][-1]`,再检查关闭标志。适配只把零标志返回提前,保留原来的“不修改该 LCU 像素”行为;没有关闭边界校验。非消费式 `com_bsr_next(32)` 对三字节结束哨兵仍允许补零 peek,真正读过末尾会返回解码错误。 -- 当前源码/构建:`third_party/avs3-hpm/`;临时输出 `build/avs3-native/hpm15-host{,-prefix}`;FFmpeg 合成源码 `build/avs3-native/hpm15-ffmpeg/libavcodec/libuavs3d.c`。尚未替换已提交的 AAR/MPV codec、未构建/安装新 App。上游原文件只读,打包 source archive 保留原许可证。 -- 手机状态:本轮 `adb devices -l` 为空,已请求重新连接,独立构建继续。磁盘可用约 1 GB,像素 oracle 走 FIFO 流式哈希,不生成 3 GB YUV。 -- 原片接口结果:128 帧、checksum `a4d0f6cfddf70dbc`、最后一帧 `e5c3dfc79218ed72`,累计受管分配 633550512 字节;33.213 秒 CPU 含逐像素哈希,不能当作纯解码性能。符号隔离后 host 重编译通过,待下列 oracle 执行;分配量和参考进程 RSS 是不同指标,不据此断言泄漏。 -- **唯一下一动作**:执行 FIFO 参考像素对照及交错实例/flush 重放,随后 Exo ARM 构建。 - -## 授权、范围与验收 - -用户明确要求“连接我的手机,当前正在播放 avs3 视频,不支持。我希望支持解码”,并要求深度研究。此为新增 AVS3 视频能力的实施授权;已有 AVS3A/AV3A 音频不是本次新功能。完整功能不能用 baseline profile 的通用测试替代当前 0x32 样片。 - -2026-09-17 最新“需求实现了吗?打tag,打包apk”继续授权完成当前修复及交付。范围只增加原本干净的 `MpvPlayer.java`,原 `C-AVS3-video` guard 和 `app/.cxx/` 104 个受保护文件保持。08:30 Asia/Shanghai 估计剩余12分钟:修复2、单ARM64增量构建4、实机切换/跳转4、文档及提交/tag2,目标08:42。保留已经通过的容器及native验证,不重复构建native;未支持0x30/0x32的限制随此次baseline单元交付明确保留。 - -允许路径详见 guard:唯一文档与任务索引、AVS3 构建/校验脚本、Media3/nextlib/FFmpeg AVS3 补丁与依赖锁、相关 Maven 和双 ARM MPV 制品、Exo 解码器接线及定向测试。禁止引入无关上游升级、替换已验证 FEL/ASS 实现、改变其他编码的硬解/软解选择、降低画质或冒称硬解。Native 输出仍保持 Exo `libav*` 与 MPV `libmv*`/`libmw*` 命名隔离。 - -验收: - -1. 正确识别实际 MP4 的视频轨、profile、尺寸、时间戳、初始化数据;畸形/截断配置安全失败。 -2. 当前手机实际解码并输出原样片;首帧、连续播放、seek/flush/退出可用,清楚报告软件/硬件路径。纯视频 MP4 不要求不存在的音轨。 -3. 明确支持的 profile/8-bit/10-bit 范围,不能以 decoder 注册成功代替实际解码。 -4. 两 ARM ABI 的原生构建、ELF 依赖/导出、来源/许可证与 APK 包含的库一致;仅做所改链路的必要回归。 -5. 现有解码/字幕/DV 等合同保持;记录 4K50 软件解码实际速度,未实测不承诺实时。 -6. 通过必要验证后原子提交并创建唯一 annotated 本地恢复 tag,不推送。 - -14:14 Asia/Shanghai 原估计:源码 25–35 分钟、构建和手机验证 15–25 分钟、关闭约 5 分钟,目标 15:15。发现 profile 0x32 后先解决解码器可用性这一决定性问题,不对错误 decoder 做无意义构建。 - -15:06 续接估计:参考程序实证 15–25 分钟,路线成立后的接线/构建/定向验证 35–55 分钟,本机目标 16:00–16:25;手机验收依赖恢复连接。首要不确定性已从“是否有 0x32 源码”收敛到“参考版本能否解用户实际码流”。 - -16:21–16:25 状态:手机能力查询已完成,软件参考路径仍在首图片头失败。原完成时间失效,原因是缺少已证实兼容的 Phase 2 后端,以及待登记获取的规范,而非构建等待。停止重复的泛搜和对无效 decoder 的打包;保留下面的精确失败记录,资料到达后继续同一任务。 - -## 当前代码与调用链 - -- `third_party/media-lock.json`:nextlib `6ff6cf9d0820382b3c233d018c52e4163b09d345`,制品 `1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3`,FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`,独立 Exo 构建。 -- nextlib sources jar:`FfmpegLibrary.getCodecName()` 没有 AVS3;`FfmpegVideoDecoder.getExtraData()` 只转交 H.264/H.265 初始化数据;`FfmpegVideoRenderer.supportsFormat()` 要求 MIME→可用 native decoder。Media3 当前 `MimeTypes` 和 MP4 reader 没有 AVS3 视频接线。 -- `ExoUtil.FfmpegRenderersFactory.buildVideoRenderers()`:硬解模式不创建 FFmpeg video renderer,视频解码模式必须手动切换,不能按编码添加软件回退例外。音频独立允许软件回退。 -- MPV `third_party/mpv-native-lock.json`:mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`,相同 FFmpeg 源码独立按 NDK29 构建;两 ABI `CONFIG_LIBUAVS3D=0`、`CONFIG_LIBUAVS3D_DECODER=0`,parser/demuxer 已启用。 -- FFmpeg `libavcodec/libuavs3d.c`:官方 wrapper 负责帧输出、线程、flush、初始化数据;`libavformat/isom_tags.c` 已映射 `avs3`,`mpegts.c` 已映射 AVS3 stream type。官方 wrapper 本身不能扩大 uavs3d 的 profile 能力。 -- AV3A 已有 native decoder、MIME、TS/MP4/DASH reader 与多声道适配,不重复实施。 - -## 研究证据(2026-09-16) - -| 证据/等级 | 固定来源 | 实際支持的结论、适用性与限制 | -| --- | --- | --- | -| A:用户设备与码流 | 本目录 recovery anchor 中样片 SHA-256;`phone-baseline.log` / `logcat-baseline.txt` / `sample-probe.json` | Exo 无视频轨;真实输入为 0x32、50fps 纯视频 fMP4。文件名 AVS3A 目录不能用于判断视频 profile。 | -| A:uavs3d 源码与 README | [uavs3/uavs3d](https://github.com/uavs3/uavs3d/tree/0e20d2c291853f196c68922a264bcd8471d75b68),`source/decoder/parser.c:dec_parse_sqh`、`source/decore/com_sys.h:com_check_val2`、CMakeLists、README、COPYING | 仅实现 `0x20/0x22`,发布版宏会改写未知 profile,需调用前显式拒绝;10bit 需 `COMPILE_10BIT=1`;BSD-3-Clause。ARMv7 写死 hard-float,需适配 Android softfp。对 0x32 输入不可用。 | -| A:FFmpeg wrapper/官方文档 | [FFmpeg 本地固定源码](https://github.com/FongMi/FFmpeg/tree/177f090e0503b7e013922ca903bde14b1c375f18),`libavcodec/libuavs3d.c`、`avs3.h`、`doc/decoders.texi`、`configure` | 成熟接线是 FFmpeg 外部 decoder;必须编译外部库并显式启用。当前常量同样仅 baseline profile,不能据此承诺 high profile。 | -| A:Android/Media3 官方文档 | [Supported formats](https://developer.android.com/media/media3/exoplayer/supported-formats),访问日 2026-09-16,全文保存在证据目录 | 容器支持与 sample decoder 支持分离;默认能力来自平台 MediaCodec,软件扩展需自行编译/接入。不能仅改 MIME 就声称可解码。 | -| A:手机系统能力实测 | `CodecProbe.java`、`codec-probe.jar`、`phone-mediacodecs.txt`,2026-09-16,原设备 | 通过 `MediaCodecList.ALL_CODECS` 枚举全部非 encoder 视频 MIME,打印名字和 hardwareAccelerated;55 条结果中无 AVS。结论仅覆盖公开 MediaCodec 路径;不能再将容器接线当作这台手机的解码解决方案。 | -| B:成熟播放器映射 | [VLC](https://github.com/videolan/vlc/blob/778ec071ff216602f2a6e4ddd522dfdb6b791472/modules/codec/avcodec/fourcc.c),已读 `VLC_CODEC_CAVS3→AV_CODEC_ID_AVS3` | VLC 复用 libavcodec,无证据说明这个映射本身可解 0x32;保留格式接线和 decoder 实际能力两道检查。 | -| B/C:维护者 issue 与用户复现 | [uavs3d #35 high profile](https://github.com/uavs3/uavs3d/issues/35)、[#37 extradata](https://github.com/uavs3/uavs3d/issues/37)、[#14 10bit](https://github.com/uavs3/uavs3d/issues/14),已读公开正文 | #35 的 High profile 请求仍开放,未提供可用实现;#37 显示启用 decoder 后仍可能缺少序列头/尺寸;#14 是旧版 Android 10-bit 链接问题。只能支持能力与构建风险判断,不能代替本样片验证。 | -| A:Phase 2 参考实现 | [pclmeng/avs3_v1](https://github.com/pclmeng/avs3_v1/tree/d3d11b73e2d84ed37c2fb404fcb8b34ff3830b5c),HPM 12.2,`inc/com_typedef.h`、`src/dec_eco.c:dec_eco_sqh/dec_eco_pic_header` | 包含 0x30/0x32 的完整参考算法。真实样片需要 37 组 RPL,而默认容量为 32;上游 GOP32 配置提供 64 组。参考软件的存在、能通过序列头均不等于完整码流可解。许可证有用途限制,见下节。 | -| B:FFmpeg 高档次补丁全文 | [2022-02-26 mailing list patch](https://ffmpeg.org/pipermail/ffmpeg-devel/2022-February/293513.html) | 标题虽称 High profile 支持,全文仅修改 `avs3.h`、`avs3_parser.c`、`avs3dec.c` 的高层识别/位深,不含 Phase 2 图像 decoder;不能据标题判断能力。其指向 AVS 官方标准下载链接现已 404。 | -| C:GPU 解码论文全文 | [GPU Based Real-Time UHD Intra Decoding for AVS3](https://hpc.pku.edu.cn/docs/pdf/a20200915258.pdf),HPM4.0/All-Intra/i7-8700K/RTX2080Ti | CPU 熵解码,GPU 执行逆变换、预测、DBF/SAO/ALF;异步队列、事件与有界流水线可借鉴。基于 Phase 1 和桌面 CUDA,不能据其 4K 速度推断手机 0x32 能力或性能。 | -| C:跨项目实时系统论文全文 | [A Real-Time AVS3 8K-UHD Encoding and Decoding System](https://www.jdl.link/doc/2011/20201229_12.pdf),2020 IEEE | DAVS3 采用帧/LCU 多粒度并行、SIMD 友好数据布局、减少内存访问,并接入 FFmpeg。论文基于 Phase 1/HPM5.0 和桌面 CPU;未提供可公开接入的 Phase 2 代码。原站证书过期,此公开 PDF 读取关闭了证书校验,来源证据强度受此限制。 | -| C:屏幕内容编码综述全文 | [Overview of Screen Content Coding in Recently Developed Video Coding Standards](https://arxiv.org/pdf/2011.14068),HPM9.0 | IBC/ISC 等解释 Phase 2 不能靠放宽 profile 判定完成;算法可参考,但不是 Android 实时 decoder 的现成实现。 | -| C:独立生态梳理 | [Codec Wiki AVS3](https://wiki.x266.mov/docs/video/AVS3),已读全文 | 区分 Main/High;常规 FFmpeg 接入仍基于 uavs3d。只作为源码证据的补充。 | -| A:正式规范获取渠道 | [AVS 官网资料下载](https://www.avs.org.cn/index/list?catid=7) → [视频标准登记](https://standard.avs.org.cn/avs3_download/),访问 2026-09-16 | 官网提供 `T/AI 109.2-2021`。用户后来已提供完整 PDF,哈希及所读章节见 recovery anchor;已包含高级档次。登记页的参考软件选项属于 P10 语音,未找到公开 P2 HPM 下载入口。 | - -已审阅其他播放器/平台:`rbaucells/uavs3d` 的 Android 分支 parser 仍只收 0x20/0x22;`xeonliu/FFmpeg-AVSPlus` 的支持链和 `JunliangRen/LAV-FFmpeg` 的 configure 仍使用 libuavs3d;`unacez/LAVFilters-GB-CAVS-AVS2-AVS3-decoder@247cf77da613575d54a2d828571b0ec3624b8f6a` 的 Windows 构建脚本同样启用 libuavs3d。`Choise-ieee/AVS3-uavs3d-CUDA@835f4ecf47736a9d70792d816acaceeb708e71fd` 的完整 Git 树只有 README 和四个视频文件,没有可审阅的 CUDA decoder 源码。以上均不能视为已找到成熟 High profile 解码器。 - -补充排查结果(均为只读,不合并任何上游提交): - -- `pclmeng/CAC1.0@d37c309cdce441bc2c9b86ce694aac39da220f70`:读取 README、宏定义、完整 `dec_eco.c`;仍是 HPM 12.2,图片/序列头代码与原候选一致,不能解决当前差异。 -- `zhuxihehe/HW_LCU_IME_FOR_AVS3@4a2b7d120032eb4350caaf9f914d660ec309094a`:完整树只有 README,说明基于 HPM 4.0.1,既无可用新版本代码也无 High profile 证据。 -- `rujiewan/AVS3@744cee2dbb7ce0878a94428709f1f22b858d4b29`(Gitee):Git 树与实际宏定义确认是 HPM 7.1,较旧,未对当前样片作无意义重建。 -- `blindwang/ZJUHDR` 的公开 README(非实施输入)注明研究使用 HPM 15.7、仅 AVS 工作组成员可获取;官方 `gitlab.com/AVS3_Software/hpm` 匿名 Git 读取要求认证,Web 读取遇到挑战页,未取得源码。 -- `hty19971211-tech/AVS3converter`、`DanielZhang17/ffmpeg-mmt-avs`、`zmcity/ffmpeg-codec-player` 的完整 README 均明确依赖 uavs3d;它们是构建/封装参考,未据此声称拥有新的 High profile 解码器。收费 VLC 衍生项目只有宣传信息,不将营销性能数字当证据。 -- GitHub/Gitee 其余同名 HPM 候选经树/语言检查为网页或 Java 等无关项目,排除。未要求 GitHub token,未购买闭源播放器,未联系维护者,未绕过成员访问限制。 - -### 开源实现范围澄清及跨平台补查(2026-09-16 16:41–16:51) - -本轮决定性问题:是否存在此前漏查的独立 High profile 软件解析实现,能解释 HPM 在首图片头的字段差异。继续现有 guard 和授权,仅追加文档及临时证据,没有修改参考程序、生产代码或再次运行已失败的相同解码命令。 - -- **uavs3d 是确实存在的成熟开源软件解码器。**固定版本 README 明确列出 Android/iOS/Windows/Linux/macOS、ARMv7/ARMv8/SSE4/AVX2、10-bit;`source/decoder/parser.c:71–72` 明确限制 `0x20/0x22`。这里的 `AVS3-P2` 表示 AVS3 标准第 2 部分(视频),不能仅凭名称把它理解为包含全部 High profile 工具。已有播放器接入与当前样片兼容性应分别表述。 -- A,公开晶晨驱动:[`khadas/common_drivers@3a11a86a02e759fc57fc79410215f7c0c3a0d8e0`](https://github.com/khadas/common_drivers/tree/3a11a86a02e759fc57fc79410215f7c0c3a0d8e0/drivers/media_modules/frame_provider/decoder/avs3)。已读取 `dec_eco.c` 的序列头准入、完整 `dec_eco_pic_header()`,以及 `avs3_bufmgr.c` 调用位置、`avs3_global.h` 参数结构、宏定义。`com_typedef.h` 标注 HPM 10.0;序列头准入包含 `0x30/0x32`,但函数输入是 `union param_u`,字段取自 `param->p`,软件读位/ESAO/CCSAO 等代码位于未启用的 `ORI_CODE` 分支。ALF 系数也取自参数区。**这是硬件配套驱动,不是可在高通 CPU 上运行的完整软件 decoder;仅有 profile 分支也不能证明该版本能解当前样片。**文件保留 HPM 用途限制许可,不能因位于公开驱动仓库而改称普通 BSD/GPL 算法。处置:只读参考,不合并该提交。 -- A,跨到 GPAC/WebAssembly:[`Bevara/libuavs3d@7283de9b9a0bfef4eda7371efd9a7c45e6a8feab`](https://github.com/Bevara/libuavs3d/tree/7283de9b9a0bfef4eda7371efd9a7c45e6a8feab)。已完整读取 `dec_avs3.c`、`CMakeLists.txt`、`filters.cmake` 和 README。`avs3dec_process()` 调用 `uavs3d_create/decode/flush/delete`,构建链接仓库的静态 `libuavs3d.a`;`avs3dec_on_frame()` 明确拒绝 `bit_depth != 8`。它是另一平台的接入实现,未提供新的 High profile 算法。其整文件处理、单活动实例和单线程约束也不适合直接搬进 WebHTV。处置:只读参考,不合并该提交。 -- A/D,码流分析器候选:[`zymill/hysAnalyser@1c7eff0bef2111d7ce086eca97840e2eb74251fa`](https://github.com/zymill/hysAnalyser/tree/1c7eff0bef2111d7ce086eca97840e2eb74251fa)。完整 Git 树是 README、Windows 压缩包和图片;README 描述支持 AVS3 分析,但没有可审阅的解析源码,不能据此声称找到独立开源 decoder。未购买、执行或修改其二进制。处置:不作为实现输入。 -- 公共代码索引补查使用 `ph_awp_refine_flag`、`HPM_VERSION|PHASE_2_PROFILE` 和 `dec_eco_pic_header` 三组查询;保存完整 Sourcegraph 响应。无适用结果只说明该索引未返回源码,不能推导互联网范围的不存在结论。晶晨源码是直接按项目目录取得,恰好说明代码索引覆盖并不完整。 - -新证据仍放在 `/private/tmp/webhtv-avs3-20260916-1412/`:`khadas-avs3-ref.json`、`khadas-avs3-contents.json`、固定版本源码子目录;`bevara-ref.json`、`bevara-tree.json`、`bevara-dec_avs3.c`、构建文件;`hysanalyser-ref.json`、树和 README;三份 `sourcegraph-*-search.txt`。五个晶晨源文件已按 Git blob SHA-1 与 API 内容清单逐一核对。 - -结果:补查纠正了“找不到 AVS3 开源实现”的过宽表述,扩大了已核验的跨平台实现范围,但没有补齐当前样片的首图片头语义。原 0 帧验证结果仍有效;不把参考程序失败解释为全体 High profile 实现缺失,也不把登记规范视为唯一可能路线。下一步仍是取得可核验的对应语法并逐字段对照现有 trace,而不是给只支持其他 profile 的库做无效 App 打包。 - -### HPM 参考实证与使用边界 - -- 临时源码/构建位于证据目录 `avs3_v1-d3d11b73e2d84ed37c2fb404fcb8b34ff3830b5c/`、`hpm-host/`。仅构建 `app_decoder`,x86_64 macOS、Clang、RelWithDebInfo `-O2 -g -DLINUX -DX86_64 -msse4.2`,保留 assert(源码包含在 assert 内消费码流的操作,不能加 NDEBUG)。 -- 两项平台适配:Apple `posix_memalign` 代替不可用的 `/memalign`;文件内部函数 `dec_is_pix_ref_valid` 改为 `static inline` 避免 C inline 链接差异。未改变像素解码算法。 -- `sample-16frames.avs3` 由原 MP4 前 16 个压缩包加 `dump_extra` 抽取;235 字节序列初始化、首图片头 350 字节。独立逐位读取与参考程序均得到 3840×2160、10-bit 内部精度、37 组 RPL。 -- `hpm-decode-16frames.log` / `hpm-decode-probe.log` 留存默认 32 组容量下的越界。启用现有 `RPL_GOP32=1` 并加入探针边界停止后,`hpm-decode-gop32.log` 序列头通过,首图片头尾部断言失败。此为未通过的兼容性验证,输出 YUV 仍为 0 字节。 -- 最终位级证据:`hpm-decode-bit-trace.log`、`hpm-picture-bit-trace.json` 记录 434 次读取及源码行;原始图片头无 `00 00 02` 防竞争字节,独立 Python 按固定位数/无符号和有符号 Exp-Golomb 复核所有值一致。参考程序解释字段到 bit 969,随后 bit 971 读到 `1` 而期望填充 `0`。不修改码流、不跳过断言、不伪造输出。 -- `hpm-host-probe.patch` 保存所有临时平台适配、GOP32 容量和诊断插桩;`hpm-experiment-summary.json` 保存原样片、16 包裸流、源码归档、实际探针二进制、字段记录和手机能力输出的 SHA-256。可直接重放,后续不必重新搜索已排除的仓库。 -- HPM 许可证原文:`Redistribution and use ... are permitted only for the purpose of developing standards within ... AVS and for testing and promoting such standards.` 当前用于私有标准测试与算法参考;不是普通 BSD 全用途许可,不能无依据作为公开发布依赖。它没有直接写“禁止商业使用”,也不能将该结论附会给原文。正式分发的适用性另行核实;用户已允许依据算法逻辑独立实现。 -- 已发现参考代码使用全局状态、输入数组无边界检查、assert 退出和负数移位,不能未经适配原样放入 App 进程。正确性 oracle 与生产后端是不同验收阶段。 - -## 方案比较与当前决策 - -| 路线 | 收益 | 问题 | 当前处置 | -| --- | --- | --- | --- | -| 不改动 | 零回归与包体变化 | 用户样片仍无画面 | 不满足目标 | -| 原样启用 FFmpeg + uavs3d | 成熟 API,支持 baseline profile | 已读源码不接受本样片 0x32;ARMv7 构建还需适配 | 不能作为本需求完成方案 | -| 窄接线 + 真正支持 0x32 的 decoder / 平台硬解 | 有望解决实际样片;沿用既有容器/生命周期 | 实现、授权来源、移动 ABI 和性能尚需核实 | 当前调查方向;证据足够再落地 | -| 复用 BSD 基础实现,依据规范独立补 Phase 2 | 能覆盖真实 High profile;可复用已证实等价的 NEON/内存管理 | 完整头语法、熵解码、预测、变换、滤波及一致性验证是实质工作;当前首图片头还不兼容 | 用户已允许;规范/正确性基准未满足前不发布后端 | -| 原样把 HPM 放进 App | 最短的参考 API 接线 | 实际样片失败;已实证数组越界,且有全局状态/assert 退出、使用范围等问题 | 拒绝作为生产实现 | -| 修改 profile 检查冒充 baseline | 无可靠收益 | 语法工具集可能不同,解码错误/内存安全风险 | 拒绝 | - -回滚:保留上述基线 commit/tag,按 Exo/MPV 兼容制品集合原子恢复,不跨播放器替换库。不接受来源不明、无可核验许可证或不含目标 ABI 的二进制作为正式依赖。 - -## 实施路线与当前未满足的条件 - -用户已授权目标和独立实现方向。以下是接线边界及最小顺序;正式规范和码流兼容性仍是未满足的设计条件,不能称为已完成的最佳实践实现。 - -1. 先核对真实 `0x32` 字段语义,得到首个完整图像和可用的正确性对照;明确支持的工具集、位深、输入边界,所有解析失败返回错误,不退出 App 进程。 -2. 在可复用的许可明确代码基础上补齐 Phase 2。解码状态按实例持有,参考帧计数和输出重排有界,支持 flush/seek/EOS;避免把 HPM 全局变量、裸数组和 assert 作为输入校验直接移入播放器。 -3. FFmpeg 负责正常 packet/frame、时间戳、AVBufferRef 生命周期;Exo 与 MPV 从相同固定源码分别构建,维持库名/SONAME 隔离。AV3A/ASS/DV/FEL 补丁及现有 ABI 合同完整保留。 -4. Exo 补 MP4/TS/DASH 等实际需要的格式接线,只有 native 确实有 decoder 才报告可解码;AVS3软件视频renderer仅在用户手动选择软解后注册。此前“AVS3例外回退”设计错误,按2026-09-17用户明确要求纠正。Exo 实测成立后再接 MPV。 -5. 正确性成立后参考 uavs3d、DAVS3 的帧/LCU 调度、SIMD 数据布局及内存复用,先测 CPU 热点再决定 NEON 优化;CUDA 论文只提供异构调度思路,不直接搬到 Android,也不预先承诺 4K50 实时。 - -16:25 历史状态(已由下方基础档次实施记录更新):当时尚未实现视频后端,High profile 的正确图像仍未得到。后续不重启已有仓库调查。 - -## 17:13 授权后的可落地实施单元 - -用户已明确批准实际实现全部可落地能力,且接受此前提出的基础解码与 High profile 分阶段推进。当前单元保持 guard `C-AVS3-video` 与原允许路径;已有两个任务文档是本任务编辑,104 个 `app/.cxx/` 文件仍为保护路径。 - -### 已决设计 - -1. 采用 `uavs3/uavs3d@0e20d2c291853f196c68922a264bcd8471d75b68` 和已固定 FFmpeg 官方 wrapper;该源码本单元处置为“实施”。启用 10-bit 编译;wrapper 的 `uavs3d_img_cpy_cvt()` 根据实际位深输出 8/10-bit,不能通过关闭检查将 0x32 当成 baseline。Android ARMv7 沿用 NDK softfp ABI,修正源码面向 musleabihf 的 hard-float 构建选项。 -2. Exo 补齐 AVS3 MIME/codec string、MP4 初始化信息和实际可用的容器入口;nextlib 添加 MIME→`libuavs3d` 映射、初始化数据传递。仅手动软解模式注册软件视频renderer;不存在硬解优先模式或AVS3自动软解例外,软件性能模式不强制改变 AVS3 画质。 -3. Exo 与 MPV 分别使用既有 NDK/FFmpeg 配置构建,uavs3d 静态链接进入各自 libavcodec;保持 `libav*` 与 `libmv*`/`libmw*` 隔离,不增加冲突的同名动态依赖。保留 AV3A、ASS、DV/FEL 及全部现有补丁。 -4. 正式标准表 29、38、39、37用于解释 HPM 参考失败;最先修正已证实的 DBR 顺序和 ESAO 亮度参数顺序。HPM 仅作为标准验证参考,尚无正确图像前不纳入产品;P10 实时语音不冒充 P2 视频能力。 - -### 取舍、验收与回滚 - -- 不改动不满足用户目标;原样打开 FFmpeg 开关缺少外部库、Exo 容器/renderer 接线和 ARMv7 ABI 适配;窄适配方案可独立交付基准档次,High profile 未完成时仍明确报告范围。 -- 本单元验收:8-bit 和 10-bit 基准档次真实帧输出;Exo/MPV 各自首帧及 seek/flush;受影响双 ABI ELF/依赖/来源与 APK 内容一致;截断初始化数据安全失败;其他格式解码选择和既有原生依赖不被替换。4K50 软件实时性需实测,未验证不承诺。 -- 本单元通过后可独立提交、打本地 annotated 恢复 tag;文档明确保留 High profile 未满足项。失败时保留已验证旧制品,不安装无视频能力的替代包。回滚到 `dbff1ffecd973c6d89eef1bf139f7243ac6b3ead` 对应接线/锁/制品集合,不触及其他任务。 -- 17:15 Asia/Shanghai 预计:资料 10–15 分钟、接线 20–30 分钟、双 ARM 构建与必要验证 25–40 分钟、关闭约 5 分钟,目标 18:15–18:45。已有 MPV 双 ARM 缓存;Exo 源码需准备,使用已提交制品的源码和兼容合同保持本地修复。 - -## 基础档次实现与验证(2026-09-16) - -- 共享 builder 固定 uavs3d 源码/归档哈希并按播放器、ABI 隔离构建;`COMPILE_10BIT=ON` 的同一后端支持实际 8/10-bit 输出。版本头不读取外层 WebHTV Git;ARMv7 使用 Android softfp,Android pthread 由 libc 提供。BSD-3-Clause 许可证进入 nextlib AAR/APK 的 `assets/licenses/uavs3d.txt`。 -- `media3-exo-avs3.patch` 补 MIME/codec string、`avs3`/`av3c` MP4 box、MKV `V_AVS3`、TS `0xD4`;`Avs3Config` 按标准读取 profile、库标记、尺寸、位深、宽高比与帧率,有界检查截断初始化数据;`Avs3Reader` 沿用 Media3 ElementaryStreamReader/PES 生命周期,处理跨块 start code、I/P/B 边界、seek 和 EOS。 -- nextlib 映射 `video/avs3` 到 `libuavs3d`,转交初始化数据;只对 AVS3 禁用低分辨率/跳帧/滤波降质参数。renderer 类和 `supportsFormat` 允许继承。上一提交误在硬解模式添加AVS3软件后备,此行为已被用户认定为bug,本次移除;AVS3仅在手动软解时使用FFmpeg。 -- FFmpeg wrapper 补 av3c 长度/起始码检查,传递 uavs3d 错误、空序列保护、配置零初始化、失败清理、flush 后输出指针清除及 EOS 空指针算术修正;不放宽 profile 检查。 -- 两套播放器均只替换各自 `libavcodec.so` / `libmvcodec.so`;nextlib 其余 `.so` 与 AAR 项、Media3 非目标 class、MPV 其余 18 个库均与基线逐字节一致。含原始 profile 拒绝的最终 MPV codec 增量为 ARM64 488,440 字节、ARMv7 443,572 字节(未压缩),无新增独立动态库。ASS/FEL/渲染/音频库保持原制品。 -- 首次 APK 定向编译抓到 `FfmpegVideoRenderer` 类仍为 final,已在源码、补丁、sources.jar 与 classes.jar 同步修正。未重编无关 native 库。新 nextlib r4 不再声明未提供的旧 javadoc 制品。 - -### 已完成的决定性证据 - -| 检查 | 结果/记录 | -| --- | --- | -| Exo / MPV、ARM64、8-bit / 10-bit | 原生探针各解 16 帧,再 flush 重放;四条链路两次输出分别匹配独立编码器重建的 SHA-256,详见 recovery anchor 和 `build/avs3-native/{exo,mpv}-device-final-{8,10}.log`。不是只检查 decoder 注册。 | -| 双 ABI | Exo NDK28、MPV NDK29 构建通过;MPV `--stage-only` 成功;`verify_mpv_native_assets.sh --require-elf` 已通过现有全部能力标记、SONAME/DT_NEEDED 和 ABI 校验。ARMv7 手机运行尚未实测,当前手机为仅 ARM64。 | -| 真实测试流 | 固定 uavs3e `e1ff0f37a8d67814e1d650f6b49d495c49dde946`,256×144、25fps、16 帧、B 帧;Apple 位深宏修正后用串行编码,解码仍两线程。旧并行编码生成流与其重建不一致,已弃用,不再拿错误 oracle 定位 decoder。 | -| Java 提取器 | `test_avs3_contract.sh --baseline dbff1ffecd973c6d89eef1bf139f7243ac6b3ead` 对实际交付 AAR 运行,3 项通过:原始/av3c 8/10-bit 信息,截断/畸形安全失败,1/2/3/5/188/4096 字节分块、seek、重复 EOS 且完整保留 16 帧。 | -| 包装保留 | 同一脚本核对 Maven sidecar/module 哈希、源码哈希、固定 decoder、许可证、ABI/namespace、非目标 AAR 项/class 及 MPV 非 codec 库与基线相同。最终 APK 另核对其原生条目。 | -| 容器样片 | `avs3_mux_fixture.c` 用 FFmpeg 获取真实 display order,再设 PTS/DTS;不把 B 帧解码顺序误当显示顺序。8/10-bit 的 MP4/MKV/TS 均封装成功;MP4 重复完整 GOP 为 32 秒,用于可观察的应用 seek。 | - -### High profile 仍未完成的准确边界 - -用户 PDF 的表 29(DBR 参数顺序)、38(ESAO 参数早于亮度类型)、39(各 CCSAO set 的参数紧接偏移)与 HPM12.2 不同;标准校正后首 I/B 图片头已对齐。仅首 I 的参考运行约 2.58 秒输出一个 10-bit 4K 图像,但可见块损坏;完整输入在 B slice `dec_eco_cbf` / `ctp_zero_flag == 1` 断言失败。**该图像不是正确性 oracle,不关闭断言,不将 HPM 打包进 App。**真实 `0x32`/4K50 样片仍不支持;基准档次验收不替代它,也不承诺 4K50 软件实时性能。P10 目录为语音标准,未作为视频库接入。 - -### 19:20 应用复现后的两项必要修正 - -- 用户已确认 Exo 基准档次硬解优先/软解均可见正确画面。MPV `mpv-user-failure.txt` 记录 `hwdec=no` 后立即 `Software decoding fallback is disabled`。已固定 mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` 的 `video/decode/vd_lavc.c` 和 `DOCS/man/options.rst` 是本次 A 级证据(2026-09-16):`software_fallback == INT_MAX` 同样阻止显式软解。保持现状无法播放;全局启用 fallback 会改变硬解合同;采用 App 根据 `decode` 传参,硬解仍 `no`,显式软解为 `yes`,不改 libmpv。范围补入 `MpvPlayerEngine.java` 与现有 `MpvHardwareDecodePolicyTest.java`,属于用户明确反馈的 MPV 失败修复。 -- uavs3d 固定源码 `parser.c:dec_parse_sqh` → `com_sys.h:com_check_val2` 为 A 级证据:发布版不执行断言,且把未知 profile 写为 0x22。原样 wrapper 及仅传递返回值均不能防止误解;窄适配在原始 extradata/av3c 与每个 in-band 序列头调用前验证长度、起始码、原始 profile,未知档次返回 `AVERROR_PATCHWELCOME`,截断返回 `AVERROR_INVALIDDATA`。不修改输入标识,不更换 uavs3d 算法,不阻止平台硬件 renderer 自行支持高级档次。 -- 两项均为既有设计的正确性收口;验收补充基准档次仍可解、High profile 两种初始化形式及 in-band 明确拒绝、MPV 显式软解实际出帧/跳转,回滚仍为本任务原子基线。 -- 官网当日复查:公开 [登记页面](https://standard.avs.org.cn/avs3_download/) 提供视频标准文本与 **P10 语音**参考软件,未列出 P2 HPM 视频参考软件。已给用户 [官方联系方式](https://www.avs.org.cn/index/list?catid=13),建议申请匹配正式标准和勘误的 HPM(0x30/0x32)、一致性码流/参考 YUV 与校验值、版本说明及使用许可;没有代用户提交个人资料或联系第三方。 -- 用户补充 Gemini 建议后逐项核对:FFmpeg 2022 补丁仅为解析器/识别;实际阅读 [mpv #10695](https://github.com/mpv-player/mpv/issues/10695) 正文,是 2022 年 MKV CodecID 支持问题,未提供 0x32 图像解码证据;[ExoPlayer #2148](https://github.com/google/ExoPlayer/issues/2148) 正文是 AC3 音频直通/FFmpeg 扩展选择,与 AVS3 视频无关。GitHub API 限流后改读公开 HTML,保存于 `build/avs3-native/gemini-*.{html,txt}`;两条 issue 不作为高级档次能力依据。`profile_id` 与 TS `stream_type` 不混用,不全局强制软件 renderer。 -- 用户询问 Exo 选择硬解却能播放:明确解释实际 decoder 为 FFmpeg/libuavs3d 软件后备,手机公开 MediaCodec 无 AVS3;不能把设置按钮或画面出现当成实际硬解证据。 - -### 最终 profile 修复制品与验证 - -| 交付件 | SHA-256 | 字节数 | -| --- | --- | ---: | -| Exo ARM64 libavcodec | `e42513f52849349c083e57c2641928eeec1c48e1e1d339172c3e9cc0bc6b4708` | 5,346,424 | -| Exo ARMv7 libavcodec | `a80871856bdb562210227e6f4ae68b06c32b35967b67fac83061e10d4f93ba42` | 4,848,096 | -| MPV ARM64 libmvcodec | `37114bda4673f642229fb71eb3bf4f63d82d3d4939f18cc10556ab51f43359f6` | 15,809,952 | -| MPV ARMv7 libmvcodec | `e4413018818c5853a2222e0791bb19c5b5dd34ee25a30a391199b5157bdc0404` | 14,920,264 | - -- nextlib r4 AAR:`bfd9c635cd18613c728e6e4d9635da2f80716d20adb1927b360cf5818085fb16`;sources JAR 保持 `2a08cc5f58718a3da3dc437784891680a0710c376b322a385cd6e5adcde01492`。FFmpeg AVS3 补丁:`4a04bf7d11b592602590983fec0e0e12c2769f2623ff19850e1d9e6df9b8a929`。 -- 最小重建:MPV 两套既有 FFmpeg build tree 仅编译 wrapper、链接并安装 codec,使用原 staging 完成 namespace/strip;Exo ARM64 增量,ARMv7 按原配置重建所需 codec 依赖。日志 `rebuild-profile-*.log`,未重建 libmpv/libplacebo/ASS/FEL;阶段内临时驱动脚本 `build/avs3-native/rebuild_profile_guard.py` 保留精确命令,正式可复现接线仍是已提交构建脚本与补丁。 -- 最终两套 ARM64 手机探针均通过:raw/av3c/in-band 的 0x30、0x32 返回不支持,截断头返回无效数据;同一探针再验证 8/10-bit 各 16 帧及 flush 重放,像素哈希与 manifest 一致。记录 `build/avs3-native/{exo,mpv}-profile-device-{8,10}.log`。 -- `MpvHardwareDecodePolicyTest` 2 项通过,硬解模式 `no`、显式软解 `yes`;同次 ARM64 APK 构建通过,1m18s。`profile-apk-contract.log` 已确认最终 AAR/双 ABI/hash/license/namespace、非目标二进制保留和 APK native 条目一致;`mpv-profile-assets.log` 的原生能力检查通过。无关已通过的提取器测试不重复运行。 - -### 用户追加的手机测试库 - -- 已按新授权写入 `/sdcard/Download/影音测试库/A09_AVS3A`:新增 0x22 的 8-bit 与 10-bit,各自 MP4/MKV/TS/裸流共 8 个视频,另有 2.0/5.1/7.1.4 AVS3A 联播的 3 个 DASH MPD。每段视频 800 帧/32秒,含 I/P/B 帧;低分辨率用于能力验证,不是 4K 性能测试。最初 8-bit 被误标为 0x20,2026-09-17 已核对原始头部并修正文档和 manifest,没有修改 profile 字节。 -- 初次音视频混流实证发现固定 FFmpeg TS muxer 把 AV3A 写成私有 data,MOV muxer 没有 AV3A tag;这些失败制品移入临时 discarded 目录,没有写入手机。采用原有 DASH 分轨方式,复用未改动的 AV3A 音频。最初视频 MP4 的二次重封装丢失 av3c,旧初始化/索引范围不再使用;2026-09-17 改由 fixture muxer 直接以 dash/global_sidx/negative_cts_offsets 输出最终文件,两份 MP4 的初始化范围均为 0–831、sidx 832–1471、timescale 12800。 -- 根 README 总媒体/播放列表数 91→102,A09 16→27(不计系统 `.DS_Store`、README 与 JSON)。增加 A09 README 和 `AVS3_MANIFEST.json`,明确原有 0x32/4K50 未支持、按钮不等于实际硬解,以及官方 High 一致性码流的未覆盖项。 -- 已保存原根 README,更新前核对未被外部修改;8 个视频各 800 帧、32秒元数据及 3 个 MPD 引用检查通过。复制后的 14 个文件(11 个媒体/播放列表与 3 个说明/校验文件)SHA-256 全部匹配,见 `build/avs3-native/library-phone-hashes.txt`。主机镜像在 `build/avs3-native/library/`。 - -### 21:15 用户复测失败:CPU/GPU Surface 交接 - -- A 级设备证据:`user-recheck-current.log` 中 MPV 明确 `decode=0`、`hwdec=no`;`user-recheck-logcat.txt` 中同一 SurfaceView BufferQueue 连续报告 `cur=2 req=1`、`native_window_api_connect` 失败和 `EGL_BAD_ALLOC`。带音频 MPD 可以只播放音频,不能视为视频成功。早先一次完整 8-bit 正常包播放记录为 `normal-software-start.png/.log`;两种现象并存,不把失败归咎于用户选择硬解。 -- A 级实现证据:固定 nextlib `6ff6cf9d0820382b3c233d018c52e4163b09d345` 的 `media3ext/src/main/cpp/ffvideo.cpp` 使用 `ANativeWindow_lock/unlockAndPost`;析构仅 `ANativeWindow_release`。当前 `PlayerManager.switchPlayer` 的直接、fresh-spec、fresh-result 三条入口释放 decoder 后均传 `onPlayerRebuild(player, false)`。`PlaybackActivity.resetVideoSurfaceForDecoderSwitch` 已通过替换 render view 创建新 Surface,但此前只由部分软硬解切换触发。 -- A 级平台证据:[Android Surface and SurfaceHolder](https://source.android.com/docs/core/graphics/arch-sh),访问 2026-09-16,正文已保存 `aosp-surface.html`。官方明确 CPU producer 连接后直到 Surface 被销毁才断开,不能直接交给 GLES/视频 decoder。经系统代理 `127.0.0.1:7897` 成功读取;此前直连超时不是资料缺失。 -- 决策:保持现状会稳定复现黑屏;在 native 中调用非公开 disconnect 接口有兼容与所有权风险;采用现有 App Surface 重建回调,在**内核实际变化**时重建,保留同内核 fresh-source 行为。三个入口统一,不依赖“硬解”按钮推断是否使用了 CPU renderer,因为 AVS3 硬解优先也可能用软件后备。属于用户已授权 AVS3 落地与复测故障修复;范围仅补入原本干净的 `PlayerManager.java`,不更改 nextlib/MPV 原生图形实现。 -- 成本与验收:只在内核切换时创建新 Surface;稳态解码、逐帧渲染、字幕与 DV/FEL 算法不变。必须以同一活动页面中的 Exo 软件出帧 → MPV 软件出帧、实际 Surface 更替、跳转后继续出帧验证;不能用重启 App 后的独立播放代替交接检查。同内核软件/硬件 producer 交接作为邻接风险按实际验证结果处理,不据本修复声称已全面解决。回滚仍为任务基线的原子提交单元。 - -### 2026-09-17:测试库 MP4 配置修正 - -- A 级实证:同版本交付 AAR 的 `FragmentedMp4Extractor` 在两份新增 MP4 的 `Avs3Config.parse` 抛出 `Invalid AVS3 sequence size`;二者 av3c 总长8字节、payload为空。相同提取器对两份 MKV 输出 `video/avs3`、`avs3.22.6A`、256×144、800 samples、时间戳0..31960000及一份初始化数据。证据在 `build/avs3-native/container-probe/result*.log`。MKV 解析通过不替代手机 decoder/output 验证。 -- A 级源码依据:固定 FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18` 的 `libavformat/movenc.c:mov_write_av3c` 要求 extradata,`mov_write_av3c_tag` 未传播缺失错误;同版本 `mov.c` 没有 av3c reader。fixture muxer 原本已提供正确 raw sequence,之后的 MP4→fMP4 中间重封装丢掉配置。 -- 决策:不变会保留损坏样本;放宽 Media3 初始化解析会掩盖损坏输入;窄修正是在 `avs3_mux_fixture.c` 接受可选 movflags,直接生成最终 fMP4,保留原有 PTS/DTS 与32秒内容。无生产 parser 放宽、无 FFmpeg 或 APK 重建。此为既有生成方案的局部修复,无新的架构或上游合并,已形成决定性本地源码/失败证据,不重复外部研究。 -- 本机工具已重新编译,两个 MP4 写入成功,av3c payload均116字节,与各自原始序列头逐字节一致;FFmpeg复核800 packets/32秒/256×144与8/10-bit精度。三个MPD的codec字符串、视频码率、init/sidx范围已更新;清单按最终文件重算哈希。两级README澄清实际profile均0x22、没有0x20一致性样本,以及复用约60秒音轨导致部分播放器超出32秒Period的问题。 -- 复现入口:`python3 build/avs3-native/repair_library_mp4.py`;坏样本备份 `library-discarded/*.missing-av3c`,命令/metadata为 `library-mp4-repair.log/.json`。主机版本待复制到手机;ADB未连接时不冒称手机已更新或修复已通过实播。 -- 决定性验证已通过:`python3 build/avs3-native/container-probe/run.py`,原样交付 AAR 对修正后的 MP4 与已有 MKV 四个文件均 sniff=true、`video/avs3`、`avs3.22.6A`、256×144、800 samples、时间戳0..31960000、init=1,完整结果在 `container-probe/result-repaired.log`。这只验证容器提取,不替代手机连续出帧和跳转。 - -### Surface 重建后的第二个时序缺陷(07:19) - -- A 级设备证据:安装助手在2026-09-16 21:45报告成功;21:48:01.601日志显示已执行Surface重建,之后新SurfaceView尺寸0×0、holder无效。21:48:01.876 MPV开始加载时明确 `surface=false attached=false`,21:48:02.021才绑定新Surface;原生输出随即报告 `vo/gpu-next/android: Missing surface pointer`。故此次不是旧CPU producer仍占用,而是VO初始化早于Surface绑定。完整记录 `exo-container-user.log`,故障trace `p-196fj0t-x`。 -- A 级成熟实现:本地 `build/mpv-native/mpv-android/app/src/main/java/is/xyz/mpv/BaseMPVView.kt`,固定 `99a60ad2141d5ace94453590903c2c6b9a0a2443`,07:19已读全文;`playFile`只保存路径,`surfaceCreated`先 `MPVLib.attachSurface`,后 `loadfile`。`force-window`也在Surface就绪前关闭,避免提前VO初始化。沿用已读Android SurfaceHolder生命周期资料;该问题是有既定设计的局部生命周期修正,无需泛搜新论文。 -- 本地调用链:`MpvPlayer.openCurrent`→`continueOpenCurrent`→`startPreparedMedia`→`loadCurrentUri`;已有 `pendingOsdLoadGeneration` 只等OSD,普通GPU不走该等待。`bindVideoOutput`由holder回调绑定视频Surface,但此前不恢复等待视频Surface的加载。 -- 方案对比:保持现状会继续触发初始化失败;直接搬上游单视图字段会丢失本地媒体代次、OSD与后台音频合同;推荐把现有等待拓展到前台可见SurfaceView的有效且已绑定主Surface,并在绑定/OSD回复后恢复。沿用媒体代次校验、stop/replace取消;headless或隐藏窗口不增加主Surface等待。无固定延时、无失败重试掩盖、无native/ABI或依赖升级。 -- 具体补丁:`build/avs3-native/mpv-surface-load-gate.patch`;只涉及上述App adapter一文件。恢复前检查released/stopping/initialized/error,清除等待代次后才发loadfile,避免重复回调加载。08:30已获继续授权并应用,最终APK构建通过;用户随后明确接受并要求打tag,本轮未另跑handoff探针。 -- 验收:同一Activity里Exo CPU出帧→MPV GPU出帧,日志中第一次loadfile必须在视频Surface绑定之后;8/10-bit与seek、无窗口音频不阻塞、stop/替换后的旧回调不重启媒体;保持已有OSD等待语义。最小编译为单ARM64 APK增量,设备场景用既有命令探针。回滚仍为原任务基线;不以原Surface包安装成功代替此验收。 -- 07:22 本机收口:修正文件的manifest/hash、三个MPD的codec/init/sidx检查通过;8文件更新包 `build/avs3-native/AVS3-test-library-update-20260917.zip` 已生成。`sync_library_corrections.py` 会先核对手机旧文件仍为本任务原哈希,再逐个复制并复核,避免覆盖用户另行修改的README。脚本尚未执行,ADB仍无设备。上游checkpoint校验通过(0 error,1项预期的本任务依赖制品脏状态warning),候选补丁 `git apply --check` 通过;没有重复native/APK构建或已通过的提取器测试。 -- 当前手机已安装的Surface重建APK SHA-256:`09f6c0f64f03aa3ec8c80c13adfcc0333d4ecfadc05a66945e35562dc8dfcdb7`。它不包含尚待范围批准的主Surface加载等待补丁;旧正常日志APK哈希不能代替此记录。待批准后应用补丁、一次增量构建,再在恢复连接的手机上验证并关闭同一任务。 - -### 07:49–08:03 手机同步与 Exo 容器实播 - -- 同步后补查发现,07:12生成脚本误用了 `build/avs3-native/fixtures` 的旧缓存,虽容器可解析,但不是已独立重建核对的正式码流。已改用 `app/src/test/java/androidx/media3/extractor/fixtures`,强制核对fixture manifest SHA与库中裸流=原始16帧×50。这个差异不能由容器解析通过掩盖。 -- 最终8-bit输入SHA `d0dbd57f983ac27cae2ff70376e22bcc8275a747fd6bc0452b404a124af71cb4`,10-bit输入SHA `6fb9cfddb8645942b9883304cc5f783da8368d139715dc1e09cd88068f994df7`。最终MP4分别为265,320字节 / `b64d5026911b978c35ac48e154adb10bf0384fd021c7edc8f7433660f1a018d5` 与359,420字节 / `df0b77b988b23198bdd2bbc7fef7454f89adadec76b30c69043b5dc57b5ad80a`。init/sidx范围保持0–831 / 832–1471。 -- `library-phone-sync-verified-fixtures.log`:8个修正文件全部同步并核对哈希;重算后的8文件更新ZIP为32,239字节、SHA `0378dd55ff06f681443367214af88de9ea4f2c39b16f2b69317ab35ba200ce76`。旧ZIP已被替换,旧缓存版本不再交付。`container-probe/result-verified-fixtures.log`:实际AAR对正式输入的MP4/MKV四项通过。 -- 命令探针前两次是启动/就绪问题,尚未执行容器实播:首次OEM拒绝后台启动HomeActivity(结果102),改为shell前台启动;第二次服务存在但spec仍为空,补等spec和isOwner。未将这些错误登记为播放器故障,也未靠模拟点击切换。 -- 最终 `command-probe/containers-ready-service.log`:Exo明确选软解,8-bit TS基线及8/10-bit MP4/MKV五项均播放至1.5–2.3秒并取得真实视频Surface。MP4/MKV四项像素分别包含4074、2371、4598、4598种颜色,10-bit MKV图像已查看为预期连续渐变。图片为 `command-probe/exo*-20260917.png`。此项验收是实际出帧/推进,不声称已验证整段32秒或所有容器seek。 -- 该次instrumentation结束时异步Web日志未持久化到最后场景,`containers-runtime-log-20260917.txt`的末条时间仍为07:58,因此不把它冒充08:03最终实播的decoder日志;最终场景以命令结果和PixelCopy证据为准。用户原player首选项恢复为2,目标进程已停止。 - -### 最终打包与用户确认(2026-09-17) - -- `MpvPlayer.java` 已加入原guard范围并应用主Surface加载等待:可见窗口需有效且已绑定的Surface,绑定及OSD回复恢复加载;清除输出允许原后台路径继续,stop/替换清除等待代次。未改native图形后端或重新构建AVS3依赖。 -- 实际Gradle构建通过:`app-surface-gate-build-final.log`,`assembleMobileArm64_v8aDebug --offline --max-workers=4`,103项任务中7项执行。首次命令因沙箱不能写Gradle缓存锁而未开始构建;获准使用缓存后成功。Gradle报告56分33秒,超出原4分钟估计;未重复成功构建。 -- 最终APK:`app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk`;SHA-256 `2e40323b300942eecc88482e3254b8a36f9c6e240fcd0f8eadb0a30651ee978f`。首次安装遇设备断开;11:10设备重连后再次调用OEM安装助手,用户随后明确“可以了,打tag”。此确认关闭该场景的额外验证,不把它记录为命令探针结果。 -- 交付范围是uavs3d可落地的baseline 0x20/0x22、8/10-bit接线与相关容器/内核切换修复;实测fixtures均为0x22。原始4K50/0x32样片仍不支持,本次tag不代表High profile或4K实时软件性能已经实现。 - -### 严格视频解码模式修复(2026-09-17 11:28) - -- 用户合同:视频只能人工手动切换硬解/软解,绝对禁止自动切软解;音频允许自动回退。此前将硬解解释为“硬解优先”错误,不能通过改文案保留违规行为。 -- 根因及证据:`769e53471dbb3e9ad4f9b94842099ba97a92fdbd` 在 `ExoUtil.FfmpegRenderersFactory.buildVideoRenderers` 的 `EXTENSION_RENDERER_MODE_OFF` 分支仍追加了仅支持 `video/avs3` 的 `FfmpegVideoRenderer`,绕过原硬解模式的提前返回;故系统无AVS3 MediaCodec也能悄悄出帧。 -- 最小修复:恢复该分支直接返回,不更改AVS3 native decoder/容器接线、视频Surface逻辑或UI文字。原 `getVideoCodecSelector` 在硬解时只接受 `hardwareAccelerated`;手动软解仍注册FFmpeg。`buildAudioRenderers` 独立保留初始化失败回退及软件音频renderer。MPV硬解继续 `hwdec-software-fallback=no`,只有手动软解才使用 `hwdec=no` 与允许软件decoder的参数。 -- 决策:这是已建立合同的局部回归,证据已由原提交、本地调用链和现有MPV源码研究确定;不重复泛搜或变更依赖。保留异常分支或修改标签均违背用户要求,选择移除例外。验证必须覆盖视频硬解排除软件、人工软解可用、音频软件renderer仍可用。 -- 范围/回滚:新guard `C-AVS3-video-hardware-only` / quick-fix;仅 `ExoUtil.java`、本文件、任务索引及 `build/avs3-native` 临时验证;保护既有104个 `app/.cxx/` 文件。回滚锚点为上述基础AVS3提交,源/库未变更。 -- 11:28 Asia/Shanghai估计:修复核对3分钟、单ARM64打包和定向验证5分钟、提交tag1分钟,目标11:37;不重新构建native或重复通过的容器矩阵。 -- 修复已完成:移除硬解分支的匿名 `FfmpegVideoRenderer`,恢复直接返回。核对 `getVideoCodecSelector` 仍仅提供硬件视频decoder;`buildAudioRenderers` 保留FFmpeg与初始化失败回退。`PlayerManager.retryHardDecodeSwitch` 明确重建HARD模式,没有自动改成SOFT;预缓存的renderer能力查询不负责实际播放解码。 -- 验证:`app-strict-video-build.log` 显示单ARM64 APK及 `MpvHardwareDecodePolicyTest`、`ExoAudioCodecSelectorTest` 通过,2分19秒,112项任务中11项执行。未重编native;MPV硬解禁用自动软解、手动软解允许软件decoder,音频硬件排序及软件候选保留。 -- APK已重新生成在 `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk`,并已发起OEM安装助手安装;随后用户明确“可以了,打tag”。上节SHA-256属于前一版APK,不用于标识本次修复包。用户确认后停止额外验证:新的renderer策略探针只准备了代码,未执行,不冒称手机探针通过。 - -## MPV AVS3 MediaCodec(2026-09-17) - -### 已批准的设计与研究 - -决定性问题:能否复用现有 FFmpeg/MPV 的 MediaCodec 会话,把原始 AVS3 数据交给系统真实硬件组件,同时保持手动软硬解和现有播放器输出合同。用户已明确批准该接入;没有新增转码、自动降级、厂商私有服务或强制软件后备。 - -| 证据 / 访问日 | 固定来源及处置 | 结论、适用性与限制 | -| --- | --- | --- | -| A / 2026-09-17 | `FongMi/FFmpeg@177f090e0503b7e013922ca903bde14b1c375f18`,`mediacodecdec.c`、`mediacodecdec_common.c`、`mediacodec_wrapper.c`、`configure`、`allcodecs.c`、`Makefile`;保留版本,窄补接线 | 共用 wrapper 已负责 CSD、输入/输出队列、Surface、PTS、flush 和资源释放;缺 AVS3 注册和 MIME。现有本地音频补丁已提供 `hardware_only` 查询,API29+核对 `isHardwareAccelerated`,旧系统使用现有软件名称排除;可直接复用。NDK 按 MIME 创建的兜底无法保证硬件属性,AVS3 必须禁止该兜底。 | -| A / 2026-09-17 | 同 FFmpeg 的 `movenc.c:mov_write_av3c` 和现有 `libuavs3d_decode_extradata`;保留软件实现 | `av3c` 为 version=1、16位序列长度、序列头和library字段;不是可直接送给设备的原始序列。硬件 wrapper 只做有界解包,保留原 `00 00 01 B0`、profile及位深,不调用软件解码器、不修改输入。无extradata时保留流内序列头路径。 | -| A / 2026-09-17 | [AOSP MediaCodec.java](https://github.com/aosp-mirror/platform_frameworks_base/blob/android-15.0.0_r1/media/java/android/media/MediaCodec.java#L416)、[MediaCodecInfo.java](https://github.com/aosp-mirror/platform_frameworks_base/blob/android-15.0.0_r1/media/java/android/media/MediaCodecInfo.java#L149),revision `android-15.0.0_r1`;接口依据 | `csd-0` 随configure提交、start自动送入,不额外重复提交;硬件属性由厂商提供。AOSP没有标准化AVS3 profile常量,不能把码流0x32当作Android profile枚举强塞;交原序列给硬件configure/解码判断,失败如实返回。 | -| A / 2026-09-17 | `FongMi/mpv@cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`,`vd_lavc.c:add_all_hwdec_methods`、`hwdec-codecs`与软件fallback分支;已覆盖,无MPV native修改 | MPV按 `avcodec_get_hw_config` 枚举 wrapper 的 MediaCodec direct/copy能力;App在 `MpvPlayer` 和性能参数overlay两处覆盖硬解编码列表,必须都加avs3。继续复用既有软件模式及禁止硬解自动软解策略。 | -| B / 2026-09-17 | [VLC MediaCodec](https://github.com/videolan/vlc/blob/778ec071ff216602f2a6e4ddd522dfdb6b791472/modules/codec/omxil/mediacodec.c),`ParseExtra` / CSD / flush;参考,不移植 | 成熟做法是按codec准备CSD并复用公共会话;没有证据支持为AVS3复制一套Surface/解码循环。此版本未提供AVS3专属硬解证明。 | -| A / 2026-09-17 | [GPAC codec registry](https://github.com/gpac/gpac/blob/768fe974c4a6c2328a3ffd79107fcfe61616cfcc/src/utils/constants.c#L156);参考 | `GF_CODECID_AVS3_VIDEO` 映射 `video/avs3`,与现有Exo一致。它证明媒体类型映射,不证明某款电视暴露该MIME或支持特定档次。没有证据的厂商别名不猜测添加。 | -| B / C / 2026-09-17 | [mpv PR16304](https://github.com/mpv-player/mpv/pull/16304)、[PR5602](https://github.com/mpv-player/mpv/pull/5602)、[issue7777](https://github.com/mpv-player/mpv/issues/7777) 原文;参考,均非本轮移植提交 | GPU输出可支持MediaCodec;Surface、暂停和copy路径历史上有独立生命周期/掉帧问题。因此保留当前已修复的输出实现,不因新增codec修改默认VO或承诺性能数值。报告不能替代当前设备验收。 | - -原文保存在 `build/avs3-native/mediacodec-research/`。经配置代理 `127.0.0.1:7897` 访问;grep.app限流、Google返回挑战页,改读固定AOSP/VLC/GPAC及GitHub原文。晶晨公开vendor树 `41bd0acec72455d30424a37799d558b0e67623a1` 本轮未找到适用的AVS3 MediaCodec接口实现,故不声称已验证厂商CSD私有约定。论文/新算法基准本轮不适用:只接标准压缩输入和现有设备API,不改编码算法、渲染/调度,也不据文献预测4K吞吐。设备性能与厂商差异需实际硬件,停止继续泛搜。 - -### 取舍与保持的合同 - -- **不改动**:MPV硬解无法发现AVS3,不满足需求。**原样照用通用wrapper**:传av3c原包装会给出错误CSD,且无JVM的NDK按MIME兜底可能选软件组件,拒绝。**窄适配(选择)**:独立 `ffmpeg-avs3-mediacodec.patch` 接在现有FFmpeg补丁之后,注册wrapper、规范化CSD,只对AVS3启用hardware-only及失败关闭;App两处列表同步增加avs3。 -- 无新增外部依赖/公开ABI、无Java/native会话或Surface所有权更改,复用已验收的缓冲释放锁和flush。两套ARM只替换MPV `libmvcodec.so`,其他18个MPV库与Exo所有制品逐字节保持基线。uavs3d/HPM、AV3A、ASS、FEL和P8 HDR10已提交实现必须保留。HPM原许可不变。 -- 有限CSD解包只检查读取所需边界和序列起始码,完整语法/profile由设备判断;不声称校验全部AVS3标准。标准流内序列原样送入,容器时间戳和色彩沿用公共wrapper。硬解只创建枚举验证过的组件,失败由用户手动选软解。软件设置不会因新增wrapper变成硬解。 -- API29+信任平台硬件属性;较旧平台复用既有名称分类能力,无法从应用证明物理ASIC实现。不以设置按钮、注册标记、成功configure或一个Surface对象作为真实出帧证明。 - -### 验收、边界和回滚 - -1. 用生产函数的定向C检查覆盖raw/av3c、原0x32字节保留、截断/错误长度/无初始化数据;直接执行硬件选择函数,验证查无硬件时JNI/NDK都不走按MIME创建,并覆盖原HEVC和音频合同。 -2. 两ARM按当前已打补丁源码和原工具链增量构建codec;补丁能在固定基线+既有补丁链应用。ELF/SONAME/DT_NEEDED、16KB对齐、符号与所有非目标native哈希核对;正式构建脚本强制包含wrapper,普通Debug/Release打包无需额外开关。 -3. 最小App验证包含硬解列表/选项优先级与现有禁止软解fallback检查、单Mobile ARM64 APK,核对APK内native字节。已有软件像素验证不无因重跑;native软件源/依赖和其他库身份作为本轮保留证据。 -4. 当前vivo设备既有枚举无AVS3 MediaCodec,且ADB尚未连接;**真实AVS3硬解出帧、4K50性能和厂商特定profile/CSD为未实测边界**。本单元交付通用接入,不能把编译/模拟平台接口检查当作硬件播放验收。若设备公开兼容 `video/avs3`,手动硬解才会尝试该组件。 -5. 源码补丁、构建/校验、双ARMcodec、App列表及记录同一guard原子提交并创建annotated本地恢复tag,不推送。回滚本单元提交即可整体回到 `13053755eaea00aa9c6449e8ad55c2ccf1fbc68a`;保持已完成0x32软件和续播修复。 - -### 实施中的补充边界 - -- `f_decoder_wrapper.c:reinit_decoder` / `common/codecs.c:mp_select_decoders` 会在初始化失败后尝试下一个同编码wrapper;`hwdec=no` 本身不排除直接选到 `_mediacodec` decoder。新增AVS3必须要求调用方显式提供MediaCodec硬件上下文(MPV direct/copy的现有路径均提供),防止未知软件profile失败后反向绕入硬件。该约束只落在新AVS3分支,保持既有HEVC和音频路径;没有修改MPV native或扩大其他格式行为。 -- 首次ASan/UBSan生产函数检查已通过配置解包与硬件查询拒绝、原HEVC/音频邻接合同。首轮双ARM构建中ARMv7已完成;ARM64完成后补上上述上下文约束,再仅重编受改的MediaCodec对象,不重复configure。源码固定基线+全部补丁字节对照和最终制品校验将在最终代码上执行一次。 -- 19:00前后ADB已恢复识别原vivo设备。已准备通过 `app_process` 加载最终codec的JNI/NDK命令检查,不安装探针APK、不改变用户播放器设置。它只验证设备无AVS3组件时安全拒绝,不能代替AVS3硬件实际出帧。 - -### 最终实现与验证 - -- `ffmpeg-avs3-mediacodec.patch` 注册 `avs3_mediacodec` / `video/avs3`,按公共MediaCodec HW配置供MPV发现direct/copy;CSD接受原始序列或有界av3c解包,保留profile/位深和原始packet。AVS3专用分支要求显式MediaCodec上下文、只选硬件、无按MIME绕过筛选的兜底。`MpvOptionPriorityPolicy.HARDWARE_CODECS` 同时供初始化及性能overlay使用。正式MPV构建显式启用并校验新wrapper,普通App构建无需额外功能开关。 -- 最终补丁SHA-256:`4dcd2c4d95664d154150b7d3c26ab4cfbb90e1159b33a4b4ccea1e40e2aa4704`。依赖revision、NDK29/API24、原有patch/静态HPM/uavs3d输入保持,`mpv-native-lock.json`无版本变更。`mediacodec-patch-chain-complete.log`证明从固定FFmpeg原提交顺序应用7个补丁,11个涉及文件与实际构建源逐字节一致。首次验证驱动只解析git格式头,漏收普通unified patch中的两个文件;修正文件枚举后完整链通过,无生产补丁放宽。 -- `mediacodec-build-{arm64,armv7l}.log` 为同一配置和源的双ARM构建;新增显式上下文检查后,各自 `*-final.log` 只重编 `mediacodecdec_common.o` 并重链接,没有再次configure或重建其他播放器。`mediacodec-contract-final.log` 的真实生产函数ASan/UBSan覆盖raw/av3c、长度截断/坏起始码/空CSD、profile原样保留、JNI/NDK无硬件及无显式上下文拒绝、Surface参数转交、HEVC和音频邻接分支;平台调用为mock,未伪称真实硬件输出。 -- `mediacodec-device-final.log`:原vivo/Android15公开 `video/avs3` 硬件组件为0;最终已打包ARM64 codec通过 `app_process` 调用真实Android MediaCodecList,JNI和NDK的raw/av3c四项返回 `AVERROR_DECODER_NOT_FOUND`,两项无硬件上下文返回EINVAL。注册的HW设备配置正确,默认软件decoder仍为 `libuavs3d`。初次Java编译路径误写 `android-37`,实际SDK为 `android-37.0`;修正路径后通过,没有把调用环境错误算作播放器错误。 -- `mediacodec-assets.log`:官方仓库校验脚本双ABI版本/能力/命名空间/ELF检查通过。`mediacodec-artifact-contract.log`:两ABI各178个公开符号完全不变,LOAD均至少16KB对齐;相对前一已交付APK,50个native条目只有ARM64目标codec改变。原Exo制品、其他18个MPV库和全部许可证保持。所有最终设备检查与APK使用同一codec字节。 -- `mediacodec-app-build.log`:40秒完成单Mobile ARM64 APK与9项定向测试(选项优先级7项、手动解码策略2项),112项任务中13项执行。APK 181,893,921字节;副本与原Gradle输出逐字节一致,SHA见最新Recovery anchor。未触发安装助手、未更改用户首选项、未重跑原HPM逐像素/整片/性能矩阵。 -- `mediacodec-checkpoint.log` 为0 errors,1项预期的本任务目标二进制/补丁未提交warning,由同一guard负责关闭。目标设备硬解实际出帧与吞吐继续明确为未测;恢复tag只代表本次接入及上述已验证合同。 - -| 最终MPV codec | SHA-256 | 字节数 / 相对基线 | -| --- | --- | --- | -| ARM64 `libmvcodec.so` | `275c63b297bb1357580c925faa23dba7de2d9f044555de762ba5843219abf1d6` | 16,526,960 / +1,216 | -| ARMv7 `libmvcodec.so` | `74e5289c1f6457a9c270cd34198c2a6f886e7092bed3eb89a8d04d1db714706c` | 15,470,492 / +832 | diff --git a/docs/C10-beta-conflict-resolution.md b/docs/C10-beta-conflict-resolution.md deleted file mode 100644 index 6a4bdff572..0000000000 --- a/docs/C10-beta-conflict-resolution.md +++ /dev/null @@ -1,78 +0,0 @@ -# C10:解决 dev2 向 beta 合并冲突 - -## Recovery anchor - -- 目标:将最新 `origin/beta` 合入当前 `dev2`,保留 C8 修复与 beta 后续应用修复,解决内容冲突,验证合并树并推送,使 `dev2` 可继续向 `beta` 合并。 -- 状态:语义合并和验证已完成,待守卫原子提交、恢复标签、推送与同步确认。 -- 当前第一父基线:`dev2@602203b7f3266473a23cc88f2edbaf06e1a08481`。 -- beta 目标:`origin/beta@bb53d224e084348518bd13c6733d3c359db4ed51`。 -- 合并基线:`308694aaadd59d9d1ef230bded83cf84dafa114c`。 -- 任务守卫:`C10-beta-conflict-resolution`,模式 `upstream`,范围 `app/**`、`docs/**`;任务开始前无预先脏路径。 -- 排除项:不修改 FFmpeg、Media3、MPV、JNI、lock、AAR、APK、`.so` 或其他 native/二进制资产;不重写已发布标签。 -- 回滚:合并未提交前使用 `git merge --abort`;提交后使用本任务恢复标签或 `git revert -m 1 `。 -- 下一动作:执行 task guard finish,随后只推送当前 `dev2` 和新恢复标签,并执行 `git pull --ff-only`。 - -## Authority and decision - -- 用户明确要求解决当前无法自动合并到 `beta` 的冲突;本任务授权在当前 `dev2` 上合入最新 `origin/beta`,修复冲突、测试、提交、创建恢复标签并推送当前分支。 -- 决策问题:如何在不丢失 C8 的 IntroSkip 状态/生命周期边界的前提下,吸收 beta C9 对片段身份、解析边界和受影响测试的后续修复。 -- 反事实:直接保留任一侧会丢失另一侧已验证的行为;无合并则远端 PR 继续冲突。 -- 推荐方案:使用 Git 三方合并定位冲突,保留两侧互不重叠改动;对同一方法以当前最终行为和测试契约为准,优先保留更严格的边界/生命周期保护,再纳入 beta 的新增测试与功能,不采用整文件覆盖。 - -## Conflict evidence - -- 预览合并显示 4 个内容冲突: - - `app/src/main/java/com/fongmi/android/tv/player/IntroSkipPlayback.java` - - `app/src/main/java/com/fongmi/android/tv/service/IntroSkipService.java` - - `app/src/test/java/com/fongmi/android/tv/service/IntroSkipServiceTest.java` - - `docs/upstream-player-dependency-merge-assessment-2026-08-20.md` -- 其余 beta 差异由 Git 可自动合并,仍须在最终 diff 中检查是否意外覆盖本地修复。 -- beta 侧 C9 已有独立记录 `docs/C9-beta-sync.md`;本任务只记录 dev2 为解决合并冲突所需的整合与验证,不重写 C9 的历史。 - -## Alternatives - -- 不合并:保留当前稳定 `dev2`,但无法消除向 beta 的合并阻塞,拒绝。 -- 全部采用当前侧:可能丢失 beta 的后续播放器/布局/测试修复,拒绝。 -- 全部采用 beta 侧:可能回退 C8 的严格 IntroSkip 生命周期、缓存、溢出和回调保护,拒绝。 -- 三方语义合并:保留双方有效契约并用受影响测试验证,采用。 - -## Acceptance criteria - -1. 合并完成后无未解决路径、冲突标记或意外删除,且当前 `dev2` 的有效改动与 beta 的有效增量同时存在。 -2. `IntroSkipPlayback`、`IntroSkipService` 的状态机、片段身份、时长/溢出和缓存边界保持可解释且有测试覆盖。 -3. beta 新增及现有受影响的 IntroSkip、布局、Exo 测试通过,失败/错误/跳过为 0。 -4. Mobile 与 Leanback Arm64 Java 编译通过。 -5. 最终 diff 检查未发现 Critical/Important 冲突引入;不改 native/依赖/锁/二进制所有权。 -6. 由 task guard 创建一个原子合并提交和 annotated recovery tag,推送 `dev2` 后 `git pull --ff-only` 保持同步。 - -## Verification plan - -- 最小冲突验证:冲突清零、`git diff --check`、冲突标记扫描。 -- 受影响单测:`IntroSkipServiceTest`、`VideoActivityLayoutTest`、`ExoUtilTest`,以及 C8/C9 涉及的源代码回归测试(按实际测试任务可用性选择)。 -- 编译验证:`:app:compileMobileArm64_v8aDebugJavaWithJavac` 与 `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`。 -- 发布闭环:task guard finish、恢复标签、推送当前 `dev2`、`git pull --ff-only`。 - -## Provenance ledger - -| # | Repository/ref | Full commit | Disposition | -| ---: | --- | --- | --- | -| 1 | `origin/beta` | `bb53d224e084348518bd13c6733d3c359db4ed51` | 合并目标;最终树复审 | -| 2 | 当前 `dev2` | `602203b7f3266473a23cc88f2edbaf06e1a08481` | 第一父基线;保留 C8 最终树 | -| 3 | `origin/beta` ancestor | `308694aaadd59d9d1ef230bded83cf84dafa114c` | 三方合并基线;历史已在双方树中保留 | - -## Checkpoint 1:2026-09-04 00:24 Asia/Shanghai - baseline frozen - -- Completed:刷新 `origin/beta`,确认目标与当前 `dev2` 的完整身份;工作区干净,预览三方合并确认 4 个内容冲突。 -- Source identities:第一父 `602203b7f3266473a23cc88f2edbaf06e1a08481`,beta `bb53d224e084348518bd13c6733d3c359db4ed51`,merge base `308694aaadd59d9d1ef230bded83cf84dafa114c`。 -- Review boundary:只处理 `app/**` 与 `docs/**`;冲突核心为 IntroSkip 代码/测试和总评估台账。 -- Rollback anchor:保持第一父不变;未提交合并可用 `git merge --abort`。 -- Unresolved:实际合并、语义冲突解决、测试/编译、提交/tag/push/pull。 -- Next action:执行 `git merge --no-commit --no-ff origin/beta`,然后按冲突块逐项合并。 - -## Checkpoint 2:2026-09-04 09:35 Asia/Shanghai - conflicts semantically resolved - -- Completed:执行 `git merge --no-commit --no-ff origin/beta`;`IntroSkipPlayback`、`IntroSkipService`、`IntroSkipServiceTest` 和总评估索引已清除冲突标记。 -- Resolution:`IntroSkipService` 保留 C8 的显式片段身份、缓存完整性和长整型安全比较,吸收 C9 的负边界拒绝与尾部段越参考时长拒绝;`IntroSkipPlayback` 采用规范化稳定身份,区分同源多段且不随本地时长折算漂移;测试保留 C8 与 C9 的互补契约。 -- Validation:首次 `:app:testMobileArm64_v8aDebugUnitTest` 因合并保留的重复 `identity` 赋值失败;删除重复赋值后同一定向测试任务通过。`IntroSkipServiceTest`、`VideoActivityLayoutTest` 和 `ExoUtilTest` 均无失败、错误或跳过;`:app:compileLeanbackArm64_v8aDebugJavaWithJavac` 通过。`git diff --check` 和冲突标记扫描通过。 -- Unresolved:原子提交、恢复 tag、push 和 pull。 -- Next action:运行 task guard finish,随后推送 `dev2` 与新恢复标签并执行 `git pull --ff-only`。 diff --git a/docs/C10-binary-upstream-alignment.md b/docs/C10-binary-upstream-alignment.md deleted file mode 100644 index 6e07c16145..0000000000 --- a/docs/C10-binary-upstream-alignment.md +++ /dev/null @@ -1,132 +0,0 @@ -# C10:播放器二进制与依赖输入以上游为准 - -- 任务 ID:`C10` -- 类别:通用/播放器供应链 -- 用户决定:只同步二进制、AAR、lock、MPV native override 和相关构建输入;不修改 Exo、MPV、IJK Java 播放行为。 -- 状态:C10 二进制/依赖输入和本地 MPV native 残留清理已验证,待 task guard 生成清理提交与 recovery tag。 -- 本地基线:`dev4@80ded1386a108dc8d1b08610c5b616d4d0f1f77f` -- 上游基线:`fish2018/webhtv:main@ec478b0b697422a7785171c7b51a35b7a526564e` -- 回滚锚点:`80ded1386a108dc8d1b08610c5b616d4d0f1f77f`;提交后使用本任务 recovery tag 或 `git revert`。 - -## 目标与边界 - -把以下实际参与播放器供应链的输入恢复为上游版本: - -- Nextlib Media3 extension 的 AAR、sources/module sidecar; -- Media3 ExoPlayer 本地 Maven 产物及校验 sidecar; -- `third_party/media-lock.json`、`third_party/ijk-native-lock.json`; -- Media3 E-AC3 patch 的上游文件名和引用; -- 上游不存在且未被正式 MPV 构建脚本引用的 GPU timing/release-acquire/single-backend 补丁; -- Media3 patch 的上游文件名、构建脚本引用和 lock 条目。 - -明确不在本任务内: - -- `app/src/main/java`、MPV/IJK/Exo Java 策略、`PlayerManager` 和运行时 watchdog; -- `gradle/libs.versions.toml` 中与播放器无关的应用依赖整理; -- `app/src/main/jniLibs` 的 sherpa/实验资产,以及当前已与上游相同的 APK 内 MPV/IJK `.so`; -- MPV/FFmpeg 重新编译。若已打包的 native asset hash 相同,先不做无收益的重建。 - -## 决策与替代方案 - -| 方案 | 处理 | 结论 | -| --- | --- | --- | -| 不变 | 保留本地 AAR、lock 和 stale patch 集合 | 拒绝;会继续产生源码/锁/产物来源分叉,且旧 patch 引用已与上游文件名不一致 | -| 完全覆盖 | 将选定的 AAR、校验文件、lock、patch 和 native override 全部取上游树 | 作为二进制输入基线;能恢复可追溯的上游文件集合 | -| WebHTV 窄适配 | 对选定输入取上游;保留 `build_media_deps.sh` 的 Windows/CRLF patch 应用兼容辅助,仅把实际 patch 名称改为上游名称 | 采用;不改变已发布资产的 Java/API 行为,也避免在本机重建时因换行格式失败 | - -## 证据 - -| 问题 | 来源/版本 | 等级 | 结论 | -| --- | --- | --- | --- | -| AAR、Maven sidecar 和锁是否应作为一个单元 | `upstream/main@ec478b0b697422a7785171c7b51a35b7a526564e` 的 `third_party/maven`、`third_party/media-lock.json` | A | 采用上游同一文件集合和 blob,避免坐标、产物和校验值不一致 | -| MPV native 输入是否已经影响当前 APK 资产 | 当前 `HEAD` 与上游的 `app/src/*/assets/mpv-libs`、`libijkplayer.so` blob 对照 | A | 已打包 MPV/IJK 资产相同;本任务只同步未来重建输入,不宣称重新验证设备行为 | -| 本地独有 v556 输入是否被当前构建脚本需要 | `scripts/build_mpv_native.sh`、`third_party/mpv-native-overrides` 与全仓引用审计 | A | 主构建脚本只使用 stable override;v556 文件和专用验证脚本没有正式构建或发布消费者,按上游文件集合一并删除 | -| IJK lock 精确性 | 本地与上游 `third_party/ijk-native-lock.json` 完整内容对照 | A | 以上游 lock 为准;不在本任务重新构建 IJK native | - -## 实施清单 - -1. 保持已有上游覆盖的 AAR、Maven sidecar、Nextlib module、Media3/Nextlib lock 和 patch 内容。 -2. 删除上游不存在的 `aimagereader-v556` 覆盖、三项本地 MPV patch 和旧 Media3 patch 文件;使用上游同名 Media3 patch。 -3. 将 `scripts/build_media_deps.sh` 的 Media3 patch 路径改为 `media3-exo-pixel-eac3-joc-guard.patch`,保留本地仅用于主机换行兼容的 helper。 -4. 清理只服务于 v556 的离线验证脚本和 `.gitignore` 放行规则;不改变已打包 native asset。 -5. 验证选定路径与 `upstream/main` 的文件集合/blob 完全一致;验证 lock JSON、patch 引用、AAR sidecar 和构建脚本引用;运行受影响的 Java 编译和必要的 artifact/ELF 静态门禁,不重建 native。 - -## 验收与风险 - -- 选定的 AAR、sources/module/POM sidecar、lock 和 patch 输入与上游完全一致。 -- `scripts/build_media_deps.sh` 不再引用不存在的旧 patch 名称;所有 lock 中的 patch 路径均存在且 SHA-256 匹配。 -- 不改变 `app/src/main/java`、Exo FFmpeg renderer 选择、MPV/IJK Java 状态机或已发布 APK native asset。 -- 风险:恢复上游 AAR/patch 输入后,未来重建可能失去本地未上游化的 codec/容错差异;通过完整路径对照和后续目标设备播放回归暴露,不在本任务隐式保留。 -- 风险:IJK lock 从完整 commit 字段回到上游的 tag/短配置记录会降低本地记录粒度;这是“以上游为准”的明确取舍,若要恢复可复现性应另开 lock/provenance 任务。 - -## Checkpoint 1:2026-09-03 二进制输入对齐进行中 - -- 已完成:接管上一轮暂存的 21 个 AAR/lock/patch 改动;确认当前 HEAD `80ded1386a108dc8d1b08610c5b616d4d0f1f77f`、上游 `ec478b0b697422a7785171c7b51a35b7a526564e`。 -- 已确认:当前已打包 MPV/IJK native asset 与上游相同;本地独有文件为 v556 override、三项 MPV patch 和旧 Media3 patch 名称。 -- 当前文件:AAR/lock/sidecar 已按上游提交,3 个未引用 MPV patch、v556 覆盖和专用验证脚本已删除,Media3 patch 已改为上游名称。 -- 下一动作:运行 lock/AAR/patch 引用和 native asset 一致性检查,再做受影响的 Java 编译。 - -## Checkpoint 2:2026-09-03 正式二进制输入完成对齐 - -- 选定输入的暂存索引与 `upstream/main@ec478b0b697422a7785171c7b51a35b7a526564e` 对照无 local-only/upstream-only/content-diff:Media3 ExoPlayer 产物、Nextlib module/sources、Media3/Nextlib patch、MPV stable override 和两份 lock 均一致。 -- Active Nextlib 坐标仍为 `1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r1`;active AAR SHA-256 为 `89ac342c534a862743dde58ffa2803e9fa1eecd2462c25d6d6b1b5f6ea048d00`,未改变;历史 softload 产物已恢复为上游 blob。 -- Media3 patch 已改为 `media3-exo-pixel-eac3-joc-guard.patch`,构建脚本同步改名;`media-lock.json` 中 13 个 patch 路径均存在,两个 lock JSON 可解析,旧 patch 名称引用为 0。 -- 已删除上游不存在且未被正式构建脚本引用的 `aimagereader-v556` 覆盖、三个 MPV patch 和旧 Media3 patch;已打包 MPV/IJK player asset 与上游 blob 相同,未重建 native。 -- 暂存改动不包含 `app/src/main/java`、JNI、APK native asset 或无关 Gradle 应用依赖;`git diff --cached --check` 已通过。 -- 下一动作:运行可用环境下的 MPV asset 门禁和 Mobile/Leanback Arm64 Java 编译,然后完成 C10 提交与 recovery tag。 - -## Checkpoint 3:2026-09-03 验证通过并进入提交收口 - -- MPV native 资产门禁:使用 Python 3.13.2、NDK `28.2.13676358` 的 `llvm-readelf`/`llvm-strings` 运行 `scripts/verify_mpv_native_assets.sh --require-elf`;stable Vulkan shader、P2 generic UV、arm64-v8a、armeabi-v7a、SONAME/DT_NEEDED 与打包规则全部通过。 -- Java 接口验证:`:app:compileMobileArm64_v8aDebugJavaWithJavac --no-daemon` 通过,`BUILD SUCCESSFUL in 2m 3s`;`:app:compileLeanbackArm64_v8aDebugJavaWithJavac --no-daemon` 通过,`BUILD SUCCESSFUL in 1m 39s`。 -- 供应链静态验证:Media3/Nextlib/patch/stable override/lock 选定集合与 `upstream/main` 无文件集合或 blob 差异;两个 lock JSON 可解析,13 个 patch 路径均存在,AAR SHA-256 sidecar 一致,旧 Media3 patch 名称无活动引用。 -- 范围验证:无 `app/src/main/java`、JNI、已打包播放器 `.so` 或无关 Gradle 依赖改动;`git diff --cached --check` 与 task guard scope check 通过。 -- 既有提示:Gradle deprecation 与 `CXX5202` 32 位 native library warning 仍存在,不是本任务引入。 -- 未验证边界:未重建 native、未运行目标电视播放 A/B;当前结论限于上游输入对齐、静态二进制门禁和 Java 接口兼容。 -- C10 实施提交:`79597d2c688a804f2f6f4f3b27815f5c60595da8`。 -- C10 recovery tag:`recovery/C10-binary-upstream-align/20260903111337-79597d2c688a`。 -- 当前状态:正式二进制/依赖输入上游对齐已完成;C10 v556 native 残留清理已完成验证,待生成清理提交,不推送。 - -## Checkpoint 5:2026-09-03 清理本地 MPV native 残留 - -- 已删除:四个 `aimagereader-v556` native override 文件、`scripts/verify_mpv_v556_shader_contract.py` 和 `.gitignore` 对应放行规则。 -- 依据:这些路径不在正式 MPV 构建脚本中,且 `upstream/main` 不包含对应文件;保留它们会使 MPV 原生覆盖仍偏离上游。 -- 保护:`aimagereader-stable` override、已打包 MPV/IJK `.so`、Java 播放逻辑和 C10 已完成的 AAR/lock/patch 对齐不变。 -- 验证:本地/上游 native 输入集合无差异、无 v556 死引用;双 ABI ELF 门禁通过。 -- 下一动作:创建清理提交与 recovery tag。 - -## Checkpoint 6:2026-09-03 清理验证通过 - -- native 输入对照:`third_party/mpv-native-overrides` 与 `upstream/main` 文件集合和 blob 完全一致;`aimagereader-v556`、专用验证脚本和忽略规则均已移除。 -- 死引用检查:`aimagereader-v556`、旧 Media3 patch 名称和三个已删除 MPV patch 均无活动引用;`media-lock.json` 的 13 个 patch 路径全部存在。 -- ELF 门禁:`scripts/verify_mpv_native_assets.sh --require-elf` 使用 NDK `28.2.13676358` 的 `llvm-readelf`/`llvm-strings` 通过 arm64-v8a、armeabi-v7a、SONAME、DT_NEEDED 和打包规则。 -- 范围:本次清理只删除 native 输入夹具/验证辅助和 `.gitignore` 规则,没有修改 Java 播放逻辑、AAR、lock 或已打包播放器 `.so`。 -- 下一动作:执行 task guard finish,创建清理提交和 recovery tag。 - -## Checkpoint 5:2026-09-03 清理本地 MPV native 残留 - -- 已删除:四个 `aimagereader-v556` native override 文件、`scripts/verify_mpv_v556_shader_contract.py` 和 `.gitignore` 对应放行规则。 -- 依据:这些路径不在正式 MPV 构建脚本中,且 `upstream/main` 不包含对应文件;保留它们会使“MPV 原生覆盖以上游为准”不成立。 -- 保护:`aimagereader-stable` override、已打包 MPV/IJK `.so`、Java 播放逻辑和 C10 已完成的 AAR/lock/patch 对齐不变。 -- 下一动作:运行本地/上游 native 输入集合与死引用检查、双 ABI ELF 门禁和文档 diff 检查,然后创建清理提交与 recovery tag。 - -## Checkpoint 7:2026-09-03 v556 清理最终验证 - -- 删除范围:四个 `aimagereader-v556` native override、`scripts/verify_mpv_v556_shader_contract.py` 和 `.gitignore` 放行规则已删除。 -- 上游对照:`third_party/mpv-native-overrides` 与 `upstream/main` 的文件集合和 blob 完全一致;v556、旧 Media3 patch 名称和已删除 MPV patch 均无活动引用。 -- 运行验证:`scripts/verify_mpv_native_assets.sh --require-elf` 通过 arm64-v8a、armeabi-v7a、stable Vulkan shader、SONAME、DT_NEEDED 和打包规则。 -- 范围保证:本次清理未修改 Exo、MPV、IJK Java 逻辑、AAR、lock 或已打包播放器 `.so`。 -- 下一动作:执行 task guard finish,创建 C10 清理提交与 recovery tag。 - -## Checkpoint 4:2026-09-03 C10 文档收口 - -- 已完成提交:`79597d2c688a804f2f6f4f3b27815f5c60595da8`。 -- 已完成恢复标签:`recovery/C10-binary-upstream-align/20260903111337-79597d2c688a`。 -- 任务状态:C10 已关闭;本次只同步正式播放器二进制/依赖输入,未修改 Exo、MPV、IJK Java 行为。 - -## Checkpoint 8:2026-09-04 beta 对齐复核 - -- 已合并:`dev4@902decfcf0f9d84b6b50370206cd18d2dbbbdad9` 与 `origin/beta@bb53d224e084348518bd13c6733d3c359db4ed51`,远端侧新增内容为已验证的 E-SP7 合并提交。 -- 复核结论:合并树相对 `origin/beta` 仅保留 C10 供应链/文档改动;相对 `upstream/main@ec478b0b697422a7785171c7b51a35b7a526564e`,播放器 AAR、lock、patch、MPV native override 和已打包 MPV/IJK `.so` 无差异。 -- 引用检查:v556、旧 Media3 patch 名称和三个已删除 MPV patch 均无活动引用;两份 lock JSON 可解析,13 个 Media3 patch 路径存在。 -- 边界:本次未重建 native、未修改 Java 播放逻辑;沿用 C10 已通过的 Mobile/Leanback Java 编译和 MPV ELF 门禁结果。 diff --git a/docs/C11-beta-sync.md b/docs/C11-beta-sync.md deleted file mode 100644 index 0c809f709d..0000000000 --- a/docs/C11-beta-sync.md +++ /dev/null @@ -1,130 +0,0 @@ -# C11:合并 origin/beta 到 dev3 并评审修复 - -## Recovery anchor - -- 目标:将最新 `origin/beta` 合入 `dev3`,评审合并引入的改动,修复缺陷并验证通过,然后提交、推送、向 beta 发起 PR。 -- 第一父基线:`dev3@9fc6ca2457883ef2e846b64f7c6119cc3694fb79`。 -- beta 目标:`origin/beta@a90be6678a`。 -- 合并基线:`97e980c8bda8af2187ac7e678ca59d5c78dbd40e`。 -- 任务守卫:`C11-beta-sync`,模式 `upstream`,范围 `app/**`、`docs/**`、`scripts/**`、`third_party/**`、`.gitignore`。 -- 任务开始前脏路径:`.claude/worktrees/agent-ac882912b5956f97d/`(受守卫保护,不进提交)。 -- 回滚锚点:`recovery/beta-sync-20260904-1526-pre/20260904-072657`(合并前 HEAD)。 -- 排除项:不改 FFmpeg/Media3/MPV/JNI 源码与二进制所有权;`third_party` 下的变更全部由 beta 带入,本任务未手工改动。 - -## 合并结果 - -`git merge --no-ff origin/beta` 自动合并成功,**零冲突**,83 个文件变更。 - -本地两个未推送提交(`5a1e4f0f76`、`9fc6ca2457`:详情缓存三道挡板)完整保留。已核实 beta 未触及 -`SiteApi.java`、`CatWebEvent.java`、`CatAction.java`、`VodDetailCache.java`,无静默覆盖。 - -冲突标记扫描与 `git diff --cached --check` 均通过。已删除类 `com.fongmi.android.tv.update.GithubProxy` -在全仓无残留引用(12 处调用点全部指向 `utils.GithubProxy`)。 - -## 评审发现与修复 - -### 1. mobile 加速源列表首次点击需先抢焦点(本次合并引入) - -`app/src/mobile/res/layout/adapter_github_proxy.xml` - -beta 的 `55a6ed3531` 给 `@id/text` 与 `@id/remove` 加了 `android:focusable="true"`, -后续 `6d8e97763c` 只删掉 `focusableInTouchMode`。这与本地 `d3f2fd31b4` 立的契约冲突—— -`AboutDialogLayoutTest.mobileGithubProxyActionsDoNotRequireFocusBeforeClick` 断言手机端不得 -可聚焦,因为首次点击应直接生效而非先抢焦点。`GithubProxyAdapter` 用 `setOnClickListener` -绑定,触控路径不需要 `focusable`。 - -修复:移除两处 `android:focusable="true"`,保留 main(电视)布局的可聚焦属性不变。 - -### 2. 迁移把刚合并出的代理列表抹成一条 - -`app/src/main/java/com/fongmi/android/tv/setting/Setting.java` - -`migrateLegacyGithubProxy()` 先 `addSources` 合出完整列表,末尾又 -`if (!hadGlobalSources) putGithubProxy(url)` 用单个 URL 覆盖回去。旧版用户选了 `ghfast` -升级后,5 个内置源被抹成只剩一条。 - -同时 `legacyGithubProxySources(String selectedUrl)` 完全忽略入参,用户当年选中的地址 -没有排到列表首位(即生效源)。 - -修复:`selectedUrl` 置于候选源首位后并入,去掉覆盖那一行;方法改用 `List` 拼装, -避免自填地址为空时 `String.join` 产生空元素。 - -### 3. 迁移存在丢写窗口 - -同文件。`migrateLegacyGithubProxy()` 由 `getGithubProxy()`/`getGithubProxyMode()` 两个 getter -调用,先读后删旧键,无同步。「关于」对话框(主线程)与 `Task.submitLarge` 的探测线程可能 -同时进入,两边都看到旧键存在,后写的 `putGithubProxy` 盖掉前一次已扩好的列表。 -窗口只有升级后第一次读,但那是唯一一次机会。 - -修复:方法改为 `private static synchronized`。 - -### 4. 备份丢弃迁移所依赖的旧键 - -`app/src/main/java/com/fongmi/android/tv/bean/Backup.java` - -beta 把 `update_github_proxy`、`update_github_proxy_url`、`update_github_proxy_mode` 从 -`isAppPref` 白名单移除。这三个键虽已废弃,但正是迁移的输入:从旧版备份恢复到新版时它们 -不再落盘,`migrateLegacyGithubProxy()` 永不触发,用户的代理选择被丢弃而非迁移。 -代价仅为三个死键随备份走。 - -修复:恢复三个旧键,并同步把 `BackupPreferenceFilterTest` 的断言改回 `assertTrue` -(beta 侧连同断言一起改成了 `assertFalse`)。 - -### 5. 段落身份跨 provider 归一导致片尾永久失效 - -`app/src/main/java/com/fongmi/android/tv/player/IntroSkipPlayback.java` - -合并新增的别名归一把 TheIntroDB 的 `credits#0` 折成 `outro`,与 IntroDB 的 `outro` -撞成同一个 `OUTRO|outro`。当两家片尾边界差异大到服务层 `overlaps()` 判为不同段 -(起点差 > 3s 且重叠 < 60%)时,去重不合并、计划里保留两段;自动跳过其中一段后写入 -`skipped`,另一段被 `isSegmentHandled` 永久吞掉,用户再也跳不到真正的片尾。 - -修复:id 保留 provider 维度(`kind|provider|identity`)。别名归一只消解同一家内部的 -字段名差异;同段合并交给服务层 `addDeduped`/`overlaps` 按时间轴判断,那里才有边界信息。 -新增 `IntroSkipPlaybackStateTest.differentProvidersDoNotShareSegmentIdentity` 钉住该契约。 - -### 6. 更新设置弹窗电视端焦点死路(本次合并引入) - -`app/src/main/java/com/fongmi/android/tv/ui/dialog/UpdateSettingsDialog.java` - -GitHub 代理面板从该弹窗移除后,`focusPrimary` 仍无条件 `binding.ociMirror.requestFocus()`。 -GitHub 标签下 `ociPanel` 是 `GONE`,对隐藏视图 `requestFocus` 恒返回 false,遥控从标签条 -向下走不动,保存按钮再也聚焦不到。`focusLastControl` 同理,从保存按钮向上回不到标签条。 - -修复:GitHub 源时 `focusPrimary` 跳到保存按钮、`focusLastControl` 回到标签条。 - -## 评审为 clean 的区域 - -- `utils/GithubProxy` 的 `apply`/`config`/`sources` 语义、`GithubProxyAdapter` 的 - `ConcurrentHashMap` 用法、`AboutDialog.probeLatency` 的线程编排(工作在 - `Task.submitLarge`,结果经 `App.post` 回主线程,不阻塞 UI)。 -- `IntroSkipService` 的锁条带索引、`safeAdd` 溢出哨兵、`integer()` 夹取、缓存身份与替换语义。 -- `IntroSkipPlayback` 的 `beginConfirmation`/`cancelConfirmation`/`completeConfirmation` - 取消流与 `reset()` 顺序。 -- mobile/leanback `advanceEpisode`/`checkPrev` 的 revPlay offset 方向与提示文案映射自洽。 -- `SearchSourceVisibility.shouldShow(boolean)` 签名收窄,两个调用点与四个测试一致。 - -## 未纳入本任务的既有问题 - -- `hasNextEpisode()`(mobile:10466、leanback:10723)未随 revPlay 取反,但全仓无调用方, - 是合并前既有的死代码,不属本次范围。 -- `ReaderHistory.find()` 对未打 `mediaType="reader"` 的旧行返回 null,属 beta 侧 - `b235cee740` 的有意取舍("宁可让旧进度失效"),会让既有阅读进度一次性重置,建议在 - 发布说明中提及。 -- `FfmpegVc1SupportTest` 2 项失败为 C10 上游二进制对齐的已知取舍,见 - `docs/pull-merge-beta-20260904.md`,与本次合并无关。 - -## 验证 - -- `:app:compileMobileArm64_v8aDebugJavaWithJavac` + `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`:BUILD SUCCESSFUL。 -- 修复前基线 `:app:testMobileArm64_v8aDebugUnitTest`:4123 项 3 失败 - (`AboutDialogLayoutTest.mobileGithubProxyActionsDoNotRequireFocusBeforeClick` 为合并引入, - `FfmpegVc1SupportTest` 2 项为已知 C10)。 -- 修复后 `:app:testMobileArm64_v8aDebugUnitTest`:**4124 项 2 失败**,仅剩 - `FfmpegVc1SupportTest`。合并引入的失败已消除;用例总数 +1 即新增的 - `differentProvidersDoNotShareSegmentIdentity`,该用例通过。 -- 修复后 `:app:testLeanbackArm64_v8aDebugUnitTest`:**3311 项 2 失败**,同为 - `FfmpegVc1SupportTest`。 -- `git diff --cached --check` 通过;全仓无冲突标记残留。 -- 已知失败归属:`FfmpegVc1SupportTest` 属 C10 上游二进制对齐取舍 - (`docs/pull-merge-beta-20260904.md`),该测试文件与被测路径均未被本次合并或本次修复触及。 diff --git a/docs/C12-beta-sync.md b/docs/C12-beta-sync.md deleted file mode 100644 index f11097dfe2..0000000000 --- a/docs/C12-beta-sync.md +++ /dev/null @@ -1,85 +0,0 @@ -# C12:合并 origin/beta 到 dev3 并复审全部本地改动 - -## Recovery anchor - -- **目标:** 将远端 `origin/beta` 最新代码合入 `dev3`,审查 beta 变更与本地已提交未推送的广告规则批量导入/启停功能;发现问题时修复并验证,复审直至无阻断问题,然后提交、推送 `dev3`、创建中文 PR 到 `beta`,并拉取远端最新代码。 -- **任务守卫:** `C12-beta-sync`,模式 `upstream`,范围 `app/**`、`docs/**`、`scripts/**`、`third_party/**`、`.gitignore`。 -- **任务开始时间:** 2026-09-06 01:05,`Asia/Shanghai`;任务开始前工作树干净,无受保护 dirty 路径。 -- **本地基线:** `dev3@60fc55e18cf755d25dc9c140908188fb21898c44`,提交为本地未推送的 `feat(ad rule): implement batch import and enable/disable for imported rules`。 -- **beta 目标:** `origin/beta@cebe42b190d5d7f1306e4ea3d0b6d833112ad464`。 -- **合并基线:** `1a41a6f0ae03d418eb8cdf55a88f1b8bbcd0bf50`。 -- **回滚锚点:** 合并前 HEAD `60fc55e18cf755d25dc9c140908188fb21898c44` 及恢复标签 `recovery/ad-rule-bulk-enable/20260906004930-60fc55e18cf7`;在未提交阶段使用 `git merge --abort`,提交后按本任务恢复标签回退/反向提交。 -- **最便宜决定性验证:** 先运行受影响的 Mobile/Leanback Java 编译与新增/修改单测;随后按审查发现补充最小回归测试,不运行与本任务无关的全量矩阵。 - -## 当前合并状态 - -- `git fetch --prune origin beta dev3` 已完成;远端 beta 实际头为上列完整 commit。 -- `git merge --no-ff --no-commit origin/beta` 已完成,自动合并、零未合并路径;合并结果暂存,尚未提交。 -- 合并结果保留本地广告规则批量功能文件的 HEAD 版本,但 beta 同样修改了这些文件;因此本任务把这些路径视为冲突风险路径,必须逐文件复审,不能仅凭 Git 无冲突结论跳过。 -- 合并后 staged 生产/测试/documentation 路径共 35 个;`git diff --cached --check` 已通过。仓库内已有示例/许可证文本中的分隔线不作为冲突标记。 - -## 研究与决策边界 - -### 决策问题 - -最终树是否同时保留 beta 的弹幕匹配记忆、触控优化、TMDB 焦点、MPV duration 恢复和偏好备份修复,并且不回退本地广告规则批量导入/启停契约;尤其要确认同文件合并选择没有丢失任一方的独立行为。 - -### 证据与适用性 - -| 证据 | 等级 | 用途/结论 | -| --- | --- | --- | -| 本地 Git 完整 commit、parent、最终树与 staged merge diff | A | 作为实现范围、血缘、合并结果和回滚依据;所有 beta-only commit 在下表逐一列出。 | -| beta 分支提交中的测试、设计文档与后续修复提交 | A/B | 用于判断意图、回归覆盖及是否被后续提交取代;不以 commit subject 单独作为正确性证据。 | -| WebHTV 当前调用方、布局、存储类和现有测试 | A | 用于核对线程/生命周期、持久化、TV 焦点、缓存 key 和播放器状态契约。 | -| Android/Media3 官方行为 | A | 仅在代码审查发现 API 生命周期或播放器状态语义疑问时按具体符号核对;本任务不改变依赖、ABI 或 native 二进制,故不展开无关供应链研究。 | -| 设备实机播放/触控验证 | 未取得 | 当前环境无授权设备证据;不把 Java 编译/单测扩大为完整设备验收,在结论中明确记录。 | - -### 备选方案 - -1. **不合并 beta:** 不满足用户要求,排除。 -2. **盲目接受 beta 的同文件版本:** 可能丢失本地广告规则批量功能,排除。 -3. **保留最终合并树并按契约逐处复审,必要时做最小适配:** 推荐;保持 beta 能力、本地已完成能力、可测试性和可回滚性之间的最小变更。 - -## beta 完整提交台账(相对合并基线) - -每个 commit 均已纳入最终树审查范围;合并提交也不省略。 - -| 完整 commit | 摘要 | disposition | -| --- | --- | --- | -| `ad702b041f18a1a40edefae77df8cafc86f02f2a` | feat: add leanback touch optimization mode | candidate/included,需审查 TV 焦点与生命周期 | -| `eab79fba9251dd6d07283fe149d7ce4f6874554d` | Merge pull request #211 | integration-only,最终树保留 | -| `317490478fc89917287d946ca7414cf5c4969794` | ui: move touch optimization below personal settings | included,需审查设置入口与备份 | -| `868e1a9020047141edeb7a72b3c6a0758afc578a` | Revert incorrect test assertion in TmdbDetailActivityLayoutTest | test-correction,需核对最终断言 | -| `97a7dd8898c0635fe13b696402c25c57acbfb3be` | feat: remember manual danmaku search intent | included/combined,需审查请求身份与缓存 | -| `83176f4d2a3a1f6bc6bbcaf3912fcf854189991b` | Merge remote-tracking branch origin/beta into dev1 | integration-only | -| `36b04d1a51a0ef2dab4fe304831944362754ed42` | feat: 实现弹幕手动匹配记忆功能 | included/combined,需审查 key 隔离与持久化 | -| `78b36585ef0ab9063eb3d87e53c9c63626c7a008` | feat: move touch optimization to personal settings | included/combined | -| `6831f897a8927ab5d1da7b05c998d329409247e6` | Merge remote-tracking branch origin/beta into dev2 | integration-only | -| `727e4d640e986d344b0d5dff1d24ac1bb4ec0c75` | Merge pull request #213 | integration-only | -| `db44647432ea1ac094298cebca9f535f73dc8202` | Merge pull request #214 | integration-only | -| `3d6cb55868501592348fbb3eb02742591fb0987d` | Center focused TMDB detail episode in list mode | included | -| `399023ced875b3bdb298afd7b09219c8e0eef57e` | Fix danmaku auto-line selection | included | -| `db022097bff5d015934288ed2258ff28a18c61d0` | Merge remote-tracking branch origin/beta into dev4 | integration-only | -| `a970acb89834de182cf2227883673fde79208f6a` | fix: repair beta merge and center TMDB episodes | included/repair | -| `bc5f9b42e090dd7fd303a56c052618dcb5506047` | Merge pull request #215 | integration-only | -| `97a5e3e8a59e82c88c1747ecbec7055f8287a9c4` | fix(mpv): preserve unknown duration and recover timeline on restart | included/partially superseded by readback follow-up | -| `5a479aa6962d3082dff35b4f330ef3232ad96961` | 合并 beta 并修复弹幕搜索记忆与偏好备份 | included/repair | -| `b077fd23adf0478a946cbdfa62c0253e8c031cb6` | beta: fix keyword binding..., retain overlay preference | superseded by `8ce1fbd...` final tree; inspect final result | -| `0ce6f13e6d597953ea02132eee50ba553191f14f` | fix(mpv): recover missing duration during playback state refresh | included/repair | -| `8ce1fbd783b6cf90e9332391f37d1bb68c1afe50` | beta: fix keyword binding..., retain overlay preference, resolve conflicts | included/final repair | -| `53e4f53788cf40832cdb63413ebb1eba13f6d4fb` | Merge pull request #216 | integration-only | -| `cebe42b190d5d7f1306e4ea3d0b6d833112ad464` | Merge pull request #217 | integration-only/final beta head | - -## 审查轮次与验证记录 - -### Checkpoint 1:合并完成,待逐簇审查 - -- **完成:** 获取 beta、建立 guard、完成无冲突 merge;本地广告规则 commit `60fc55e18cf755d25dc9c140908188fb21898c44` 仍为第一父系本地改动。 -- **工作区:** `dev3`,HEAD 仍为 `60fc55e18cf755d25dc9c140908188fb21898c44`,合并结果暂存,`MERGE_HEAD` 由 Git 管理。 -- **未决风险:** 同文件广告规则功能是否丢失 beta 的独立修复;弹幕缓存/异步回调;触控优化对 TV 焦点和输入控件;MPV duration 状态;备份兼容。 -- **下一动作:** 对上述功能簇进行只读 adversarial review,并运行最小受影响测试/编译。 - -## 结论(待完成) - -- 当前尚未允许提交、推送或创建 PR。 -- 只有在所有重要发现完成修复、验证结果可复现、最终复审无阻断问题后,才执行 task guard finish、推送 `dev3`、创建中文 beta PR,并再次 `git pull --ff-only`。 diff --git a/docs/C13-beta-sync-review.md b/docs/C13-beta-sync-review.md deleted file mode 100644 index 59a1fc6ae5..0000000000 --- a/docs/C13-beta-sync-review.md +++ /dev/null @@ -1,73 +0,0 @@ -# C13:dev1 合并 beta 最新代码并复评全部未推送改动 - -## Recovery anchor - -- **目标:** 将 `origin/beta` 最新代码合入 `dev1`,复评本地已提交未推送的手机版“外观与语言”入口、个性设置布局崩溃修复,以及 beta 在此前 PR 后新增的播放器、音频、标题识别和构建资产;发现问题即最小修复并重新验证,随后提交、创建恢复标签、推送 `dev1`、创建中文 PR 到 `beta`,最后拉取远端最新代码。 -- **任务守卫:** `beta-sync-review-dev1-20260907`,模式 `standard`,范围 `app/**`、`docs/**`、`gradle/**`、`gradlew`、`scripts/**`、`third_party/**`;初始受保护 dirty 路径为 `gradlew` 的执行位,已显式保留。 -- **本地基线:** `dev1@57932a7e7055489403d3e170bba706048df0e065`;未推送提交包含手机版外观与语言入口及个性设置布局崩溃修复。 -- **beta 目标:** `origin/beta@161b190fac6c304c240bfb3b142b4dfa531fb1d5`;共同祖先为 `b158b71b97ca7abc76cbc2cf9e4e4784ede8af43`。 -- **合并状态:** 针对上述最新 ref 重新 `git merge --no-commit --no-ff` 自动完成,零未合并路径;相对 beta 的最终本地差异为手机版个性设置布局修复、测试保护修复及本任务文档。 -- **回滚:** 未提交时可 `git merge --abort`;提交后按本任务恢复标签整体回退。合并前可回退至 `recovery/mobile-personal-settings-crash/20260907131223-57932a7e7055`。 - -## 评审范围和结果 - -### 本地已提交未推送改动 - -- `c344741692b3cb4e7dda473a8dbebb433ea50e65` 将手机版设置页分散的界面缩放、主题色、图片尺寸和语言四个入口合并为一个“外观与语言”入口。 -- 该变更只改 `SettingFragment.java` 与 `fragment_setting.xml`,调用仓库中已经存在并由 TV 端共用的 `AppearanceDialog`;选择后的语言/主题事件、界面重建和图片尺寸刷新仍由该对话框负责。 -- 与 beta 的新增文件列表无交集,合并结果相对 beta 仍只有上述两个功能文件和本轮修复/记录,未发现行为回退。 - -### beta 增量 - -- beta 在旧目标 `1f37b723f9ccdc02bc4cc44b6490d1c2cc547e2c` 之后又进入四个最新集成提交:`f0e9ab75bd0770b17db9f9bcd6101026d2c63466`、`7e6fd548190834408e40332e8392d2e379536511`、`161b190fac6c304c240bfb3b142b4dfa531fb1d5`,以及其父链中的 `fca898f9d940816c378ec37785184f46b334cd4d`。这些提交带入音频策略、MPV/Exo 兼容性、LitePan 标题识别、C4 供应链资产和 PR224 冲突收口;本轮以最终树复核,不把旧 merge 结果误称为最新 beta。 -- `docs/beta-sync-review-20260907-dev3.md`、`docs/C4-main-upstream-merge.md` 及对应任务记录已覆盖这些上游播放器/标题/供应链阶段的验证边界;本轮确认最终 beta 树继承且无未合并路径。 - -- `dev4` 上次已合入 beta 的 PR #220 对应 `c78b7cb11550209b2c0f884441e4a0fb65784e05`。其后的播放器首播续播、MPV 时长恢复、进度绑定、站点主题及 PR #218 冲突处理均已有独立提交/恢复标签和验证记录;本轮复核最终树、对应测试和合并血缘,不重复已成功的设备场景。 -- 广告规则批量导入/启停来自已合入的 PR #219,最终实现和测试未被后续合并改写;本轮聚焦测试重新覆盖导入存储、规则来源、批量操作和焦点保持。 -- PR #218 的冲突修复 `016edd1d50fede43e119613737fe2288c99e45ba` 已用双端 Arm64 Java 编译验证主题事件语义;最终树保留 mobile 的 `LANGUAGE/THEME` 和 Leanback 的 `LANGUAGE/UI_SCALE/THEME`。 - -## 评审发现与修复 - -1. **`gradlew` 可执行位回退。** beta 历史把包装器模式带回 `100644`,会让标准 `./gradlew` 调用失败;恢复为 `100755`,脚本内容和镜像配置不变。 -2. **语言事件测试保护退化。** 旧断言把两个事件写成一个相邻字符串,最新改动还只验证包含整体条件,可能漏掉单独删除 `LANGUAGE` 的回归;改为分别断言 mobile 的 `LANGUAGE/THEME`、Leanback 的 `LANGUAGE/UI_SCALE/THEME` 和 `recreate()`。 -3. **旧合并目标导致评审不完整。** 首次发现工作树针对 `1f37b723f9ccdc02bc4cc44b6490d1c2cc547e2c`,而远端已推进到 `161b190fac6c304c240bfb3b142b4dfa531fb1d5`;已中止旧合并并按最新 ref 重算合并,不提交遗漏 LitePan/音频/供应链增量。 - -## 验证与最终复评 - -- 第一轮旧 beta 目标的 Mobile Arm64 聚焦测试曾通过,但因目标已过时不能作为本轮闭环证据。 -- 最新 beta 合并树修复后,Mobile Arm64 定向测试 59 项全部通过(失败 0、错误 0、跳过 0),覆盖标题识别、AI 标题解析、缓存、站点主题、Exo 音频策略、MPV 音频策略和诊断;Mobile/Leanback Arm64 Java 编译均通过(`BUILD SUCCESSFUL in 1m 21s`)。未将 Java 编译扩大为真实设备播放结论。 -- `git diff --cached --check`、无未合并路径和 `gradlew` index 模式均作为提交前门禁;修复后再次审查最终树。 - -## 完整 beta 提交台账(相对共同祖先) - -| 完整 commit | disposition | -| --- | --- | -| `60fc55e18cf755d25dc9c140908188fb21898c44` | 已由 PR #219 合入;广告规则批量导入/启停,最终树与聚焦测试覆盖 | -| `1a95bf977543bca2de9f62e8005506520ae8608b` | 已合入;主题事件即时重建,后续冲突修复保留语义 | -| `11256015f307d5abfaa28f1b8338f99cb95f1207` | 集成承载,已由 PR #219 合入 | -| `99d0dad351f851bc0dd58ee0de37f3a3e996e6d3` | dev1 合并承载,最终树已复核 | -| `74e60ef606ce6b64f5e3dab21230213e180cc504` | 已合入;移除 Mobile 重复主题订阅,BaseActivity 仍负责重建 | -| `4242a06987b1080a923e14800380a866c95e8f67` | 站点主题中间修复,已被后续窄实现取代 | -| `85217abc610cad7d5c623f365ecde8197bd3beb1` | 构建清理中间提交,最终树保留所需结果 | -| `c1d8b10807693baba69bbcc51177e704d34898df` | Gradle 下载排查中间提交,最终配置由后续提交取代 | -| `8d104591b32732f08ec8dfd871c243592f285155` | Gradle 下载排查中间提交,最终配置由后续提交取代 | -| `3a2d7723666eb2f331bec2a2b22cbcaa3c617aef` | 站点主题中间实现,已被后续修复取代 | -| `1558c90fb1c27cbb0f2325ea845640288986a527` | 站点主题中间修复,已被后续窄实现取代 | -| `66b5505282eb3de95538a1eab39ad9196d27ba22` | 已合入;Mobile EXO 首播续播位置一次注入,聚焦测试和编译覆盖 | -| `33d9ef2393747dc689fb735a9c91de2ca4f6c0cb` | 已合入;通用浅色弹窗 surface 跟随主题,最终资源编译通过 | -| `f627d99e88078ccd8760986cbae65cc4a3eb5adc` | 站点主题中间实现,已由 `7a380654...` 取代 | -| `7a3d815ba566ed5ce3e0a8aa76561ad5e67eaf00` | 已合入;Gradle 镜像和广告规则评审;包装器模式在本轮最终恢复 | -| `e6833fb8e068bc807529f8e98994b29294e5fae6` | 已由 PR #219 合入;Leanback 变量冲突修复 | -| `6b0490907da3e0b09a6563c1572cf283a3ae49d3` | 已合入;MPV 缺失时长恢复,聚焦测试和编译覆盖 | -| `7a380654329fa4800c55799440b5dd0798e22167` | 已合入;SDK 28 站点主题窄实现,历史设备探针与本轮聚焦测试覆盖 | -| `6a0b967dd533250d061e9457a93b4c15a724b5f5` | PR #219 集成承载,已合入 beta | -| `cb386895da22a8836bb5587cafff3ef10f89a4fa` | 已合入;播放器重建后进度绑定,已有模拟器证据,本轮编译覆盖 | -| `912208261e4e342ced009b1a0b71feed4855a01d` | dev2 合并承载,已由 PR #221 合入 | -| `c78b7cb11550209b2c0f884441e4a0fb65784e05` | PR #220 集成承载,是本轮增量复评起点 | -| `7a93ec7336b57dcf418c30b399862193e6991446` | PR #221 集成承载,已合入 beta | -| `016edd1d50fede43e119613737fe2288c99e45ba` | PR #218 冲突修复;双端主题事件语义与编译已验证 | -| `cc88e278a8ddc2088a82a68dbf1671e419606a29` | PR #218 最终集成;本次 beta 合并目标 | - -## 下一动作 - -定向验证已通过;下一步由任务守卫原子提交当前合并、测试修正、包装器模式和本文档并创建恢复标签;随后推送 `dev1`、创建中文 PR 到 `beta`,再拉取远端最新状态。 diff --git a/docs/C14-beta-sync-review-dev4-20260911.md b/docs/C14-beta-sync-review-dev4-20260911.md deleted file mode 100644 index d8cf5d1c2d..0000000000 --- a/docs/C14-beta-sync-review-dev4-20260911.md +++ /dev/null @@ -1,99 +0,0 @@ -# C14:dev4 合并 beta 最新代码并复评未推送改动(2026-09-11) - -## Recovery anchor - -- **目标:** 将 `origin/beta` 最新代码合入 `dev4`,评审合并树与已提交未推送的 `08b8bbc5401c32cc562d403e96f95ece3db5c029`,修复所有本任务发现的问题,验证并复评至通过,提交、推送 `dev4`、创建中文 PR 到 `beta`,最后拉取远端最新代码。 -- **状态:** 合并、修复、双端编译与聚焦复评已通过,待任务守卫提交合并单元、推送和创建 PR。 -- **分支/基线:** `dev4@08b8bbc5401c32cc562d403e96f95ece3db5c029`;`origin/dev4@4e303d30d34c499e1676a3481b4b45358618638d`。 -- **beta 目标:** `origin/beta@1e7d79ef29fd7568f376fe571bde0bd4cd7c6838`,相对当前基线 31 个提交,共同祖先为 `4e303d30d34c499e1676a3481b4b45358618638d`。 -- **保护路径:** `build-tv-debug.sh`;任务启动时唯一既有 dirty 路径,不纳入本任务。 -- **回滚:** 未提交合并结果前使用 `git merge --abort`;提交后使用本任务恢复 annotated tag 或 `git revert -m 1 `。 -- **唯一下一步:** 使用 `task_guard.sh finish` 提交当前无冲突合并树并创建恢复标签。 - -## Authority and scope - -- 用户明确授权:拉取远端 `beta` 最新代码、合入当前 `dev4`,审查全部当前改动(含 `08b8bbc...`),发现问题即修复并验证、再次复评,随后提交、推送、创建中文 PR,并再次拉取远端最新代码。 -- 任务守卫:`C14-beta-sync-review-dev4-20260911`,模式 `standard`。 -- 声明路径:`app/**`、本文档、`docs/upstream-player-dependency-merge-assessment-2026-08-20.md`。 -- 排除范围:不升级或修改 FFmpeg、Media3、MPV/IJK native、JNI、AAR、lock、patch、APK、`.so` 和 `build-tv-debug.sh`;仅评审 beta 在应用层的最终树影响及本地 EXO 上游恢复提交与其 App 接线。 -- 合并前已确认:没有未完成的 task guard;没有提交冲突(`git merge-tree --write-tree HEAD origin/beta` 成功);任何实际合并仍须保留本地 task guard 修复和受保护 dirty 文件。 - -## Source commit ledger - -以下为 `HEAD..origin/beta` 的完整 31 个提交;合并后逐项以最终树、父提交关系和相关测试复审,不因 merge commit 或已有历史评审而省略记录。 - -| # | 完整 commit | 父提交 | 内容 | 初始 disposition | -| ---: | --- | --- | --- | --- | -| 1 | `619ed77dabd66bb0e7cdb492be483451aedccd25` | `ca4d79dc389294d469301492fa2e92be77919c84` | 设计 TweakCN 风格主题颜色系统 | 已在 beta,合并后复核最终树 | -| 2 | `341da7f9d099056c375c7477364a9b06dcbedf1b` | `619ed77dabd66bb0e7cdb492be483451aedccd25` | 移动端主题配置编辑器 A+B | 已在 beta,合并后复核最终树 | -| 3 | `9c77828cfd3f248d029e6f36b6c5030122b36fc2` | `341da7f9d099056c375c7477364a9b06dcbedf1b` | 原生语义主题 token 绑定 | 已在 beta,合并后复核最终树 | -| 4 | `0316262eeb70cd1831fa686012c2304dc6da4b98` | `9c77828cfd3f248d029e6f36b6c5030122b36fc2` | 主题导入导出与 TweakCN 适配 | 已在 beta,合并后复核最终树 | -| 5 | `1c0bd8e1d75e0c8d3a2f420a39fcb5b23e96f48b` | `e09e785d87b95f9da249825a79f7ad0a213f61b6` | TV 详情页照片焦点导航修复 | 已在 beta,合并后复核最终树 | -| 6 | `cb7229964f77cd43c68157111dae639d3de88531` | `0316262eeb70cd1831fa686012c2304dc6da4b98` | 完成移动端主题导入导出 | 已在 beta,合并后复核最终树 | -| 7 | `32ace88636f90f507288df859c325afd493b3e89` | `cb7229964f77cd43c68157111dae639d3de88531` | TV 主题焦点绑定与本地 catalog/cache | 已在 beta,合并后复核最终树 | -| 8 | `400c059369ead1041e1afbd9fc589bddd51490c3` | `1c0bd8e1d75e0c8d3a2f420a39fcb5b23e96f48b` | 炫彩详情 dismiss 后焦点恢复 | 已在 beta,合并后复核最终树 | -| 9 | `e4b6e9e169ac155bb55540ed6de551c71d9287a0` | `32ace88636f90f507288df859c325afd493b3e89`、`253ffaa4f7908d0b90fa092f020cb20611aaadb5` | 同步 beta 并修复主题导入安全边界 | merge,复核冲突解决与安全边界 | -| 10 | `ae64c42369a6b2ee660fee86cbe47c40382df667` | `e4b6e9e169ac155bb55540ed6de551c71d9287a0` | 记录 beta 同步关闭证据 | 文档,复核不污染运行时 | -| 11 | `74e572173fcc9ab7e274a94b6245b8de90b217c1` | `253ffaa4f7908d0b90fa092f020cb20611aaadb5`、`ae64c42369a6b2ee660fee86cbe47c40382df667` | 合并 PR #238 | merge,复核最终树 | -| 12 | `f89c301d65c0c8d4bf6e44a3eab11e6c9c9266dd` | `400c059369ead1041e1afbd9fc589bddd51490c3` | 炫彩详情长按集焦点恢复 | 已在 beta,合并后复核最终树 | -| 13 | `4d42cc34c8a5ba74bed68455c5ab1c9761fd14ad` | `f89c301d65c0c8d4bf6e44a3eab11e6c9c9266dd`、`74e572173fcc9ab7e274a94b6245b8de90b217c1` | 同步 beta 并完成 dev3 TV 焦点复评 | merge,复核最终树 | -| 14 | `1658013bd5241d112d74f6bda663e28723638e23` | `4d42cc34c8a5ba74bed68455c5ab1c9761fd14ad` | 播放速度选择对话框替代循环切换 | 已在 beta,复核 TV 焦点/状态保存 | -| 15 | `6eb76c5bca0b00e69520256fffee900bc9ed8dac` | `fbb7197e489a233686208fc948c821586f88a91a` | TV 首页广告拦截快捷入口 | 已在 beta,复核入口和可见性 | -| 16 | `a488d11b62dd2d05a9e5109d5a5f065959c3dd05` | `6eb76c5bca0b00e69520256fffee900bc9ed8dac` | TV 首页站点注入开关快捷入口 | 已在 beta,复核入口和状态 | -| 17 | `bfef295dce3174ceb8cc41882979a70795bd03f5` | `a488d11b62dd2d05a9e5109d5a5f065959c3dd05` | 从首页快捷入口打开站点注入编辑器 | 已在 beta,复核导航目标 | -| 18 | `dc9003e3134e1c3a82b5c4f964567e7c0d479beb` | `1658013bd5241d112d74f6bda663e28723638e23` | TV 速度选择保持当前焦点项可见 | 已在 beta,复核对话框焦点 | -| 19 | `6ac335c020ce7a168d8e5a25365d3774e6a3d77d` | `dc9003e3134e1c3a82b5c4f964567e7c0d479beb`、`5246948300c176d198b284167b02d2f576c29bb2` | dev3 合并 beta,Backup 冲突取 beta APP_PREFS | merge,重点复核 Backup 数据兼容 | -| 20 | `d85060eb91497ebd5fa94764bb23ae6da9f360fa` | `bfef295dce3174ceb8cc41882979a70795bd03f5`、`5246948300c176d198b284167b02d2f576c29bb2` | dev2 合并 beta 并复评 | merge,复核最终树 | -| 21 | `7dd78510730895282d599becdd912fb64199e418` | `6ac335c020ce7a168d8e5a25365d3774e6a3d77d` | Backup 冲突收口并保留语音广告规则 | 已在 beta,复核数据与规则共存 | -| 22 | `76f2be03ca680687f7d902dd067d235988a0873b` | `5246948300c176d198b284167b02d2f576c29bb2`、`d85060eb91497ebd5fa94764bb23ae6da9f360fa` | 合并 PR #245 | merge,复核最终树 | -| 23 | `1b7c9f70f9db8cf4e33cdf2ae756d29024a26181` | `76f2be03ca680687f7d902dd067d235988a0873b`、`7dd78510730895282d599becdd912fb64199e418` | 合并 PR #247 | merge,复核最终树 | -| 24 | `79fd8c6fb5707798d372050d5f318911ad7b533c` | `7dd78510730895282d599becdd912fb64199e418` | TV 质量与线路焦点互相导航 | 已在 beta,复核焦点边界 | -| 25 | `5bd95ba0c96232015d0ab551f5b085a978431cb0` | `5246948300c176d198b284167b02d2f576c29bb2` | 音频诊断验证注释 | 文档/注释,复核无行为变化 | -| 26 | `30e90403591ab9006050dda3ebfc876f4d1d6c10` | `5bd95ba0c96232015d0ab551f5b085a978431cb0`、`1b7c9f70f9db8cf4e33cdf2ae756d29024a26181` | beta 分支合并 | merge,复核最终树 | -| 27 | `db9210136006a5e880d320a17ce12eb49d18c0ff` | `79fd8c6fb5707798d372050d5f318911ad7b533c`、`30e90403591ab9006050dda3ebfc876f4d1d6c10` | dev3 合并 beta | merge,复核最终树 | -| 28 | `35c6975776ca03eeabf3b77715294146547293e1` | `30e90403591ab9006050dda3ebfc876f4d1d6c10`、`4e303d30d34c499e1676a3481b4b45358618638d` | 合并 PR #246 | merge,复核与本地 EXO 恢复的关系 | -| 29 | `96d53148173a1fd7a8b1f0235cb60384e2398df1` | `79fd8c6fb5707798d372050d5f318911ad7b533c`、`30e90403591ab9006050dda3ebfc876f4d1d6c10` | dev3 再次合并 beta | merge,复核最终树 | -| 30 | `f769befae8c8724573c55b0ec7dae7ef8ea01ebd` | `96d53148173a1fd7a8b1f0235cb60384e2398df1` | 首页 part 缺失 paddingEnd 修复 | 已在 beta,复核 TV 焦点布局 | -| 31 | `1e7d79ef29fd7568f376fe571bde0bd4cd7c6838` | `35c6975776ca03eeabf3b77715294146547293e1`、`f769befae8c8724573c55b0ec7dae7ef8ea01ebd` | 合并 PR #248 | merge,复核最终树 | - -## Review and verification log - -### 首轮 - -- `git merge --no-commit --no-ff origin/beta` 自动完成,无未合并路径;beta 的完整 31 个提交进入暂存合并树。 -- 受保护的 `build-tv-debug.sh` 未被暂存;`task_guard.sh check` 与 `git diff --cached --check` 通过。 -- 静态复评确认 `E-ROLLBACK-EXO` 的 EXO 核心/直接测试仍保持与 `upstream/main@784b90420d646eb6c7ddcc63ad622a92c65b02b4` 对齐;HLS 广告反馈接线仍使用共享 `PlaybackResourceClassifier`。 -- 首轮发现并修复:beta 保留的 `PlayerManagerLifecycleSourceTest` 中 4 个已删除 EXO 停滞看门狗断言,以及 `TrackDialogTest` 中 1 个已删除 `TrackUtil.uniqueActiveFormat` 断言,均与批准的上游模式恢复相冲突;保留同文件仍适用的其他生命周期/字幕断言。 -- 修复后第二轮复评:Mobile Arm64 Java 编译和聚焦测试通过;Leanback Arm64 Java 编译和聚焦测试通过。日志:`/tmp/c14-beta-review-focused-20260911.log`、`/tmp/c14-beta-review-focused-leanback-20260911.log`。 -- 聚焦范围覆盖主题 profile/catalog/import/export/controller、备份偏好、速度对话框、站点主题、TV 站点注入/焦点、播放器 ownership、HLS 分类、`PlayerManagerLifecycleSourceTest`、`TrackDialogTest`、TMDB 布局以及本地 EXO 策略测试。 -- 复评结论:未发现 beta 与本地 EXO 上游恢复之间的未解决编译、引用、冲突或行为契约问题;全量 Mobile 中此前的 10 项失败仍属于本任务前已存在的基线契约/二进制失败或本任务明确移除的行为,已由正确聚焦测试覆盖并通过,不扩大范围修复。 - -### Best-practice / design boundary - -- 本轮是应用层已存在提交的同步与复评,不引入新的依赖、二进制、公共 API 或架构设计;不重复改写已经在 `C5`–`C13` 任务文档中完成的主题、焦点、速度和播放器行为设计。 -- 仍以实际源代码、完整 commit ledger、现有回归测试和最终编译结果为决策证据;若合并揭示新的跨模块行为冲突,再在本文补充独立方案、验收和回滚记录后修复。 - -## Final acceptance criteria - -1. `origin/beta` 的 31 个提交全部进入最终 `dev4` 历史,合并无未解决冲突。 -2. `08b8bbc...` 的 EXO 上游恢复、HLS 兼容接线、task guard 修复和受保护 `build-tv-debug.sh` 均按授权处理,不被 beta 覆盖或误提交。 -3. 首轮和修复后复审覆盖所有最终变更路径;不得遗留编译错误、冲突标记或可由本任务修复的定向测试失败。 -4. 通过最小风险比例验证后,使用 task guard 原子提交并创建恢复标签。 -5. 推送当前 `dev4` 和恢复标签,创建中文 PR 到 `beta`,最后 `git pull`/fetch 验证远端最新状态。 - -## Rollback - -- 合并未提交时:`git merge --abort`。 -- 合并提交后:使用本文记录的恢复标签,或 `git revert -m 1 `;修复提交保持独立可回滚。 -- `build-tv-debug.sh` 始终保留为本地保护 dirty 文件,不纳入提交。 - -## Follow-up:2026-09-11 12:17 后 origin/beta 最新增量 - -- 最终拉取发现 `origin/beta` 已从 `1e7d79ef29fd7568f376fe571bde0bd4cd7c6838` 前进至 `dbff441aa8a4bb54883ae07f722e53071413dd99`,新增完整提交链: - - `68df13503c07b20e68cdac072fa381037f672461`:修复 TV 分类边界切换中的过期焦点回调; - - `2cf5a93087d695e7cdd36a045d9b8af15a7430c7`:dev2 合并 beta 并完成复评; - - `dbff441aa8a4bb54883ae07f722e53071413dd99`:合并 PR #249 到 beta。 -- 只读 `git merge-tree --write-tree cf2a4c915a836b52a6cd0d0ae9f20a7d9535ecf7 origin/beta` 成功,实际 `git merge --no-commit --no-ff origin/beta` 无冲突。 -- 新增 beta 最终树相对当前 `dev4` 仅增加 7 个 dev2 已评审路径:`HomeActivity.java`、`VodActivity.java`、`FolderFragment.java`、`TypeFragment.java`、`VodActivityCategoryEdgeTest.java`、`HomeCategoryNavigationSourceTest.java` 和 `docs/beta-sync-review-dev2-20260911.md`。 -- 这些路径由 `docs/beta-sync-review-dev2-20260911.md` 覆盖,记录了源码生命周期/焦点复评、Leanback 分类测试 3 项、Mobile 定向测试 77 项及 Mobile/Leanback Arm64 Java 编译通过;本次新增 beta 仅为该已评审提交链及其 PR merge,按“已有评审覆盖且无合并冲突”条件跳过重复构建和复评。 -- 当前后续单元状态:最新 beta 已合入暂存树,guard/diff-check 通过,待提交、推送和更新 PR。 diff --git a/docs/C15-beta-sync-review-dev1-20260911.md b/docs/C15-beta-sync-review-dev1-20260911.md deleted file mode 100644 index dbce4275cf..0000000000 --- a/docs/C15-beta-sync-review-dev1-20260911.md +++ /dev/null @@ -1,70 +0,0 @@ -# C15:dev1 合并 beta 最新代码并复评 E-SP8(2026-09-11) - -## Recovery anchor - -- **目标:** 将 `origin/beta` 最新代码合入 `dev1`,复评 beta 增量及 `dev1` 已提交但尚未推送的 E-SP8 短剧 Exo 队列;发现问题即最小修复、验证并再次复评,随后提交、推送 `dev1`、创建中文 PR 到 `beta`,最后刷新远端状态。 -- **状态:** 合并冲突已解决;源码、双端编译、播放器相关单测、Mobile Debug APK 组装与设备启动检查均通过;待本 guard 创建两父合并提交和 recovery tag。 -- **任务守卫:** `beta-sync-review-dev1-20260911`,`standard`;范围 `.codex`、`app`、`docs`。开始前工作区无 dirty 路径,未保护任何 pre-existing dirty path。 -- **分支与基线:** `dev1@2b22c5240d52a8c2054299326f44fee6743ab26f`(`recovery/E-SP8/20260911201514-2b22c5240d52`);共同祖先 `ae64c42369a6b2ee660fee86cbe47c40382df667`。 -- **beta 目标:** `origin/beta@be1b02e06b22a4fa2f08c791555536e3e6154c95`,最新提交为 `revert: remove dynamic theme color system (Task C/D/E)`;当前仍处于无冲突的 `--no-commit --no-ff` 合并状态。 -- **回滚:** 提交前使用 `git merge --abort`;提交后使用本任务 recovery tag 或 `git revert -m 1 `。E-SP8 可独立使用其已有 recovery tag 回滚。 -- **唯一下一步:** 使用当前 task guard 完成原子合并提交与 recovery tag,随后推送、创建 PR 并 `fetch --prune`。 - -## 本轮 beta 增量完整记录 - -相对已覆盖的 `C14` 评审,当前 `origin/beta` 末端新增的三个完整提交均按最终树复核: - -| 完整 commit | 父提交 | 内容 | disposition | -| --- | --- | --- | --- | -| `24bda15fb12172e44eb2b58fce588992acdbcf90` | `cf2a4c915a836b52a6cd0d0ae9f20a7d9535ecf7`、`dbff441aa8a4bb54883ae07f722e53071413dd99` | 同步 beta 后续最新代码,包含 TV 分类边界焦点修复及其测试/文档 | 已在最终 beta 树;对应 dev2 评审已覆盖,合并无冲突,复核通过 | -| `e0e6ebebfc04f6db178c82ba8a4354938feaf487` | `dbff441aa8a4bb54883ae07f722e53071413dd99`、`24bda15fb12172e44eb2b58fce588992acdbcf90` | 合并 PR #250,包含 EXO 上游恢复、播放器相关清理及既有 beta 评审结果 | 已在最终 beta 树;保留 beta 明确删除项,并与 E-SP8 接口复核通过 | -| `be1b02e06b22a4fa2f08c791555536e3e6154c95` | `e0e6ebebfc04f6db178c82ba8a4354938feaf487` | 删除动态主题颜色系统 | 已在最终 beta 树;删除路径、资源和测试未被恢复,双端编译通过 | - -## 审查边界与已有覆盖 - -- `HEAD..origin/beta` 共 61 个可达提交。此前的 `beta-sync-review-dev2-20260911.md`、`beta-sync-review-dev3-20260908-round4.md`、`beta-sync-review-dev4-20260909-round3.md`、`beta-sync-review-dev4-20260909-round4.md` 和 `C14-beta-sync-review-dev4-20260911.md` 已分别覆盖主题系统、TV 焦点、播放速度、广告音频、EXO 上游恢复及其无冲突后续同步。 -- 本轮仍检查最终合并树,而不是仅依赖历史文档:beta 目标的动态主题回滚已删除对应生产、资源和测试路径;双端 Java 编译与运行时首页启动均未出现遗留类型或资源引用。 -- 合并后相对 `origin/beta` 的本地唯一行为差异为 E-SP8 的 14 个预期路径:两端 `VideoActivity`、`SiteViewModel`、`PlayerManager`、`PlayerEngine`、`ExoPlayerEngine`、`ExoUtil`、`MediaSourceFactory`、`PreCache`、队列协调器及其测试/任务文档/索引。没有将 beta 已删除的 EXO recovery、旧 HLS helper 或主题实现重新引入。 - -## 冲突解析与最终复评 - -1. **短剧队列与 beta 播放改动同时保留。** 两端 `VideoActivity` 同时保留 beta 的播放速度选择、共享 `PlaybackResourceClassifier.isHlsUrl()` 等接线,以及 E-SP8 的单项队列、独立取址、自然切集事务和失败回退。 -2. **Exo 资源与缓存隔离保持一致。** `ExoUtil` 保留 beta 的当前 `PlaySpec`/请求元数据结构,并保留带稳定 `mediaId` 的播放项重载;`MediaSourceFactory` 前台 `CacheKeyFactory` 与 `PreCacheHelper` 的 scoped cache 使用同一 header-derived namespace,防止不同认证上下文复用缓存。 -3. **beta 的 EXO 上游恢复保持原状。** `ExoBufferingStallWatchdog`、`PreCacheWorkerRecovery`、旧 `MediaSourceFactoryTest` 等 beta 明确删除的类/测试未恢复;生产引用已由 `PlaybackResourceClassifier` 和当前 Exo 树替代。 -4. **自然切集事件归属已复核。** 两端在 `PlayerManager.commitPlaylistTransition()` 替换当前 `PlaySpec` 前关闭上一集 `PlaybackEventCollector`,避免上一集停止事件写入下一集媒体信息;提交失败仍保留一次旧路径回退。 -5. **最终树完整性。** 没有未合并路径、没有冲突标记;`git diff --cached --check` 通过。合并包含 beta 的广告规则、TV 焦点、主题回滚、速度选择和 EXO 恢复最终树,未发现新的编译或可定位的行为阻断问题。 - -## 验证记录 - -1. 修正错误的 Gradle 任务名后,在确认没有其他实际 Gradle/assemble/bundle 任务运行的情况下执行: - - ```text - ./gradlew \ - :app:compileMobileArm64_v8aDebugJavaWithJavac \ - :app:compileLeanbackArm64_v8aDebugJavaWithJavac \ - :app:testMobileArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.player.*' \ - --tests 'com.fongmi.android.tv.player.exo.*' \ - --tests 'com.fongmi.android.tv.ui.player.*' \ - --continue - ``` - - 结果:`BUILD SUCCESSFUL in 50s`;Mobile/Leanback Arm64 Java 编译完成,播放器、EXO 与 UI player 相关 Mobile 单测共 1263 项完成。 - -2. 在同一设备验证槽位中执行: - - ```text - ./gradlew :app:assembleMobileArm64_v8aDebug --no-daemon --console=plain - ``` - - 结果:`BUILD SUCCESSFUL in 54s`,生成 Mobile Arm64 Debug APK。 - -3. 将 APK 安装至 `192.168.50.3:5559`(V1923A、Android API 28),安装成功;启动后应用进入 `HomeActivityCurrent` 且进程存活,无 `FATAL EXCEPTION`。设备 UI 可正常加载点播首页与短剧内容卡片。 - -4. 点击一个详情卡片后,系统记录 `pid 5484` 对应用执行 `Force stopping com.silent.android.webhtv`,随后进程收到 SIGKILL;日志没有 Java `FATAL EXCEPTION`、native tombstone 或 ANR 归因。该外部 force-stop 不能作为当前合并回归,也不构成真实短剧连续切集的成功证据。 - -## 验收结论与边界 - -- **本次合并/代码复评结论:通过。** beta 最终树、E-SP8 差异、冲突组合、请求头缓存隔离和自然切集事件顺序均已复核;定向编译、单测、APK 组装和设备启动检查支持提交。 -- **未扩大结论:** E-SP8 的实验开关仍遵守既有实验策略;没有把一次 APK 启动或外部中止的详情页尝试描述为连续切集、网络争用、字幕/弹幕时序或 Leanback 实机验收。上述场景仍是后续正式放量前的真实设备风险。 -- **提交后动作:** 推送 `dev1` 与本任务的新 recovery tag,创建中文 PR 到 `beta`,最后 `git fetch --prune origin` 并核对远端分支和 PR。 diff --git a/docs/C16-tmdb-source-detail-contract.md b/docs/C16-tmdb-source-detail-contract.md deleted file mode 100644 index 3d8475d433..0000000000 --- a/docs/C16-tmdb-source-detail-contract.md +++ /dev/null @@ -1,2005 +0,0 @@ -# C16:T3/T4 详情内嵌 TMDB 元数据设计 - -> 状态:C16 基础协议阶段 1-6 已完成并通过模拟器验收;无 TMDB Key 的“源内嵌数据驱动详情模式”第 18 节已按阶段 A-F 实现,并通过 Mobile/Leanback 设备验收。第 19 节的临时 Key 主合同已按订阅配置接口根对象修订并实施:根字段剥离、订阅 epoch、用户优先、官方 HTTPS 白名单、旧异步隔离、source-first/fill-only 接线和双端设备验收均已完成。未提供真实第三方 TMDB Key,因此真实网络补齐成功及真实 401/403 仍为未验证;T4 服务端仍需在其独立仓库按本文合同实施。不得据此宣称“发布完成”。 - -## Recovery anchor - -- 目标:扩展现有详情返回协议,使 T3 客户端爬虫和 T4 服务端接口可在 `detailContent` 结果中直接携带 TMDB 数据;APP 优先采用身份匹配的源数据,仅为缺失能力按需访问 TMDB。没有 App 配置的 TMDB Key 时,可把**订阅配置接口响应根对象**的 `tmdb_api_key` 放入只属于当前订阅接口的内存凭据作用域;详情响应根对象中的同名字段只用于向后兼容剥离,不再作为凭据来源。切换订阅接口时先清空旧 Key,新接口配置有 Key 才加载,没有则保持为空;Key 只允许在当前接口内跨站点、页面、详情和 Activity 使用,不得落盘、进入缓存/Parcel/日志或覆盖用户配置。 -- 基线:WebHTV `dev4@32a52698e5dab09fe18e49d18849a947057ca717`;OmniBox `main@d57b3e6672337febd46feca0d8ad59c6a2b507c3`;alist-tvbox `master@8a222f69a80291e836db702c34daf1ed5bdd5630`;atv-player `master@09feed1d5e5102f13bf91c9fbb76ea92808cb76d`。 -- 范围:实现合同;阶段 1-6 已完成本仓库 APP 侧协议、解析、合并、详情接入、延迟能力和设备验收。第 19 节临时源 Key 已完成订阅配置根级接收、详情 legacy 字段剥离、作用域、有效配置、source-first 补齐和设备验收。 -- 回滚:删除本文档并撤销总评估索引中的 C16 条目即可。 -- 追加结论:alist-tvbox 适合作为 T4 的元数据持久化和图片访问参考,但当前 `/vod` 输出仍是平铺 `vod_*` 字段;atv-player 适合作为 APP 的字段级合并、季级身份、缓存和异步取消参考,不能直接作为 Android 协议实现。 -- 当前进展:阶段 1-5 已完成客户端协议、纯逻辑、源缓存、source-first 详情接入和延迟能力;阶段 6 已在 `HD1910/Android 9` 模拟器完成 Mobile 与 Leanback 验收。第 18 节阶段 A-E 已完成纯策略、设置解耦、独立页、原生增强和交互收口;阶段 F 已在无 Key Mobile/Leanback 设备完成验收。第 19 节原实现和订阅配置根级修订均已通过 JVM 回归与分配设备 `SM-N9700/Android 9 @ 192.168.50.3:5557` 的双端矩阵验证。 -- 下一动作:客户端无待办;T4 生产服务的版本门控、Key 轮换、真实 Key 验证与双端回归需在独立仓库继续。 - -## 1. 设计结论 - -推荐在现有详情结果的 `Vod` 对象中增加可选 `tmdb` 字段,而不是照搬 OmniBox 的独立元数据请求接口。 - -核心规则: - -1. 保持 `detailContent(ids)` 方法签名和顶层 JSON 不变,旧源和旧客户端继续兼容。 -2. T3 与 T4 返回完全相同的数据合同,不在 APP 中按来源类型分叉。 -3. `tmdb.id + tmdb.media_type` 是剧集级身份锚点;若详情处于选中季上下文,还必须包含 `season_number`。 -4. APP 按能力组判断缺口。源数据完整时不得连接 TMDB;部分缺失时只补所需能力。 -5. 合并采用 fill-only,源提供的合法非空字段优先,网络结果不得覆盖它们。 -6. 空值默认表示未知。只有能力组列入 `complete` 时,空数组才表示明确为空。 -7. 季、集、视频以及更多推荐等延迟能力只在用户实际访问时补齐。 - -## 2. 两项目现状分析 - -### 2.1 WebHTV 当前链路 - -`SiteViewModel.detailContent()` 最终进入 `SiteApi.detailContent()`: - -- T3 调用爬虫的 `detailContent(List ids)`,再由 `Result.fromJson()` 解析。 -- T4 以 `ac=detail`、`ids=` 等参数调用服务端,再由 `Result.fromType()` 解析。 -- 两种来源随后统一进入 `Result.list`、播放线路解析和 `VodDetailCache`。 -- 当前 `Vod` 只有传统详情和播放字段,没有结构化 TMDB 数据。 -- TMDB 详情模式当前通过 `TmdbService` 独立访问 TMDB,详情请求覆盖图片、演职员、翻译、外部 ID、视频、推荐和相似内容;电视内容另有季与单集请求。 -- `TmdbDetailPrefetch` 使用 `tmdbId + mediaType` 标识请求并负责取消与身份隔离。 - -因此,扩展点应是 `Vod` 的结构化可选字段,并由统一仓库层消费,不能让 Activity 或 T3/T4 各自实现合并逻辑。 - -### 2.2 OmniBox 可借鉴逻辑 - -OmniBox 固定提交 `d57b3e6672337febd46feca0d8ad59c6a2b507c3` 使用 `TMDBMetadata` 容器保存资源身份、刮削类型、`scrapeData.tmdb`、更新时间和视频映射。`TMDBDetails` 包含 ID、电影/剧集名称、海报、背景、简介、评分、日期、类型、演职员和剧集信息。页面异步获取元数据,存在时显示增强内容,不存在时退回普通源详情。 - -可复用部分是结构化对象、媒体类型、资源映射及页面降级。不能直接照搬的部分是: - -- 独立元数据接口会在详情之后增加一次请求,不符合源随详情直接返回的目标。 -- OmniBox 字段不足以覆盖 WebHTV 的图片集、外部 ID、视频、推荐、相似内容和季集分层能力。 -- 整体 `hasData()` 不能表达“已有核心信息但缺少演职员”这类局部缺口。 - -### 2.3 alist-tvbox:T4 服务端参考 - -固定版本 `8a222f69a80291e836db702c34daf1ed5bdd5630` 是 AList/TVBox 服务端。它的相关实现分成两条链: - -- `src/main/java/cn/har01d/alist_tvbox/dto/TmdbDto.java:9-29` 和 `TmdbCredits.java:7-11` 将 TMDB 的 ID、电影/剧集标题、简介、类型、语言、国家、演职员、海报、评分和日期收敛为服务端 DTO。 -- `src/main/java/cn/har01d/alist_tvbox/entity/Tmdb.java:20-38` 保存较窄的 TMDB 媒体快照;`TmdbMeta.java:23-40` 将本地路径、站点和 TMDB 记录关联起来。 -- `src/main/java/cn/har01d/alist_tvbox/entity/MediaMetadata.java:15-53` 是更接近本需求的实现:以 `provider + metaId + season` 唯一索引保存 `status`、完整 JSON `payload` 和 `fetchTime`,用于完结剧长期零网络、在播剧按状态/TTL 刷新。 -- `src/main/java/cn/har01d/alist_tvbox/dto/MetadataDetails.java:7-62` 已覆盖简介、季总数、集数、播出状态、别名、分集、评分、外部 ID、播放链接、导演、编剧、演员、背景图和背景图候选,比单纯 `TmdbDto` 更适合作为服务端快照字段清单。 -- `src/main/java/cn/har01d/alist_tvbox/service/metadata/MetadataService.java:93-214` 实现“持久层 -> provider 内存/网络 -> 持久化”的读路径;旧快照或坏快照视为过期,失败的空对象不覆盖已有快照。 -- `src/main/java/cn/har01d/alist_tvbox/service/TmdbEndpoint.java:18-32,126-174` 处理 TMDB API/图片镜像、Worker 池、URL 安全和 API key/Bearer token 隔离。 -- `/vod` 详情入口位于 `web/TvBoxController.java:66-103`,最终由 `TvBoxService` 返回 `MovieDetail`;`tvbox/MovieDetail.java:11-35` 没有 `tmdb` 对象。`TvBoxService.java:2687-2768` 将 TMDB 记录映射回 `vod_pic`、`vod_year`、`vod_remarks`、`vod_actor`、`vod_director`、`vod_area`、`type_name`、`vod_lang` 和 `vod_content`。 - -因此,alist-tvbox 对 C16 的结论是“**服务端生产和快照设计有帮助,输出协议不能直接复用**”:它证明 T4 可以在服务端预抓取并持久化 TMDB,且季必须进入缓存键;但若只照搬其当前 `/vod` 平铺映射,APP 无法区分源字段和 TMDB 字段,也无法表达局部能力完整性。C16 应保留 `Vod.tmdb` 结构化对象,同时允许 T4 内部采用 `provider + tmdbId + season` 的持久化键。 - -### 2.4 atv-player:客户端参考 - -固定版本 `09feed1d5e5102f13bf91c9fbb76ea92808cb76d` 是桌面客户端,当前 HEAD 包含完整 metadata 子系统;TMDB Worker 轮询池提交 `e33fc859752f46547b992ecfffab7f5e48b902e1` 不是 HEAD 的祖先,因此本评估不把该提交的轮询实现当作冻结基线,只采纳 HEAD 中已存在的 provider、缓存和详情代码。 - -可借鉴证据: - -- `src/atv_player/metadata/models.py:14-99` 将查询、匹配和详情记录分层;`MetadataRecord` 明确保存标题、原始标题、年份、海报、背景图候选、简介、评分、演员/导演、角色/演职员详情、类型、别名、季集、IMDb/TMDB ID 和详情字段。 -- `src/atv_player/metadata/providers/tmdb.py:39-52,880-925` 将电视剧季号编码进 provider ID(`:season:`),并从 TMDB 原始响应保留 `external_ids`、`images`、cast/crew 角色、别名、季简介和背景图候选;`get_detail_full` 在 `:927-958` 进一步补充季/集和剧集级统计字段。 -- `src/atv_player/metadata/merge.py:13-73,140-185` 采用按字段的 provider 优先级,而不是整条记录覆盖;简介还区分 `tmdb` 与 `tmdb_season`,说明剧集总简介和选中季简介不能混作一个无上下文字符串。 -- `src/atv_player/metadata/cache.py:48-102` 对 provider/detail ID 做 TTL 缓存并使用 SHA-256 文件键;空结果支持更短 TTL。`cache_key.py:6-25` 在已有 TMDB 外部锚点时跳过标题搜索。 -- `src/atv_player/metadata/async_runner.py:17-54` 将 provider 搜索/详情封装为可并行、可捕获单 provider 异常的任务;详情控制器在 `controllers/media_detail_controller.py:509-539,584-620,667-729` 以 TMDB 记录为基础再加载其他 provider,并按当前视图身份清理缓存。 -- 测试 `tests/test_metadata_cache.py:26-101` 覆盖详情往返、空结果短 TTL 和通用 payload;`tests/test_metadata_cache_key.py:6-25` 覆盖 TMDB 外部锚点;`tests/test_media_detail_page_ui.py:25-67` 使用 `tv:1399:season:1` 和角色、评分、IMDb/TMDB ID、集列表验证详情展示。 - -因此,atv-player 对 C16 的结论是“**客户端消费机制有帮助,数据模型不直接移植**”:WebHTV 应引入字段级缺口规划、季级身份和缓存隔离,但仍以本项目已有 `TmdbService`、`TmdbDetailPrefetch`、Gson/Parcelable 和 Android 生命周期为实现边界。其 provider 优先级不能原样变成 WebHTV 的覆盖规则,因为用户要求源内嵌 TMDB 字段优先于 APP 网络补齐;在 C16 中它只作为“网络补齐内部不同 provider 的次级策略”参考。 - -## 3. 方案比较 - -### 3.1 保持现状 - -APP 继续自行匹配并请求 TMDB。兼容风险最低,但无法满足源数据直用,会继续产生重复请求、凭据依赖和匹配误差,不推荐。 - -### 3.2 复制 OmniBox 独立接口 - -模型清晰,但 T3 难以统一实现,而且页面仍需额外网络往返,不推荐。 - -### 3.3 在 Vod 中增加可选 tmdb 对象 - -T3/T4 现有响应、解析、缓存和页面入口不变;新客户端识别该字段,旧客户端忽略未知字段。APP 按能力组定向补齐,兼容性和可回滚性最佳,推荐。 - -## 4. 统一返回协议 - -在详情结果的第一个 `Vod` 中增加可选 `tmdb`: - -```json -{ - "list": [{ - "vod_id": "source-item-id", - "vod_name": "示例剧集", - "vod_play_from": "线路一", - "vod_play_url": "第1集$https://example/1.m3u8", - "tmdb": { - "schema": 1, - "id": 1399, - "media_type": "tv", - "language": "zh-CN", - "fetched_at": "2026-09-19T00:00:00Z", - "complete": ["core", "credits", "images"], - "detail": {} - } - }] -} -``` - -字段约束: - -- `schema`:协议主版本,首版固定为整数 `1`。 -- `id`:正整数 TMDB ID。 -- `media_type`:仅允许 `movie` 或 `tv`。 -- `season_number`:可选非负整数;仅 `tv` 使用,表示选中季上下文。电影固定为 0。 -- `language`:可选,表示数据主要语言,例如 `zh-CN`。 -- `fetched_at`:可选 ISO 8601 UTC 时间,只用于新鲜度判断。 -- `complete`:可选能力组数组,表示生产者确认该组完整,即使内容为空。 -- `detail`:保持 TMDB v3 的 snake_case JSON 形状,生产者只需返回实际拥有的字段。 - -### 4.1 detail 支持字段 - -首版支持当前 TMDB 详情模式使用的字段: - -- 身份与标题:`id`、`title`、`name`、`original_title`、`original_name`。 -- 摘要与状态:`overview`、`tagline`、`status`。 -- 图片:`poster_path`、`backdrop_path`、`images`。 -- 日期与评分:`release_date`、`first_air_date`、`vote_average`、`vote_count`。 -- 分类与地区:`genres`、`origin_country`、`original_language`。 -- 时长:电影 `runtime`,电视 `episode_run_time`。 -- 电视:`number_of_seasons`、`number_of_episodes`、`seasons`。 -- 扩展:`external_ids`、`credits`、`videos`、`recommendations`、`similar`。 - -图片字段可为 TMDB 相对路径或绝对 HTTPS URL。绝对 URL 原样使用;以 `/` 开头的路径由 APP 使用当前图片基址和尺寸策略拼接;其他形式无效。源不能通过该对象提供 TMDB API 地址、Authorization 或请求头。 - -### 4.2 季与集 - -`seasons` 可内嵌已知季;季对象可带 `episodes`。单集沿用 TMDB 字段,如 `id`、`season_number`、`episode_number`、`name`、`overview`、`air_date`、`still_path`、`runtime`、`guest_stars` 和 `crew`。 - -当详情对应“某一选中季”而不是整部剧时,`season_number` 参与缓存和合并身份;不能只用剧集级 `id + media_type`,否则不同季的简介、集列表或海报可能互相污染。等价身份为 `id + media_type + season_number(无则 0)`。 - -只有 `complete` 包含 `season:1` 时,该季空 `episodes` 才表示确认无数据;否则用户进入该季时仍可按需补齐。单集完整声明使用 `episode:1:1`。 - -### 4.3 能力组 - -首版定义: - -- `core` -- `credits` -- `images` -- `external_ids` -- `videos` -- `recommendations` -- `similar` -- `season:N` -- `season_videos:N` -- `episode:N:E` -- `episode_videos:N:E` - -推荐和相似内容的 `complete` 只表示首批结果完整,不表示所有分页已加载。 - -## 5. 缺失、空值与合并语义 - -- 字符串缺失、`null` 或空白均视为缺失。 -- ID 必须大于 0。 -- 分数必须有限且在合理范围内。未声明完整时零分视为未知;完整组中的零分可表示真实值。 -- 对象类型错误时只丢弃该字段,不得使普通详情失败。 -- 非空数组可直接采用;空数组只有在相应组包含于 `complete` 时才表示明确为空。 -- 图片加载失败只尝试同组候选或普通 `Vod` 图片,不因此自动发起 TMDB 元数据请求。 - -同一字段优先级: - -1. 身份匹配且合法的源内嵌字段。 -2. 本地缓存中同 `id + media_type + season_number + language` 的有效字段。 -3. 本次按需请求 TMDB 得到的字段。 -4. 普通 `Vod` 的标题、封面和简介回退。 -5. UI 默认值。 - -合并必须为 fill-only。用户刷新来源时重新执行详情函数;用户刷新 TMDB 时只更新非源字段。 - -身份最小校验:`schema == 1`、`id > 0`、媒体类型合法;若 `detail.id` 存在,必须与外层 ID 一致。强冲突时拒绝增强对象,继续显示普通详情并使用现有 TMDB 匹配流程。 - -## 6. APP 请求规划 - -### 6.1 首屏 - -1. 解析并验证 `tmdb`。 -2. 在启动 `TmdbDetailPrefetch` 之前计算当前页面所需能力组。 -3. 所需字段完整时直接渲染,TMDB 请求数必须为 0。 -4. 部分缺失且用户 TMDB 配置可用时,复用现有详情请求,并只填缺口。 -5. 只有合法 ID 时跳过标题搜索,直接按 ID 补齐。 -6. ID 缺失或身份无效时维持现有匹配流程。 -7. TMDB 未配置或补齐失败时,保留源增强数据与普通详情,不把增强失败升级为整页失败。 - -### 6.2 延迟能力 - -- 选择某季时检查 `season:N`。 -- 打开单集扩展信息时检查 `episode:N:E`。 -- 打开视频区时检查对应视频组。 -- 推荐和相似内容先用内嵌首批结果,加载更多继续走现有分页。 -- 页面关闭、切源或身份变化时继续使用现有取消与 identity 机制,旧请求不得覆盖新页面。 - -内嵌数据不依赖 APP 的 TMDB API Key。没有凭据时仍应完整显示已有源数据,只是不补缺口。 - -## 7. 数据模型与代码边界 - -批准实施后建议新增: - -- `Vod.tmdb: TmdbSourcePayload` -- `TmdbSourcePayload`:版本、身份、语言、时间、完整组及详情对象 -- `TmdbCapabilityPlanner`:纯函数计算缺口 -- `TmdbSourceMerger`:校验、图片规范化和 fill-only 合并 -- `TmdbDetailRepository`:统一源数据、本地缓存与网络补齐 - -同步覆盖 Gson、`Vod` Parcelable 或替代的大对象传递策略、`Result` 复制路径、`VodDetailCache`、页面生命周期和缓存键。Activity 不直接解析 JSON,源对象也不能未经校验写入 `TmdbService` 缓存。 - -## 8. 缓存、安全与负载 - -- 详情缓存继续保存整个 Result,旧缓存没有 `tmdb` 时按旧逻辑工作。 -- TMDB 补齐缓存键必须包含 `id + media_type + season_number + language`,不能让同一剧不同季共享详情或简介。 -- T4 内部持久化可采用 `provider + tmdbId + season_number` 唯一键,并保存状态、抓取时间和 JSON 快照;完结数据可长 TTL,在播数据按状态刷新。网络失败或空白快照不得覆盖已有有效快照。 -- 合并后保留字段来源归属,防止缓存命中后误覆盖源字段。 -- `fetched_at` 只影响刷新建议,不阻止旧数据先展示。 -- APP 补齐只能使用用户当前 TMDB 配置,禁止源控制 API 地址、图片基址或凭据。 -- 建议限制 `tmdb` 对象为 2 MiB、单数组 500 项、单字符串 64 KiB;超限时局部丢弃增强组,不影响播放线路。 -- 日志只记录站点 key、Vod ID、TMDB ID、组命中和拒绝原因,不记录凭据或完整原始内容。 - -## 9. T3 与 T4 生产者指南 - -### 9.1 T3 - -继续返回原有 `detailContent(List ids)` JSON,只在目标 Vod 内增加 `tmdb`。不新增回调,也不要求宿主向爬虫提供 TMDB 凭据。Java、JS 和 Python 桥接均将其作为普通 JSON 字段。 - -### 9.2 T4 - -继续响应现有 detail 请求。有合法 TMDB 数据时内嵌相同合同;没有时省略 `tmdb`,不得返回伪造 ID 或滥用 `complete`。 - -只有身份数据也有价值: - -```json -"tmdb": { - "schema": 1, - "id": 1399, - "media_type": "tv", - "detail": {} -} -``` - -完整首屏对象应准确声明已有能力。若 `credits`、`images`、`videos`、`recommendations` 或 `similar` 已列入 `complete`,其中的空数组就是确认为空,APP 不再访问 TMDB。 - -T4 内部若有持久化元数据,建议按 `provider=tmdb、metaId=id、season=season_number` 保存,再在 `/vod` 的 `MovieDetail`/详情 `Vod` 序列化阶段组装为 `tmdb`。不能仅把 `Tmdb` 表的 `cover/score/actors` 平铺字段当作协议,因为这会丢失来源、季上下文、能力组和空值语义。 - -## 10. 收益、风险与取舍 - -收益:完整数据可实现零 TMDB 请求,降低延迟和凭据依赖;合法 ID 可避免标题误匹配;部分数据可增量补齐;旧源和普通详情兼容。 - -主要风险及控制: - -- 错误 ID:严格身份校验,冲突时降级。 -- 大型季集数据:能力组、延迟加载和大小上限。 -- 空数组歧义:使用 `complete`。 -- 文案或图片差异:源优先,fill-only。 -- 数据陈旧:记录语言和抓取时间,时间敏感组按访问场景判断。 -- T3/T4 分叉:两端只生产同一合同,APP 集中解析和消费。 - -## 11. 分阶段实施计划 - -### 阶段 1:协议和纯逻辑 - -新增模型、能力组、校验与合并器;给 `Vod` 增加可选字段;覆盖 Gson、Parcelable、复制和缓存合同测试。 - -### 阶段 2:详情模式接入 - -在预取前规划缺口;完整首屏直接渲染;部分缺失复用现有 `TmdbService.detail()`;保持取消和身份隔离。 - -### 阶段 3:季集与延迟组 - -接入季、集和各级视频组;推荐/相似首批内嵌,分页保持现状;补充诊断和生产者文档。 - -三个阶段独立可回滚。本轮不授权任何阶段实现。 - -## 12. 验收矩阵 - -1. 无 `tmdb` 的旧 T3/T4 响应与现状一致。 -2. T3/T4 返回相同 JSON 时解析结果和请求计划一致。 -3. 完整首屏打开详情时 TMDB 请求数为 0。 -4. 只缺 `credits` 时最多发一次补齐请求,源字段不被覆盖。 -5. 只有合法 ID 时跳过搜索并直接按 ID 补齐。 -6. ID、媒体类型或 `detail.id` 冲突时拒绝增强对象,普通详情和播放仍可用。 -7. 未声明完整的空数组触发按需补齐;已声明完整的空数组不联网。 -8. 未配置 TMDB Key 时仍展示全部源增强数据。 -9. 绝对图片 URL、相对路径和非法路径分别按合同处理。 -10. 详情缓存往返后字段、来源归属和 `complete` 不丢失,旧缓存可读。 -11. Parcelable 或复制后身份与组不丢失,且不产生超大 Binder 传输。 -12. 切源、快速退出或切换条目时,旧请求不能覆盖新页面。 -13. 电视内容只在打开缺失季或集时访问对应接口。 -14. 超限或类型错误 payload 被局部拒绝,普通详情和播放线路不受影响。 - -最小实机验证:移动端和电视端各测试一条完整 T3、一条部分 T4 和一条无 TMDB 的旧源,记录首屏请求数、字段渲染、季集访问和切源隔离。 - -## 13. 证据与边界 - -- WebHTV 基线 `32a52698e5dab09fe18e49d18849a947057ca717`:`SiteViewModel`、`SiteApi`、`Vod`、`TmdbService`、`TmdbDetailPrefetch` 证明 T3/T4 已汇聚到 Result/Vod,并说明当前 TMDB 请求和生命周期边界。 -- OmniBox 基线 `d57b3e6672337febd46feca0d8ad59c6a2b507c3`:`TMDBMetadata`、`TMDBDetails`、服务端元数据处理和页面消费证明结构化元数据及降级方式,也显示独立请求模型不满足本需求。 -- alist-tvbox `8a222f69a80291e836db702c34daf1ed5bdd5630`:`dto/TmdbDto.java`、`dto/MetadataDetails.java`、`entity/MediaMetadata.java`、`service/metadata/MetadataService.java`、`service/metadata/TmdbMetadataProvider.java`、`service/TmdbEndpoint.java`、`web/TvBoxController.java` 和 `service/TvBoxService.java`。证据等级 A/B:实际源码和服务端测试;支持“服务端可按季持久化 TMDB 快照、失败不覆盖旧值、图片/凭据应隔离”,同时证明其当前 `/vod` 是平铺 `vod_*` 输出而非 C16 结构化合同。 -- atv-player `09feed1d5e5102f13bf91c9fbb76ea92808cb76d`:`metadata/models.py`、`metadata/providers/tmdb.py`、`metadata/merge.py`、`metadata/cache.py`、`metadata/cache_key.py`、`metadata/async_runner.py`、`controllers/media_detail_controller.py` 及 `tests/test_metadata_cache.py`、`test_metadata_cache_key.py`、`test_media_detail_page_ui.py`。证据等级 A/B:实际源码和测试;支持“客户端应按字段合并、按季隔离身份、缓存外部锚点、异步任务隔离旧视图”。TMDB Worker 轮询提交 `e33fc859752f46547b992ecfffab7f5e48b902e1` 非该 HEAD 祖先,未纳入采用范围。 -- 外部 PR、Issue、论文和性能基准不适用:本阶段只设计兼容 JSON 合同,不升级依赖、不改变运行行为,也不提出未经测量的性能结论。 - -## 14. 最终建议 - -**建议实施** `Vod.tmdb` 可选对象、TMDB 原生字段形状、能力组完整声明及 fill-only 合并;增加 `season_number` 上下文和 `id + media_type + season_number + language` 缓存键。吸收 alist-tvbox 的服务端按季快照、状态/TTL、失败不覆盖和图片/凭据隔离,吸收 atv-player 的字段级合并、外部 ID 直达、季级 provider ID、空结果短 TTL 和异步隔离。不要增加独立 T3/T4 元数据接口,不要在数据完整时连接 TMDB,也不要让源控制 APP 的 TMDB 地址或凭据。 - -## 15. 实现合同 - -本节是实现的唯一执行依据。若本节与前文概念描述冲突,以本节为准;未列入本节的 TMDB 字段不得成为首版的必需字段。 - -### 15.1 WebHTV 修改文件清单 - -首版客户端实现限定在以下路径: - -| 文件 | 必须改动 | -| --- | --- | -| `app/src/main/java/com/fongmi/android/tv/bean/Vod.java` | 增加可选 `TmdbSourcePayload tmdb`,Gson 解析、getter/setter、Parcelable、`isSameContent` 参与内容比较 | -| `app/src/main/java/com/fongmi/android/tv/bean/TmdbSourcePayload.java` | 新增协议容器、校验、能力组、来源标记和深拷贝 | -| `app/src/main/java/com/fongmi/android/tv/bean/TmdbSourceDetail.java` | 新增 TMDB 原始详情包装;内部 `JsonObject` 以 JSON 字符串保存,不直接放进 Parcelable | -| `app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourcePayloadParser.java` | 新增大小限制、身份校验、字段类型校验和图片 URL 规范化 | -| `app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceCapabilityPlanner.java` | 新增能力组判定和缺口计划,必须是无 Android UI 副作用的纯逻辑 | -| `app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceMerger.java` | 新增 source/cache/network 的字段级 fill-only 合并 | -| `app/src/main/java/com/fongmi/android/tv/api/SiteApi.java` | 不改变 T3/T4 请求;仅确保解析后的 `Vod.tmdb` 随 Result 缓存返回 | -| `app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java` | 重排 `loadContent` 为先取源详情、再决定 TMDB 请求;增加 payload 到现有 `TmdbBundle` 的适配 | -| `app/src/main/java/com/fongmi/android/tv/service/TmdbService.java` | 仅补充缺口请求入口/缓存键;保留现有请求 URL 和认证策略 | -| `app/src/main/java/com/fongmi/android/tv/utils/VodDetailCache.java` | 不改变源详情键;如新增 TMDB 合并缓存,单独使用带季和语言的键,不把网络合并结果写回源内容缓存 | - -不允许在首版修改 `catvod/`、`quickjs/`、`chaquo/` 的爬虫 ABI,也不允许新增 TMDB 独立 HTTP 接口。 - -### 15.2 Java 模型字段 - -`TmdbSourcePayload` 固定字段如下: - -| 字段 | Java 类型 | JSON | 规则 | -| --- | --- | --- | --- | -| `schema` | `int` | `schema` | 仅接受 `1` | -| `tmdbId` | `int` | `id` | `> 0` | -| `mediaType` | `String` | `media_type` | 仅 `movie`/`tv`,统一小写 | -| `seasonNumber` | `int` | `season_number` | `tv` 可为 `0` 或正数;`movie` 必须为 `0` | -| `language` | `String` | `language` | 空值规范为 `""` | -| `fetchedAt` | `String` | `fetched_at` | 仅校验长度和 ISO-8601 形状,不参与身份 | -| `complete` | `Set` | `complete` | 未知组丢弃;保留合法组并去重 | -| `detailJson` | `String` | `detail` | 保存 TMDB snake_case JSON;默认 `{}` | -| `sourceKind` | `String` | 不出现在协议 | `source_tmdb` / `cache_tmdb` / `remote_tmdb`,仅 APP 内部使用 | - -`Vod` 中的字段声明为 `@SerializedName("tmdb") private TmdbSourcePayload tmdb;`,不添加 Simple XML `@Element`,因此 XML 详情自然忽略该扩展。旧 JSON、旧缓存和没有 `tmdb` 的源必须得到 `null`。 - -`TmdbSourceDetail` 不把所有 TMDB v3 字段复制成 Java 成员;保留 `JsonObject` 字符串并提供以下白名单读取器:`id`、标题、简介、海报、背景图、日期、评分、类型、地区、时长、`images`、`credits`、`aggregate_credits`、`external_ids`、`videos`、`recommendations`、`similar`、`seasons`、`episodes`、`translations`、`content_ratings`、`release_dates`。读取器遇到类型错误返回空值,不抛出到详情页。 - -### 15.3 JSON 规范与完整示例 - -T3/T4 只能在详情 `list[0]` 中返回 `tmdb`。搜索列表不返回该字段,播放接口也不返回该字段。`detail` 必须是 JSON object;不能是字符串、数组或 TMDB API 响应外层包装。 - -电影完整示例: - -```json -{ - "list": [{ - "vod_id": "movie-1", - "vod_name": "示例电影", - "vod_pic": "https://source.example/poster.jpg", - "vod_content": "源详情简介", - "vod_play_from": "线路一", - "vod_play_url": "正片$https://source.example/movie.m3u8", - "tmdb": { - "schema": 1, - "id": 550, - "media_type": "movie", - "season_number": 0, - "language": "zh-CN", - "complete": ["core", "credits", "images", "external_ids", "videos", "recommendations", "similar"], - "detail": { - "id": 550, - "title": "Fight Club", - "original_title": "Fight Club", - "overview": "...", - "poster_path": "/poster.jpg", - "backdrop_path": "/backdrop.jpg", - "release_date": "1999-10-15", - "vote_average": 8.4, - "vote_count": 28000, - "genres": [], - "credits": {"cast": [], "crew": []}, - "external_ids": {"imdb_id": "tt0137523"}, - "videos": {"results": []}, - "recommendations": {"page": 1, "results": []}, - "similar": {"page": 1, "results": []} - } - } - }] -} -``` - -电视剧选中季示例: - -```json -{ - "list": [{ - "vod_id": "show-1", - "vod_name": "示例剧集", - "vod_play_url": "S01E01$https://source.example/1.m3u8", - "tmdb": { - "schema": 1, - "id": 1399, - "media_type": "tv", - "season_number": 1, - "language": "zh-CN", - "complete": ["core", "credits", "images", "external_ids", "season:1"], - "detail": { - "id": 1399, - "name": "Game of Thrones", - "original_name": "Game of Thrones", - "overview": "...", - "first_air_date": "2011-04-17", - "vote_average": 8.4, - "number_of_seasons": 8, - "number_of_episodes": 73, - "seasons": [{"season_number": 1, "episode_count": 10, "episodes": []}], - "aggregate_credits": {"cast": [], "crew": []}, - "external_ids": {"imdb_id": "tt0944947"} - } - } - }] -} -``` - -`season:1` 的完整语义是 `seasons[]` 中第 1 季对象、`episodes` 数组和该季需要的 `images/credits` 均已由生产者确认;如果只返回季摘要而没有集列表,不能声明 `season:1`,只能返回 `core`。 - -### 15.4 能力组到字段和请求映射 - -| 能力组 | 最低字段/条件 | APP 现有请求 | 首屏还是延迟 | -| --- | --- | --- | --- | -| `core` | `id`、标题、简介、日期、`vote_average`、`genres`;TV 还需 `number_of_seasons`/`number_of_episodes`(若 TMDB 有值) | `TmdbService.detail(item, config, false)` | 首屏 | -| `credits` | `credits` 或 TV 的 `aggregate_credits`,cast/crew 均可为空但必须声明完整 | 同上 | 首屏 | -| `images` | `poster_path`、`backdrop_path` 和 `images` 已覆盖当前页面需要的语言候选 | 同上 | 首屏 | -| `external_ids` | `external_ids` object(无 ID 也必须能声明空) | 同上 | 首屏 | -| `videos` | 详情级 `videos.results` | `TmdbService.videos` 或现有相关视频入口 | 延迟 | -| `recommendations` | `recommendations.page=1` 和 `results` | `TmdbService.recommendations(item, config, 1)` | 首屏/延迟加载 | -| `similar` | `similar.page=1` 和 `results` | `TmdbService.similar(item, config, 1)` | 首屏/延迟加载 | -| `season:N` | 季摘要、`episodes`、季图和季演职员 | `TmdbService.season(item, N, config, detail)` | 选季时 | -| `season_videos:N` | 当前季视频 results | `TmdbService.seasonVideos(...)` | 打开视频时 | -| `episode:N:E` | 单集字段和当前集详情字段 | `TmdbService.episode(item, N, E, config, detail)` | 打开单集时 | -| `episode_videos:N:E` | 单集视频 results | `TmdbService.episodeVideos(...)` | 打开视频时 | - -规划器规则:首屏只要求 `core`、`credits`、`images`;`external_ids` 只在当前 UI 显示 IMDb/外部 ID 或需要身份确认时要求。`recommendations`、`similar`、`videos` 不得因为缺少而阻塞首屏。TV 选季后才要求对应 `season:N`;不能用 `season:1` 满足 `season:2`。 - -### 15.5 详情页精确调用顺序 - -修改 `TmdbDetailActivity.loadContent()`(当前 `:2312`)为以下顺序,禁止在源详情完成前提交 `loadTmdbResult()`: - -```text -generation = ++loadGeneration -detailTasks.cancelAll() -sourceResult = SiteApi.detailContent(key, id) -loadedVod = sourceResult.getVod() -payload = TmdbSourcePayloadParser.parse(loadedVod.getTmdb()) -sourceBundle = TmdbSourceAdapter.toBundle(payload, loadedVod, config) -plan = TmdbSourceCapabilityPlanner.plan(sourceBundle, payload, currentUiState) -applyLoaded(loadedVod, sourceBundle, ...) - -if plan.hasInitialNetworkGaps() and tmdbConfig.isReady() and siteAllowsTmdb: - networkBundle = loadTmdbMissing(plan, payload.identity()) - mergedBundle = TmdbSourceMerger.fillOnly(sourceBundle, networkBundle) - applyLoadedIfGenerationCurrent(loadedVod, mergedBundle) -``` - -具体分支: - -1. payload 合法且首屏能力完整:不创建 `TmdbLoadResult`,不调用搜索、`loadTmdbBundle` 或 `TmdbService.detail`。 -2. payload 合法但缺 `core`:用 payload 的 ID 构造 `TmdbItem`,直接调用详情接口;禁止标题搜索。 -3. payload 合法但缺 `credits/images`:直接按 ID 请求完整详情并只合并缺口。 -4. payload 只有 ID 且 `detail` 为空:直接按 ID 请求;只有 payload 无效或 ID 缺失时才进入原有 `searchResolvedTmdbMatch()`。 -5. payload 冲突(外层 ID、`detail.id`、媒体类型不一致):丢弃 payload,保留普通源详情,并执行现有匹配流程。 -6. `reusableBundle` 非空时仍先合并合法 source payload;复用对象只作为低优先级网络缓存,不得覆盖源字段。 -7. 每个后台结果回 UI 前检查 `generation`、`identity`、`season_number` 和 `mediaType`,任一不符直接丢弃。 - -`TmdbSourceAdapter.toBundle()` 必须填充现有内部 record: - -| `TmdbBundle` 成员 | 来源 | -| --- | --- | -| `item` | payload 身份和 detail 标题/简介/图片/评分构造 `TmdbItem` | -| `detail` | `detailJson` 解析后的 `JsonObject` | -| `cast`/`creators` | `credits.cast/crew` 或 `aggregate_credits` 经现有转换器 | -| `photos` | `images.backdrops/posters` 与 detail 主图去重 | -| `related` | `recommendations.results` + `similar.results` 去重 | -| `seasons`/`seasonCounts` | `detail.seasons` | -| `seasonEpisodes` | 当前 `season_number` 对应季的 `episodes` | -| `seasonCast`/`seasonPhotos` | 对应季对象的 `credits`/`images` | - -适配器不得调用网络;缺字段返回空集合,交给 planner 决定是否补齐。 - -### 15.6 合并、来源和缓存实现 - -`TmdbSourceMerger` 保存三层对象:`sourcePayload`、`networkPayload`、`effectiveBundle`,不把网络结果写回 `Vod.tmdb`。字段来源以能力组记录:`SOURCE`、`LOCAL_CACHE`、`REMOTE_TMDB`、`VOD_FALLBACK`。 - -缓存键固定为: - -```text -tmdb-detail:{mediaType}:{tmdbId}:season={seasonNumber}:lang={language}:include={capabilityMask} -``` - -能力 mask 必须排序后生成,不能以 Java Set 的迭代顺序直接拼接。详情源缓存仍使用现有 `VodDetailCache` 的 `source + NUL + id` 键;不要改变其 TTL 或把合并结果存入该缓存。TMDB 网络缓存可沿用 `TmdbService` 现有 cache,但新季键必须追加 `seasonNumber`。 - -fill-only 的字段规则:字符串空白、数值未定义、对象缺失、未声明 complete 的空数组都允许由低优先级填充;源字段非空或其能力组明确声明空数组后,低优先级不得覆盖。`detail.id` 和身份字段永不由低优先级修改。 - -`Vod` Parcelable 只传 `tmdb` 的协议字段和 `detailJson` 字符串;传输前执行 256 KiB 单对象限制。超过限制不走 Parcelable,继续通过 `VodDetailCache.put()` 传递 key;绝不把 `JsonObject` 或完整图片/推荐对象直接写入 Binder。 - -### 15.7 T3/T4 服务端实现边界 - -T3 只需在现有 `detailContent(ids)` 返回的 `list[0]` 增加 `tmdb`,不新增函数、不改变 `ids` 语义;JS/Python/Java 爬虫桥接均把它当作普通 JSON。 - -T4 只需改造其 `ac=detail&ids=` 响应的 `MovieDetail`/Vod 序列化层;若使用 alist-tvbox 的实现,内部可将 `MediaMetadata(provider, metaId, season, status, payload, fetchTime)` 映射为 C16 对象,但不能把 `vod_*` 平铺字段当作替代合同。T4 必须: - -- 没有合法快照时省略 `tmdb`,不能返回空 ID 对象。 -- 读取快照失败时返回普通详情,不让整个 `ac=detail` 失败。 -- 按 `provider=tmdb + metaId + season` 读写,网络失败不覆盖旧 payload。 -- 图片只返回相对 TMDB path 或受信任的 HTTPS 图片 URL,不能返回 API key、Authorization、API base 或任意请求头。 -- 服务端响应保持 JSON;XML 类型站点不承担 `tmdb` 合同,APP 仅从 JSON 读取该字段。 - -T4 的服务端测试必须验证旧客户端仍可读取 `vod_*`,新客户端读取 `tmdb`,同一 `ids` 在无快照、完整快照、部分快照和坏快照四种状态下均有确定响应。 - -### 15.8 测试文件和通过条件 - -首版必须新增或扩展以下测试: - -| 测试 | 必须证明 | -| --- | --- | -| `TmdbSourcePayloadTest` | Gson 解析、schema/media/id/season 校验、未知组和坏类型处理 | -| `TmdbSourceCapabilityPlannerTest` | 每个能力组缺口、季/集隔离、完整空数组不联网 | -| `TmdbSourceMergerTest` | source > cache > network > Vod fallback,源值不被覆盖 | -| `TmdbSourceAdapterTest` | payload 无网络转成现有 `TmdbBundle` 所需的所有集合 | -| `VodTmdbParcelableTest` | Parcelable 往返、旧对象兼容、256 KiB 超限走 cache key | -| `SiteApiT3TmdbDetailTest` | type 3 JSON 详情保留 tmdb 且缓存往返不丢失 | -| `SiteApiT4TmdbDetailTest` | type 4 JSON 详情保留 tmdb,普通字段不变 | -| `TmdbDetailSourcePayloadTest` | 完整 payload 不调用 TMDB;部分 payload 只按 ID 补齐 | -| `TmdbDetailGenerationTest` | 切源、切季、退出后旧任务不能更新页面 | -| `TmdbServiceCacheKeyTest` | media/id/season/language/capability 不串缓存 | - -“零 TMDB 请求”必须通过可注入的 fake `TmdbService` 或请求计数器断言,不能仅通过 UI 截图推断。每个测试失败都必须保留实际请求 URL/能力组,便于确认没有误触发标题搜索或整包详情请求。 - -## 16. 实施顺序与提交边界 - -实施必须按以下顺序,每阶段独立编译/测试并保持可回滚: - -1. **协议模型**:新增 payload/detail/parser,扩展 `Vod` Gson、Parcelable、copy/diff;运行模型和解析测试。 -2. **纯逻辑**:实现 adapter/planner/merger,运行字段、能力、季集、来源优先级测试。 -3. **源接入**:确认 T3/T4 JSON 详情和 `VodDetailCache` 往返,运行 SiteApi 测试。 -4. **详情页接入**:重排 `loadContent` 的先源后网状态机,接入 `TmdbBundle`,运行零请求、缺口补齐和 generation 测试。 -5. **延迟能力**:接入季、集、视频、推荐分页,运行季集和视频回归。 -6. **设备验收**:移动端、Leanback 各验证完整 T3、部分 T4、无扩展旧源;记录网络请求计数和页面结果。 - -每阶段只允许修改对应任务文档声明的客户端文件;T4 服务端应使用独立任务文档和独立提交,不与 APP 提交混合。任何新增字段、接口、依赖或跨仓库变更都必须先更新本实现合同并重新评审。 - -## 17. 实施记录 - -### 阶段 1:协议模型 - -- 提交:`4865bd7ad08cfd1dd12fba60a202b346e71336a0`;恢复标签 `recovery/C16-stage1/20260919104302-4865bd7ad08c`。 -- 已实现 `TmdbSourcePayload` 的固定协议字段、内部来源标记、深拷贝、身份合法性判断、确定性能力组集合和 Binder 大小判断。 -- 已实现 `TmdbSourceDetail` 的 `JsonObject` 字符串保存及白名单读取器;读取器遇到缺失或类型错误返回空值,不向详情页抛出异常。 -- 已实现 `TmdbSourcePayloadParser`:校验 `schema/id/media_type/season_number/detail.id/detail.season_number`,清理未知能力组和坏类型字段,规范化图片 URL,并执行 2 MiB、500 项、64 KiB 限制。 -- 已扩展 `Vod`:`tmdb` 参与 Gson、Parcelable 和 `isSameContent`;超过 256 KiB 时不把 `detailJson` 放入 Binder,而是通过现有 `VodDetailCache.put()` 生成 key。 -- 定向验证:`TmdbSourcePayloadTest`、`VodTmdbParcelableTest` 共 7 项通过;同一 Gradle 调用完成 Mobile Arm64 单测和 Leanback Arm64 Debug Java 编译,结果 `BUILD SUCCESSFUL`。 -- 未验证项:真实 Android Binder 往返、设备详情页消费和 T3/T4 源接入,按第 16 节后续阶段继续处理。 - -### 阶段 2:纯逻辑 - -- 提交:`bbd0a4de5fbbbe497cc4f5935a6c17ed46d50708`;恢复标签 `recovery/C16-stage2/20260919105729-bbd0a4de5fbb`。 -- 将原活动内私有 `TmdbBundle` 提升为 `ui.helper.TmdbBundle`,保持现有字段和详情页调用不变,供纯逻辑和后续源接入共用。 -- 新增 `TmdbSourceAdapter`:从已校验 payload 无网络构造 `TmdbItem`、详情 JSON、演职员、图片、相关推荐、季集、季演职员和季图片;绝对 HTTPS 图片保持原样,相对路径使用当前 APP 图片基址。 -- 新增 `TmdbSourceCapabilityPlanner`:按首屏、季、集和视频场景计算 required/available/missing,完整空组视为已满足,季与集严格隔离。 -- 新增 `TmdbSourceMerger`:按 source > local cache > remote TMDB 的 fill-only 规则合并 item、detail、集合和季映射,并记录字段来源;源声明完整的空值不会被覆盖。 -- 修复 `TmdbSourcePayloadParser` 对数组元素递归校验时误用父数组字段类型、导致 cast/images/seasons 等数组被清空的问题;新增 adapter/planner/merger 测试覆盖。 -- 定向验证:`TmdbSourceAdapterTest`、`TmdbSourceCapabilityPlannerTest`、`TmdbSourceMergerTest` 共 10 项通过;同一 Gradle 调用完成 Leanback Arm64 Debug Java 编译,结果 `BUILD SUCCESSFUL`。 -- 未验证项:真实网络补齐请求、Activity 状态机接线、真实设备 UI 和源详情缓存往返,按阶段 3-6 继续处理。 - -### 阶段 3:源接入 - -- 提交:`070efd6f4ec818a840f4b2bccb384389d116b368`;恢复标签 `recovery/C16-stage3/20260919105928-070efd6f4ec8`。 -- 新增 `SiteApiT3TmdbDetailTest`:使用 T3 `detailContent` 的 JSON 形状验证 `Vod.tmdb`、身份、能力组和详情读取器保留,并通过 `VodDetailCache` 内容往返后仍保留完整协议。 -- 新增 `SiteApiT4TmdbDetailTest`:使用 T4 `ac=detail` 的 JSON 形状验证电视剧季上下文、`season:1`、季数组和缓存往返,普通详情字段未被改变。 -- 本阶段确认 Gson、`Result` 列表复制和现有详情缓存无需生产代码改动;`SiteApi` 的 T3/T4 请求与分派保持不变。 -- 定向验证:两个测试类共 4 项通过,Mobile Arm64 Debug 单测任务返回 `BUILD SUCCESSFUL`。 -- 未验证项:Activity 尚未在调用 `TmdbDetailPrefetch` 前消费 payload,真实 T3/T4 网络请求和页面请求数留待阶段 4/6。 - -### 阶段 4:详情页接入 - -- 提交:`7de6bed8886ab34e429f4e66295d12e946815393`;恢复标签 `recovery/C16-stage4/20260919111304-7de6bed8886a`。 -- `TmdbDetailActivity.loadContent` 已改为先完成 `SiteApi.detailContent`、解析并应用 `Vod.tmdb`,再决定是否创建旧 TMDB 匹配任务;旧源路径仍在源详情完成后串行执行。 -- 合法 payload 先按 identity 构造 `TmdbBundle`,再计算 `TmdbSourceCapabilityPlanner` 首屏缺口;`core/credits/images` 完整时直接返回,不调用来源详情 TMDB 请求。 -- 部分 payload 使用 `TmdbService.detailForSource()` 按 TMDB ID 请求,合并后通过 `TmdbSourceMerger.fillOnly()` 仅填缺口;不再为有效身份执行标题搜索。 -- `TmdbService` 新增 source detail 缓存键,包含媒体类型、TMDB ID、季上下文、语言和排序后的能力 mask;请求 URL、认证和磁盘缓存实现保持现有策略。 -- `TmdbSourceAdapter.fromNetwork()` 复用与服务端 payload 相同的字段解析、图片规范化和季集映射,确保 source 与 network bundle 形状一致。 -- 后台合并回 UI 前继续检查 generation、当前 `Vod` 和 TMDB identity;旧页面、切源或退出不会应用迟到结果。 -- 同步调整旧 standalone 源码测试:仍保留单次首屏绑定和季预加载,但断言顺序改为 source detail -> payload plan -> TMDB wait。 -- 定向验证:`TmdbDetailSourcePayloadTest`、`TmdbDetailGenerationTest`、`TmdbServiceCacheKeyTest`、`TmdbSourceAdapterTest`、`TmdbDetailActivityLayoutTest`、`TmdbUIAdapterTest` 共 75 项通过;Leanback Arm64 Debug Java 编译通过,Gradle 返回 `BUILD SUCCESSFUL`。 -- 未验证项:真实网站 T3/T4 网络请求计数、移动端/电视端字段渲染和真实退出/切源竞态,留待阶段 6 设备验收。 - -### 阶段 5:延迟能力 - -- 新增活动级 source payload 身份状态;仅当当前 TMDB 身份仍与源 payload 一致时,季、集和视频的完整组短路生效。 -- `fetchSeasonIfNeeded` 对已由 `season:N` 完整声明的季直接返回;内部 stale split 探针也跳过已完整声明的季。用户显式 refresh 仍保留原刷新语义。 -- 长按单集时,`episode:N:E` 完整声明直接使用已绑定的 TMDB 分集元数据打开详情,不再请求单集接口;缺失该组时继续走原补齐路径。 -- 新增 `TmdbSourceAdapter.videos()`:无网络从 detail、season 和 episode 对象解析 `TmdbVideo`,保持 title/season/episode scope,并按现有规则去重排序。 -- 视频区仅在当前上下文需要的 `videos`、`season_videos:N`、`episode_videos:N:E` 全部声明完整时使用内嵌视频;否则保持现有网络聚合,避免只补一部分造成上下文缺口。 -- 推荐和相似内容继续使用阶段 2 的内嵌首批 `related`;加载更多和个性化推荐仍走现有分页/服务流程。 -- 定向验证:`TmdbSourceAdapterTest` 新增三 scope 视频解析;`TmdbDetailSourcePayloadTest` 新增季、集、视频短路断言;连同 generation、缓存键、布局和 UI 接线测试共 203 项通过;Leanback Arm64 Debug Java 编译通过。 -- 未验证项:真实设备上选择缺失季、长按缺失集、打开视频区的请求数与 UI 结果,留待阶段 6。 - -### 阶段 6:设备验收 - -- 测试入口:新增 `app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16TmdbSourceDetailDeviceTest.java`。测试通过 `ActivityScenario` 启动未导出的 `TmdbDetailActivity`,等待真实 View 文本后断言字段渲染;夹具使用 `adb reverse` 暴露本机 T4/T3 源。测试仅在 `Config.vod()` 指向 C16 夹具时执行,普通设备保持 JUnit skip,不影响既有 AndroidTest。 -- 设备:`192.168.50.3:5561`,`HD1910`,Android 9,`x86_64 + arm64` 兼容层,1920×1080/280 dpi。所有数据改动在测试前备份,验收后已恢复 `com.silent.android.webhtv_preferences.xml` 和 `databases/tv`;测试 APK 已卸载,reverse 已移除。 -- Mobile APK:SHA-256 `17e0ec2c403a9185134cbc609fb80867a65a54094b5ccc1f6166ba2c79256dbe`。Leanback APK:SHA-256 `563a222b1773ad8df7beeddb765d49cf6e6e39701a2fbe1622c63785ce873887`。AndroidTest APK:SHA-256 `4a659334f8c617b45e5bcb53c5867a52b5c7250c74b15684864e266a86367ed8`。 -- Mobile 结果:完整 T4、部分 T4、无扩展旧源、完整 T3 各 1 项仪器测试通过。 -- Leanback 结果:完整 T4、部分 T4、无扩展旧源、完整 T3 各 1 项仪器测试通过;另通过完整 TV 季集场景 1 项。 -- 完整 T4 请求证据:夹具仅收到 `/config.json` 与 `/t4_complete?ac=detail&ids=c16-t4-complete-1`;日志为 `source payload plan required=[core, credits, images] missing=[]`,没有 TMDB detail/search 请求。UI 显示 `C16 Complete T4`、`Embedded complete overview`、`Embedded Actor`。 -- 完整 T3 请求证据:夹具仅收到 `/config.json` 与 `/t3_complete.js`;Mobile 和 Leanback 均为 `required=[core, credits, images] missing=[]`,没有 TMDB detail/search 请求。JS Spider 返回普通 `detailContent` JSON,证明 T3 JSON 字段无需 ABI 改动即可透传。 -- 部分 T4 请求证据:夹具收到 `/t4_partial?ac=detail&ids=c16-t4-partial-1`;Mobile 日志为 `missing=[credits, images]`,Leanback 同组,随后只出现按 TMDB ID 的 `requestJson type=detail`,没有标题搜索。UI 保留 `C16 Partial T4` 和 `Source partial overview must win`。 -- 旧源证据:夹具收到 `/legacy?ac=detail&ids=c16-legacy-1`;Mobile/Leanback 继续走原有 `load tasks ... singlePass=true`、`tmdb wait` 与单次首屏绑定,UI 显示普通标题、`line-a` 线路和 `正片` 选集,无崩溃。 -- 季集证据:Leanback 完整 TV 夹具仅收到 `/t4_tv_complete?ac=detail&ids=c16-t4-tv-1`;`season:1`、`episode:1:1` 与视频组均已内嵌,UI 显示 `Embedded Episode 1`,日志没有 `season`、`episode` 或 TMDB detail/search 请求。 -- 切源/退出隔离:设备场景不制造真实竞态;阶段 4 的 `TmdbDetailGenerationTest` 已验证迟到的 source-fill 结果在 generation、当前 Vod 或 TMDB identity 变化后不会更新页面。真实弱网快速切源仍需发布前抽样关注,但不阻塞本期合同完成。 - -## 18. 补充设计:无 Key 的源内嵌 TMDB 详情 - -### 18.1 目标与结论 - -本补充设计解决两个相互独立但当前被同一把锁绑住的问题: - -1. 用户是否能选择 TMDB 详情模式和主题; -2. APP 是否有权向 TMDB 发起网络请求。 - -设计要求把这两件事彻底分开: - -- 模式、主题始终是可持久化的用户配置,不要求 API Key。 -- TMDB Key 只决定是否允许在线匹配和按缺口网络补齐。 -- 爬虫详情 `Vod.tmdb` 是独立数据源。没有 Key 时,只要其中存在用户可见的 TMDB 数据,就可以使用用户选择的模式和主题做 source-only 渲染。 -- 没有可展示 `Vod.tmdb` 且没有 Key 时,运行时不使用 TMDB 模式,也不显示空壳或 `detail_tmdb_need_key`,而是按影视原始模式打开并复用已经取得的源详情。 -- API 未返回的区域直接隐藏。标题、简介、海报等同义字段允许按既有规则回退到 `Vod`;TMDB 专属区域不得用空标题、空列表、零值或空白间距占位。 - -推荐方案是“配置模式与运行时模式分离 + source-first 决策 + 无 Key 网络禁用”。禁止采用“无 Key 也总是打开 `TmdbDetailActivity`”或“只在 Activity 内吞掉 Key 提示”的方案,因为它们都会制造空详情页或错误模式页面。 - -### 18.2 决策依据与本项目证据 - -本节是 C16 的增量设计,不改变第 4-9 节的生产者协议,也不改变 TMDB 在线 API 的认证合同。现有证据已经足够决定实现方向: - -| 证据 | 结论 | -| --- | --- | -| C16 阶段 1-5 已实现 `TmdbSourcePayload`、`TmdbSourcePayloadParser`、`TmdbSourceAdapter`、`TmdbSourceCapabilityPlanner` 和 `TmdbSourceMerger` | 源内嵌数据已经能无网络转成现有 `TmdbBundle`,不需要新增协议或重新研究上游 | -| `TmdbDetailActivity.loadContent()` 已先请求源详情、再解析 payload、再决定在线补齐 | 独立详情页三模式只需补齐“无 Key 路由”和“源数据不可展示时回退” | -| `TmdbConfig.isReady()` 只表示 API Key 或 Access Token 存在 | 该条件适合控制网络,不应继续作为模式/主题可配置性或详情页可进入性的同义词 | -| `Setting.getDetailOpenMode()` 在无 Key 时把 TMDB 模式强制降为 `DETAIL_OPEN_DIRECT` | 这是当前“选不了/选了不生效”的直接根因 | -| 设置页在无 Key 时弹出 `TmdbSourceDialog` 并拒绝保存模式 | 这是第二个直接根因,和运行时降级重复 | -| `VideoActivity` 的独立模式路由依赖 `Setting.isTmdbDetailPage()`,其中包含 Key 就绪 | 无 Key 时不会进入已经具备 C16 消费能力的页面 | -| 现有 C16 设备测试通过显式启动 `TmdbDetailActivity` 验证源数据渲染 | 证明渲染能力成立,但没有覆盖正常设置、入口路由、无 Key 回退和 Mobile/Leanback parity | -| `loadTmdbMediaBlocks()` 仍包含个性化推荐等网络型后处理 | source-only 路径必须显式跳过,不能只靠单个 `canMatchTmdb()` 分散拦截 | - -证据适用范围: - -- 上游仓库和成熟项目证据继续沿用第 2 节,本轮不新增 OMDB、T4 或播放器依赖,不形成新的 commit ledger。 -- 官方 API 合同没有变化:在线 TMDB 请求仍必须经过 `TmdbConfig.isReady()`;本设计只处理在不请求 API 时消费源数据。 -- Android 生命周期、进程重建、并发 generation 和 Binder 大小合同继续沿用第 8、15 节;本设计不改变这些边界。 - -### 18.3 三种方案比较 - -| 方案 | 行为 | 结论 | -| --- | --- | --- | -| 保持现状 | 无 Key 时模式被强制改为影视原生,设置页也拒绝选择 | 不满足需求,继续把“在线能力”误当作“展示能力” | -| 仅删除 Key 校验,总是进入 TMDB 模式 | 无 Key、无源 TMDB 的详情会出现空壳,并可能显示 Key 提示 | 拒绝,破坏普通详情体验 | -| 配置模式与运行时模式分离,按源数据状态选择 | 有源数据使用用户模式;没有源数据时影视原始;无 Key 时零 TMDB 网络 | 推荐,改动边界清晰且可回滚 | - -### 18.4 状态定义 - -#### 18.4.1 持久化配置 - -`configuredMode` 是用户在选择器中保存的模式,允许值: - -```text -DETAIL_OPEN_DIRECT -DETAIL_OPEN_ORIGINAL_ENHANCED -DETAIL_OPEN_FUSION -DETAIL_OPEN_ENHANCED -DETAIL_OPEN_PLAYER -``` - -`configuredTheme` 是用户保存的详情主题: - -```text -DETAIL_STYLE_NATIVE -DETAIL_STYLE_PROFILE -DETAIL_STYLE_CINEMA -``` - -`configuredMode` 和 `configuredTheme` 不依赖 `TmdbConfig.isReady()`。 - -#### 18.4.2 源 TMDB 状态 - -新增纯逻辑枚举 `TmdbSourceState`: - -| 状态 | 定义 | 无 Key 的行为 | 有 Key 的行为 | -| --- | --- | --- | --- | -| `ABSENT_OR_INVALID` | 无 `Vod.tmdb`、解析失败、身份冲突,或 `toBundle()` 失败 | 影视原始 | 原自动匹配/搜索流程 | -| `IDENTITY_ONLY` | payload 身份合法,但没有任何用户可见字段 | 影视原始 | 使用 payload 的 ID/media type 直接补齐,不做标题搜索 | -| `RENDERABLE` | 至少存在一个用户可见 TMDB 字段 | source-only 用户模式 | source-first 用户模式,仅补齐缺口 | - -“用户可见 TMDB 字段”至少包括一项: - -- 标题或原名; -- 简介、标语、状态; -- 海报、背景、图片集; -- 日期、评分、分类、地区; -- 演职员; -- 外部 ID; -- 季、集或视频; -- 推荐或相似内容。 - -仅存在 `schema/id/media_type/season_number` 或仅声明空能力组,不等于 `RENDERABLE`。 - -#### 18.4.3 运行时模式 - -`runtimeMode` 是一次详情页面会话实际采用的模式,不写回偏好: - -```text -configuredMode == DIRECT - -> DIRECT - -configuredMode == ORIGINAL_ENHANCED - source == RENDERABLE or tmdbReady - -> ORIGINAL_ENHANCED - otherwise - -> DIRECT - -configuredMode in {FUSION, ENHANCED, PLAYER} - source == RENDERABLE or tmdbReady - -> configuredMode - otherwise - -> DIRECT -``` - -`runtimeMode` 一旦基于当前 `Vod` 决定,必须在本次会话内稳定;切源、切 ID、`onNewIntent()` 或显式刷新时重新计算。 - -### 18.5 完整行为矩阵 - -| 配置模式 | Key | 源 payload | 运行时结果 | 网络行为 | -| --- | --- | --- | --- | --- | -| 影视原生 | 任意 | 任意 | 影视原生 | 不因 payload 改变 | -| 原生增强 | 无 | 无/无效 | 影视原生 | 零 TMDB 请求 | -| 原生增强 | 无 | 仅身份 | 影视原生 | 零 TMDB 请求 | -| 原生增强 | 无 | 可展示 | 原生增强 source-only | 零 TMDB 请求 | -| 原生增强 | 有 | 无/无效 | 原生增强 | 原自动匹配 | -| 原生增强 | 有 | 仅身份 | 原生增强 | 按 ID 补齐 | -| 原生增强 | 有 | 可展示 | 原生增强 source-first | 仅补缺口 | -| 独立三模式 | 无 | 无/无效 | 加载后回退影视原生 | 零 TMDB 请求 | -| 独立三模式 | 无 | 仅身份 | 加载后回退影视原生 | 零 TMDB 请求 | -| 独立三模式 | 无 | 可展示 | 用户选择模式和主题 | 零 TMDB 请求 | -| 独立三模式 | 有 | 无/无效 | 用户选择模式 | 原匹配/搜索 | -| 独立三模式 | 有 | 仅身份 | 用户选择模式 | 按 ID 补齐 | -| 独立三模式 | 有 | 可展示 | 用户选择模式 | source-first,仅补缺口 | -| 任意 TMDB 模式 | 任意 | 任意,但站点被 TMDB 规则排除 | 影视原始 | 保持现有站点策略 | - -### 18.6 纯策略接口 - -新增纯逻辑类,建议路径: - -`app/src/main/java/com/fongmi/android/tv/setting/DetailRuntimeModePolicy.java` - -接口: - -```java -public final class DetailRuntimeModePolicy { - - public enum SourceState { - ABSENT_OR_INVALID, - IDENTITY_ONLY, - RENDERABLE - } - - public record Input( - int configuredMode, - boolean tmdbReady, - boolean siteAllowed, - SourceState sourceState - ) {} - - public record Decision( - int runtimeMode, - boolean sourceOnly, - boolean networkAllowed - ) {} - - public static Decision resolve(Input input) { - if (!Setting.isTmdbMode(input.configuredMode())) { - return new Decision(Setting.DETAIL_OPEN_DIRECT, false, false); - } - if (!input.siteAllowed()) { - return new Decision(Setting.DETAIL_OPEN_DIRECT, false, false); - } - boolean renderable = input.sourceState() == SourceState.RENDERABLE; - if (!renderable && !input.tmdbReady()) { - return new Decision(Setting.DETAIL_OPEN_DIRECT, false, false); - } - return new Decision( - input.configuredMode(), - renderable && !input.tmdbReady(), - input.tmdbReady() - ); - } -} -``` - -约束: - -- 该策略不得读取 Activity、Intent、网络或 UI。 -- `siteAllowed` 由 `TmdbSitePolicy` 在调用侧解析后传入。 -- `sourceOnly` 只表示本次会话是否禁止网络补齐,不表示数据一定完整。 -- `networkAllowed` 为 false 时,所有 TMDB 网络入口必须短路。 - -新增纯逻辑可用性判断,建议路径: - -`app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAvailability.java` - -接口: - -```java -public final class TmdbSourceAvailability { - public static TmdbSourceState classify( - Vod vod, - TmdbSourcePayload payload, - TmdbBundle bundle - ); - - public static boolean isRenderable(TmdbBundle bundle); -} -``` - -实现必须复用已经规范化后的 bundle,不得用原始 JSON 直接猜字段。`classify()` 的判定顺序固定为: - -```text -payload == null or bundle == null or identity conflict -> ABSENT_OR_INVALID -!isRenderable(bundle) -> IDENTITY_ONLY -otherwise -> RENDERABLE -``` - -### 18.7 设置层改造 - -#### 18.7.1 Setting - -`Setting.getDetailOpenMode()` 必须改为“纯配置读取”: - -```diff -- return isTmdbMode(mode) && !isTmdbReady() ? DETAIL_OPEN_DIRECT : mode; -+ return mode; -``` - -保留 `Setting.isTmdbDetailPage()` 作为“在线 TMDB 详情能力就绪”的旧语义,不把它改成无 Key 也返回 true。新增独立方法: - -```java -public static boolean isTmdbDetailModeConfigured() { - return isTmdbMode(getDetailOpenMode()) && getTmdbModel() == TMDB_MODEL_NATIVE; -} -``` - -路由使用 `isTmdbDetailModeConfigured()`;网络补齐继续使用 `isTmdbReady()` 或 `TmdbConfig.isReady()`。 - -`getDetailThemeMode()`、`getTmdbDetailStyle()`、`putDetailThemeMode()` 不需要 Key 条件。主题选择只要求模式属于 TMDB 模式。 - -#### 18.7.2 移动端设置页 - -文件:`app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingTmdbFragment.java` - -删除 `setDetailOpenMode()` 中的: - -```java -if (Setting.isTmdbMode(mode) && !Setting.isTmdbReady()) { - dialog.dismiss(); - Notify.show(R.string.detail_tmdb_need_key); - TmdbSourceDialog... - return; -} -``` - -改为直接保存模式: - -```java -Setting.putDetailOpenMode(mode); -setText(); -dialog.dismiss(); -``` - -主题行显示条件继续使用 `Setting.isTmdbMode(Setting.getDetailOpenMode())`。由于模式读取不再降级,无 Key 时也会显示并可保存。 - -#### 18.7.3 Leanback 设置页 - -文件:`app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingTmdbActivity.java` - -执行与移动端相同的接线移除,不新建第二套规则。 - -#### 18.7.4 保持 Key 化的功能 - -以下能力继续要求 `Setting.isTmdbReady()`,本设计不解锁: - -- 自动匹配、手动重匹配、手动选季和 TMDB 搜索; -- 按缺口在线补齐; -- 个性化 TMDB/豆瓣/AI 推荐; -- TMDB 字幕辅助、历史聚合和需要远程 API 的 OMDb 评分; -- 任何写 TMDB 匹配缓存或季匹配缓存的动作。 - -source-only 页面应隐藏或禁用这些入口,不得点击后再弹 Key 对话框。 - -### 18.8 独立三模式接线 - -目标文件:`app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java` - -#### 18.8.1 入口路由 - -移动端和 Leanback `VideoActivity.shouldOpenLegacyTmdbDetail()` 改用: - -```java -return canOpenLegacyTmdbDetail(key, id, cast) - && Setting.isTmdbDetailModeConfigured() - && Setting.isStandaloneTmdbDetailMode(Setting.getDetailOpenMode()); -``` - -不再直接要求 `Setting.isTmdbDetailPage()`。由于此时还不知道爬虫详情是否带 payload,允许先进入 `TmdbDetailActivity`,由加载完成后的运行时策略决定保留或回退。 - -#### 18.8.2 loadContent 分支 - -`TmdbDetailActivity.loadContent()` 固定顺序: - -```text -generation = ++loadGeneration -cancel previous detail tasks -sourceResult = SiteApi.detailContent(key, id) -loadedVod = sourceResult.getVod() -payload = TmdbSourcePayloadParser.parse(loadedVod.tmdb) -bundle = TmdbSourceAdapter.toBundle(payload, loadedVod, tmdbConfig) -siteAllowed = isTmdbAllowedForCurrentSite() -sourceState = TmdbSourceAvailability.classify(loadedVod, payload, bundle) -decision = DetailRuntimeModePolicy.resolve( - getDetailMode(), - tmdbConfig.isReady(), - siteAllowed, - sourceState) - -if decision.runtimeMode == DIRECT: - fallbackToOriginalDetail(loadedVod, directFallbackReason) - return - -if sourceState == RENDERABLE: - applyLoaded(loadedVod, bundle, emptySearchItems, error, false) - if !decision.networkAllowed or no initial gaps: - bindSourceOnlyBlocks(bundle) - return - fillMissingGroupsOnly(...) - return - -if decision.networkAllowed: - run existing loadTmdbResult() / search flow -else: - fallbackToOriginalDetail(...) -``` - -关键约束: - -- `fallbackToOriginalDetail()` 之前不得显示 TMDB 空状态、Key 状态或空列表。 -- `applyLoaded()` 后不得因为 `!tmdbConfig.isReady()` 再显示 `detail_tmdb_need_key`。 -- source-only 路径不得创建 `tmdbFuture`、不得调用搜索、不得调用 `detailForSource()` 或任何分页网络接口。 -- 在线补齐的 generation、identity 和 season 隔离保持第 15.6 节合同。 - -#### 18.8.3 回退影视原生 - -回退必须复用已经加载的 `loadedVod`,禁止再次请求爬虫详情。建议新增: - -```java -private void fallbackToOriginalDetail(Vod loadedVod, String reason) { - if (isFinishing() || isDestroyed()) return; - VideoActivity.startDirectResolved(this, loadedVod); - finish(); -} -``` - -`VideoActivity.startDirectResolved()` 负责: - -- 将 `loadedVod` 放入 `VodDetailCache`; -- 传递 `EXTRA_DETAIL_RUNTIME_MODE = DETAIL_OPEN_DIRECT`; -- 传递 `collect=false`、`cast=false`; -- 不传递 `auto_play=true`; -- 不触发再次 TMDB 路由。 - -回退原因只用于日志,不写偏好: - -```text -source_absent_or_invalid -source_identity_only_without_key -site_tmdb_disabled -``` - -### 18.9 原生增强接线 - -目标文件: - -- `app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` -- `app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` - -#### 18.9.1 运行时模式字段 - -两端各增加: - -```java -private int runtimeDetailMode = Setting.getDetailOpenMode(); - -private boolean isRuntimeFusionMode(); -private boolean isRuntimeOriginalEnhancedMode(); -private boolean isRuntimeDirectMode(); -private void applyRuntimeDetailMode(int mode); -``` - -`EXTRA_DETAIL_RUNTIME_MODE` 只允许: - -- `DETAIL_OPEN_DIRECT`:来源于独立页 fallback; -- `DETAIL_OPEN_ORIGINAL_ENHANCED`:显式直达原生增强源数据页; -- 未传时使用 `configuredMode`。 - -活动内所有影响布局和交互的 `Setting.isFusionDetailPage()`、`Setting.isOriginalEnhancedDetailPage()`、`Setting.isDirectDetailPage()` 必须逐步替换为 runtime 版本;设置页仍读取配置版本。首轮至少覆盖: - -- 移动端 VideoActivity:`isTmdbSourceEnabled()`、`shouldUseUpstreamNativeEpisodeModule()`、`shouldUseTmdbDetailLayout()`、首屏 reveal、Fusion backdrop/control 分支、Original Enhanced 动作区; -- Leanback VideoActivity:`shouldUseUpstreamNativeEpisodeModule()`、`isTmdbSourceEnabled()`、`prepareInitialDetailShell()`、`setOriginalEnhancedActionVisibility()`、`shouldRevealShellWhileLoading()`; -- `TmdbHeaderView` 继续读取当前独立页的配置模式;它不处理 `VideoActivity` fallback。 - -#### 18.9.2 初始化适配器 - -`initTmdbMode()` 不再因为 `!mTmdbUIAdapter.isReady()` 直接放弃 source-only 能力。建议拆成: - -```text -configured TMDB mode && site allowed - -> 创建 TmdbUIAdapter - -canNetworkLoad = adapter.isReady() && site allowed -hasSourceBundle = false - -detail loaded - -> classify source - -> if RENDERABLE: adapter.loadSource(bundle, vod) - -> if no key && no source: applyRuntimeDetailMode(DIRECT) -``` - -`TmdbUIAdapter` 新增无网络方法: - -```java -public void loadSource(TmdbBundle bundle, Vod vod, TmdbSourcePayload payload); -``` - -该方法只允许: - -- 做 generation 校验; -- 写入 `tmdbItem`、`tmdbDetail`、`tmdbCast`、季节/集数、媒体块; -- 调用 `enrichVod()`; -- 派发 `VOD_CORE` 等本地刷新事件; -- 标记 `loaded=true` 和 `sourceOnly=true`。 - -不得调用 `tmdbService.detail()`、`season()`、`episode()`、`search()`、推荐分页或 AI。 - -#### 18.9.3 无 Key、无源数据回退 - -在 `setDetail(Vod item)` 的最早稳定点决策: - -```java -TmdbSourcePayload payload = TmdbSourcePayloadParser.parse(item.getTmdb()); -TmdbBundle sourceBundle = TmdbSourceAdapter.toBundle(payload, item, tmdbConfig); -TmdbSourceState sourceState = TmdbSourceAvailability.classify(item, payload, sourceBundle); -DetailRuntimeModePolicy.Decision decision = DetailRuntimeModePolicy.resolve( - Setting.getDetailOpenMode(), - tmdbConfig.isReady(), - TmdbSitePolicy.isEnabled(getKey(), getId()), - sourceState); -applyRuntimeDetailMode(decision.runtimeMode()); -``` - -顺序必须在以下操作前完成: - -- `mFlagAdapter.addAll()` 和原生/增强选集模块选择; -- `setOriginalEnhancedActionVisibility()`; -- `shouldUseTmdbDetailLayout()`; -- `loadNativePersonalRecommendations()`。 - -`applyRuntimeDetailMode(DIRECT)` 后: - -- `isTmdbSourceEnabled()` 返回 false; -- 继续使用原始线路、原始集名和源详情字段; -- 不显示 TMDB Key 状态; -- 不调用已创建的 adapter 网络路径。 - -#### 18.9.4 有源数据的原生增强 - -当 `sourceState == RENDERABLE` 时: - -```java -applyRuntimeDetailMode(DETAIL_OPEN_ORIGINAL_ENHANCED); -mTmdbUIAdapter.loadSource(sourceBundle, item, payload); -``` - -无 Key 时,`loadSource()` 只做本地绑定和 UI 刷新;有 Key 时,继续按现有 C16 planner 仅补缺失能力。 - -### 18.10 缺失区域的显示合同 - -#### 18.10.1 字段优先级 - -| UI 字段 | source-only 规则 | 有 Key 的补充规则 | -| --- | --- | --- | -| 标题、原名 | TMDB 标题优先,缺失回退 `Vod.name` | 缺失时按现有网络详情补齐 | -| 简介、标语、状态 | TMDB 值优先,简介缺失可回退 `Vod.content` | 缺失时按能力组补齐 | -| 海报、背景 | 合法 TMDB 图片优先,缺失回退 `Vod.pic/wallPic` | 缺失时补齐图片 | -| 日期、评分、类型、地区 | 有值显示;无值隐藏 | 缺失时补齐 | -| 演职员、主创 | 数组非空才显示 | 缺失且未声明完整时补齐 | -| 图片集、海报集 | 非空才显示 | 缺失且未声明完整时补齐 | -| 外部 ID | 非空才显示 | 缺失且未声明完整时补齐 | -| 季、集 | 内嵌有数据才显示 TMDB 信息;源选集始终保留 | 缺季时按 `season:N` 补齐 | -| 视频 | 内嵌有数据才显示 | 缺组时按 scope 补齐 | -| 推荐、相似 | 内嵌有数据才显示 | 缺组时按页补齐 | -| 个性化/AI 推荐 | 无 Key 永远隐藏 | 有 Key 且原设置开启时保留 | - -#### 18.10.2 视图规则 - -对每个 TMDB 专属区域统一执行: - -```text -hasData == false - -> title GONE - -> content/list GONE - -> previous section bottom margin 重置 - -> 不创建空 adapter 项目 -``` - -不得使用以下占位: - -- `"暂无"`、`"未知"`、`"null"`、`"0"`; -- 空字符串 TextView 但保留高度; -- `INVISIBLE` 代替 `GONE`; -- 空列表仍保留标题、分隔线或固定高度; -- `detail_tmdb_empty` 或 `detail_tmdb_need_key` 作为 source-only 的缺字段提示。 - -`binding.tmdbStatus` 规则改为: - -```text -sourceOnly && sourceRenderable - -> GONE -!tmdbReady && !sourceRenderable - -> 不展示状态,因为最终应回退 DIRECT -tmdbReady && !siteAllowed - -> 保持现有 site_disabled 行为 -tmdbReady && no blocks - -> 保持现有 empty 行为 -``` - -### 18.11 分阶段实施顺序 - -不得跳过阶段 A 和支持矩阵。每阶段必须保持可独立回滚。 - -#### 阶段 A:纯策略与测试 - -范围: - -- 新增 `DetailRuntimeModePolicy`; -- 新增 `TmdbSourceAvailability`; -- 增补 `TmdbSourceState`; -- 为配置模式、Key、站点和三种源状态建立纯单元测试。 - -验收:不改任何现有运行时行为;所有决策矩阵测试通过。 - -#### 阶段 B:设置解耦 - -范围: - -- `Setting.getDetailOpenMode()` 去除无 Key 降级; -- 新增 `isTmdbDetailModeConfigured()`; -- 移动端和 Leanback 设置页移除模式 Key 门禁; -- 主题行在任意 TMDB 模式下可选。 - -验收:无 Key 时模式与主题保存后立即回显,重启后保持;网络型功能仍按 Key 隐藏/禁用。 - -#### 阶段 C:独立三模式 - -范围: - -- 路由改用 `isTmdbDetailModeConfigured()`; -- `TmdbDetailActivity.loadContent()` 接入运行时决策; -- `RENDERABLE` 走 source-only; -- 无源数据回退 `VideoActivity` 原始详情并复用缓存; -- 隐藏 source-only 的 Key/空状态。 - -验收:三种独立模式、三种主题、完整/部分/旧源矩阵在无 Key 下行为正确。 - -#### 阶段 D:原生增强 - -范围: - -- 两套 `VideoActivity` 增加 runtime mode; -- `TmdbUIAdapter.loadSource()`; -- 无 Key、无源数据直接使用影视原生路径; -- 无 Key、有源数据启用原生增强 source-only。 - -验收:移动端和电视端均不因配置为原生增强而出现空增强模块或网络错误。 - -#### 阶段 E:缺失区域与交互收口 - -范围: - -- 审计所有 TMDB 标题、列表、间距、状态、重匹配和选季按钮; -- 无 Key 时禁用/隐藏网络型入口; -- 校验回退页、返回、切源、`onNewIntent()` 和进程重建。 - -验收:无空标题、空列表、空值、错误 Key 提示或点击后才失败的入口。 - -#### 阶段 F:设备验收 - -范围: - -- 复用 C16 fixture,增加无 Key 配置; -- Mobile 和 Leanback 各跑完整矩阵; -- 记录请求计数、页面文本、返回栈和偏好保持。 - -### 18.12 最小改动文件清单 - -| 文件 | 改动 | -| --- | --- | -| `app/src/main/java/com/fongmi/android/tv/setting/Setting.java` | 配置读取解耦、新增 configured 判断,不改变网络就绪方法 | -| `app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingTmdbFragment.java` | 移除模式 Key 门禁 | -| `app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingTmdbActivity.java` | 移除模式 Key 门禁 | -| `app/src/main/java/com/fongmi/android/tv/setting/DetailRuntimeModePolicy.java` | 新增纯策略 | -| `app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAvailability.java` | 新增源状态判断 | -| `app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java` | source-only、无源回退、状态隐藏、网络入口收口 | -| `app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java` | 新增无网络 `loadSource()` | -| `app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` | runtime mode、source payload、无源回退 | -| `app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` | runtime mode、source payload、无源回退 | -| `app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbPersonActivity.java` | 仅如需传递 runtime mode,不改变人物页协议 | - -禁止修改: - -- `Vod.tmdb` JSON 合同和 `TmdbSourcePayload` schema; -- T3/T4 的生产者调用协议; -- `TmdbService` 的网络认证或 URL 规则; -- Exo、MPV、FFmpeg、JNI 和二进制依赖。 - -### 18.13 测试矩阵 - -#### 18.13.1 纯单元测试 - -新增或更新: - -| 测试 | 必须证明 | -| --- | --- | -| `DetailRuntimeModePolicyTest` | 所有模式 × Key × site × source state 组合正确 | -| `TmdbSourceAvailabilityTest` | 仅身份不算可展示;任一可见字段算可展示;无效身份拒绝 | -| `SettingDetailModeTest` | 无 Key 时模式读取不再降级;网络就绪判断仍保持 Key 语义 | -| `TmdbSourcePayloadParserTest` | 原有协议和清理规则不回归 | -| `TmdbDetailGenerationTest` | source-only、清空、切源后旧异步结果不能更新 | -| `TmdbUIAdapterTest` | `loadSource()` 只本地绑定,不触发网络 | - -#### 18.13.2 源码/接线测试 - -| 测试 | 必须证明 | -| --- | --- | -| `SettingTmdbSourceOnlyWiringTest` | 两个设置页不再弹出 Key 后拒绝保存模式 | -| `VideoActivityRuntimeModeWiringTest` | Mobile/Leanback 路由和布局使用 runtime mode,而非配置模式 | -| `TmdbDetailSourceOnlyWiringTest` | 无 Key、无源数据调用回退;无 Key、有源数据不创建网络任务 | -| `C16TmdbSourceDetailDeviceTest` | 设备场景覆盖正常路由,而非只显式启动 Activity | - -#### 18.13.3 设备矩阵 - -每项在 Mobile 和 Leanback 各执行一次: - -| 场景 | 期望 | -| --- | --- | -| 无 Key + 旧源 + 配置独立三模式 | 加载后进入影视原生,无 Key 提示,无 TMDB 请求 | -| 无 Key + 仅身份 payload | 影视原生,不按 ID 联网 | -| 无 Key + 完整 payload + 沉浸融合 | 融合布局、用户主题、无 TMDB 请求 | -| 无 Key + 完整 payload + 炫彩详情 | 富信息布局、无 TMDB 请求 | -| 无 Key + 完整 payload + 详情直放 | 播放器优先布局、无 TMDB 请求 | -| 无 Key + 部分 payload | 有值区域显示,缺区域全 GONE,无多余间距 | -| 无 Key + 完整 payload + 原生增强 | `VideoActivity` 内显示源增强,零网络 | -| 无 Key + 旧源 + 原生增强 | 使用影视原生线路和选集,无空增强模块 | -| 有 Key + 旧源 | 原自动匹配不回归 | -| 有 Key + 完整 payload | 零 TMDB 补齐请求 | -| 有 Key + 部分 payload | 只请求缺失能力,源字段优先 | -| 任意 Key + 站点规则禁用 | 影视原生,不进入源 TMDB UI | - -请求计数必须来自可注入的 fake `TmdbService`、request counter 或夹具访问日志。仅凭截图通过不能证明“零网络”。 - -### 18.14 验收标准 - -全部条件必须同时满足: - -1. 无 Key 可以保存任意详情模式和主题,设置回显和重启持久化正确。 -2. 无 Key、无有效源 TMDB 时,独立三模式和原生增强均退化为影视原始行为,不显示 TMDB 空壳或 Key 提示。 -3. 无 Key、有可展示源 TMDB 时,用户选择的模式和主题生效,且全流程零 TMDB 网络请求。 -4. 有 Key 时,原自动匹配、按 ID 补齐、source-first 缺口补齐和站点策略不回归。 -5. 缺字段时 TMDB 专属标题、内容、列表、状态和间距全部消失;没有 `null`、`0`、“暂无”或空占位。 -6. 源线路、源集名、播放 URL、历史续播和返回栈不因模式回退而丢失。 -7. Mobile 与 Leanback 行为一致,差异只允许来自既有布局实现。 -8. 不使用手工修改偏好来伪造设置页通过;设置保存、读取和运行时结果必须闭环。 - -### 18.15 风险、兼容性与回滚 - -| 风险 | 控制 | -| --- | --- | -| 无 Key 用户曾在旧版本保存过 TMDB 模式但一直被运行时降级,升级后行为改变 | 这是目标行为;无源数据仍自动回退,不产生空壳 | -| `getDetailOpenMode()` 变为配置语义后,活动内旧判断误把页面当 TMDB | 阶段 D 必须把布局相关调用全部替换为 runtime mode,并用接线测试锁定 | -| source-only 后处理遗漏网络入口 | 在 `loadTmdbMediaBlocks()`、推荐、季节、单集、视频和重匹配处统一检查 `networkAllowed` | -| 回退重新请求源详情 | 通过 `VodDetailCache` 传递已加载 Vod,并用 fixture 请求日志断言只有一次详情请求 | -| 来源策略被绕过 | 路由和 `TmdbDetailActivity.loadContent()` 都必须检查 `TmdbSitePolicy` | -| 缺失字段导致布局空洞 | 所有区域统一使用 `GONE` 和动态间距复位,设备测试覆盖部分 payload | -| 回滚不完整 | 阶段 A-F 分别提交;关闭第 18 节接线即可回到现有 C16 source-first 行为,协议和生产者数据保持不变 | - -兼容性结论: - -- 旧源、旧客户端和现有 T3/T4 协议不受影响; -- 无 Key 的新行为只增加 source-only 展示,不扩大在线能力; -- 有 Key 的现有路径保持 source-first 和 fill-only 语义; -- 不改变 ABI、依赖、许可证、二进制、缓存格式或 Binder 上限。 - -### 18.16 设计完成定义 - -第 18 节从“文档完成”到“实现完成”必须分别记录,不得混用: - -- 文档完成:本节的决策、接口、文件清单、测试矩阵和回滚合同经用户批准; -- 纯逻辑完成:阶段 A 测试通过; -- 配置完成:阶段 B 设置保存/回读验收通过; -- 独立页完成:阶段 C 三模式及回退矩阵通过; -- 原生增强完成:阶段 D Mobile/Leanback 通过; -- 功能完成:阶段 E 无空字段审计通过; -- 交付完成:阶段 F 设备请求计数和 UI 验收通过,并记录 APK、提交和 recovery tag。 - -阶段 A-F 的提交、验证与设备证据见第 18.17 节。 - -### 18.17 实施记录 - -第 18 节按第 18.11 节顺序完成,未采用“先删除 Key 校验、再补运行时路由”的危险捷径: - -- 阶段 A:`19128b7d18107b5c614078ebdadc863faa2476b5`,标签 `recovery/C16-stage-A/20260919153225-19128b7d1810`。新增 `DetailRuntimeModePolicy`、`TmdbSourceState` 和 `TmdbSourceAvailability`;11 项策略/可用性测试通过,Mobile/Leanback Arm64 Java 编译通过。 -- 阶段 B:`1c4213db0baf24e9da9495edfdbaa9dcab43f527`,标签 `recovery/C16-stage-B/20260919154930-1c4213db0baf`。`getDetailOpenMode()` 变为纯配置读取,新增 `isTmdbDetailModeConfigured()`,Mobile/Leanback 设置页不再因缺少 Key 拒绝保存模式或主题;3 项接线测试通过。 -- 阶段 C:`6a994fa1acd85cd307bf111bc077f6e5a1385da0`,标签 `recovery/C16-stage-C/20260919155514-6a994fa1acd8`。独立详情页按运行时策略选择用户模式/source-only/影视原生;无有效源数据时用已加载 `Vod` 直接回退,不重复请求详情;4 项接线测试通过。 -- 阶段 D:`b2343d1d1de476226b52bef11b80248e1d5033d9`,标签 `recovery/C16-stage-D/20260919160225-b2343d1d1de4`。Mobile/Leanback `VideoActivity` 使用会话级 runtime mode;新增完全无网络的 `TmdbUIAdapter.loadSource()`,补入内嵌季/集缓存、适配器状态和本地 `VOD_CORE` 刷新;220 项定向 Mobile 测试和双 flavor Java 编译通过。 -- 阶段 E:`6e2cde565ae34c1a0da392c7ca93f34cb97aaa99`,标签 `recovery/C16-stage-E/20260919160601-6e2cde565ae3`。source-only 页隐藏重新匹配/选季和网络评分入口,阻止手动匹配、搜索和匹配缓存写入;无数据 TMDB 专属区域不保留空壳;10 项交互/运行时测试通过。 -- 阶段 F:本次收口新增 `C16KeylessSourceDetailDeviceTest`,在测试内清除 TMDB Key、切换可控 C16 假源并在结束后恢复原 VOD/TMDB 配置。设备 `V1923A`、Android 9、`192.168.50.3:5559`;Mobile 和 Leanback 各执行 3 项:完整内嵌 payload 独立页、完整内嵌 payload 原生增强、无扩展旧源回退,全部 `3/3` 通过,无 Key 提示;夹具只收到 `/config.json` 与对应 `/c16_keyless_*` 详情端点。 -- 阶段 F APK SHA-256:Mobile `ffd6ebfdb7d509f68ec4ced17c0bed37326307d00d71f77f32f69440768e805e`;Leanback `c603991d937850a3c705835ae44d912d42f878f8c4617eed5ee225f9a3e0d750`;Mobile AndroidTest `ac46e38fc5a241e25c7837d93071633463b9a7bdb085b4c8971dc2ec02c8dafe`;Leanback AndroidTest `55ce93d5b93f1f0e4108b3630a8a70d84879a0dd93e4d4a7289d9651cb7890f1`。阶段 F 自身的提交与 recovery tag 由本次收口命令创建。 -- 未在本仓库验证:真实 T4 服务端生产数据、真实弱网切源和发布包签名分发;这些不改变客户端第 18 节合同完成状态,但接入生产 T4 前必须由服务端仓库按第 4-9 节完成请求计数和恢复测试。 - -## 19. 订阅接口级 `tmdb_api_key` 临时作用域设计 - -> 状态:用户已确认临时 Key 的预期层级是订阅配置接口响应根对象;本节按该层级实施。详情响应根对象只保留 sanitized 剥离,不再写入凭据作用域。 - -### 19.1 设计修正与结论 - -此前把 Key 放在 `Vod.tmdb` 内、随后又按整个 App 进程全局保留的设计都与最终目标不符。最终合同是: - -1. 正式源字段固定为**订阅配置接口响应根对象**的 `tmdb_api_key`,例如 `/sub/...` 返回的 `sites` 配置对象。不是 `detailContent` 响应根字段,不是 `list[].tmdb.key`,也不进入 `Vod`、`Result`、`TmdbSourcePayload` 或任何详情项。详情响应若仍带同名字段,客户端为了日志/缓存安全会剥离,但不会接受它。 -2. 临时 Key 的使用范围是**当前订阅接口**,而不是整个 App 进程,也不是单条详情。当前接口内的不同站点、条目、详情页和 Activity 可以共用它。 -3. 切换订阅接口时必须先清空旧接口的临时 Key 和作用域 epoch;新接口返回 `tmdb_api_key` 时才加载,新接口没有返回时保持为空,不允许沿用上一个接口的值。 -4. App 已配置的 `accessToken` 或 `apiKey` 永远优先;存在用户配置时,源 Key 必须丢弃,不进入订阅临时作用域。 -5. Key 只存在当前 App 进程内存中,并且必须绑定到规范化后的订阅接口身份 `(configId, configUrl)`;不能写入偏好、数据库、文件、备份、Parcel、Intent、Binder、日志、崩溃信息、详情缓存或诊断包。 -6. 同一订阅接口存活期间不设置时间 TTL。页面切换、站点切换、条目切换和 Activity 重建都不清空;只有切换订阅接口、用户配置出现、收到新的有效 Key、认证失效、显式清理或进程结束才变化。 -7. 源 Key 只允许向官方 HTTPS TMDB API 主机发送;用户自定义 API Base、HTTP、局域网和代理地址一律忽略该 Key。 -8. 源 Key 失效、网络失败或能力不支持时,无提示回退到已有源内嵌数据或影视原始详情,不弹 Key 配置提示,不循环重试。 - -一句话概括:**Key 跟随“当前订阅接口”的内存会话;切换接口先清空,接口内一直可用,接口外绝不复用。** - -### 19.2 用户可见行为 - -在用户未配置 TMDB Key 的前提下: - -| 场景 | App 行为 | -| --- | --- | -| 当前订阅配置无 `tmdb_api_key` | 保持第 18 节 source-only/影视原始详情,不新增 TMDB 请求 | -| 当前订阅配置首次返回合法 `tmdb_api_key` | 配置解析成功后剥离原文,绑定当前接口身份存入内存;后续本接口请求可使用 | -| 同一接口内切换站点、条目或详情页 | 临时 Key 继续可用,不重新要求源返回 | -| 同一接口内 Activity 重建 | 只要 App 进程没有重建,临时 Key 继续可用 | -| 切换到另一个订阅接口 | 先清空旧接口 Key 和 epoch;新接口有 Key 再加载,没有则保持为空 | -| 切回原订阅接口 | 视为一次新的接口切换,先清空;只有新订阅配置再次返回 Key 才能使用 | -| App 进程重启 | 临时 Key 不存在,必须重新加载订阅配置获取 | -| Key 无效、超时或限流 | 保留源数据或公开缓存,缺失区域隐藏,回退不阻塞详情 | -| 用户随后配置了 TMDB Key | 立即以用户配置为准;源临时 Key 不再参与请求 | - -用户不应看到源 Key、Key 来源或“正在使用订阅临时凭据”等技术信息。设置页中的“TMDB Key 是否已配置”仍只表示用户配置,不因订阅临时作用域有值而改变。 - -### 19.3 当前实现与切换点 - -| 位置 | 当前行为 | 修正设计的影响 | -| --- | --- | --- | -| `VodConfig.config(Config)` | `124-127` 行是所有启动、切换和 failover 路径设置当前接口的统一入口 | 在这里比较旧/新 `config.id + url`;身份变化时先清空旧 Key、递增 epoch,再保存新接口身份 | -| `VodConfig.startNewLoad()` / `loadFailoverAttempt()` | `103-107`、`115-118` 行最终都会调用 `config(config)` 后加载新接口 | 不在每个调用点复制凭据逻辑,统一依赖 `config()` 的作用域切换 | -| `VodConfig.clear()` | `133-149` 行会在新接口加载前清空站点、规则和 loader 状态 | 不应无条件清除凭据,否则同接口刷新会误清;由随后调用的 `config(Config)` 根据身份决定保留或清空 | -| `VodConfig.load(Config)` | 订阅配置 JSON 在 `CatSource.normalize()` 前被解析 | 成功解析且订阅身份/epoch 匹配时,从配置响应根对象接收 `tmdb_api_key`;失败或旧 epoch 不写入 | -| `SiteApi.detailContent()` | T3/T4 原始详情在日志、解析和缓存前完整存在 | 详情根对象中的 `tmdb_api_key` 仅被 ingress 剥离并丢弃;不得写入订阅作用域 | -| `Result.fromJson()/fromType()` | 只关心 `list/code/...` 等公开字段 | subscription-config ingress 已剥离 Key,`Result` 不需要持有 Key | -| `VodDetailCache.putContent()` | `225-227` 行缓存 `Result.toString()` | 只缓存 sanitized Result,绝不缓存原始带 Key JSON | -| `TmdbConfig` | `apiKey/accessToken` 只来自用户配置,`isReady()` 表示用户配置就绪 | 新增当前订阅作用域快照与 `effective()`,不修改 `Setting` | -| `TmdbService` | 已有 api_key/Bearer 请求、认证熔断和响应缓存 | 继续接收 `TmdbConfig`;临时 Key 只能经当前订阅作用域进入 | -| `TmdbUIAdapter` | 构造时固定用户配置并长期持有 | 每次请求前取当前订阅的有效配置,旧 epoch 请求必须失效 | -| `TmdbDetailActivity` / 两个 `VideoActivity` | 无用户 Key 时只运行第 18 节路径 | 可使用当前订阅临时配置,但切换接口后旧页面不得继续使用旧 Key | -| 旧客户端 | 在解析前记录完整源详情 | 新客户端无法保护旧客户端,生产源必须做版本/能力门控 | - -### 19.4 方案比较 - -| 方案 | 优点 | 缺点 | 决定 | -| --- | --- | --- | --- | -| 不变更,用户必须自己配置 Key | 零新增风险 | 不满足源直接提供 Key 的目标 | 仅作为关闭开关 | -| Key 放入每条 `Vod.tmdb`,当前详情使用 | 身份绑定清晰 | 不符合接口级复用目标,且容易进入详情模型/缓存 | 拒绝 | -| Key 在 App 进程中全局复用 | 使用最方便 | 切换订阅接口会串用旧 Key,不符合用户要求 | 拒绝 | -| 订阅配置响应根级 `tmdb_api_key` + 当前订阅接口临时作用域 | 与订阅身份天然同层;接口内可复用;配置切换即清空;不落盘 | 需要完整的切换、epoch 和异步失效控制 | 推荐 | -| 源端代理 TMDB,App 完全不接触 Key | 安全最好 | 需要源端实现和额外数据合同 | 长期首选替代方案 | - -### 19.5 数据合同 - -订阅配置接口(例如 `/sub/...`)返回示例: - -```json -{ - "tmdb_api_key": "", - "sites": [ - { - "key": "example_site", - "name": "示例站点", - "api": "https://source.example/api" - } - ] -} -``` - -字段规则: - -1. `tmdb_api_key` 是**订阅配置响应根对象**的可选字符串字段;仅作为 TMDB v3 `api_key`,不是 Bearer Token,不是 v4 Access Token。 -2. 不提供、空字符串、非字符串、含控制字符、超长或 JSON 类型异常时,按“当前接口没有源 Key”处理;错误不能破坏 `sites` 配置解析。 -3. 规范化值为去除首尾空白后的 16 至 256 个可打印 ASCII 字符;不接受空白、换行和控制字符。 -4. C16 详情的 `schema` 仍为 `1`,`list[].tmdb`、`Vod`、`Result` 和旧详情响应结构不变。 -5. 订阅配置 Key 在 `VodConfig` 调用 `CatSource.normalize()` 和保存站点配置之前剥离;只有订阅配置解析成功、站点列表非空、当前订阅身份和 epoch 匹配且用户没有 TMDB 配置时才进入订阅级内存作用域。 -6. 不允许放在 HTTP header、播放 URL、图片 URL、`vod_*`、`tmdb.detail`、`ext` 或详情响应中作为正式来源;生产者必须使用订阅配置响应根字段。 -7. 详情响应根对象若出现同名 `tmdb_api_key`,客户端只在日志、解析和详情缓存前剥离,随后丢弃;它不能覆盖或补充订阅配置 Key。 -8. 旧接口的异步配置/详情响应在切换后即使返回 Key,也必须因 epoch/接口不匹配而丢弃。 - -### 19.6 订阅接口级凭据作用域 - -#### 19.6.1 接口身份 - -“订阅接口”使用 `VodConfig` 当前 `Config` 的稳定身份: - -```text -subscriptionKey = config.id + NUL + normalizedConfigUrl -``` - -`normalizedConfigUrl` 至少去除首尾空白和无语义尾部斜杠。若 URL 的响应会重定向,仍以用户选择并写入 `Config` 的规范化 URL 为准,不能把重定向后的临时地址当成新接口。 - -#### 19.6.2 数据结构 - -建议新增进程内单例: - -```java -public final class SubscriptionTmdbCredentialStore { - public static synchronized Scope beginSubscription( - int configId, - String configUrl, - String reason - ); - - public static synchronized void accept( - String apiKey, - int configId, - String configUrl, - long scopeEpoch, - String sourceKey, - String sourceRevision - ); - - public static synchronized Snapshot snapshot( - int configId, - String configUrl, - long scopeEpoch - ); - - public static synchronized void clear(); -} -``` - -`beginSubscription()` 语义: - -- 若当前作用域身份与传入接口相同,保留已有临时 Key 和 epoch,用于同一接口刷新/重载。 -- 若身份不同,先清除旧 Key,递增 epoch,再建立新接口作用域;此时新接口 Key 为空,等待订阅配置响应。 -- 首次启动或 failover 切换同样走该入口,不允许绕过。 - -`Snapshot` 至少包含 `apiKey`、`subscriptionKey`、`scopeEpoch`、`receivedAt`、`sourceKey` 和不可逆指纹。没有 Key 时返回空的 `Snapshot`,不能用 `null` 让调用方误读成“尚未初始化”。 - -#### 19.6.3 优先级 - -| 用户配置 | 当前接口临时 Key | 有效配置 | 行为 | -| --- | --- | --- | --- | -| 有 `accessToken` 或 `apiKey` | 有/无 | 用户配置 | 源 Key 丢弃,不能覆盖或旁路用户错误 | -| 无用户配置 | 有,且订阅身份和 epoch 匹配 | 临时源 Key | 在官方 HTTPS API 主机上作为 `api_key` 使用 | -| 无用户配置 | 无 | 无 | 保持第 18 节 source-only/原始详情 | -| 有用户配置但认证失败 | 有 | 用户配置 | 不自动回退源 Key | -| 无用户配置 | 有但 API Base 非官方 | 无 | 不发送源 Key | - -#### 19.6.4 生命周期 - -1. **接口建立/切换**:所有路径最终进入 `VodConfig.config(Config)`;该入口调用 `beginSubscription()`,身份变化时原子清空旧 Key 和旧 epoch,同接口重载则保留。 -2. **接收**:`VodConfig.load(Config)` 在订阅配置 raw JSON 进入 `CatSource.normalize()` 前提取根级 `tmdb_api_key`;`SiteApi` 对详情中的同名字段只剥离、不接受。 -3. **验证与写入**:订阅配置解析成功、站点列表非空且候选接口身份、epoch 仍匹配时,才允许 `accept()`。 -4. **接口内使用**:请求线程每次都使用当前订阅身份和 epoch 取快照;不同站点、条目和页面可复用,但不可跨接口。 -5. **切换失效**:切换接口立即清除 Key 并递增 epoch;旧请求完成时身份或 epoch 不匹配,不能写入、刷新 UI 或影响新接口。 -6. **认证失效**:401/403 只清除当前接口对应 Key;不污染其他接口的潜在值,因为其他接口的值在切换时本已清空。 -7. **释放**:用户配置出现、显式清空或进程结束即释放引用。Activity `onDestroy()` 不清空,除非它同时触发了接口切换。 -8. **重建**:Activity 配置变更或普通 `onDestroy()` 不丢失 Key;整个 App 进程重建后作用域为空,必须重新加载订阅配置获得。 - -Java `String` 无法可靠擦除底层字节,因此“不落盘”的准确含义是不进入持久化、序列化或日志路径,并在订阅作用域结束、认证失效或进程结束时清除引用;不能宣称内存字节被物理清零。 - -### 19.7 安全边界 - -#### 19.7.1 网络白名单 - -源 Key 只允许发送到: - -- `https://api.tmdb.org/3` -- `https://api.themoviedb.org/3` - -校验必须基于规范化后的 `apiBase`:HTTPS、精确 host 匹配、无用户名密码、无非标准端口。任何自定义域名、IP、镜像、代理、HTTP 或含附加认证路径的 Base 都不能注入源 Key。 - -#### 19.7.2 禁止泄漏面 - -| 泄漏面 | 必须行为 | -| --- | --- | -| `SpiderDebug` / raw detail | 先剥离根字段再记录;异常路径只记录长度、站点和解析状态 | -| URL / HTTP 异常 | `api_key`、`key`、Bearer 和查询参数全部脱敏 | -| `VodDetailCache` | 只缓存 sanitized JSON;带原始 Key 的字符串不得进入缓存 | -| `Result` / `Vod` / `TmdbSourcePayload` | 不新增 Key 字段;Gson、Parcel、copy、equals、toString 均不可见 | -| Intent / Bundle / Binder | 不传 Key 明文;界面之间只通过当前订阅作用域读取 | -| SharedPreferences / Room / 文件 / 备份 | 不写 Key;导入导出和诊断包扫描不到 Key | -| TMDB 响应缓存 | 只保存公开响应;不得保存可还原的认证 URL 或明文 Key | -| UI / 通知 / 截图 / accessibility | 不显示、不朗读、不设置文本 | -| 崩溃与性能日志 | 异常和指标不得包含根字段、认证 header 或完整请求 URL | - -旧客户端的限制必须单独写清:旧版本会在 `SiteApi` 解析前记录完整源响应。新客户端无法阻止旧客户端泄漏,因此生产源必须使用客户端版本/能力门控;无法门控时不得把共享 API Key 直接返回给所有客户端。 - -#### 19.7.3 权限与范围 - -订阅级临时 Key 可以在**当前订阅接口内**由既有 `TmdbConfig` 请求路径使用,但不具备跨订阅能力: - -- 只允许官方 TMDB API; -- 只允许当前订阅身份和当前 epoch 读取快照; -- 切换接口后,旧页面、旧任务和旧详情不能再读取; -- 用户设置和用户操作仍以用户配置为准; -- 不把临时 Key 写入设置、匹配绑定、历史、备份或跨进程共享; -- 不把源字段解释为 Bearer Token; -- 不允许通过自定义 API Base、代理或第三方域名使用; -- 用户配置出现后立即丢弃临时 Key。 - -如果产品希望进一步限制为“当前订阅内只用于详情/季集、不允许搜索或个人推荐”,应在批准阶段 2 时明确收窄;本文默认按当前订阅内的现有 TMDB 请求能力设计。 - -### 19.8 运行时架构 - -#### 19.8.1 切换与入口管线 - -```text -用户选择/自动切换订阅接口 - -> VodConfig.startNewLoad() / loadFailoverAttempt() - -> VodConfig.clear().config(config) - -> SubscriptionTmdbCredentialStore.beginSubscription(configId, configUrl, reason) - -> 身份不同:清除旧 Key,epoch++ - -> 身份相同:保留当前 Key - -> config(config).load() - -subscription config raw JSON (/sub/...) - -> TmdbSourceCredentialIngress.extractRootAndStrip() - -> sanitized config JSON - -> candidate apiKey - -> CatSource.normalize() / sites 解析 - -> 配置有效、站点非空且当前订阅身份/epoch 匹配时 accept() - -> 业务层只通过 effective config 读取 - -detailContent raw JSON (legacy compatibility only) - -> TmdbSourceCredentialIngress.extractRootAndStrip() - -> sanitized detail JSON - -> candidate is discarded, never accepted - -> Result.fromJson() / Result.fromType() / VodDetailCache -``` - -推荐接口: - -```java -public final class TmdbSourceCredentialIngress { - public static Ingress extractRootAndStrip(String rawJson); -} - -record Ingress(String sanitizedJson, String candidateKey) {} -``` - -核心要求: - -1. 订阅配置根字段只允许字符串;解析错误移除字段并继续 `sites` 配置解析。 -2. 同一个剥离器可用于订阅配置和详情响应,但只有 `VodConfig` 的订阅配置路径可以调用 `accept()`;`SiteApi` 不得接受详情候选。 -3. `VodDetailCache` 不参与 Key 刷新;详情缓存命中不能恢复或更新临时凭据。 -4. 认证失败、无效形式、超长输入和异常只影响候选 Key,不影响订阅配置、普通详情和播放线路。 -5. 旧接口提交来的 Key 即使格式正确,只要 `subscriptionKey` 或 `scopeEpoch` 不匹配就必须丢弃。 - -#### 19.8.2 有效配置 - -新增只读工厂方法,不改变用户 `Setting`: - -```java -TmdbConfig configured = TmdbConfig.objectFrom(Setting.getTmdbConfig()); -TmdbConfig effective = TmdbConfig.effective( - configured, - SubscriptionTmdbCredentialStore.snapshot( - currentConfigId, - currentConfigUrl, - currentScopeEpoch - ) -); -``` - -规则: - -1. `configured.isReady()` 为 true:返回用户配置副本,并清空订阅临时 Key。 -2. 用户未配置且当前接口快照有 Key:返回临时 `apiKey` 配置,来源标记为 `TRANSIENT_SUBSCRIPTION`。 -3. 无有效来源:返回无凭据配置,保持第 18 节路径。 -4. 临时配置必须保留官方 `apiBase`、语言和图片配置;不得继承用户自定义 Base 后再注入源 Key。 -5. 调用方必须同时校验 `subscriptionKey` 和 `scopeEpoch`;只有 Key 存在但接口不匹配时按无 Key 处理。 - -需要区分: - -- `configuredReady`:用户是否在设置中配置了 Key; -- `effectiveReady`:当前订阅接口是否有用户配置或合法临时 Key。 - -设置页、Key 状态、备份和用户偏好继续使用 `configuredReady`;请求规划使用 `effectiveReady`。 - -#### 19.8.3 Adapter 接线 - -1. `TmdbUIAdapter` 不永久持有构造时的用户配置或临时 Key;每次加载或每个网络任务开始前读取当前订阅快照。 -2. 网络任务启动时捕获 `subscriptionKey`、`scopeEpoch`、generation 和 TMDB identity;完成时必须全部匹配。 -3. `VodConfig` 切换接口递增 epoch 后,旧任务的结果不得通过 `RefreshEvent`、详情缓存或 UI 更新写回。 -4. `release()`、Activity `onDestroy()`、换源站点和普通页面刷新只释放当前任务引用;只有 `VodConfig` 接口切换、用户配置变化、401/403、显式重置或进程结束才清空订阅作用域。 -5. `TmdbMatcher`、详情、季集、视频和推荐等现有请求都从 effective config 取凭据;临时 Key 不绕过站点策略。 -6. 若产品决定限制搜索或个人推荐,则在对应入口检查 `origin == TRANSIENT_SUBSCRIPTION` 后短路;不能只依赖 UI 隐藏。 - -### 19.9 缓存、并发和失败策略 - -#### 19.9.1 缓存 - -1. 原始带 Key JSON:绝不缓存。 -2. sanitized 详情 JSON:按现有 `VodDetailCache` 规则缓存,不含 Key。 -3. TMDB 公开响应:沿用现有缓存;认证参数不得进入文件内容、文件名、日志或可还原 URL。 -4. 订阅临时作用域:只在当前 App 进程和当前订阅接口中存在,不参与 `Result`、`Vod`、Parcel 或 `SavedState`。 -5. 进程重启:缓存可以提供公开 TMDB 数据,但不能恢复源 Key。 -6. 接口切换:即使详情或 TMDB 响应缓存仍存在,旧接口 Key 也不得被新接口读取。 - -#### 19.9.2 并发 - -1. 读取使用不可变快照;每个网络任务只持有当前接口和 epoch 对应的一次性引用。 -2. `beginSubscription()`、`accept()`、`clear()` 使用同步或等价原子更新,避免切换和详情写入竞态。 -3. 切换接口时先递增 epoch 再清除 Key;任何旧 epoch 的完成回调都必须丢弃。 -4. 用户配置出现后,后续请求必须立即拒绝订阅临时快照。 -5. 认证熔断继续使用不可逆指纹,不把原始 Key 作为 map key、日志标签或指标维度。 -6. 同一接口内多个站点的 Key 以最后一次成功订阅配置响应为准;新接口不继承旧接口值。 - -#### 19.9.3 失败降级 - -| 失败 | 行为 | -| --- | --- | -| 根字段缺失或非法 | 当前接口池保持为空或旧值不变,继续第 18 节逻辑 | -| 用户已有配置 | 丢弃源 Key,使用用户配置 | -| API Base 非白名单 | 丢弃源 Key,不发送 | -| 401/403 | 只清除当前订阅临时 Key,保留已有源数据或公开缓存 | -| 429/超时/网络失败 | 保留源数据,不循环重试;是否保留 Key 由 4xx/5xx 分类决定 | -| 源数据完整 | 即使当前接口有 Key,也不因 Key 产生新的首屏请求 | -| 切换到新接口且新接口无 Key | 新接口有效配置为空,回退 source-only/原始详情 | -| 切换回旧接口 | 旧 Key 已清除;该接口的新订阅配置响应再次提供 Key 后才可使用 | - -### 19.10 生产者实施合同 - -T3 爬虫和 T4 服务端: - -1. 只在订阅配置响应根对象返回 `tmdb_api_key`;不要在详情响应、每条 `Vod.tmdb` 或订阅 URL 查询参数中重复。 -2. 不把 Key 放入 HTTP header、播放地址、图片地址、`vod_*`、错误信息或第三方 analytics。 -3. 尽量通过 HTTPS 提供订阅配置;HTTP 会暴露 Key,必须在源端风险评估中单独处理。 -4. 优先使用可轮换、低配额、可撤销的共享 Key,并准备失效后的快速轮换流程。 -5. 源端必须实施客户端版本/能力门控;旧客户端会记录完整 raw response。 -6. 源已经提供完整 C16 数据时应优先返回数据;Key 只用于客户端确有缺失能力的场景。 -7. 源端不得假设 Key 会跨订阅接口复用;客户端切换接口时会主动清除。 - -### 19.11 实施文件与职责 - -| 文件 | 计划改动 | -| --- | --- | -| `app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java` | 在统一的 `config(Config)` 入口比较订阅身份并建立作用域;`load(Config)` 在订阅配置解析和站点校验成功后从响应根对象接收 Key;`clear()` 不无条件清除凭据 | -| `app/src/main/java/com/fongmi/android/tv/api/SiteApi.java` | 详情入口先执行根级剥离,防止 legacy 同名字段进入日志/缓存;但绝不调用 `accept()`,也不把详情字段写入临时作用域 | -| 新增 `TmdbSourceCredentialIngress.java` | 根字段解析、类型/长度/字符校验、sanitized JSON 生成和无异常降级 | -| 新增 `SubscriptionTmdbCredentialStore.java` | 订阅身份、epoch、可选 Key、原子切换/清理和快照读取 | -| `app/src/main/java/com/fongmi/android/tv/bean/TmdbConfig.java` | 当前订阅的有效配置、临时来源标记、官方 host 校验;不改用户持久配置 | -| `app/src/main/java/com/fongmi/android/tv/service/TmdbService.java` | 认证/URL 脱敏、临时凭据指纹熔断、保证缓存不保存 Key | -| `app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java` | 每次请求读取当前订阅有效配置,校验订阅身份/epoch/generation | -| `app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java` | 无用户 Key 时按当前订阅有效配置决定网络补齐;切换后回退第 18 节 | -| `app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` | 原生增强路径使用当前订阅有效配置,不把 Key 放入 `Vod` | -| `app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` | 与 Mobile 等价接线和生命周期验证 | -| 测试目录 | 订阅配置根字段、详情 legacy 字段剥离、订阅切换清空、接口内复用、旧响应隔离、401、缓存、日志、Parcel 和双端设备测试 | - -不得修改 `TmdbSourcePayload.SCHEMA_VERSION`,不得新增 `Vod.tmdb.key`,不得让 `Result` 或 `Vod` 携带 Key。 - -### 19.12 分阶段实施计划 - -每阶段独立提交、验证和回滚;前一阶段的泄漏/作用域边界未通过,不得进入下一阶段。 - -#### 阶段 1:订阅配置根级接收、详情剥离、订阅作用域和不落盘 - -范围: - -- 新增 `TmdbSourceCredentialIngress` 和 `SubscriptionTmdbCredentialStore`; -- `VodConfig.load(Config)` 在 `CatSource.normalize()` 前剥离订阅配置根级 `tmdb_api_key`,配置有效且身份/epoch 匹配后写入作用域; -- `VodConfig` 在接口切换前建立新作用域,切换时清空旧 Key; -- `SiteApi` 在详情日志/解析/缓存前仍剥离 legacy `tmdb_api_key`,但丢弃候选、不写入作用域; -- 锁定 `Result`、`Vod`、`TmdbSourcePayload`、Parcel 和详情缓存的不可见性。 - -验收: - -- 原始 Key 不出现在 sanitized JSON、日志、详情缓存、`toString()`、Parcel、偏好、数据库或文件; -- 无根字段旧订阅配置逐字段保持当前行为; -- 新接口没有 Key 时旧接口 Key 不能被读取; -- 重复、非法、超大订阅配置根字段只影响 Key 候选,不影响站点解析;详情 legacy 根字段只影响剥离,不影响详情。 - -#### 阶段 2:有效配置、接口内复用与切换竞态 - -范围: - -- 实现当前订阅 `effective()`、官方 Base 白名单和 configured/effective 双语义; -- `TmdbUIAdapter`、详情页、Mobile/Leanback 原生增强改用当前订阅有效配置; -- 用户配置出现时清空临时作用域; -- 旧接口的异步详情、匹配和详情补齐结果在 epoch 不匹配时全部丢弃。 - -验收: - -- 用户配置优先,源 Key 不覆盖用户; -- 同一订阅内跨站点、条目、详情和 Activity 保持可用; -- 切换订阅接口立即清空,新接口无 Key 时为空,新接口有 Key 才生效; -- 自定义 Base、HTTP、非白名单 host、401/403 均不会继续发送。 - -#### 阶段 3:详情、季集、视频、推荐能力收口 - -范围: - -- 复用 C16 `TmdbSourceCapabilityPlanner` 的首屏缺口; -- 接通季、集、视频、推荐/相似和个人推荐的现有请求; -- 保持完整源数据零新增请求和 fill-only 合并; -- 切换接口时取消或隔离所有旧详情任务。 - -验收: - -- 完整 payload 零网络; -- 缺失能力只按现有 planner 请求; -- 电视季集和推荐分页遵守当前订阅作用域、取消和去重; -- 无效 Key 回退不出现空标题、空列表或 Key 提示。 - -#### 阶段 4:泄漏扫描与回归 - -范围: - -- 扫描 `SpiderDebug`、异常、URL、偏好、Room、文件、备份、Parcel、Intent、Bundle 和诊断导出; -- 补齐 T3/T4 同合同测试; -- 记录测试 Key 的注入、切换、清空和撤销结果。 - -验收: - -- 自动化与设备日志均找不到测试 Key; -- 用户配置 Key、无 Key 第 18 节路径、当前订阅临时路径互不回归; -- 临时作用域在接口切换、接口内复用、401/403、用户配置和进程重启场景行为正确。 - -#### 阶段 5:Mobile/Leanback 设备验收 - -场景: - -- 无用户 Key、根字段合法、源 payload 完整/部分/仅身份; -- 用户已配置 Key 且源同时返回 Key; -- 同一接口跨条目/跨站点复用; -- 切换到无 Key 新接口后不回退旧 Key,新接口返回 Key 后才可用; -- 切回旧接口后旧 Key 不恢复; -- 401/403、进程重启和 Activity 重建; -- Mobile 与 Leanback 各至少 3 组核心场景。 - -验收: - -- 记录请求端点、次数、源码来源、当前订阅身份、UI 状态和 Artifact SHA-256; -- 真实第三方 Key 未验证时必须标记为未验证,不能用 stub 通过替代。 - -### 19.13 验收矩阵 - -| 编号 | 场景 | 预期 | -| ---: | --- | --- | -| 19-A1 | 旧订阅配置与旧 T3/T4 均无 `tmdb_api_key` | 与第 18 节完全一致,无新增 TMDB 请求 | -| 19-A2 | 用户已配置 Key,订阅配置返回根字段 | 只用用户配置;源值不进入作用域、不进入日志/缓存 | -| 19-A3 | 无用户配置,订阅配置合法根字段 + 完整 payload | 源数据上屏;首屏 TMDB 请求数为 0 | -| 19-A4 | 无用户配置,订阅配置合法根字段 + 部分 payload | 按有效配置只补缺失能力,源字段不被覆盖 | -| 19-A5 | 无用户配置,订阅配置合法根字段 + 仅身份 | 可用有效配置加载详情;失败回退原始详情 | -| 19-A6 | 同一订阅接口内跨站点/条目/详情 | 临时 Key 持续可用,不清空 | -| 19-A7 | 切换到无 Key 的新订阅接口 | 旧 Key 先清空;新接口有效配置为空,绝不沿用旧 Key | -| 19-A8 | 切换到有 Key 的新订阅接口 | 新订阅配置返回 Key 后加载;旧接口值与旧异步结果均不可用 | -| 19-A9 | 切回原订阅接口 | 之前 Key 已清除,必须重新由该接口的订阅配置响应提供 | -| 19-A10 | 订阅配置根字段非法/重复/超长 | 不写作用域、不崩溃、站点配置正常;详情 legacy 同名字段只剥离 | -| 19-A11 | 自定义 API Base | 不发源 Key,按无有效配置处理 | -| 19-A12 | HTTP/非白名单 host | 不发源 Key | -| 19-A13 | 站点被 TMDB 策略排除 | 不新建临时请求,保持普通详情 | -| 19-A14 | 401/403 | 清除当前订阅临时 Key,保留源数据/公开缓存,无循环重试 | -| 19-A15 | 429/超时 | 不无限重试,按失败策略回退 | -| 19-A16 | 进程重启 | 作用域为空;公开缓存不能恢复 Key | -| 19-A17 | `Result.toString()`、Parcel、详情缓存、Room、偏好 | 均不包含/不恢复 Key | -| 19-A18 | 日志、崩溃、URL、诊断、备份 | 扫描不到 Key | -| 19-A19 | 订阅配置接口 + T3/T4 详情 | 配置根字段是唯一凭据来源;详情 legacy 字段只剥离;订阅身份、epoch、优先级和请求计划一致 | - -### 19.14 验证证据要求 - -1. 单元测试覆盖订阅配置根字段合法、缺失、重复、非字符串、超长、控制字符,以及无 `sites`/无 `tmdb` 上下文;详情 legacy 同名字段必须仅剥离。 -2. 订阅作用域测试覆盖同接口复用、不同接口清空、A→B→A 不复用旧 Key。 -3. 并发测试证明切换接口后旧 epoch 的订阅配置、详情、匹配和补齐响应不能写入凭据或刷新 UI。 -4. 序列化测试证明 `Result.toString()`、`Vod.toString()`、`TmdbSourcePayload.ProtocolAdapter` 和 Parcelable 不包含 Key。 -5. 缓存测试证明 raw 带 Key JSON 不进入 `VodDetailCache`,sanitized JSON 保持现有缓存行为。 -6. 配置测试证明用户 Key 优先、官方 Base 白名单、非官方 Base 禁用、401/403 清理和用户配置覆盖。 -7. 生命周期测试证明同接口 Activity 重建不清空,接口切换立即清空,进程重建后为空,卸载/备份/诊断包找不到 Key。 -8. 请求测试记录端点、次数、订阅身份、epoch、认证来源和回退状态;日志、异常和缓存文件扫描不到测试 Key。 -9. 最终至少执行 `git diff --check`、定向单测、双 flavor Java 编译和可用设备场景;构建成功不能替代安全验证。 - -### 19.15 风险、兼容性与回滚 - -| 风险 | 等级 | 控制 | -| --- | --- | --- | -| 切换订阅接口后旧 Key 串用 | 高 | `beginSubscription()` 比较接口身份,变化时先清 Key、递增 epoch,再加载新接口 | -| 旧异步详情在新接口写入 Key | 高 | Key 写入和业务消费同时校验 subscriptionKey + scopeEpoch | -| 旧客户端记录 raw root field | 高 | 服务端版本/能力门控;无法门控则不投放共享 Key | -| 自定义 Base 截获 Key | 高 | 仅官方 HTTPS host,其他配置直接忽略 | -| 日志、缓存、Parcel、备份泄漏 | 高 | 入口剥离、sanitized 缓存、订阅内存作用域和全路径测试 | -| 共享 Key 被限流/撤销 | 中高 | 源端轮换、限额、完整数据零请求、尊重 429,不做无限重试 | -| 同一接口重载时误清 Key | 中 | 身份相同且 epoch 相同时保留;身份变化才清空 | -| Java String 无法物理擦除 | 中 | 限定作用域和引用释放;不承诺物理清零 | -| 数据合同破坏旧 C16 | 低 | 根字段可选、schema 不变,无字段行为不变 | -| 权限在订阅内扩展到搜索/推荐 | 中 | 若产品要求收窄,阶段 2 可配置为详情/季集白名单 | - -兼容性结论: - -- 不影响无根字段的旧源、旧 T4 和无 Key 第 18 节行为; -- 不改变用户配置 Key 的现有功能和优先级; -- 不改变 C16 `tmdb` 内嵌数据、`complete` 和 fill-only 合并合同; -- 不增加依赖、ABI、二进制、许可证或网络协议; -- 可单独关闭:服务端停止返回根字段,App 入口不再写入订阅作用域即可回退。 - -回滚: - -1. 移除 `VodConfig.config(Config)` 对 `beginSubscription()` 的调用,再移除 `SiteApi` 根字段提取;未知字段自然被忽略。 -2. 删除 `SubscriptionTmdbCredentialStore` 的使用点,恢复直接使用用户 `TmdbConfig`。 -3. 生产者停止返回 `tmdb_api_key`;无需修改 C16 `schema` 或每条 `Vod.tmdb`。 -4. 如果发生泄漏,先轮换源 Key 并停止投递该字段,再回滚 App;不能只依赖代码回滚解决已暴露凭据。 - -### 19.16 实施完成定义 - -- **设计完成**:本节的根字段、订阅身份、切换清空、接口内复用、epoch、文件清单、验证矩阵和回滚通过用户确认。 -- **阶段 1 完成**:剥离、sanitized 日志/缓存、订阅作用域写入和切换清空测试通过。 -- **阶段 2 完成**:用户优先、接口内复用、切换清空、官方 Base、401/403 和旧响应隔离通过。 -- **阶段 3 完成**:详情/季集/视频/推荐复用当前订阅能力,完整数据零新增请求。 -- **阶段 4 完成**:日志、缓存、偏好、Room、备份、Parcel、Intent、进程重启扫描通过。 -- **发布完成**:源端有版本门控和 Key 轮换方案,双端设备矩阵、Artifact SHA-256、提交和 recovery tag 已记录。 - -任一项没有证据,不得宣称“订阅接口级 TMDB API Key 支持已完成”或“Key 已安全保存”。 - -### 19.17 评审证据 - -| 声明 | 来源 | 等级 | WebHTV 适用性 | 决策影响 | -| --- | --- | --- | --- | --- | -| TMDB v3 使用 query `api_key` 或 Bearer;官方示例使用 `https://api.themoviedb.org/3` | [TMDB Application Authentication](https://developer.themoviedb.org/docs/authentication-application),页面 `updated_at=2025-10-27`,访问于 2026-09-19 | A(官方文档) | 现有 `TmdbService` 已支持两种认证;本节只新增 v3 `api_key` | 根字段只解释为 v3 API Key | -| TMDB 仍有限流和 429 要求,约每秒 40 次级别 | [TMDB Rate Limiting](https://developer.themoviedb.org/docs/rate-limiting),页面 `updated_at=2025-10-20`,访问于 2026-09-19 | A(官方文档) | 订阅接口内可能复用共享 Key | 保留 source-first、禁止无限重试、限制并发并尊重 429 | -| TMDB API 免费用于非商业项目、要求 attribution,官方强烈建议 SSL;商业用途需许可 | [TMDB FAQ](https://developer.themoviedb.org/docs/faq),页面 `updated_at=2025-10-07`,访问于 2026-09-19 | A(官方文档) | 源 Key 共享不改变产品性质,但 HTTP/泄漏影响凭据安全 | 生产要求 HTTPS、归属展示和源端许可评估 | -| `api.tmdb.org` 与 `api.themoviedb.org` 当前均为官方 TLS 域名,未带 Key 请求返回 401 | 2026-09-19 `openssl s_client` 和 HTTPS 探测 | A(可复现实测) | 默认 API Base 为 `api.tmdb.org/3` | 订阅临时作用域只允许这两个官方 HTTPS host | -| `VodConfig.config(Config)` 是启动、手动切换和 failover 设置当前接口的统一入口;`clear()` 会先执行但在随后配置新接口 | `api/config/VodConfig.java:90-149`;`bean/Config.java:22-35,133-155` | A(当前源码) | 能在身份变化时统一清空旧 Key,同时避免同接口刷新误清;`Config.id/url` 可构成稳定接口身份 | 作用域清理接入 `config()`,不散落在 Activity | -| 当前 `SiteApi` 先记录 raw detail,再解析 `Result`,`VodDetailCache` 缓存 `Result.toString()` | `api/SiteApi.java:174-183,211-227` | A(当前源码) | 根级字段必须最先剥离,不能依靠 `Vod` 事后清理 | 新增入口 ingress 与 sanitized JSON 管线 | -| `Vod.tmdb` 进入 Parcelable、copy 和缓存合同;当前 `TmdbConfig` 只表达用户配置 | `bean/TmdbSourcePayload.java:151-217`、`bean/Vod.java:93-115,372-394`、`bean/TmdbConfig.java:16-43,98-164` | A(当前源码) | 临时 Key 不能进入 `Vod`,也不能写回 Setting | 新增独立订阅内存作用域、effective config 和白名单 | - -外部 PR、Issue、论文和性能基准对本节不构成必要证据:本能力是本地凭据生命周期和 JSON 合同设计,不涉及播放器 ABI、性能算法或依赖升级。 - -### 19.18 最终建议 - -建议实施,但先批准第 19.12 节阶段 1,并要求先证明: - -- 根字段在日志、缓存、Gson、Parcel、`Vod`、Room 和备份中不可见; -- 无根字段旧响应与第 18 节完全一致; -- 用户已配置 Key 时源 Key 不写入订阅作用域、不产生任何请求; -- 同一订阅接口内跨站点、条目和 Activity 可以使用; -- 切换到新订阅接口时旧 Key 立即清空;新接口无 Key 则保持为空,新接口有 Key 才加载; -- A→B→A 不会恢复 A 的旧 Key; -- 无用户配置时,订阅临时作用域只在官方 HTTPS host 运行,401/403 后能清理并回退。 - -阶段 1 通过后再进行阶段 2。本文明确拒绝“整个 App 进程全局复用”的实现;如果后续要扩大作用域,必须另行设计并与订阅切换清理策略一起评审。 - -### 19.19 实施记录 - -> 说明:本节“订阅配置根级修订”之前的记录保留了当时实现的提交事实,其中把 `SiteApi` 详情根字段作为接收入口的表述已被下面的修订取代。 - -#### 阶段 1:根级剥离、订阅作用域和不落盘 - -- 状态:已完成并收口。 -- `TmdbSourceCredentialIngress`:在 T3/T4 共用入口剥离根级 `tmdb_api_key`,只接受去首尾空白后的 16-256 位可打印 ASCII Key;缺失时原样保留旧 JSON,非法、重复、非字符串、控制字符和超长值只丢弃候选 Key。 -- `SubscriptionTmdbCredentialStore`:Key 只存在于进程内当前订阅作用域;身份使用 `configId + normalizedUrl`,接口切换和显式清理递增 epoch,同一接口重载保留,A→B→A 不恢复旧 Key。 -- `SiteApi`:原始详情在 `SpiderDebug`、`Result` 解析和 `VodDetailCache` 之前统一剥离;只有详情列表有效、当前 epoch 匹配且用户未配置 TMDB Key 时才写入订阅作用域。 -- `VodConfig.config(Config)`:所有启动、切换和 failover 的统一入口调用 `beginSubscription()`;`clear()` 不无条件清除凭据。 -- 验证:`git diff --check` 通过;`bash ./gradlew --console=plain :app:testMobileArm64_v8aDebugUnitTest --tests 'com.fongmi.android.tv.api.TmdbSourceCredentialIngressTest' --tests 'com.fongmi.android.tv.api.config.SubscriptionTmdbCredentialStoreTest' --tests 'com.fongmi.android.tv.api.TmdbSourceCredentialWiringTest'` 返回 `BUILD SUCCESSFUL in 9s`,12 项测试通过。 -- 提交:`a50de802a1c2c3491088fa0307c0484bc95e7f1e`;tag:`recovery/C16-19-stage1/20260919190829-a50de802a1c2`。 -- 回滚:撤销本阶段提交即可恢复未知根字段被忽略的旧行为;若发现 Key 泄漏,必须先轮换源 Key 并停止源端投递。 - -#### 阶段 2:有效配置、接口内复用与切换竞态 - -- 状态:已完成并收口。 -- `TmdbConfig.effectiveCurrent()` / `effective()`:用户 `apiKey` 或 `accessToken` 始终优先;无用户配置时,当前订阅快照才生成 `TRANSIENT_SUBSCRIPTION` 配置,并固定使用 `https://api.tmdb.org/3`,保留语言和图片配置;临时配置的 `toJson()` 不包含源 Key。 -- 官方 Base 白名单:仅允许 HTTPS 的 `api.tmdb.org/3` 与 `api.themoviedb.org/3`,拒绝端口、用户信息、查询、附加路径、HTTP 和自定义镜像;`TmdbService` 在请求构造前再次防守。 -- `TmdbUIAdapter`:构造和每次 `prefetch/load/autoMatch/search` 入口重新读取有效配置;本页保留订阅身份与 epoch,旧 epoch 的详情、匹配、季集、视频和推荐回调都不能通过 `isCurrentGeneration()` 写回;`ConfigEvent` 调用 `invalidateSubscription()` 取消旧任务。 -- Mobile/Leanback:详情运行策略改用有效配置;订阅切换事件使 Adapter 进入失效状态,不再复用旧接口任务。 -- `TmdbDetailActivity`:初始化与源详情返回后刷新有效配置;订阅切换触发详情重新加载,旧 generation 结果被丢弃。 -- `TmdbService`:源 Key 命中 401/403 时清理当前订阅临时作用域;熔断键仍只使用不可逆摘要,不记录 Key。 -- 验证:定向 15 项单测通过,Mobile 与 Leanback `Arm64_v8aDebug` Java 编译均返回 `BUILD SUCCESSFUL`;`git diff --check` 通过。 -- 提交:`7cc55beae44c891e01c0cbc5c4ea2ffee844cb3f`;tag:`recovery/C16-19-stage2/20260919191419-7cc55beae44c`。 -- 回滚:撤销本阶段提交即可恢复用户配置直连和页面内固定配置;阶段 1 的剥离与内存作用域保持独立。 - -#### 阶段 3:详情、季集、视频和推荐能力收口 - -- 状态:已完成并收口。 -- `VideoActivity` 的 Mobile/Leanback 路径改为:只要 C16 source 状态为 `RENDERABLE` 就优先调用 `loadSource()`;没有可用 Key 时保持完全离线,有有效 Key 时仅按 planner 补齐首屏缺口。 -- `TmdbUIAdapter.loadSource()` 先把 source bundle 直接上屏,再计算首屏 `core/credits/images` 缺口;完整 source 不发起请求,部分 source 使用 `detailForSource(missing)`,结果通过 `TmdbSourceMerger.fillOnly()` 合并,源字段不被覆盖。 -- 季集缓存继续从 source bundle 预填;无 Key 的 `sourceOnly` 路径在缓存未命中时直接返回空列表,不再尝试 TMDB 网络。 -- 视频打开前先用 `TmdbSourceCapabilityPlanner.isAvailable()` 检查 `videos`、`season_videos` 或 `episode_videos`;完整源组本地读取,缺失组才使用当前订阅有效配置。 -- `PersonalRecommendationService`、Mobile/Leanback 单集详情、人物详情页与人物弹窗的请求入口改用 `TmdbConfig.effectiveCurrent()`,当前订阅临时 Key 在当前页面链路内复用。 -- 验证:6 个定向测试类共 19 项通过,覆盖 planner、merger、source-only、source-first 接线、运行模式与有效凭据入口;Mobile 与 Leanback `Arm64_v8aDebug` Java 编译均返回 `BUILD SUCCESSFUL`;`git diff --check` 通过。 -- 提交:`73abd0350b9808c5da727763deff3764e590d970`;tag:`recovery/C16-19-stage3/20260919192119-73abd0350b98`。 -- 回滚:撤销本阶段提交即可恢复仅无 Key 时使用 source、普通网络路径仍按用户配置匹配的旧行为;阶段 1-2 的凭据作用域不受影响。 - -#### 阶段 4:泄漏扫描与回归 - -- 状态:已完成并收口。 -- `TmdbService.execute()`:OkHttp 异常重新包装前先调用 `redactMessage()`,查询参数 `api_key/apikey/key/token/access_token` 和 Bearer 值不会进入异常文本、`SpiderDebug` 或上层日志。 -- T3/T4 合同回归:同一 `tmdb` 对象经过根字段剥离后使用相同模型,序列化、详情缓存和两个详情入口的结果不包含源 Key;`Vod`、`Result`、`TmdbSourcePayload`、`VodDetailCache` 源码中没有根字段。 -- 生命周期回归:测试覆盖合法 Key 注入、同一订阅复用、不同接口清空、A→B→A 不复用、403 清理、用户配置优先和 `toJson()` 不写临时 Key。 -- 泄漏面核对:日志/异常经脱敏;详情缓存只接收 sanitized JSON;TMDB 缓存文件名使用 MD5;临时 Key 不进入 Preference、Room、Backup、Intent、Bundle 或 Parcel 模型;诊断文本走已有敏感字段过滤。 -- 验证:C16 定向回归共 57 项通过;Mobile 与 Leanback `Arm64_v8aDebug` Java 编译均返回 `BUILD SUCCESSFUL`;`git diff --check` 通过。 -- 提交:`d2136e463391a6c34a4bf042d85cc001982cd001`;tag:`recovery/C16-19-stage4/20260919192800-d2136e463391`。 -- 回滚:撤销本阶段提交即可恢复未脱敏的底层网络异常文本和旧合同测试;凭据生命周期实现不受影响。已暴露在旧版本日志中的 Key 必须轮换,不能用代码回滚补救。 - -#### 阶段 5:Mobile/Leanback 设备验收 - -- 状态:已完成并收口。 -- 设备与夹具:分配设备 `SM-N9700`、Android 9、`192.168.50.3:5557`;使用 `/tmp/c16_subscription_fixture.py`、`/tmp/c16_keyless_fixture.py` 和 `adb reverse tcp:18080/18081`。测试结束后已停止本地夹具并移除 5557 的反向端口。 -- 凭据矩阵:Mobile 和 Leanback 各 5/5 通过,覆盖根字段接收与 source UI、部分 source 回退、同一订阅复用、A→B→A 不恢复、用户配置优先、非法 Key 不写入。 -- 无 Key 回归:Mobile 和 Leanback 各 3/3 通过,覆盖独立详情页、原生增强详情和无扩展旧源回退,无 Key 提示。 -- 泄漏扫描:设备测试 `@After` 在包仍安装时递归读取 app data 和 external files,测试 Key 未出现在持久化文件、偏好、数据库或缓存;5557 `logcat -d` 也未命中测试 Key。 -- Artifact SHA-256: - - Mobile APK:`26e87690843c0c0b8f3cb0abf35110ddeea8a66ad100c08685c787db6972a217` - - Leanback APK:`9829589622294d2f087a0d949928d131dcc718435c952c3aad7d154d83cb9256` - - Mobile androidTest APK:`fdeaeb20df76b74641ef748f4865e478f165bcb9264776dbecefc504dfd59236` - - Leanback androidTest APK:`7bb4c76601170d03aa482db220d27a7a6581299c5f1a8cc4a9b8d59c5e4bcc1d` -- 未验证:工作区未提供真实第三方 TMDB Key;设备矩阵验证了无效 Key 不得误写、用户优先、source 回退与清空路径,但真实有效 Key 的网络补齐成功、真实 401/403 和源端版本门控/轮换仍未验证。确定性 401 状态处理仍由 JVM 单测覆盖,不能用 stub 结果替代真实 Key 结论。 -- 提交:`fd593f3d3c3972811c7572ca92342b78f16b9fb3`;tag:`recovery/C16-19-stage5/20260919201842-fd593f3d3c39`。 -- 回滚:撤销本阶段测试提交即可恢复原有无 Key 三场景设备测试;生产实现及阶段 1-4 锚点保持不变。 - -#### 订阅配置响应根级修订(2026-09-19) - -- 用户确认的预期层级:`http://192.168.50.50:4567/sub/2024/buye-991566.xyz` 这类订阅配置接口返回 JSON 的**根对象**携带 `tmdb_api_key`,例如与 `sites` 同级。 -- `VodConfig.load(Config)`:在 `CatSource.normalize()` 前调用 `TmdbSourceCredentialIngress.extractRootAndStrip()`;`sites` 配置解析成功、站点列表非空且当前 `configId + normalizedConfigUrl + epoch` 匹配后,才把 Key 写入当前订阅内存作用域。 -- `SiteApi.detailContent()`:保留 ingress 剥离以满足旧源/自定义爬虫的泄漏防护,但候选 Key 不再传给 `SubscriptionTmdbCredentialStore`;详情根字段不能成为凭据来源。 -- 用户配置优先级保持不变:用户 `apiKey/accessToken` 存在时,即使订阅配置返回合法 Key,也不写入临时作用域。 -- 切换语义保持不变:切换订阅接口先清空旧作用域并递增 epoch;切回原接口必须由新的订阅配置响应重新提供 Key,而不是恢复旧值。 -- 测试更新:定向 JVM 测试覆盖订阅配置根字段解析、详情 legacy 字段剥离但不接受、订阅作用域切换;设备夹具改为只有 `/config.json` 根对象携带 Key,详情端点不携带 Key。 -- 验证:7 个定向 JVM 测试类共 26 项通过;分配设备 `SM-N9700/Android 9 @ 192.168.50.3:5557` 上 Mobile 凭据 5/5 + 无 Key 3/3、Leanback 凭据 5/5 + 无 Key 3/3 通过;app 私有目录自动扫描和 `logcat -d` 均未发现测试 Key。 -- 提交:`b51a3795e9c88d2f370271f64513e5928d6e067f`;tag:`recovery/C16-config-root-credential/20260919211254-b51a3795e9c8`。 -- 回滚:撤销本次修订即可恢复“详情响应根字段接收”的旧实现;已暴露在日志或旧客户端中的 Key 必须轮换。 - -#### 认证失败竞态修复(2026-09-19) - -- 设备复现:5557 上旧进程日志显示 `runtime policy mode=1 runtime=2 source=ABSENT_OR_INVALID tmdbReady=false`,详情页因此回退 `VideoActivity`;重启并重新加载订阅配置后,同一详情返回 `runtime=1 tmdbReady=true bundle=true`。 -- 根因:旧订阅或旧测试任务的 401/403 会无条件调用 `SubscriptionTmdbCredentialStore.clear()`,可能在新订阅配置已接收 Key 后把新凭据一起清掉。 -- 修复:`TmdbConfig` 的临时凭据快照携带 `subscriptionKey + scopeEpoch`;`TmdbService` 的 401/403 改为 `clearIfCurrent()`,只清除与失败请求相同订阅身份和 epoch 的 Key。旧任务失败不再影响新订阅。 -- 诊断日志:订阅配置接收路径新增低敏 `tmdb-credential` 日志,只记录候选是否存在、用户配置是否就绪、订阅 ID/URL 是否匹配、accept 结果和 epoch,不记录 Key 内容。 -- 测试:新增 JVM 与设备用例,构造 A→B 后由 A 的旧请求返回 401,断言 B 的 Key 保持不变;B 自身 403 仍会清除 B。 -- 提交:`9cd5c6399dac79411354c1be104d28a826e37127`;tag:`recovery/C16-config-key-runtime-diagnosis/20260919215433-9cd5c6399dac`。 - -#### 远端 beta 合并后的凭据边界硬化(2026-09-19) - -- 订阅配置加载的凭据接收以实际加载参数 `Config` 为准,而不是全局 `getConfig()`;迟到的旧订阅响应无法再把 Key 写入新订阅作用域。 -- 同一订阅内检测到 Key 替换时推进 `scopeEpoch`;`TmdbService` 在每次请求前验证临时凭据快照仍属于当前订阅,失效快照直接拒绝。 -- 畸形 JSON 的 ingress 在结构性解析失败且检测到根字段时返回空对象,不再把可能含 Key 的原文本交给日志、缓存或后续解析。 -- `TmdbUIAdapter` 的订阅失效路径和 Leanback 缓存详情入口先刷新有效凭据再开始新任务,避免切换后继续使用旧配置。 -- 验证:17 个定向 JVM 测试类共 248 项通过,Leanback `Arm64_v8aDebug` Java 编译通过;详细合并与 PR 记录见 `docs/beta-sync-review-dev2-20260919.md`。 diff --git a/docs/C17-beta-sync-review-dev4-20260919.md b/docs/C17-beta-sync-review-dev4-20260919.md deleted file mode 100644 index 4b1d2eeb3e..0000000000 --- a/docs/C17-beta-sync-review-dev4-20260919.md +++ /dev/null @@ -1,42 +0,0 @@ -# C17:dev4 合并 beta 最新代码与合并后复评 - -> 状态:已完成。合并、两轮复评与验证通过,提交与恢复标签已创建,`dev4` 已推送,PR #317 已创建并可合并,远端最新状态已拉取。 - -## Recovery anchor - -- 目标:将 `origin/beta@da00b7a1f20815fd49921d415c315c21fd6d8bb1` 合入 `dev4`,评审合并后的全部本地改动(包括 `c4fd5acd74` 等已提交未推送的 C16 提交),修复发现的问题,循环复评直到通过,然后提交、推送、创建 PR 到 `beta`,最后拉取远端最新代码。 -- 本地基线:`dev4@c4fd5acd747e1a26c5dfc54273967fa0d01038de`,相对 `origin/dev4` 领先 11 个提交;工作树启动时干净。 -- 合并基线:共同祖先 `32a52698e5dab09fe18e49d18849a947057ca717`;目标 beta 头 `da00b7a1f20815fd49921d415c315c21fd6d8bb1`。 -- 范围:`app/**`、`docs/**`;不修改依赖、锁、二进制、爬虫 ABI 或公开接口,除非复评证明合并必须处理且仍在授权范围内。 -- 回滚:最终合并提交以 `dev4` 当前头为第一父;如需回滚,使用 `git revert -m 1 `。既有 C16 提交及恢复标签保持不动。 -- 下一动作:无;等待 PR #317 的正常评审与合并。 - -## 合并提交账本 - -| 提交 | 内容 | 当前处置 | -| --- | --- | --- | -| `39a5f783e9d899cff4161d73fce09f038e84f3ab` | 避免启播阶段重复重建和缓冲的 Exo/播放器修复 | 待合并后复评 | -| `fe88163cf750e62b8aa664c74334955c59dfda54` | 节流预加载任务突发 | 待合并后复评 | -| `e87161fdd952c3dadb10a4223547641d2f4de0a5` | beta 同步合并提交 | 结构提交,检查最终树 | -| `837847cb8598f4a14bae5fdc8842620826d6e812` | beta PR #315 合并提交 | 结构提交,检查最终树 | -| `93c2413b022ea229ff6c1534f94cba569555b0f9` | Leanback 广告统计对话框电视端交互优化 | 待合并后复评 | -| `da00b7a1f20815fd49921d415c315c21fd6d8bb1` | beta PR #316 合并提交 | 结构提交,检查最终树 | - -## 评审与验证计划 - -1. 以 `--no-commit --no-ff` 合并 `origin/beta`,确认无冲突或明确处理所有冲突。 -2. 评审最终差异:C16 TMDB 详情协议与 beta 播放器/Leanback 改动是否有覆盖、语义冲突、生命周期或 UI 回归风险。 -3. 对发现的问题做最小修复;每轮修复后先跑能直接证伪问题的定向检查,再进行一次合并树编译与相关测试。 -4. 首轮通过后再次按需求逐项复评,确认没有遗漏或不一致,再进入提交与 PR。 - -## 证据记录 - -- 2026-09-19 Asia/Shanghai:确认工作树干净;抓取 `origin/beta` 和 `origin/dev4`;冻结上述基线并建立任务守卫。 -- 2026-09-19 Asia/Shanghai:`git merge --no-commit --no-ff origin/beta` 自动完成,无未合并路径;评审确认 beta 播放器修正、预加载节流、Leanback 广告统计交互与 C16 TMDB 详情协议调用链相互独立,没有覆盖 C16 已提交但未推送的实现。 -- 第一轮验证:`bash ./gradlew --console=plain :app:compileLeanbackArm64_v8aDebugJavaWithJavac` 返回 `BUILD SUCCESSFUL in 26s`。 -- 第一轮定向测试:`bash ./gradlew --console=plain :app:testMobileArm64_v8aDebugUnitTest` 配合 14 组过滤规则返回 `BUILD SUCCESSFUL in 34s`;覆盖 C16 解析/适配/合并/缓存/详情接线、TMDB UI、广告统计布局、预加载策略和播放器取址约束。 -- 第二轮复评:最终树无冲突标记、无未合并路径、无越界或二进制变更;`git diff --check` 与 `git diff --cached --check` 均通过;6 个 beta 提交及其两个合并父关系保持完整,10 个 C16 非合并提交仍在最终树中。 -- 复评结论:未发现需要修改的正确性、兼容性、性能、生命周期、资源或回滚问题;无需追加代码修复。 -- 收口结果:合并提交 `5acd3f084155e7016b3798528eb8343f2f076177`;恢复标签 `recovery/C17-beta-sync-review-dev4-20260919/20260919122049-5acd3f084155`;`origin/dev4` 已更新到该提交。 -- PR:,目标分支 `beta`,创建时为非草稿、`MERGEABLE`、状态 `OPEN`。 -- 远端同步:在推送后执行 `git pull --ff-only`,结果为 `Already up to date.`,当前 `dev4` 与 `origin/dev4` 一致。 diff --git a/docs/C18-beta-sync-review-dev4-20260920.md b/docs/C18-beta-sync-review-dev4-20260920.md deleted file mode 100644 index 40dc155a7e..0000000000 --- a/docs/C18-beta-sync-review-dev4-20260920.md +++ /dev/null @@ -1,57 +0,0 @@ -# C18:dev4 合并 beta 最新代码与合并后复评 - -> 状态:已完成。`origin/beta` 已合并到本地 `dev4`,唯一冲突已按本地跟随功能与 beta 配置事件监听合并;双端编译与移动端全量单测通过,复评未发现需追加修复的问题;提交、恢复标签、推送、PR #331 和远端拉取均已完成。 - -## Recovery anchor - -- 目标:将 `origin/beta@b4501c91c748cc5b910b5dc0ab7553e513f7fa8c` 合入 `dev4`,复评全部本地未推送改动(重点为 FOLLOW-1 关注更新和 C16 订阅凭据边界),发现真实问题即最小修复并重新验证,循环复评直到通过;随后提交当前任务改动、推送 `dev4`、创建中文 PR 到 `beta`,最后拉取远端最新代码。 -- 本地基线:`dev4@ea485ee9bfff330345adf1f565ea703a508d4cc9`,相对 `origin/dev4` 领先 46 个提交;任务开始时工作树干净。 -- 合并基线:共同祖先 `552b68bb8e781d06ca989abfd8caa96a567e494a`;目标 beta 头 `b4501c91c748cc5b910b5dc0ab7553e513f7fa8c`。 -- 冲突:仅 `app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java` 的 import 区域冲突;保留本地 FOLLOW-1 全部 import 与 beta 新增 `ConfigEvent`。 -- 范围:`app/**`、`docs/**`、`gradle/**`、`gradlew`、`scripts/**`、`third_party/**`;不修改依赖锁、播放器二进制或公开协议,除非复评证明合并必须处理。 -- 回滚:最终合并提交以本地基线为第一父;如需回滚使用 `git revert -m 1 `。本地 46 个提交及各自 recovery tag 保持不变。 -- 下一动作:无;等待 PR #331 的正常评审与合并。 - -## beta 增量账本 - -共同祖先以来 beta 侧面包含 30 个非合并提交,主要行为提交及完整 ID 如下: - -| 完整提交 ID | 内容 | 当前处置 | -| --- | --- | --- | -| `a50de802a1c2c3491088fa0307c0484bc95e7f1e` | TMDB 凭据限定到订阅范围 | 已合入并复评 | -| `7cc55beae44c891e01c0cbc5c4ea2ffee844cb3f` | 激活订阅源 TMDB 凭据 | 已合入;与本地 FOLLOW-1 接线冲突已解决 | -| `73abd0350b9808c5da727763deff3764e590d970` | 按能力缺口补齐源详情 | 已合入并复评 | -| `d2136e463391a6c34a4bf042d85cc001982cd001` | 凭据脱敏与验证 | 已合入并复评 | -| `6d9835298550ad5d3623d3e4fec14ddc518b7e8c` | 广告日志分列与列筛选 | 已合入;与本地 FOLLOW-1 不重叠 | -| `fd593f3d3c3972811c7572ca92342b78f16b9fb3` | 订阅凭据设备验证 | 已合入文档与测试 | -| `b51a3795e9c88d2f370271f64513e5928d6e067f` | 接受订阅配置根对象 Key | 已合入并复评 | -| `bc23329e49f050ce95142f102aa090b9520769e3` | 广告日志去重多选筛选 | 已合入;与本地 FOLLOW-1 不重叠 | -| `9cd5c6399dac79411354c1be104d28a826e37127` | 认证失败清理限定订阅 epoch | 已合入并复评 | -| `cd062e32428d4855899308503b34ceb584c9fe09` | 移动端广告筛选单击选择 | 已合入并复评 | -| `8cab95c225c81798a308931e732f326bf460222d` | MPV HLS 去广计划就绪通知 | 已合入;与本地 FOLLOW-1 不重叠 | -| `2456a3af52eb2849ca8aa9d2da98ed5f3180213d` | Leanback 背景与对话框统一主题 | 已合入并复评 | -| `27313a3af01b9b17aa48dfe4f0fda64040c6ba83` | Leanback 跟随活动主题颜色 | 已合入并复评 | -| `a1aad8745e8cf81b566016369f66a5496a6198e3` | 更新对话框焦点归一化 | 已合入并复评 | - -其余文档提交、beta 同步合并和 PR 合并提交作为结构提交保留;最终树以 `git rev-list` 与回归结果核验,不以提交数量替代行为验证。 - -## 首轮复评 - -- 保留本地 FOLLOW-1 改动和 beta `ConfigEvent` 配置刷新路径;删除三处冲突标记,未改变两个功能的运行逻辑。 -- `TmdbDetailActivity` 合并后同时保留 FOLLOW-1 import、beta 的 `onConfigEvent`、有效配置刷新和 `loadContent(null)` 重载。 -- C16 订阅凭据边界继续按本地已 hardened 版本评估:实际加载参数绑定订阅、同订阅 Key 替换推进 epoch、畸形 JSON 不回传原文、请求前校验临时凭据仍属于当前订阅。 -- 广告日志列筛选、MPV 通知、Leanback 主题和更新对话框焦点属于 beta 的独立行为,未覆盖本地 FOLLOW-1 或 C16 已提交改动。 - -## 验证记录 - -- `git diff --check` 与 `git diff --cached --check`:通过;无冲突标记和未合并路径。 -- 双 flavor Java 编译:`bash ./gradlew --console=plain :app:compileLeanbackArm64_v8aDebugJavaWithJavac :app:compileMobileArm64_v8aDebugJavaWithJavac`,`BUILD SUCCESSFUL in 51s`。 -- 移动端全量 JVM 单测:`bash ./gradlew --console=plain :app:testMobileArm64_v8aDebugUnitTest`,`BUILD SUCCESSFUL in 42s`;4745 项测试、0 failure、0 error、1 skipped。 - -## 交付记录 - -- 合并提交:`9f998d6808f3e8144d2d4d68f23d92308e49b599`,第一父 `ea485ee9bfff330345adf1f565ea703a508d4cc9`,第二父 `b4501c91c748cc5b910b5dc0ab7553e513f7fa8c`。 -- 恢复标签:`recovery/C18-beta-sync-review-dev4-20260920/20260920160703-9f998d6808f3`。 -- 推送:`origin/dev4` 已从 `dcfd78749bf4` 更新并包含合并提交 `9f998d6808`;收口文档提交会作为分支末端同步推送。 -- PR:,目标分支 `beta`,当前状态 `OPEN`、非草稿、`MERGEABLE`、`mergeStateStatus=CLEAN`。 -- 远端同步:推送并创建 PR 后执行 `git fetch --prune origin beta dev4` 和 `git pull --ff-only`,结果为 `Already up to date.`,当前 `dev4` 与 `origin/dev4` 一致。 diff --git a/docs/C20260906161136-mpv-duration-recovery-fix.md b/docs/C20260906161136-mpv-duration-recovery-fix.md deleted file mode 100644 index 8be8156a17..0000000000 --- a/docs/C20260906161136-mpv-duration-recovery-fix.md +++ /dev/null @@ -1,49 +0,0 @@ -# 任务编号和名称 -C20260906161136-mpv-duration-recovery-fix: MPV 首次播放时长未显示修复 - -# 所属分类 -MPV 播放器时长恢复 Bug 修复 - -# 要实现的实际能力 -确保手机版 MPV 在首次进入播放时能够正确获取并显示视频总时长,即使 MPV 在初始化过程中未及时发布 duration 属性变更事件。 - -# 当前项目已有实现 -项目中已有时长恢复机制(见提交 0ce6f13e6 和 97a5e3e8a),但仅在特定条件下触发(cachedDurationMs > 0 或 !initialized)。在某些场景下,MPV 首次加载媒体时可能不会立即发布 duration 事件,导致缓存时长保持为初始值(0 或 TIME_UNSET),UI 因而不显示总时长。 - -# 涉及仓库及完整 commit ID -- 本地仓库:https://github.com/Silent1566/webhtv.git (dev2 分支) -- 上游参考仓库:https://github.com/fish2018/webhtv (main 分支) -- 本次修改基于的本地提交:66b5505282eb3de95538a1eab39ad9196d27ba22 -- 相关既有修复:0ce6f13e6d59(fix(mpv): recover missing duration during playback state refresh) -- 相关既有修复:97a5e3e8a59e(fix(mpv): preserve unknown duration and recover timeline on restart) - -# 收益 -- 手机版 MPV 首次播放时能够正确显示视频总时长。 -- 减少用户需要切换播放核心以刷新时长的需要。 -- 修改范围窄,仅影响 MPV 播放器的状态刷新逻辑。 - -# 缺点与风险 -- 轻微增加每秒状态刷新的开销(一次额外的属性查询),但仅在未缓存有时长时触发。 -- 可能在极少数情况下导致时长抖动(如果 MPV 在同一秒内多次更新 duration),但已有相同判据(timelineDurationMs != cachedDurationMs)可防止无效写入。 -- 无已知回归风险,因为该逻辑仅在缺失有时长时尝试恢复。 - -# 与现有功能的关系 -- 与既有时长恢复机制(0ce6f13e6, 97a5e3e8a)互补,覆盖了它们未处理的场景(即 initialized 为 true 但 cachedDurationMs 仍为 <= 0)。 -- 不影响 TV 版或 Exo 播放器。 -- 与现有的定时状态刷新循环(startStateRefresh / stateRefreshRunnable)集成,无需新增定时器或回调。 - -# 建议 -实施(实施)。该修复是最小化的、风险低的改进,能够直接解决用户报告的手机版 MPV 时长未显示问题。 - -# 最小实施步骤 -1. 修改 `app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java` 中的 `refreshPlaybackState` 方法。 -2. 将现有时长恢复判断(基于 `cachedDurationMs > 0 || !initialized`)改为 `initialized && cachedDurationMs <= 0`,以便在已初始化但未缓存有时长时尝试查询 MPV 的 duration 属性。 -3. 保留现有的 `doublePropertyMs("duration", -1)` 查询和更新逻辑。 -4. 编译并运行单元测试(如有)以确保无语法错误。 -5. 在模拟器或真机上验证手机版 MPV 首次播放时长显示正常。 - -# 预计需要的验证 -- 编译通过:`./gradlew assembleDebug`(或等效命令)。 -- 运行相关单元测试(如 `MpvMainThreadPropertySourceTest`)以确保无回退。 -- 在 Android 模拟器(192.168.50.3:5555)上启动应用,选择一个视频,使用 MPV 作为播放核心,进入播放页后确认控制栏显示总时长(非 --:-- 或 0:00)。 -- 可选:切换回其他播放核心再切回 MPV,确认时长仍然正确显示(防止回归)。 diff --git a/docs/C4-main-upstream-merge.md b/docs/C4-main-upstream-merge.md index cae9510015..3ae64615f7 100644 --- a/docs/C4-main-upstream-merge.md +++ b/docs/C4-main-upstream-merge.md @@ -2,11 +2,12 @@ ## Recovery anchor -- 当前状态(第七轮,2026-09-21):`dev5` 基线 `150e29340200a1cb2173880a3ef7b92651bc2879`,上游 `fish2018/webhtv:main@8e4d9333de8ea7346491e71a0b1ab6858a852298` 已通过双父 merge commit `1bae44dba685867ae5d1bd282c9dda98ed0a3596` 合并;recovery tag 为 `recovery/C4/20260921211550-1bae44dba685`。刷新上游后 `HEAD..upstream/main` 为空,上游头是当前 HEAD 祖先。 -- 本轮接受条件:真实双父合并保留本地 E11 立即 PCM 回退,接入上游失败记忆、直通开关和已缓冲起播恢复;无冲突标记;Leanback/Mobile ARM64 Java 编译与受影响 JVM 测试通过;原子提交和 annotated recovery tag 已创建。 -- 验证边界:81 项定向用例中 80 项通过、1 项因 `ExoPlaybackException.createForRenderer()` 在 JVM 桩读取 `SystemClock` 显式跳过;同一恢复路径的 3 项真实 Media3 集成用例全部通过。未执行 emulator/device 播放。 -- 回滚锚点:`git revert -m 1 1bae44dba685867ae5d1bd282c9dda98ed0a3596` 恢复本轮实施前 `dev5@150e29340200a1cb2173880a3ef7b92651bc2879`。历史第一/第二轮细节继续见下方“第二轮增量”和 Closure 章节。 -- 下一动作:无;保持当前分支、merge commit 和 recovery tag,不执行远端 push。 +- 目标:将 `fish2018/webhtv:main@ec478b0b697422a7785171c7b51a35b7a526564e` 的有效增量合并到当前 `dev2`,保留 WebHTV 的本地播放器、评估和文档资产。 +- 状态:已完成;两父合并提交 `d0809f804f812b818bcb22f36cae8634022db673`,recovery tag `recovery/C4/20260901032617-d0809f804f81`。 +- 基线:`dev2@0452b2256b263ae7d7ec528cee7d5de5efabdb59`;共同祖先 `4489ca9ecc91c2c30fd23610cb0342aa1224717b`;回滚锚点为该基线及 `recovery/merge-beta-20260831/20260831120515-0452b2256b26`。 +- 任务 guard:`C4`,范围 `.github`、`.gitignore`、`app`、`docs`;初始工作树干净。 +- 接受条件:合并树无未解决冲突或冲突标记;本地评估文档不被删除;受影响的 Java/Kotlin 代码可编译;上游已有的聚焦单测可通过;原子提交和本地 annotated recovery tag 已创建。 +- 下一动作:在具备对应设备和网络环境时,补做真实 OCI 下载与局域网 APK URL 推送验收;该实机验证不阻塞已完成的 C4 本地集成。 ## Authority and scope @@ -66,328 +67,3 @@ - Validation: `gradlew.bat :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.update.* --tests com.fongmi.android.tv.server.process.ApkUrl* --tests androidx.media3.mpvplayer.* --tests com.fongmi.android.tv.bean.BackupPreferenceFilterTest --no-daemon --console=plain` passed; `scripts/verify_mpv_native_assets.sh --require-elf` passed for `arm64-v8a` and `armeabi-v7a`; the OCI workflow regression assertions passed after the fail-closed and immutable-pin fix. - Rollback anchor: `0452b2256b263ae7d7ec528cee7d5de5efabdb59` (or abort the uncommitted merge); do not drop the pre-existing stashes. - Remaining risk: no connected-device test was run for real OCI download or LAN APK URL push, and no new native rebuild was performed; those scenarios remain follow-up validation for their respective runtime environments. Next action: run `task_guard.sh finish` with the recorded verification evidence. - - -## 第二轮增量:2026-09-07 最新 `main` 合并 - -### 冻结基线与范围 - -| Role | Repository/ref | Full commit | -| --- | --- | --- | -| Local baseline | `dev2` | `912208261e4e342ced009b1a0b71feed4855a01d` | -| Common ancestor / previous C4 target | `fish2018/webhtv` | `ec478b0b697422a7785171c7b51a35b7a526564e` | -| Upstream target | `fish2018/webhtv:main` | `784b90420d646eb6c7ddcc63ad622a92c65b02b4` | -| Upstream target parent | `fish2018/webhtv` | `686522fa8f4a9405f906848c2b938d9da300d6cd` | - -- GitHub API 与完整 Git 父链均确认目标提交日期为 2026-09-06 16:45:23 +08:00;本地最初的 `--depth=1` 抓取已通过 `--deepen=100` 补齐,`git merge-base` 明确返回旧 C4 目标。 -- 精确增量为 51 个提交、194 个净变更路径、8213 行新增和 1062 行删除;顶层范围仅为 `app/`、`gradle/`、`scripts/`、`third_party/` 和两个旧任务文档删除。 -- 本轮包含 Exo/MPV 音频策略、双 ABI MPV 资产、Media3/Nextlib AAR 与 sidecar、APE/AV3A/ALAC、P8.1 HDR10 fallback、MPV 脚本按钮与配置同步、TV 搜索焦点修复。每个材料变更均已有同链任务文档、源码、测试或提交验证记录;本轮问题是把已审核的上游最终树安全组合进当前本地分支,而不是重新设计这些能力。 -- 上游最后提交删除了上游工作区的本地任务文档;该提交不构成删除当前仓库 `docs/`、评估索引或用户备份的授权。两个相对旧 C4 目标的删除项 `docs/OCI1-oci-apk-update.md` 与 `docs/mobile-apk-link-push.md` 继续保留。 -- 受保护的会话初始备份已从其创建时 Git 对象精确恢复,旧 guard 指纹逐一一致;SHA-256 为:四个 `MpvPlayer.java.bak2026090616*` 均为 `e311a8627594520fa8373af1ec931c814697ffaae7421ca010ea1ce0d4675e6c`,`VideoActivity.java.bak202609061540` 为 `7c0c3724329f6339e5ea8da237ad1ab443484d2f9cc07b29144ac565c1c024ae`。 - -### 现有实现、证据与方案判断 - -- 当前本地后续修复包括 MPV duration 恢复 `6b0490907da3e0b09a6563c1572cf283a3ae49d3`、移动端历史重建后 seek 进度重绑 `cb386895da22a8836bb5587cafff3ef10f89a4fa`,以及 beta 合并 `912208261e4e342ced009b1a0b71feed4855a01d`;这些均晚于共同祖先,冲突时必须保留。 -- **精确上游源码/提交/测试(A):** 51 个实际 commit/tree diff、父链、提交自带 Verification、`upstream/main^` 中对应 E/P/C 唯一任务文档;直接决定实现语义与回归门槛。 -- **官方规范/项目文档(A):** 上游任务文档已绑定 Android AudioTrack/AudioManager、Media3 与 MPV 锁定版本文档;本轮不改变其已批准设计,只验证三方集成不破坏合同。 -- **PR/issue/revert/维护者讨论(A/B):** 由同链任务文档按功能记录;本轮范围中没有新的未解释 revert,最后提交仅为远端文档清理。 -- **成熟相关项目代码与测试(A/B):** Media3、FFmpeg、MPV 和 nextlib 锁定源码、补丁与 JVM/native verifier 已随提交落盘,适用于供应链与行为核对。 -- **论文/技术文章/基准/现场报告:** 对本轮“同仓库已审核提交的三方合并”没有新增决策价值;性能和设备证据沿用各原子任务记录,本轮不把编译结果扩大为新的设备性能结论。 - -备选方案: - -1. **不变更:** 无法满足“合并上游最新代码”,排除。 -2. **以 `upstream/main` 最终树整体覆盖本地:** 会丢失 1956 个本地侧提交结果、当前任务文档和后续播放器修复,排除。 -3. **逐提交 cherry-pick 51 次:** 破坏原始上游连续历史、重复冲突且回滚边界过碎,排除。 -4. **真实两父三方 merge,并在冲突处组合本地后续修复与上游能力:** 推荐且已获用户本轮明确合并授权;保留可追溯性、最小化重演、可用单一 merge commit 回滚。 - -验收与回滚: - -- `git diff --check`、无 unmerged path/冲突标记;`git merge-base --is-ancestor 784b90420d646eb6c7ddcc63ad622a92c65b02b4 HEAD` 成功。 -- `scripts/verify_mpv_native_assets.sh --require-elf` 验证双 ABI ELF/资产;一次 Gradle 调用运行受影响单测和 Mobile/Leanback Arm64 Java 编译。 -- guard 证明 5 个 `.bak` 指纹不变、只提交声明路径;`task_guard finish` 创建双父提交及 annotated recovery tag。 -- 合并前回滚为 `git merge --abort`;提交后回滚为 revert 本轮 merge commit 或恢复 `912208261e4e342ced009b1a0b71feed4855a01d`,不改写已发布历史。 - -### 51 个上游提交完整台账 - -| # | Full commit | Summary | Planned disposition | -| ---: | --- | --- | --- | -| 1 | `b208d26546cf6fd4498a1e54d45a37106a313d69` | feat(mpv): add multichannel audio fallback policy | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 2 | `37995ff14016fd5a26fdae2b482f08470aa6a162` | Show runtime audio playback diagnostics | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 3 | `cf0a5dabc77fb2bbdc3e0f2cc867a9eac86060b8` | feat(exo): prioritize hardware audio decoders | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 4 | `d41155f16cd81f1354672a5479743462fc168ed9` | mpv: prefer hardware audio MediaCodec with fallback | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 5 | `3fdf9f82f37843699a2545ed97d4a2dd17b8ead5` | exo: recover compressed audio output failures with PCM fallback | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 6 | `d8a994af5a64b93d7aeafb81f3755f81fcb8194c` | player: align audio decode labels with video diagnostics | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 7 | `734e99253ef5e856b3639810da5d9a05b0493646` | mpv: add compressed AudioTrack output path | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 8 | `cb0a59f819a219dccd32bc1bf1c22b9caf754f07` | mpv: report actual hardware audio decoder state | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 9 | `37888d8b9d99da29f9ecfc3cd1f5eba458e09ee0` | exo: gate network protection on actual audio output | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 10 | `d00aa5737980d976cbae491948bf65dab906bf68` | mpv: repair compressed AudioTrack fallback patch hunks | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 11 | `347801b81f56bdcf515e4a9d7013814f8b777519` | docs: record Exo audio codec compatibility investigation | evidence/included:作为对应实现的设计、验证或收口证据纳入历史 | -| 12 | `5c104a199fa07ad5f33c575deb6e0b91eea6668a` | exo: route MP4 AV3A tracks to compatible decoder | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 13 | `53eab9c2d6101220f26525a2af309a5578e7dc3a` | mpv: expose AV3A audio tracks with canonical MIME | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 14 | `12ebbf8d15280e248dff9818c7969c7261173c16` | fix Exo audio output configuration recovery | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 15 | `c7d43205f9125077a471ce57ee46a9c1f206e982` | fix Exo compressed audio PCM channel fallback | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 16 | `8419003c2fe9eac200d2e6a9ef0dddf58638be31` | Fix MPV compressed audio output fallback | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 17 | `69191f78a37c5d56f87591c857d2d4be0112815d` | fix(mpv): recover AAC playback through PCM fallback | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 18 | `2f2f99fb7d91c5e8c71e8f408b1ee45b287b2917` | fix(mpv): downmix unknown AV3A channels to stereo | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 19 | `639a046125c4375685cb96c9ea004b620778bbb9` | fix(exo): parse AVS3A DASH channel configuration | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 20 | `c826ee327b1cc33c0f0522fa120b1b2039e789d8` | fix(exo): support AV3A 5.1 mixed-content channel downmix | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 21 | `56b802ef585e83099953979b802172512c2fb447` | docs: add audio and multichannel strategy assessment | evidence/included:作为对应实现的设计、验证或收口证据纳入历史 | -| 22 | `04904ff99ae67efb25ebd4cca3740b30f0178662` | fix(exo): route ALAC away from stalled vendor decoder | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 23 | `80e313830824f2e86d341079b58c330726de3e99` | fix(exo): extract ALAC cookie from QuickTime wave atoms | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 24 | `ebb5285238aa19eeab11ec4595985d496550ced2` | docs: define common audio policy contract | evidence/included:作为对应实现的设计、验证或收口证据纳入历史 | -| 25 | `43fba18a8d074268c26a6ddbd30fe3483247320a` | docs: close C4 audio policy assessment record | evidence/included:作为对应实现的设计、验证或收口证据纳入历史 | -| 26 | `0a31951e3c923154b2ef8218d1a3811a96fa446b` | common: unify audio diagnostics contract | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 27 | `047ad74f76c859862b39b64d0545ec2dc83dd865` | docs: close C4 implementation record | evidence/included:作为对应实现的设计、验证或收口证据纳入历史 | -| 28 | `e5dd86b344b8ab1c5bda68f96ecf77a2e356ad4d` | fix(exo): support APE demux and FFmpeg playback | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 29 | `aed962426d7da7ddc268091f443b72c12187bd66` | Fix MPV P8.1 HDR10 hardware fallback | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 30 | `bf22a22fd60fd2cb9e2fef93b13f379b8d8b4f9e` | Fix audio diagnostics Android API compatibility | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 31 | `8dd15ff24a5406091819c86b2dfa5e8499a6cbef` | MPV: disable automatic software video fallback | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 32 | `ea9587dcb7feb187cc91f5f958f35696fea2996a` | fix(mpv): gate compressed audio by passthrough route | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 33 | `5a5a6ef383fb601e5bbce9d932a3bcc7cfa6ccc7` | docs: record MPV script button design | evidence/included:作为对应实现的设计、验证或收口证据纳入历史 | -| 34 | `72200c16fed08bd0309ea6290fab238bd21e7fa6` | feat: add MPV script custom buttons | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 35 | `5b9a641678cfeae4db305fc490f6d5e17190921f` | fix(mpv): use scripts new button for custom controls | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 36 | `c0968836fbf3f9045449782355eb311c6dd89911` | feat(mpv): add script custom button management UI | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 37 | `8baec44153035bb71723b4b2e0012415eb3eb337` | fix(mpv): keep script creation in one dialog | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 38 | `bafdf34b765fca776ecc94a92cabb26234213f2e` | fix(mpv): reposition custom script buttons | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 39 | `7ad7e1406efc2cda01bd6b14bd94822b7c31dedd` | fix(mpv): align right script buttons | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 40 | `61b352f8086554364d0ce402eb20c27105e01c6e` | sync: add optional MPV configuration transfer | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 41 | `123db7e7553eb0066e3c815f437b8cf266fe1aa7` | sync: clear stale MPV profile preferences on restore | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 42 | `3b346c85d0a3ed519d1bdac7b2e431a238a313ed` | docs: close MPV configuration sync record | evidence/included:作为对应实现的设计、验证或收口证据纳入历史 | -| 43 | `41f02fd3c1f9e40fc64dd810475f7a45109bd4cb` | fix: open script text editor and refresh list | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 44 | `37a3685621b9b3882625fe721e3bf5a4e54ac372` | fix: unify mpv scripts settings flow | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 45 | `b70f96ec0f732cc88edcd5edc26e39ecffe2cba3` | fix: show custom script button click feedback | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 46 | `219ea082bd17719db49389b57f83fe2199bb79bf` | fix: toggle custom script button feedback | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 47 | `77df324e28c5befefe2f050e04c01ea62d556e3a` | fix: omit generated script suffix from button title | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 48 | `e1a873b37aa2243e014a9ab1be78c3f9696161be` | fix(tv): reset search result focus on CSP switch | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 49 | `54b0b6a875d9d26fcf26c96ae04d4a2302e49625` | fix(tv): intercept search result focus entry | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 50 | `686522fa8f4a9405f906848c2b938d9da300d6cd` | fix(mpv): prevent automatic script execution and remove button cap | candidate/included:三方合并;冲突处保留本地后续修复并接入上游能力 | -| 51 | `784b90420d646eb6c7ddcc63ad622a92c65b02b4` | chore: remove local docs from remote | adapted:保留提交历史与代码结果,但不删除本地任务/评估文档 | - -### 第二轮实施与验证结果 - -- 2026-09-07:已完成 14 个 Git 冲突文件的三方组合;保留本地播放器生命周期、性能与历史恢复修复,同时接入上游 Exo/MPV、音频策略、双 ABI 资产、脚本按钮/配置同步及 TV 焦点修复。额外修复合并树中的 Leanback `placePanDiagnosticAction()` 缺失、`Backup` 的 `BaseLoader` 导入和 `ExoUtil` 工厂参数对齐问题。 -- `bash .codex/scripts/task_guard.sh check`:通过;无 unmerged path,5 个初始 `.bak` 文件仍受保护。 -- `bash scripts/verify_mpv_native_assets.sh --require-elf`:通过;`arm64-v8a` 与 `armeabi-v7a` 的 ELF、锁定版本和打包规则均通过。 -- `bash ./gradlew :app:compileMobileArm64_v8aDebugJavaWithJavac :app:compileLeanbackArm64_v8aDebugJavaWithJavac :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.player.exo.ExoUtilTest --tests com.fongmi.android.tv.player.exo.ExoCompressedAudioDirectPolicyTest`:`BUILD SUCCESSFUL`;Mobile/Leanback Java 编译和两个受影响 Exo 单测通过。 -- `git diff --check`:通过。Gradle 仅报告仓库既有的 32-bit native library 警告;本轮未重建 native、未做连接设备播放回归,故不把本地构建结果扩大为实机行为结论。 -- 当前状态:代码、验证、merge commit 与 recovery tag 均已收口;不执行远端 push。 - -## Closure:2026-09-07 Asia/Shanghai - -- Merge commit:`188553addf6692220a2a715790fb8706b2f423b0`(`merge: synchronize latest fish2018 main updates`),第一父提交为 `912208261e4e342ced009b1a0b71feed4855a01d`,第二父提交为 `784b90420d646eb6c7ddcc63ad622a92c65b02b4`。 -- Recovery tag:`recovery/C4/20260907105426-188553addf66`;`git merge-base --is-ancestor` 已确认本地基线和上游目标均为 HEAD 祖先,merge metadata 已清理。 -- 最终工作树仅保留任务开始前的 5 个受保护 `.bak` 未跟踪文件;其 SHA-256 与 guard 初始指纹一致。`docs/OCI1-oci-apk-update.md` 与 `docs/mobile-apk-link-push.md` 均保留。 -- 最终状态:完成(本地未推送)。连接设备播放、真实 OCI 下载/局域网 APK 推送和 native 重建不属于本轮验证范围,后续如需验收应另开任务。 - -## 第三轮源码合并:2026-09-13 Asia/Shanghai - -- 目标:`fish2018/webhtv:main@fc62397591701b2232ae7de4f50a032bd7742064`;本地基线 `154e003520a751a19187057f103e1496c5197457`;三方合并基 `2b36396c0d76b312154d560c0c94e55909b951a2`。 -- 冲突:26 处,按“本地行为契约 + 上游新功能并集”解决;二进制/编译产物按用户要求直接覆盖更新,本地任务/评估文档保留。 -- 关键并集:播放器蓝光菜单、原盘导航、历史进度、MPV 渲染设置、短剧 dock/生命周期、Disc 菜单清理与 native patch 链;`.gitignore` 保留本地缓存目录。 -- 尚未完成:定向验证、guard finish 提交与 recovery tag。 -- 2026-09-13 05:44–06:13:修复 mobile/leanback 合并残留的结构、绑定、设置行、动作注册与初始续播冲突;恢复 mobile disc-menu 生命周期触发与 leanback `updateDiscMenuButton()`。mobile/leanback Java 编译和 8 个定向单测通过,`git diff --check` 通过。 - - -## 第四轮源码与资产同步:2026-09-18 Asia/Shanghai - -### Recovery anchor - -- 目标:将 `fish2018/webhtv:main` 从上一轮已纳入的 `fc62397591701b2232ae7de4f50a032bd7742064` 同步到最新 `88aceb110959ff50afc23b10d9b9abe3e0f53255`,在 `dev4` 保留本地后续功能与治理文档。 -- 用户授权:合并上游最新代码;非代码二进制等文件可按上游直接覆盖;代码冲突按本地行为契约与上游功能并集解决,无法判定的产品行为先确认。 -- 本轮基线:`dev4@ac39115dd99c43b861e0a255c7b7a407af2855b2`;共同祖先:`fc62397591701b2232ae7de4f50a032bd7742064`;上游目标:`fish2018/webhtv:main@88aceb110959ff50afc23b10d9b9abe3e0f53255`。 -- 回滚锚点:实施前 `ac39115dd99c43b861e0a255c7b7a407af2855b2`;完成后以本轮 merge commit 与 recovery tag 为恢复点。 -- Guard:`C4/upstream`;受保护的初始工作树脏路径:无。 -- 范围:`.github/`、`app/`、`catvod/`、`chaquo/`、`docs/`、`gradle/`、`quickjs/`、`scripts/`、`third_party/` 中本轮上游差异及本任务记录;不升级其他未在上游目标中出现的依赖,不推送。 -- 下一动作:先完成上游真实 merge 并列出所有冲突;二进制冲突按上游选取,代码冲突逐文件审阅后再验证。 - -### 决策包与证据边界 - -| 方案 | 决定与理由 | -| --- | --- | -| 不变更 | 不满足“合并上游最新代码”,且遗漏 52 个上游提交。 | -| 直接覆盖整个工作树 | 不采用;会删除/覆盖 `dev4` 后续代码、播放器生命周期保护和本地任务文档。 | -| 真实双亲 merge + 窄适配 | 采用;保留上游 provenance,对代码冲突保留本地安全/生命周期契约并接入上游新增行为,二进制和测试资产按上游目标覆盖。 | - -本轮的主要证据为上游 52 个提交的实际 diff、提交内测试/文档、当前 `dev4` 对应调用链与锁/资产清单,以及既有 C4 合并记录。播放器边界继续遵守 `README.md`、`.codex/skills/upstream-integration-governor/references/webhtv-player-gates.md`:不因主线同步而把 Exo 与 MPV 的二进制、ABI、JNI 或 lock 视为可互换;不重建 native。若上游 commit 引入新的 native/ABI 资产,按其文件 provenance 与双 ABI 门禁验证;若代码冲突无法由现有契约决定,则不擅自选择。 - -### 本轮完整上游提交台账(共同祖先之后) - -| # | Full commit | Date | Subject | Final disposition | -| ---: | --- | --- | --- | --- | -| 1 | `792c1f880bc151eb1cb6675034ec144aadc14766` | 2026-09-12T07:41:10+08:00 | mpv: checkpoint opt-in DV7 FEL reconstruction (known bugs) | 已合并(真实 merge) | -| 2 | `ec68966c1d72be1c27533e7e9450763a4189febe` | 2026-09-12T09:01:32+08:00 | mpv: avoid synchronous audio probes in unrelated log callbacks | 已合并(真实 merge) | -| 3 | `cbb02fa4c40a2d0b1d04a43d6c5be4265129f98e` | 2026-09-12T21:47:50+08:00 | fix(mpv): checkpoint pure-BL FEL reliability candidate | 已合并(真实 merge;保留本地前台崩溃恢复契约) | -| 4 | `0a82dc13e255524d7c0e4e04c2f51ec9119aec88` | 2026-09-13T02:55:08+08:00 | mpv: checkpoint FEL producer handoff with unresolved playback stalls | 已合并(真实 merge) | -| 5 | `1620bac1566727f4067eda631647a11652082e74` | 2026-09-13T11:23:03+08:00 | mpv: separate FEL cold initialization from cancellable frame handoff | 已合并(真实 merge) | -| 6 | `dc1401638532840a8362b869b3220cb952ca7b35` | 2026-09-13T13:08:42+08:00 | mpv: isolate FEL performance logs and add nonblocking stage timings | 已合并(真实 merge) | -| 7 | `ce10d5c15ef36fa83e27c6195182717a334a1036` | 2026-09-13T22:54:43+08:00 | fix(mpv): refresh FEL frame commands and trace frame ordering | 已合并(真实 merge) | -| 8 | `2ec5afd8cc3f21bf1693b198f87018488775c660` | 2026-09-14T00:42:39+08:00 | fix(mpv): bound FEL renderer warmup and gate push descriptors | 已合并(真实 merge) | -| 9 | `8965c0ddda41ef8e8daac80ed227c04f56b89b64` | 2026-09-14T13:45:41+08:00 | feat(diagnostics): add bounded playback log foundation | 已合并(真实 merge) | -| 10 | `ce2baffcffff6ebb720bc5a5793c706b18f62ca5` | 2026-09-14T14:35:21+08:00 | fix(settings): preserve playback performance focus after edits | 已合并(真实 merge) | -| 11 | `4a447f26e5fa488cb0c1c661398e374b10a56b6e` | 2026-09-14T15:23:51+08:00 | fix(settings): correct update dialog theme and sizing on TV | 已合并(真实 merge) | -| 12 | `bc2b3b284de87ada937b4ba3564f6fb12aa8a956` | 2026-09-14T17:14:48+08:00 | docs: assess Exo libass integration and MPV subtitle surface reuse | 已合并(真实 merge) | -| 13 | `845ce82c64b16c94a1db7a61ed6bd38d2d1d35ac` | 2026-09-14T18:18:00+08:00 | docs: refine E4-LIBASS integration and validation plan | 已合并(真实 merge) | -| 14 | `5cde3c015258f620f264d5f3ffe0a437c2ea3d48` | 2026-09-14T22:22:55+08:00 | fix(mpv): preserve ASS script fonts and explicit style overrides | 已合并(真实 merge) | -| 15 | `e7c0cdf0d6dafd5679425f045192e708e92dcfed` | 2026-09-15T04:24:00+08:00 | feat(exo): add gated libass rendering with attached fonts | 已合并(真实 merge) | -| 16 | `bed561691c239d6a48456c45f3727416c8c40c5c` | 2026-09-15T06:10:55+08:00 | fix(exo): render container ASS packets with original fonts | 已合并(真实 merge) | -| 17 | `0667ff435ba167cfdff716236d68fea8d7fcd16f` | 2026-09-15T07:54:28+08:00 | fix(git-cloud): support token storage without AndroidKeyStore | 已合并(真实 merge) | -| 18 | `a18c409b6d5a3c9d1e64edd673cd672daf448ac1` | 2026-09-15T10:32:16+08:00 | fix(mpv): synchronize script button activation with Lua execution | 已合并(真实 merge) | -| 19 | `684f6066393fa503b2a0573c25a2aa25d01294fc` | 2026-09-15T11:09:14+08:00 | fix(mpv): show player rebuffer statistics in playback panel | 已合并(真实 merge) | -| 20 | `57656c06bdb4e13de3cbaf8f573dc76212e7d718` | 2026-09-15T12:06:12+08:00 | feat(diagnostics): observe Exo decoder, surface and audio output evidence | 已合并(真实 merge) | -| 21 | `88780d4dc8a197fc9bb5e767f450e1847e50a35d` | 2026-09-15T12:33:53+08:00 | feat(diagnostics): retain MPV native events and cached pipeline health | 已合并(真实 merge) | -| 22 | `ac02ce84ae14332fa8bf2aab840ee4ab5a7fac2b` | 2026-09-15T12:56:23+08:00 | feat(diagnostics): add IJK evidence and bounded process recovery journal | 已合并(真实 merge) | -| 23 | `8d46751d7fd0f61bd2cb2557bb8fe99a0d33d494` | 2026-09-15T13:12:50+08:00 | fix(diagnostics): preserve MPV native attempt ownership across media changes | 已合并(真实 merge) | -| 24 | `180811f16073271ba1cb6a4f2f889008966facd3` | 2026-09-15T14:10:54+08:00 | feat(diagnostics): add categorized capture controls, incident reports and bounded probes | 已合并(真实 merge) | -| 25 | `5acbb05afff34235d66bc1a6f3d7f67427e2a239` | 2026-09-15T16:21:58+08:00 | feat(diagnostics): complete native evidence and categorized playback logging | 已合并(真实 merge) | -| 26 | `248a947ba8dcd834e386cba25e4de83984ebc1ab` | 2026-09-15T17:59:30+08:00 | feat(debug): capture crawler consoles and organize web logs into responsive tools | 已合并(真实 merge) | -| 27 | `206a57e0e337304a8b78712c487ef245d7cb0fa0` | 2026-09-15T18:20:35+08:00 | fix(exo): include ASS rendering in normal builds | 已合并(真实 merge) | -| 28 | `57211803d507ab1b6b6a0ee02c626a7f5909eebc` | 2026-09-16T05:35:16+08:00 | mpv: add bounded FEL wait diagnostics for TV logs | 已合并(真实 merge) | -| 29 | `5f6fd1a75c210eac2571e7939a4e5451f23e84e3` | 2026-09-16T06:22:00+08:00 | docs: assess FEL Vulkan descriptor stalls across projects | 已合并(真实 merge) | -| 30 | `ed3d710ef551210278920ba4cd25e8dda6e19ad6` | 2026-09-16T06:53:30+08:00 | perf(mpv): reuse unchanged FEL descriptor contents with fresh commands | 已合并(真实 merge) | -| 31 | `44dd3f1386ac47c5d9e2007b9d2b32dfa0f72d8e` | 2026-09-16T07:26:55+08:00 | fix(debug): remove pairing and promote log actions | 已合并(真实 merge) | -| 32 | `98d247ea193c58a3dbfa4033d679023282632a8e` | 2026-09-16T07:55:56+08:00 | docs(mpv): assess log33 FEL startup and persistent GPU stalls | 已合并(真实 merge) | -| 33 | `5df95f475009ed0d04d864b60d7d22b229e87e95` | 2026-09-16T08:27:50+08:00 | docs(mpv): refine FEL diagnosis with cross-project source and binary evidence | 已合并(真实 merge) | -| 34 | `8de0fd70942d513034fb8118de5229d7eb719622` | 2026-09-16T12:49:38+08:00 | fix(mpv): select DV7 FEL before video chain initialization | 已合并(真实 merge) | -| 35 | `dbff1ffecd973c6d89eef1bf139f7243ac6b3ead` | 2026-09-16T13:46:14+08:00 | feat(mpv): add bounded FEL descriptor binding diagnostics | 已合并(真实 merge) | -| 36 | `769e53471dbb3e9ad4f9b94842099ba97a92fdbd` | 2026-09-17T11:16:23+08:00 | feat(player): add baseline AVS3 decoding and fix Exo to MPV surface handoff | 已合并(真实 merge) | -| 37 | `8919cf134218a3d3bb30f91f3180e9cd83eac982` | 2026-09-17T11:46:59+08:00 | fix(exo): enforce manual video decode mode for AVS3 | 已合并(真实 merge) | -| 38 | `c9a1ac99d05f2d8bcac0558b725d34e196b468e2` | 2026-09-17T12:47:29+08:00 | fix(mpv): preserve ownership of FEL context options | 已合并(真实 merge) | -| 39 | `54e7947c272a7b3ebad8b80bfab4c889e1ea86d5` | 2026-09-17T13:14:48+08:00 | fix(mpv): apply P8 HDR10 compatibility in explicit output modes | 已合并(真实 merge) | -| 40 | `80fea0039053ab95c2e38150ecb7af63d1fdb8ae` | 2026-09-17T14:21:25+08:00 | feat(mpv): honor global smart ad skipping for HLS | 已合并(真实 merge) | -| 41 | `edf4324034fe1681a658dd4557dd8451fcfdb792` | 2026-09-17T17:51:03+08:00 | feat(player): decode AVS3 High 10-bit with isolated HPM backend | 已合并(真实 merge) | -| 42 | `13053755eaea00aa9c6449e8ad55c2ccf1fbc68a` | 2026-09-17T18:08:27+08:00 | fix(mpv): defer resume seek until file loading completes | 已合并(真实 merge) | -| 43 | `98a7f9033d46651d019e2b29a7a75ad4dac317de` | 2026-09-17T19:24:36+08:00 | feat(mpv): add hardware-only AVS3 MediaCodec decoding | 已合并(真实 merge) | -| 44 | `76a78c343974639f12257743a1baa339ca00929b` | 2026-09-17T20:11:13+08:00 | fix(mpv): preserve programme blocks in HLS ad skip timelines | 已合并(真实 merge) | -| 45 | `8dd32cb9aab018a9d2c74437d235bc0e1912eded` | 2026-09-17T21:11:57+08:00 | fix(mpv): clip smart ads before rendering and restore reused surfaces | 已合并(真实 merge) | -| 46 | `18ccd0785f04b279759399623adf3e593e86a22e` | 2026-09-17T23:15:08+08:00 | fix(live): expose playback parameters on mobile and TV controls | 已合并(真实 merge) | -| 47 | `623b069261bf8f9d559969e9c99a6775e547443c` | 2026-09-18T00:30:50+08:00 | fix(mpv): honor HLS media roles for playlist-named live segments | 已合并(真实 merge) | -| 48 | `4818057cd64c2c62c94e7208d9121719b4d11fe0` | 2026-09-18T06:07:42+08:00 | fix(diagnostics): base audio conclusions on observed output state | 已合并(真实 merge) | -| 49 | `e2f39f240743ba4f8adf75bc6599f4ef7899d48a` | 2026-09-18T07:18:47+08:00 | perf(exo): skip fixed-track constraint reselection and reuse codec diagnostics | 已合并(真实 merge) | -| 50 | `ad6f68d5120d49345e5c3b147e1580f9cc280c5f` | 2026-09-18T12:33:12+08:00 | feat(exo): support independently selected dual subtitles | 已合并(真实 merge) | -| 51 | `ee216d8ba7dee9637e9b79478d819532c846691c` | 2026-09-18T14:22:17+08:00 | fix(exo): preserve audio output ownership and tunneling after seek | 已合并(真实 merge) | -| 52 | `88aceb110959ff50afc23b10d9b9abe3e0f53255` | 2026-09-18T17:15:45+08:00 | fix(ci): skip obsolete Android SDK tools package | 已合并(真实 merge) | - -### 实施与验证结果(2026-09-18) - -#### 冲突处理与功能并集 - -- 10 个冲突文件均已解决:`VideoActivity.java`、`MpvHlsProxy.java`、`MpvPlayer.java`、`PlayerManager.java`、`MpvPlayerEngine.java`、`ExoUtil.java`、`MediaSourceFactory.java`、`DebugLogs.java`、`PlayerOsdController.java`、`dialog_update_settings.xml`。 -- 保留本地零拷贝阻断、短剧队列、PAN 配置顺序、前台崩溃恢复页、AI 日志分组/返回顶部和 MPV 字幕样式设置;接入上游结构化诊断、FEL/DV7、HLS Ad Timeline、AVS3、双字幕、Disc 与播放按钮能力。 -- MPV HLS 改为“只计算 timeline、播放时跳过”,不再把候选广告误记为实际跳过;IJK 保留既有过滤和统计。 -- 更新面板保留本地 OCI/GitHub proxy 配置;二进制、测试 fixture、AAR/POM、native lock/patch 和上游资产按 `upstream/main` 目标纳入。 - -#### 验证证据 - -- `bash ./gradlew :app:compileMobileArm64_v8aDebugJavaWithJavac :app:compileLeanbackArm64_v8aDebugJavaWithJavac :app:testMobileArm64_v8aDebugUnitTest --console=plain`:`BUILD SUCCESSFUL`;Mobile 单测统计 `4587 tests, 0 failures, 0 errors, 1 skipped`。 -- `bash scripts/verify_mpv_native_assets.sh --require-elf`:`arm64-v8a` 和 `armeabi-v7a` 通过,18 个 native 文件与 lock/打包规则一致;同时输出 Vulkan shader contract 与 P2 generic UV patch scope 通过。 -- `git diff --cached --check` 通过;全仓库无 `<<<<<<<`、`=======`、`>>>>>>>` 冲突标记。 -- 未执行 APK 全量打包、设备安装和实机播放矩阵;因此本轮结论限定为真实 merge、Java 编译、标准 Mobile 单测、双 ABI ELF/资产门禁通过,不扩大为所有机型播放验收。 - -### 接受条件 - -1. 形成以本地基线和完整上游目标为双亲的 merge commit,目标提交可从 HEAD 追溯。 -2. 52 个上游提交全部有 disposition;无未解决冲突标记,`git diff --check` 通过。 -3. 上游新增代码接入实际构建路径;本地 MPV/Exo 生命周期、DV、音频、字幕、广告和安全保护不被无证据删除。 -4. 上游新增二进制/测试资产按目标版本覆盖并完成哈希/ELF/资产门禁;不把仅编译通过扩大为实机播放结论。 -5. 受影响 Mobile/Leanback Java 编译和定向测试通过;按风险执行双 ABI/native 资产验证。 -6. C4 文档、评估索引和 merge provenance 更新;原子提交并创建本地 annotated recovery tag,不推送。 - -## 检查点 61:2026-09-18 dev4 合并后代码复审 - -- 基线:`dev4@710e1bd520796f5be7466fe75b05903112c6df37`;目标:评审该 merge commit 相对 `origin/beta@ac39115dd99c43b861e0a255c7b7a407af2855b2` 的全部已提交未推送改动。 -- 第一轮评审发现 `app/build.gradle` 重复声明 `testInstrumentationRunner` 和 `testImplementation libs.junit`,并同时使用硬编码 `androidx.test:runner:1.7.0` 与版本目录中的 `1.6.2`。修复为单一 runner 声明、单一 JUnit test 依赖,并将版本目录统一为上游的 `1.7.0`。 -- 修复验证:`mobileArm64_v8aDebugAndroidTestRuntimeClasspath` 解析成功,`androidx.test:runner:1.7.0` 生效;Mobile/Leanback arm64-v8a Debug Java 编译 `BUILD SUCCESSFUL`;`git diff --check` 通过。 -- 第二轮评审:重新获取远端后,`origin/beta` 与 `upstream/main` 均仍为 HEAD 祖先;合并提交无冲突标记、无 staged/working-tree whitespace 错误,构建配置重复项已清除,未发现新的集成问题。 -- 下一动作:提交本轮构建配置修正,推送 `dev4`,创建至 `beta` 的 PR,并在完成后拉取远端最新状态。 - -## 第五轮源码同步:2026-09-19 Asia/Shanghai - -### Recovery anchor - -- 目标:把 `fish2018/webhtv:main@2623cb812ea842b676bc7d8db699c1a7e70b8e1e` 的两项最新提交真实合并到 `dev3`,保留本地任务索引结构,并在定向验证后原子提交/打恢复 tag。 -- 授权/车道:用户持续要求“合并上游最新代码”;`C4/upstream`。范围仅限本轮上游新增的 Exo ASS Java/测试/JNI 配套产物、`docs/E4-LIBASS-exo-ass-rendering.md`、本文件和评估索引。 -- 冻结基线:本地 `dev3@d88905047649fe1249993a2b48f17bff62426664`;共同祖先 `88aceb110959ff50afc23b10d9b9abe3e0f53255`;上游目标 `2623cb812ea842b676bc7d8db699c1a7e70b8e1e`。 -- 上游台账:`e85dc87988bbe8e3d67509426cb5e1d1a2cee3b7` 记录 HDR/SSA 根因与边界;`2623cb812ea842b676bc7d8db699c1a7e70b8e1e` 放行非加密/非 tunneling HDR/DV/BT.2020 的独立 SDR RGB 字幕层,并更新 JNI、arm64 产物和 7 项定向测试。 -- 冲突与处理:仅评估索引冲突;本地已清空旧任务队列,因此不恢复上游旧队列行,改把 E4 最新状态并入本地“新增产品需求”索引。代码、JNI 源码和产物按真实 merge 自动纳入。 -- 验证状态:已完成。`bash ./gradlew :app:assembleMobileArm64_v8aDebug :app:assembleMobileArm64_v8aDebugAndroidTest --console=plain` 在 1 分钟内 `BUILD SUCCESSFUL`;在 `V1923A` arm64 真机运行上游修复相关定向集,4 项 `AssVideoPolicyTest`、1 项 native HDR/SDR RGB 与暂停时间点重绘、1 项官方 blur/transform 共 6/6 通过。 -- 产物证据:仓库、APK、manifest 与 provenance 的 `libexo_ass.so` SHA-256 均为 `31e04a1d26c606dd2f5df0b0b81f2916ed0b29c13b3415515a77cff540e83cc2`;`exo_ass.cpp` 源哈希为 `b333e896881a9a13a8a618cc147a5472914b7570290264db732bda624f4f8470`,与 provenance 一致。2026-09-19 复检 `upstream/main` 仍为 `2623cb812ea842b676bc7d8db699c1a7e70b8e1e`。 -- 已知非本轮失败:同组 `AssPlaybackTest#testPauseDelaySurfaceFallbackSeekTracksAndRelease` 无法启动 debug Activity,因为测试硬编码 `com.fongmi.android.tv`,而本地 `dev3` 的 `applicationId` 为 `com.silent.android.webhtv`;本轮 diff 不含 `AssPlaybackTest.java` 或 `app/build.gradle`,因此归类为既有本地夹具契约不一致,不把它冒充上游修复回归,也不在本合并任务中扩围修改。故 7 项设备用例中仅 6 项修复相关用例通过,不能记录为 7/7 全部通过。 -- 风险边界:本轮验证覆盖合并后的 Java/JNI 构建路径和修复相关的原生/策略断言,不声称 HDR/DV 原片逐像素或性能已重新量化;该边界与上游第 17.5 节一致。 -- 唯一下一步:由当前 `C4/upstream` guard 原子提交本轮复评文档并创建本地 annotated recovery tag;随后推送当前 `dev3` 分支及该 recovery tag,创建中文 PR 合入 `beta`,最后拉取远端最新代码。 - -## 第六轮源码同步:2026-09-21 Asia/Shanghai - -### Recovery anchor - -- 目标:把 `fish2018/webhtv:main@fa09d226f9f3763bc0f50167d6485178438fad52` 相对上一轮目标 `2623cb812ea842b676bc7d8db699c1a7e70b8e1e` 的两项修复真实合并到 `dev2`,保留本地播放器、触摸优化、线路切换与评估文档结构,并在定向验证后原子提交/打恢复 tag。 -- 授权/车道:用户要求“合并上游最新代码”;`C4/upstream`。范围:`app/build.gradle`、Leanback `VideoActivity`、新增 `FlagSelectionListener` 及其 Robolectric 测试、Exo 压缩音频直出回退策略与其测试、`docs/E11-exo-compressed-audio-direct.md`、`docs/leanback-pan-switch-layout.md`、本文件和评估索引。 -- 冻结基线:本地 `dev2@16ee00828da7f7ab00ae6e53e1222264a54d1745`(`origin/beta` 同步 head);共同祖先 `2623cb812ea842b676bc7d8db699c1a7e70b8e1e`;上游目标 `fa09d226f9f3763bc0f50167d6485178438fad52`。 -- 上游台账(2 项,均已合并): - 1. `f836d419518d1a93d4ff77414a88558f3854c7e3` fix(exo): fall back to PCM when compressed audio stalls —— 厂商压缩直出已接收数据但播放位置 2 秒无进展、随后被 Media3 以 `STUCK_PLAYING_NO_PROGRESS` 超时终止时,标记该 encoding/rate/channel-mask 配置失败并原位回退 PCM。仅 App Java,无依赖/native 变化。 - 2. `fa09d226f9f3763bc0f50167d6485178438fad52` fix: defer leanback cloud route selection until layout completes —— 线路选择回调位于 Leanback 布局期间时,把完整线路切换移出选择回调;快速切换只执行最新一次,退出/列表替换后丢弃失效选择。仅 App Java 与测试配置。 -- 冲突与处理:4 处冲突。 - - `app/build.gradle`:保留本地已声明的 `testImplementation libs.junit`,只新增上游的 `testOptions.unitTests.includeAndroidResources` 与 `testLeanbackImplementation 'org.robolectric:robolectric:4.16'`,不重复声明 JUnit。 - - Leanback `VideoActivity`:import 段保留本地既有导入,仅新增 `FlagSelectionListener`;触摸监听保留本地 `onVideoTouch`(触摸优化 + 亮度/音量/seek 手势),并把上游的 `dispatchDiscMenuTouch` 前置进 `onVideoTouch`,同时恢复上游的蓝光菜单触摸派发;`onItemClick` 合并上游退出保护(`isFinishing()/isDestroyed()`)与本地 null 安全、短剧队列失效、焦点/选集通知逻辑。 - - `ExoPlayerEngine.startInternal`:合并上游 `compressedAudioDirectPolicy.resetOutputProgress()` 与本地 `preCache.setPlaylistPreloadDurationMs`、`queuedSpec/queuedMediaId` 清理。 - - 评估索引:本地保留 5017 行完整审计台账,只把上游 8 行既有任务队列行(E11/P11/P10/C-AVS3/AV-DIAG-01/E9-3/P2-4/P9)并入“当前任务队列”,不采用上游 90 行精简副本,也不删除任何本地任务文档。 -- 与本地既有修复的交叉核对:本地 `cb21feb42287e2299b3cbc9a6c911254b58060aa` 已让 `FlagAdapter.toggle` 在布局期间不通知列表,上游 `fa09d226f9` 的 `notifyDataSetChanged()` 属不同基线;合并后 `synchronousFocusAndEpisodeRefreshReproduceReportedException` 只断言焦点刷新异常,不再要求 `toggle` 抛异常(测试已按本地合同调整并注明原因)。 -- 验证状态:已完成。`compileMobileArm64_v8aDebugJavaWithJavac`、`compileLeanbackArm64_v8aDebugJavaWithJavac`、`compileLeanbackArmeabi_v7aDebugJavaWithJavac` 一次 `BUILD SUCCESSFUL`(1m 4s);E11 定向 33 项(26 `ExoCompressedAudioDirectPolicyTest` + 7 `ExoAudioOutputStateTest`)与 `FlagSelectionListenerTest` 8 项分别通过。 -- 验证限制:E11 的 26 项测试需要临时 init 脚本开启 `unitTests.returnDefaultValues=true` 供 `android.os.SystemClock` 使用(上游原验证环境已配置),未改生产构建配置;`FlagSelectionListenerTest` 的 `synchronousFocus...` 按上述本地合同调整后通过。未执行设备安装/实机播放,Sony 同片源与 XGIMI 线路切换仍需实机回归。 -- 不涉及:FFmpeg/Media3/MPV/ijk 源码、锁文件、补丁、native 二进制与资产;`git diff --cached --check` 通过,无冲突标记。 -- 回滚锚点:本轮实施前 `dev2@16ee00828da7f7ab00ae6e53e1222264a54d1745`;本次合并提交可整体 revert。 -- 唯一下一步:本轮改动已由 `C4/upstream` guard 原子提交为 `4d80db9199b3af69b962a6d2fb42ec9a6c435280` 并创建本地 annotated recovery tag `recovery/C4/20260921103442-4d80db9199b3`;后续交付(复评、push、PR)见文末“第六轮复评与收口记录”。 - -### 第六轮复评与收口记录:2026-09-21 Asia/Shanghai - -- 复评对象:相对 `origin/beta@16ee00828da7f7ab00ae6e53e1222264a54d1745` 的已提交未推送改动(merge commit `4d80db9199b3af69b962a6d2fb42ec9a6c435280`,含上游两项修复)。`git rev-list --left-right --count HEAD...origin/beta` = `3 0`,beta 无遗漏提交;净差异 11 个文件(+849/-24),无冲突标记、无 whitespace 错误。 -- E11 音频停滞回退复评:接通点正确——`PlayerManager.onPlayerError` 在错误回调前不会执行 `ExoPlayerEngine.stop()/release()`,因此 `resetOutputProgress()` 不会提前清空停滞证据;恢复成功走 `ErrorAction.RECOVERED` 提前返回,不落入视频软解或线路重试。`getAudioOutput` 在创建前捕获 `OutputAttempt`,`resetOutputProgress()` 以新 attempt 替换,晚完成的旧初始化不会污染新 attempt;标准 PCM/offload/tunneling 输出会清空 vendor 证据,避免把停滞归因到非直出路径。停滞证据只在“已接受数据 + 播放中 + 位置连续 2 秒不变”时保留,且只在 `ERROR_CODE_TIMEOUT` 且 `StuckPlayerException.STUCK_PLAYING_NO_PROGRESS` 下用 `compareAndSet` 一次性消费。 -- Leanback 线路切换复评:`FlagSelectionListener` 把整段 `onItemClick(Flag)` 移出布局临界区,合并最新选择并在执行前复核视图附着、adapter 与候选对象身份;`onItemClick` 增加 `isFinishing()/isDestroyed()` 保护。本地 `cb21feb42287e2299b3cbc9a6c911254b58060aa` 的“布局期间不通知”契约与上游延迟合并后,测试只断言焦点刷新异常(已注明原因),行为未回退。 -- 定向验证:`:app:compileMobileArm64_v8aDebugJavaWithJavac` 与 `:app:testLeanbackArmeabi_v7aDebugUnitTest`(`ExoCompressedAudioDirectPolicyTest` 26 项、`ExoAudioOutputStateTest` 7 项、`FlagSelectionListenerTest` 8 项)一次 `BUILD SUCCESSFUL`(39 秒),JUnit XML 记录 failures/errors/skipped 全 0。 -- 复评新发现(未修复,阻断性):以提交后的真实构建配置(不加临时 init 脚本)执行 `:app:testMobileArm64_v8aDebugUnitTest` 得到 4765 项 / 12 失败 / 1 跳过,失败全部集中在 `ExoCompressedAudioDirectPolicyTest`;根因为该测试用 media3 `PlaybackException` 的公开构造函数构造夹具,其内部调用 `android.os.SystemClock.elapsedRealtime()`(单元测试桩为 native 方法,抛出 `Method ... not mocked`)。`origin/beta` 不含该类,因此这是本轮合并引入的测试门禁回归;上一轮验证通过靠临时 `unitTests.returnDefaultValues=true` init 脚本掩盖,未写入仓库配置。 -- 未采纳的修法:在 `app/build.gradle` 全局开启 `unitTests.returnDefaultValues=true`,会把整个单测环境的未实现框架方法静默降级为默认值,属于放宽既有门禁,不予采用。 -- 下一动作:在独立的 E11 后续任务中把该测试改为不依赖 `android.os.SystemClock` 的夹具构造方式,并用真实构建配置重跑 mobile 全量单测与 Leanback 定向测试后再交付。 - -- 修复记录(2026-09-21):上述 12 项失败已由独立任务 `E11-test-clock-independence` 修复——`ExoCompressedAudioDirectPolicyTest` 的夹具改用 `PlaybackException` 带时间戳的 protected 构造函数,不再触发 `android.os.SystemClock`;未放宽单测门禁。以真实构建配置重跑 mobile 全量单测与 Leanback 定向测试均通过,详见 `docs/E11-exo-compressed-audio-direct.md` 的“单测门禁修复”一节。 - -## 第七轮源码同步:2026-09-21 Asia/Shanghai - -### 冻结来源与目标 - -- 本地实施前基线:`dev5@150e29340200a1cb2173880a3ef7b92651bc2879`。 -- 上游目标与最终态:`fish2018/webhtv:main@8e4d9333de8ea7346491e71a0b1ab6858a852298`。 -- 本地播放器基线:`1050dba289abca6f0ad05dce8bc76df330ebcbc9`,用于保留厂商直出初始化失败后立即通知并重启 PCM 的修复。 -- 范围:`PlaybackErrorClassifier`、`ExoPlayerEngine`、`ExoCompressedAudioDirectPolicy`、`ExoUtil`、原生新增失败记忆/恢复 renderer、对应 JVM/集成测试及 E11/C4 文档;不改依赖锁、native 二进制或 MPV。 - -### 上游增量完整台账 - -| 完整 commit ID | 功能 | 处置 | 合并决定 | -| --- | --- | --- | --- | -| `8d7ccf42b449db6ae12eb04a38f95196ef29c737` | 记录 E11 起播研究与批准 | 已合入 | 保留研究记录;本轮实现以同一 E11 文档整合 | -| `123d871c027eb686702766bca995f616e8d2bd1e` | 按媒体、音轨格式和实际路由记忆已确认失败 | 已合入 | 接入有界进程内记忆;本地立即初始化失败路径保留并向该记忆提供证据 | -| `e9fef2b90ee8aa634327c2c179eb3dc5d22cb1be` | 定义完整首次起播恢复方案 | 已合入 | 作为后续 `8e4d9333de` 的设计依据 | -| `24fa078d2dc8a404fad23fc30972e5fc4a8a1b5b` | 关闭音频直通时禁止所有编码输出 | 已合入 | 保留 track selector、policy 和最终初始化三层准入;正常关闭设置直接走 PCM | -| `8e4d9333de8ea7346491e71a0b1ab6858a852298` | 已缓冲音频起播停滞时复用当前 period 快速恢复 | 已合入 | 接入 `ExoStartupAudioRenderer` 的 Media3 可恢复错误路径;保留本地错误分类与立即回退 | - -### 冲突处理与行为组合 - -- `ExoPlayerEngine`:普通 `startInternal` 和显式 PCM 重试统一经过 `prepareForPlayback(..., pcmRetry)`;每次起播推进 `audioOutputAttemptGeneration`,避免旧初始化失败回调重启新播放;本地空 player/spec 保护和预缓存停止逻辑保留。 -- `ExoCompressedAudioDirectPolicy`:以失败记忆、路由校验、启动供数门槛和 `maybeRequestStartupPcmFallback` 为上游主体;本地 `InitializationFailureListener` 在厂商初始化异常现场只通知一次,并同步重置到每次 preparation。 -- `ExoUtil`:保留本地 `PlaybackMediaSignalHub`/`PlaybackMediaClock` 输出流水线,同时使用上游 `directPolicy` 和 renderer 包装;直通关闭时不再注册编码 offload 或编码输出。 -- `PlaybackErrorClassifier`:保留上游对 `StuckPlayerException` 的 `playback-stuck` 分类,不把 IJK 的超时错误码误改为网络错误。 -- JVM 适配:`ExoCompressedAudioDirectPolicyTest` 中唯一直接构造 `ExoPlaybackException` 的平台桩用例显式 `@Ignore`,原因是锁定 Media3 的 `createForRenderer` 调用未 mock 的 `SystemClock.elapsedRealtime()`;恢复行为改由 `ExoStartupRecoveryIntegrationTest` 的真实 Media3 路径覆盖。未开启全局 `returnDefaultValues`,未放宽测试门禁。 - -### 验证记录 - -- `bash ./gradlew --console=plain :app:compileLeanbackArm64_v8aDebugJavaWithJavac`:`BUILD SUCCESSFUL in 1m 2s`。 -- `bash ./gradlew --console=plain :app:testLeanbackArmeabi_v7aDebugUnitTest --tests com.fongmi.android.tv.player.exo.ExoCompressedAudioDirectPolicyTest --tests com.fongmi.android.tv.player.exo.ExoAudioDirectFailureMemoryTest --tests com.fongmi.android.tv.player.exo.ExoStartupRecoveryIntegrationTest --tests com.fongmi.android.tv.player.PlaybackErrorClassifierTest`:81 项,80 通过、0 失败、1 显式跳过;其中 3/3 恢复集成用例通过。 -- `bash ./gradlew --console=plain :app:compileMobileArm64_v8aDebugJavaWithJavac`:`BUILD SUCCESSFUL in 30s`。 -- 合并后再次 `git fetch --no-tags upstream main`:上游仍为 `8e4d9333de8ea7346491e71a0b1ab6858a852298`,`HEAD..upstream/main` 为空,`git merge-base --is-ancestor upstream/main HEAD` 通过。 -- 未连接模拟器/设备;真实 Sony 厂商直出初始化失败与已缓冲停顿时延仍需目标设备复测,不能把 JVM/集成验证表述为硬件验收。 - -### 交付与回滚 - -- Merge commit:`1bae44dba685867ae5d1bd282c9dda98ed0a3596`,第一父 `150e29340200a1cb2173880a3ef7b92651bc2879`,第二父 `8e4d9333de8ea7346491e71a0b1ab6858a852298`。 -- Recovery tag:`recovery/C4/20260921211550-1bae44dba685`;不推送。 -- 回滚:`git revert -m 1 1bae44dba685867ae5d1bd282c9dda98ed0a3596`。 -- 唯一下一步:无;后续若上游 `main` 继续前进,从本文和稳定评估索引恢复,再按新的 E/P/C 任务边界处理新增提交。 diff --git a/docs/C7-beta-sync.md b/docs/C7-beta-sync.md deleted file mode 100644 index 19d321f72d..0000000000 --- a/docs/C7-beta-sync.md +++ /dev/null @@ -1,95 +0,0 @@ -# C7:合并 `origin/beta` 最新 TMDB 手动匹配修复 - -## Recovery anchor - -- 目标:将 `origin/beta` 在 C6 之后的最新应用代码合并到 `dev2`,保留当前应用行为和可回滚边界。 -- 状态:已完成;代码合并、定向验证、原子提交和本地恢复 tag 均已完成,未推送远端。 -- 当前基线:`dev2@1b376ee0461fdf9a2e424c73cfb46f31f8301f9b`。 -- beta 目标:`origin/beta@7db1b9d188e27877154757528d441150142b90ed`。 -- 合并基点:`1b376ee0461fdf9a2e424c73cfb46f31f8301f9b`;目标提交的第二父提交就是当前 `dev2`。 -- 保护范围:guard 启动时工作树已复核为干净;本任务不触碰 `app/src/main/java/com/fongmi/android/tv/bean/Result.java` 及其他未声明路径。 -- 回滚:合并未提交前使用 `git merge --abort`;提交后使用本任务恢复 tag 或 `git revert -m 1 `。 -- 下一动作:如需发布,再由用户明确授权推送当前 `dev2` 和恢复 tag;本任务不再修改代码。 - -## Authority and scope - -- 用户已明确授权拉取 beta 最新代码并合并到当前 `dev2`。 -- 任务 guard:`C7-beta-sync`,模式:`upstream`。 -- 来源:`origin` (`https://github.com/Silent1566/webhtv.git`) 的 `beta` 分支;本次 fetch 使用 Git `HTTP/1.1` 成功完成。 -- 本阶段只同步应用层 TMDB 匹配缓存、详情页和适配器逻辑;不升级或修改 FFmpeg、Media3、MPV、JNI、lock、patch、AAR、APK 或 native 二进制。 -- 声明路径: - - `app/src/main/java/com/fongmi/android/tv/bean/TmdbMatchCache.java` - - `app/src/main/java/com/fongmi/android/tv/setting/Setting.java` - - `app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java` - - `app/src/main/java/com/fongmi/android/tv/ui/helper/EpisodeSeasonSnapshot.java` - - `app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java` - - `app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheTest.java` - - `docs/C7-beta-sync.md` - - `docs/upstream-player-dependency-merge-assessment-2026-08-20.md` - -## User-visible capability - -手动选择 TMDB 条目后,重新进入详情页、标题被 TMDB 富集改写、同一站源的季度结构发生排序变化,或后台自动匹配稍后完成时,仍能保持用户的手动选择。不同作品共用一个 `vodId` 时不会因退化的标题锚点串台;全局标题缓存中手动结论也不会被自动猜测覆盖。 - -## Source commit ledger - -| # | Repository/ref | Full commit | Parent(s) | Functional area | Disposition | -| ---: | --- | --- | --- | --- | --- | -| 1 | `origin/beta` | `5a8122f8f6b3be38bcbb3f89f593ce08439040f5` | `a26a80d0c207c06d9e09192febee95ae976e8963` | 手动 TMDB 缓存、标题别名和稳定季度指纹 | 待合并验证 | -| 2 | `origin/beta` | `d5182a794ecdec79819dab782a3c3eb096aa3839` | `5a8122f8f6b3be38bcbb3f89f593ce08439040f5` + `c975ae1ed482a4bf47f106f5931bd2392e8ecce3` | dev3 与 beta 的集成合并 | 依赖性合并,随目标树保留 | -| 3 | `origin/beta` | `b0af66ac567a85856509bb4cd042c3cbce7feee2` | `d5182a794ecdec79819dab782a3c3eb096aa3839` | 标题锚点通配符和读改写竞态修复 | 待合并验证 | -| 4 | `origin/beta` | `adbc9913857d7b1f406ae3ee00143d9e4c41aeda` | `b0af66ac567a85856509bb4cd042c3cbce7feee2` | 手动别名污染、全局标题优先级和空标题读取修复 | 待合并验证 | -| 5 | `origin/beta` | `9188c8a6a1919529438257bbc7c86bc2514f4fbe` | `c975ae1ed482a4bf47f106f5931bd2392e8ecce3` + `adbc9913857d7b1f406ae3ee00143d9e4c41aeda` | beta 发布合并提交 | 依赖性合并,随目标树保留 | -| 6 | `origin/beta` | `7db1b9d188e27877154757528d441150142b90ed` | `9188c8a6a1919529438257bbc7c86bc2514f4fbe` + `1b376ee0461fdf9a2e424c73cfb46f31f8301f9b` | beta 合并当前 dev2 的目标提交 | 本次合并目标 | - -相对当前 `dev2` 的有效树差异为 6 个文件:`TmdbMatchCache.java`、`Setting.java`、`TmdbDetailActivity.java`、`EpisodeSeasonSnapshot.java`、`TmdbUIAdapter.java` 和 `TmdbMatchCacheTest.java`。没有检测到播放器/native 依赖差异。 - -## Decision record - -- 不变更:继续使用 C6 树。优点是零合并风险;缺点是保留 beta 已修复的手动 TMDB 丢失、串台和竞态问题。 -- 原样合并:采用 beta 目标树的应用修复和测试。目标提交已经把当前 `dev2` 作为第二父提交,合并基点明确,且预检 `git merge-tree --write-tree` 无冲突。 -- WebHTV 适配:本轮不额外改写上游逻辑。现有 `dev2` 已包含 C6 的应用状态,beta 增量只触及上述 6 个文件;原样保留可最大化来源可追溯性并减少语义偏差。 -- 决定:实施本次应用层同步;不推送远端,不扩展到设备播放或 native 重建。若定向测试发现回归,停止收口并在本文件记录具体失败,不降低验收标准。 - -## Acceptance and verification - -1. 合并完成后无未合并路径、冲突标记或意外删除,最终工作树源码树与 `origin/beta` 一致。 -2. `git diff --check` 和 task guard 检查通过。 -3. `:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.bean.TmdbMatchCacheTest --no-daemon --console=plain` 通过,失败、错误和跳过均为 0。 -4. `:app:compileMobileArm64_v8aDebugJavaWithJavac :app:compileLeanbackArm64_v8aDebugJavaWithJavac --no-daemon --console=plain` 通过。 -5. 不修改 `Result.java`、lock、patch、AAR、APK 或 native 资产;不要求本阶段进行真实设备播放回归。 - -## Rollout, provenance, and rollback - -- 来源完整身份:本记录台账中的 6 个 40 位 commit;目标树为 `7db1b9d188e27877154757528d441150142b90ed`。 -- 发布方式:仅创建本地原子合并提交和 task guard annotated recovery tag;未经用户明确授权不 push。 -- 回滚锚点:`1b376ee0461fdf9a2e424c73cfb46f31f8301f9b`。共享分支回滚使用 `git revert -m 1 <本次合并提交>`,未提交合并使用 `git merge --abort`。 - -## Checkpoint 1:2026-09-02 17:48 Asia/Shanghai - -- Completed:fetch `origin/beta` 成功,目标 head 冻结为 `7db1b9d188e27877154757528d441150142b90ed`;提交范围和有效树差异已核对;task guard 已启动。 -- Workspace:分支 `dev2`,基线 HEAD `1b376ee0461fdf9a2e424c73cfb46f31f8301f9b`,guard 启动时无预存脏路径或暂存路径。 -- Validation:`git merge-tree --write-tree` 生成无冲突树 `b81632fc4e83b4af1c8ab8fce778761e3cc5629d`;尚未执行实际合并和 Gradle 验证。 -- Unresolved:合并结果、定向测试、双端 Java 编译、原子提交和恢复 tag 尚未完成。 -- Next action:执行无提交 no-ff 合并并检查 staged 树。 - -## Checkpoint 2:2026-09-02 17:55 Asia/Shanghai - -- Completed:`git merge --no-commit --no-ff origin/beta` 自动完成,无冲突;beta 的 6 个有效变更文件已进入合并树。 -- Source identities:当前基线 `dev2@1b376ee0461fdf9a2e424c73cfb46f31f8301f9b`;目标 `origin/beta@7db1b9d188e27877154757528d441150142b90ed`;`MERGE_HEAD` 与目标一致。 -- Workspace:分支 `dev2`,合并进行中;工作区变更均属于 C7 声明路径,未发现 `Result.java` 或其他保护路径变化;无未合并路径。 -- Validation:`git diff --cached --check`、工作区 `git diff --check` 和 guard check 通过;`:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.bean.TmdbMatchCacheTest :app:compileMobileArm64_v8aDebugJavaWithJavac :app:compileLeanbackArm64_v8aDebugJavaWithJavac --no-daemon --console=plain` 返回 `exit=0`,Gradle 报告 `BUILD SUCCESSFUL in 2m 21s`。输出中的既有 `CXX5202` 仅为仓库已有的 32 位 native library warning,本阶段未修改 native 资产。 -- Rollback anchor:未提交合并可用 `git merge --abort`;收口后使用本次恢复 tag 或 `git revert -m 1 `。 -- Unresolved:仅剩 guard 原子提交和恢复 tag,以及将其确切身份补入本记录。 -- Next action:运行 `task_guard.sh finish`,创建 C7 合并提交和本地 annotated recovery tag。 - -## Checkpoint 3:2026-09-02 18:10 Asia/Shanghai - -- Completed:C7 合并已由 task guard 原子收口。 -- Implementation:本地合并提交为 `a8f2015363819c70b4e7ae67d419035e579b857f`;父提交为 `1b376ee0461fdf9a2e424c73cfb46f31f8301f9b` 与 `7db1b9d188e27877154757528d441150142b90ed`;恢复 tag 为 `recovery/C7-beta-sync/20260902101049-a8f201536381`。 -- Source/result:`origin/beta@7db1b9d188e27877154757528d441150142b90ed` 的有效代码路径已与 `HEAD` 一致;6 个 beta 增量文件已合入。 -- Validation:定向 `TmdbMatchCacheTest` 与 Mobile/Leanback Arm64 Java 编译返回 `exit=0`,Gradle 报告 `BUILD SUCCESSFUL in 2m 21s`;`git diff --check`、冲突检查和 task guard check 通过。仅记录既有 `CXX5202` 32 位 native library warning,未改动 native 资产。 -- Workspace:分支 `dev2`,相对 `origin/dev2` ahead 7;没有未提交改动,没有未合并路径;本轮未 push。 -- Rollback anchor:回退到 `1b376ee0461fdf9a2e424c73cfb46f31f8301f9b`,共享分支使用 `git revert -m 1 a8f2015363819c70b4e7ae67d419035e579b857f`。 -- Status:C7 已完成;真实设备回归不属于本轮授权或验收范围。 -- Next action:等待用户决定是否将当前 `dev2` 和新恢复 tag 推送到远端。 diff --git a/docs/C8-beta-sync.md b/docs/C8-beta-sync.md deleted file mode 100644 index 6a44f85df2..0000000000 --- a/docs/C8-beta-sync.md +++ /dev/null @@ -1,137 +0,0 @@ -# C8: merge origin/beta and review current unpushed changes - -## Recovery anchor - -- Objective: merge the latest `origin/beta` into `dev2`, review the effective tree including the local unpushed commit and current task changes, fix actionable defects, verify, review the repaired tree again, commit, push, and pull the latest remote state. -- Status: C8 is complete and pushed; beta merge `5cf2f2e7fddd48454d10b86c27cc9f02e979098a` is preserved, post-merge repairs and final verification passed, and the repair commit/tag are synchronized to `origin/dev2`. -- Branch and baseline: `dev2`, base `b235cee7408b8531c257f94fb65b0b4f4068d4c2`. -- Beta target: `origin/beta@308694aaadd59d9d1ef230bded83cf84dafa114c`. -- Scope: `app/**` and `docs/**`; unrelated pre-existing paths remain protected and are not part of this task. -- Next action: code task closed; retain the recovery tag as the local rollback anchor. Optional device playback follow-up is outside this task's acceptance gate. - -## Authority and review boundary - -- User-authorized operations: fetch `origin/beta`, merge it into `dev2`, review committed-but-unpushed and working-tree changes, repair actionable issues, verify, commit, push the current `dev2`, and pull the latest remote code. -- No native dependency, lock, AAR, APK, `.so`, FFmpeg, Media3, MPV, JNI, or patch ownership change is authorized by this task. -- Effective review tree: the staged merge result plus the unstaged changes relative to `HEAD`; the review includes the final contents of all changed `app/**` files. -- Rollback before commit: `git merge --abort`. -- Rollback after commit: revert the resulting merge commit as a two-parent merge with `git revert -m 1 `; the task recovery tag is the local restore point. - -## Source commit ledger - -| # | Repository/ref | Full commit | Functional area | Disposition | -| ---: | --- | --- | --- | --- | -| 1 | `origin/beta` | `d8910cc35a27c5761f8618ada758dac4efe1dca4` | automatic ending skip and segment-kind handling | merged; review final tree | -| 2 | `origin/beta` | `d1878923682800c448f946e07c847971a1d0d066` | skip landing, deduplication, and notices | merged; review final tree | -| 3 | `origin/beta` | `381266a579404087ac55de9af2bd99ac3c4f1bf1` | second-round skip state-machine correction | merged; review final tree | -| 4 | `origin/beta` | `624e0b16a33c765b1f9d4a96422fc48e4c403340` | player mask/preload regression repair | merged; review final tree | -| 5 | `origin/beta` | `fddca6e60d6ab82f690484ca552cc8bc26210153` | tolerate unreadable archive signing metadata | merged; security review required | -| 6 | `origin/beta` | `59fd2688f79d4e6ef46da23a162c8236920629e6` | narrow signature-validation tolerance | merged; security review required | -| 7 | `origin/beta` | `308694aaadd59d9d1ef230bded83cf84dafa114c` | beta publication merge target | merge parent; final tree under review | - -## Current findings and repairs - -### Finding 1: missing production contract for checksum-gated signature fallback - -- Evidence: `UpdaterFutureTest.unreadableArchiveSignatureRequiresVerifiedChecksum` references `Updater.canAcceptUnreadableArchiveSignature(boolean)`, but the final production tree did not define it. The targeted Gradle pass failed during `compileMobileArm64_v8aDebugJavaWithJavac` before tests ran. -- Root cause: the test and the intended security rule were added without wiring the rule through `validate()` and `signaturesMatch()`. -- Required behavior: an unreadable installed signature is always rejected; an unreadable candidate signature is accepted only when the downloaded APK SHA-256 matches a non-empty manifest digest; readable signatures still require signer compatibility. -- Repair status: implemented in `app/src/main/java/com/fongmi/android/tv/Updater.java` and covered by `UpdaterFutureTest`. - -## Alternatives and decision - -- No change: rejected because the current tree does not compile and the fallback rule is not enforceable. -- Unmodified beta tolerance: rejected because it allows an unreadable candidate signature without requiring the manifest checksum to be verified. -- Narrow WebHTV adaptation: use the existing SHA-256 validation result as an explicit boolean passed into package/signature validation; keep the OS installer as the final platform gate and preserve OEM compatibility only for the candidate side. - -## Acceptance criteria - -1. The final merge tree has no unresolved conflict entries or conflict markers. -2. Candidate signature metadata may be unreadable only after a matching non-empty SHA-256 has been verified; missing or mismatched checksums are rejected. -3. The targeted `Updater`, intro-skip, reader-history, and routing tests pass with zero failures/errors/skips. -4. Mobile and Leanback Arm64 Java compilation passes. -5. A second review of the repaired effective diff finds no Critical/Important issues. -6. The task guard creates one atomic merge commit and an annotated local recovery tag, then the current branch and newly created tag are pushed, followed by a fast-forward pull of the latest remote state. - -## Verification log - -- 2026-09-03 Asia/Shanghai: fetched `origin/beta`; target is `308694aaadd59d9d1ef230bded83cf84dafa114c`. -- 2026-09-03 Asia/Shanghai: `git merge --no-commit --no-ff origin/beta` completed automatically with no unmerged paths. -- 2026-09-03 Asia/Shanghai: RED targeted Gradle pass failed at Java compilation because `Updater.canAcceptUnreadableArchiveSignature(boolean)` was missing. -- 2026-09-03 Asia/Shanghai: added the checksum-gated signature fallback and fixed the first compile pass; the next compile exposed two stale `firstContent` calls and a missing `Segment.createTrailing` identity parameter, both repaired. -- 2026-09-03 Asia/Shanghai: the following compile reached test compilation but failed because `ReaderHistoryProgressTest` calls the not-yet-implemented `NovelRouter.alignResolvedHistoryProgress(History, History, String)`. -- 2026-09-03 Asia/Shanghai: the next test pass compiled production and test sources but reported six failures: stale source assertions, eager `Looper` initialization, missing legacy reader-key migration, and the expected three cancellable reader entry points. Root causes are recorded for the next repair. -- 2026-09-03 Asia/Shanghai: the following pass had 62 target tests passing and one stale source-count assertion failing; the final tree contains four `readerPayload` assignment entry points, so the expected count was corrected from 3 to 4. -- 2026-09-03 Asia/Shanghai: second-pass semantic review found two actionable risks still to test: TMDB intro-skip callbacks lacked the same live-owner guard as the other playback activities, and `Segment.withinDistance` could overflow for extreme long values. -- 2026-09-03 Asia/Shanghai: independent reviewer agents timed out without a usable report; no pass verdict is inferred from the timeout. -- 2026-09-03 Asia/Shanghai: the interrupted session committed the conflict-free beta merge as `5cf2f2e7fddd48454d10b86c27cc9f02e979098a`; post-merge review repairs remain in the worktree for one final atomic closure commit. -- 2026-09-03 Asia/Shanghai: `assembleDebug` completed successfully across the configured debug variants (335 tasks); this is compile/package evidence only. A root `test --tests` invocation failed because the aggregate `:app:test` task does not accept `--tests`, so it is an invocation error rather than a regression result. -- 2026-09-03 Asia/Shanghai: the focused Mobile Arm64 test run completed with 72 tests and 0 failures/errors/skips; the subsequent `duration=0` IntroSkip regression also completed with `BUILD SUCCESSFUL`. -- 2026-09-03 Asia/Shanghai: final semantic review found no additional Critical/Important issue; the independent reviewer timed out without a report, so no pass verdict is attributed to that reviewer. -- 2026-09-03 Asia/Shanghai: the final combined command returned `exit 0` with `BUILD SUCCESSFUL in 2m 26s`; `:app:compileMobileArm64_v8aDebugJavaWithJavac` and `:app:compileLeanbackArm64_v8aDebugJavaWithJavac` completed, and the selected Mobile Arm64 XML reported 73 tests, 0 failures, 0 errors, and 0 skips. - -## Current status - -- Merge: committed as `5cf2f2e7fddd48454d10b86c27cc9f02e979098a`, with no unresolved paths or conflict markers. -- Code review: local root-cause review repaired the checksum gate, reader routing/cancellation/progress boundaries, IntroSkip lifecycle/cache/parser boundaries, and TMDB callback ownership; no further Critical/Important issue was found in the final semantic pass. -- Verification: `assembleDebug` succeeded; the focused Mobile Arm64 run passed 72/72, the added `duration=0` IntroSkip regression passed separately, and the final combined dual Arm64 Java compile plus focused test command passed with 73/73 and no skips/errors. -- Commit/push/pull: repair commit `1d08c6fba24763023bf51792d344a3912b6d3cdb` and recovery tag `recovery/C8-beta-sync/20260903160741-1d08c6fba247` were created by task guard, pushed to `origin/dev2`, and followed by `git pull --ff-only` returning `Already up to date`. - -## Checkpoint 1: 2026-09-03 13:18 Asia/Shanghai - resumed C8 diagnosis - -- Completed: reconciled the prior Orca transcript with the authoritative workspace; confirmed the beta merge target, current HEAD, staged merge result, and unstaged repair layer. -- Source identities: local `HEAD=b235cee7408b8531c257f94fb65b0b4f4068d4c2`; `MERGE_HEAD=308694aaadd59d9d1ef230bded83cf84dafa114c`; `origin/beta=308694aaadd59d9d1ef230bded83cf84dafa114c`. -- Decisions/evidence: the prior 62/63 targeted pass is historical only; the later lifecycle and overflow regressions have not yet been run. Current source inspection confirms remaining actionable issues in TMDB intro-skip lifecycle gates, cancellable reader history writes, cross-source reader progress typing/initialization, unknown trailing endpoints, monotonic timeout, raw cache reuse, and malformed numeric/version fields. -- Workspace: branch `dev2`; merge uncommitted; no unmerged index entries; task guard `C8-beta-sync` active with scope `app` and `docs`. -- Files/artifacts changed: existing staged merge and repair files under `app/**`; `docs/C8-beta-sync.md` records this checkpoint. No native, lock, binary, or dependency ownership changes are in scope. -- Validation: checkpoint script previously reported one missing checkpoint; all current worktree/index diffs passed whitespace checks; targeted tests/compilation after this resumed session remain unverified. -- Rollback anchor: before commit use `git merge --abort`; after commit revert the two-parent merge with `git revert -m 1 `; recovery tag is created by task guard finish. -- Unresolved: final combined verification, task-guard repair commit/recovery tag, push, and fast-forward pull remain. -- Next action: run the final combined verification once. - -## Checkpoint 2: 2026-09-03 18:06 Asia/Shanghai - merge commit reconciled - -- Completed: reconciled the interrupted session with the authoritative repository; confirmed that the beta merge now exists as a two-parent commit and that the later review repairs are still a separate worktree layer. -- Source identities: `HEAD=5cf2f2e7fddd48454d10b86c27cc9f02e979098a`, parents `b235cee7408b8531c257f94fb65b0b4f4068d4c2` and `308694aaadd59d9d1ef230bded83cf84dafa114c`; no `MERGE_HEAD` remains. -- Decisions/evidence: retain the merge commit and complete the bounded repair layer; do not redo the beta merge or historical assessment. The failed root `test --tests` command is classified as an invocation error, while `assembleDebug` is fresh successful compile/package evidence. -- Workspace: branch `dev2`; task guard `C8-beta-sync` remains active with scope `app` and `docs`; post-merge source, test, and documentation repairs remain uncommitted. -- Files/artifacts changed: only existing C8 paths under `app/**` and `docs/**`; no native artifact, lock, dependency, or binary ownership change. -- Validation: `assembleDebug` succeeded in 12m48s with 335 actionable tasks; the focused Mobile Arm64 run passed 72/72, and the separate `duration=0` regression passed with a normal `BUILD SUCCESSFUL` exit. -- Rollback anchor: revert `5cf2f2e7fddd48454d10b86c27cc9f02e979098a` with `git revert -m 1` after closing the repair set; the task-guard recovery tag remains required before push. -- Unresolved: final combined verification, task-guard closure/tag, push, and fast-forward pull. -- Next action: run the final combined verification once and record its exact result. - -## Checkpoint 3: 2026-09-03 20:20 Asia/Shanghai - repair set ready for final verification - -- Completed: implemented the demonstrated C8 repairs and reconciled the final source/test boundary; removed one duplicate static test import. The beta merge remains preserved as the two-parent commit `5cf2f2e7fddd48454d10b86c27cc9f02e979098a`. -- Source identities: local `HEAD=5cf2f2e7fddd48454d10b86c27cc9f02e979098a`; parents `b235cee7408b8531c257f94fb65b0b4f4068d4c2` and `308694aaadd59d9d1ef230bded83cf84dafa114c`; beta target unchanged at `308694aaadd59d9d1ef230bded83cf84dafa114c`. -- Decisions/evidence: retain the conservative legacy reader-key behavior because untyped legacy rows cannot be distinguished from playback rows; reject unknown IntroSkip trailing segments as file-ending actions until a valid duration exists; accept the independent reviewer timeout as unavailable evidence, not a pass. -- Workspace: branch `dev2`; task guard `C8-beta-sync` active with base head set to `5cf2f2e7fddd48454d10b86c27cc9f02e979098a`; no unmerged paths; only `app/**` and `docs/**` task changes remain. -- Files/artifacts changed: source and tests under `app/**`, the C8 task/assessment documents under `docs/**`; no native, lock, AAR, APK, `.so`, FFmpeg, Media3, MPV, JNI, or dependency ownership change. -- Validation: prior focused run passed 72/72; the new `IntroSkipPlaybackStateTest` duration-zero regression passed separately; `assembleDebug` passed. Final combined dual-product Java compilation and 73-test run remain unverified. -- Rollback anchor: preserve `5cf2f2e7fddd48454d10b86c27cc9f02e979098a`; after the repair closure commit, revert the merge with `git revert -m 1 5cf2f2e7fddd48454d10b86c27cc9f02e979098a` and revert the repair commit if the review-only fixes also need removal. -- Unresolved: final combined verification, task-guard atomic repair commit, annotated recovery tag, push, and fast-forward pull. -- Next action: run `:app:compileMobileArm64_v8aDebugJavaWithJavac`, `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`, and the 73-test focused Mobile Arm64 unit-test set in one Gradle invocation. - - -## Checkpoint 4: 2026-09-03 20:53 Asia/Shanghai - final verification passed - -- Completed: ran the final combined verification after all code and test edits. Both Mobile and Leanback Arm64 Java compilation tasks completed, and the focused Mobile Arm64 test set passed. -- Source identities: local `HEAD=5cf2f2e7fddd48454d10b86c27cc9f02e979098a`; parents `b235cee7408b8531c257f94fb65b0b4f4068d4c2` and `308694aaadd59d9d1ef230bded83cf84dafa114c`; beta target remains `308694aaadd59d9d1ef230bded83cf84dafa114c`. -- Decisions/evidence: no further production change after the `duration=0` guard; untyped legacy reader keys remain intentionally rejected because they cannot be distinguished from playback rows. The independent reviewer timeout remains unavailable evidence, not a pass. -- Workspace: branch `dev2`; task guard `C8-beta-sync` active with base `5cf2f2e7fddd48454d10b86c27cc9f02e979098a`; no unmerged paths; task changes are confined to `app/**` and `docs/**`. -- Files/artifacts changed: IntroSkip lifecycle/cache/parser logic, update manifest/signature validation, reader routing/cancellation/progress handling, mobile/Leanback/TMDB callback guards, regression tests, and C8 documentation; no native/lock/AAR/APK/.so/dependency ownership change. -- Validation: final command `:app:compileMobileArm64_v8aDebugJavaWithJavac :app:compileLeanbackArm64_v8aDebugJavaWithJavac :app:testMobileArm64_v8aDebugUnitTest` with six `--tests` selectors returned `BUILD SUCCESSFUL in 2m 26s`; XML totals are 73 tests, 0 failures, 0 errors, 0 skips. -- Rollback anchor: retain merge commit `5cf2f2e7fddd48454d10b86c27cc9f02e979098a`; after the repair commit is created, revert the repair commit and use `git revert -m 1 5cf2f2e7fddd48454d10b86c27cc9f02e979098a` to remove the beta merge if required. -- Unresolved: none for the approved code-sync scope; device playback regression remains an optional follow-up outside this task's acceptance gate. -- Next action: code task closed; retain the recovery tag as the rollback anchor. - - -## Checkpoint 5: 2026-09-04 00:10 Asia/Shanghai - task closed and synchronized - -- Completed: task guard committed the repaired C8 tree atomically and created the annotated recovery tag; the current branch and tag were pushed successfully. -- Source identities: repair commit `1d08c6fba24763023bf51792d344a3912b6d3cdb`; recovery tag `recovery/C8-beta-sync/20260903160741-1d08c6fba247`; beta merge remains the two-parent commit `5cf2f2e7fddd48454d10b86c27cc9f02e979098a`; beta target remains `308694aaadd59d9d1ef230bded83cf84dafa114c`. -- Validation: final combined Mobile/Leanback Arm64 Java compilation and six-selector Mobile Arm64 test command returned `BUILD SUCCESSFUL in 2m 26s`; XML totals were 73 tests, 0 failures, 0 errors, and 0 skips; `git diff --check` passed. -- Remote: pushed `dev2` and the recovery tag with Git HTTP/1.1; `git pull --ff-only` returned `Already up to date`. -- Workspace: the approved C8 scope is closed; no native, lock, AAR, APK, `.so`, FFmpeg, Media3, MPV, JNI, or dependency ownership change was made. Device playback remains an optional follow-up, not an acceptance requirement for this sync task. -- Rollback: use the recovery tag for the verified restore point; revert the repair commit and, if needed, revert the beta merge with `git revert -m 1 5cf2f2e7fddd48454d10b86c27cc9f02e979098a`. diff --git a/docs/C9-beta-sync.md b/docs/C9-beta-sync.md deleted file mode 100644 index f02f539cfe..0000000000 --- a/docs/C9-beta-sync.md +++ /dev/null @@ -1,135 +0,0 @@ -# C9:合并 origin/beta 最新应用修复 - -## Recovery anchor - -- 目标:将 `origin/beta` 最新代码合并到当前 `dev4`,评审合并树及 E-SP7 相关改动,验证通过后提交、推送。 -- 状态:合并已完成,E-SP7 与 beta 受影响测试已通过,待 task guard 完成原子提交、创建恢复标签并推送。 -- 当前第一父基线:`dev4@ff438637b89587cf4f378843338a4122ba07e9d3`。 -- beta 目标:`origin/beta@bcfe7b22a05e32913448a228f9513c690bc8233f`。 -- 合并基线:`59fd2688f79d4e6ef46da23a162c8236920629e6`。 -- 受保护路径:无;任务开始前工作区干净。 -- 任务 guard:`C9-beta-sync`,模式 `standard`,范围为本次 beta 变更路径和三份任务记录。 -- 回滚:提交前使用 `git merge --abort`;提交后使用本任务恢复 tag 或 `git revert` 回退两父合并提交。 -- 下一动作:记录最终验证结果后执行 task guard finish,随后只推送当前 `dev4` 和新恢复标签。 - -## Authority and scope - -- 用户已明确授权:拉取远端 beta 最新代码、合并到当前分支、评审全部相关修改(包括已提交未推送部分),发现问题则修改、验证并再次评审,循环至通过后提交、推送并拉取远端最新代码。 -- 本任务不升级 FFmpeg、Media3、MPV、JNI、lock 或 native 二进制;只合并和评审 beta 已有应用/测试/文档树及当前本地移动端详情页滚动提交。 -- C5/C6 已覆盖且在本次最终树中未改变的历史代码,仅依据既有评审记录跳过逐行重复检查;其与本次同名文件、生命周期和播放器状态交互仍纳入最终树审查。 -- 评审边界:更新 APK 身份校验、片段查询/时间轴/自动跳过、实时字幕模型切换、MPV 预载与输出遮罩、TMDB/详情页状态、移动/电视布局,以及三项本地未推送移动端滚动提交。 - -## Frozen commit ledger - -`origin/beta` 相对 C6 目标 `c975ae1ed482a4bf47f106f5931bd2392e8ecce3` 的历史可达提交如下。合并提交、文档提交和已被当前第一父包含的提交均保留在账本中,不因功能影响为零而省略;本次 dev4 合并使用的最新 beta 增量另列在下方。 - -| # | Repository | Full commit | Parent(s) | Functional area | Disposition | -| ---: | --- | --- | --- | --- | --- | -| 1 | `origin/beta` | `d8910cc35a27c5761f8618ada758dac4efe1dca4` | `6ccc0de7f0060113bc52005b0d48a731407621df` | 片尾跳过、片段类型、查询缓存和确认模式扩展 | 最终树候选;重点复审时间轴和状态机 | -| 2 | `origin/beta` | `9e8e029665b1ee4de41c86de630047172667c9ec` | `d8910cc35a27c5761f8618ada758dac4efe1dca4`, `c1c53da674c0e0f2945fdc159de8a6ac4c4fe976` | dev4 beta 集成承载 | 合并承载;处置跟随最终树 | -| 3 | `origin/beta` | `d1878923682800c448f946e07c847971a1d0d066` | `9e8e029665b1ee4de41c86de630047172667c9ec` | 片段落点、去重、提示和缓存修正 | 最终树候选;重点复审边界/去重 | -| 4 | `origin/beta` | `5a8122f8f6b3be38bcbb3f89f593ce08439040f5` | `a26a80d0c207c06d9e09192febee95ae976e8963` | 手动 TMDB 绑定恢复 | 当前第一父已包含;无新增有效差异 | -| 5 | `origin/beta` | `d5182a794ecdec79819dab782a3c3eb096aa3839` | `5a8122f8f6b3be38bcbb3f89f593ce08439040f5`, `c975ae1ed482a4bf47f106f5931bd2392e8ecce3` | dev3 beta 集成承载 | 当前第一父已包含;无新增有效差异 | -| 6 | `origin/beta` | `b0af66ac567a85856509bb4cd042c3cbce7feee2` | `d5182a794ecdec79819dab782a3c3eb096aa3839` | 手动 TMDB 通配符和更新竞态 | 当前第一父已包含;交互随最终树复审 | -| 7 | `origin/beta` | `381266a579404087ac55de9af2bd99ac3c4f1bf1` | `d1878923682800c448f946e07c847971a1d0d066` | 片段状态机、无界片尾和失败缓存 | 最终树候选;重点复审重试/溢出 | -| 8 | `origin/beta` | `adbc9913857d7b1f406ae3ee00143d9e4c41aeda` | `b0af66ac567a85856509bb4cd042c3cbce7feee2` | TMDB 别名与全局标题域 | 当前第一父已包含;无新增有效差异 | -| 9 | `origin/beta` | `b7b2b1a6c92d4691e12c313e7111325c6f5d4113` | `db4b1650f73c819b3eebd7e7534e7b9e4ec65ff4` | 实时字幕原声识别语言快捷切换 | beta 新增候选;重点复审模型生命周期 | -| 10 | `origin/beta` | `9188c8a6a1919529438257bbc7c86bc2514f4fbe` | `c975ae1ed482a4bf47f106f5931bd2392e8ecce3`, `adbc9913857d7b1f406ae3ee00143d9e4c41aeda` | dev3 PR 集成承载 | 合并承载;处置跟随最终树 | -| 11 | `origin/beta` | `671888a5565bc6d26d523fa3e8087770b9567d99` | `381266a579404087ac55de9af2bd99ac3c4f1bf1`, `9188c8a6a1919529438257bbc7c86bc2514f4fbe` | dev4 beta 集成承载 | 合并承载;处置跟随最终树 | -| 12 | `origin/beta` | `a33ff92b8e65e11330ab17270b5f86a4c0b08183` | `b7b2b1a6c92d4691e12c313e7111325c6f5d4113`, `c975ae1ed482a4bf47f106f5931bd2392e8ecce3` | C6 合并及实时字幕竞态修复 | C6 已独立评审/验证;最终树交互复审 | -| 13 | `origin/beta` | `1b376ee0461fdf9a2e424c73cfb46f31f8301f9b` | `a33ff92b8e65e11330ab17270b5f86a4c0b08183` | C6 文档收口 | 文档承载;处置跟随文档一致性 | -| 14 | `origin/beta` | `7db1b9d188e27877154757528d441150142b90ed` | `9188c8a6a1919529438257bbc7c86bc2514f4fbe`, `1b376ee0461fdf9a2e424c73cfb46f31f8301f9b` | beta 发布集成承载 | 合并承载;处置跟随最终树 | -| 15 | `origin/beta` | `624e0b16a33c765b1f9d4a96422fc48e4c403340` | `671888a5565bc6d26d523fa3e8087770b9567d99` | 播放器遮罩和预载回归修复 | beta 新增候选;复审 MPV 状态交互 | -| 16 | `origin/beta` | `db7218f51f0e7df5a0c0c3fd486932c6129a5818` | `624e0b16a33c765b1f9d4a96422fc48e4c403340`, `7db1b9d188e27877154757528d441150142b90ed` | dev4 合并承载 | 合并承载;处置跟随最终树 | -| 17 | `origin/beta` | `e7c1c9cccc7949022cae432085dcbdc2576e3fd4` | `7db1b9d188e27877154757528d441150142b90ed`, `db7218f51f0e7df5a0c0c3fd486932c6129a5818` | beta PR 集成承载 | 合并承载;处置跟随最终树 | -| 18 | `origin/beta` | `fddca6e60d6ab82f690484ca552cc8bc26210153` | `db7218f51f0e7df5a0c0c3fd486932c6129a5818` | APK 签名信息不可读兼容 | beta 新增候选;安全边界复审 | -| 19 | `origin/beta` | `c493488de47eca8ff08847cd190e47358c93994f` | `fddca6e60d6ab82f690484ca552cc8bc26210153`, `e7c1c9cccc7949022cae432085dcbdc2576e3fd4` | dev4 beta 集成承载 | 合并承载;处置跟随最终树 | -| 20 | `origin/beta` | `59fd2688f79d4e6ef46da23a162c8236920629e6` | `c493488de47eca8ff08847cd190e47358c93994f` | 收紧更新签名容错边界 | beta 新增候选;安全验证必需 | -| 21 | `origin/beta` | `308694aaadd59d9d1ef230bded83cf84dafa114c` | `e7c1c9cccc7949022cae432085dcbdc2576e3fd4`, `59fd2688f79d4e6ef46da23a162c8236920629e6` | beta 发布目标合并 | 最终目标;处置跟随最终树 | - -当前 `dev3` 未推送提交也纳入评审边界: - -| # | Full commit | Area | Disposition | -| ---: | --- | --- | --- | -| 1 | `1c1ab72ec8cbfca852e5c3191f4ec8c0eabb1d05` | 移动端原生增强详情页可用高度 | 已提交未推送;最终树复审 | -| 2 | `ddb9045561faec417555e764806f66736469c030` | 原生增强集数区域外层滚动高度 | 已提交未推送;最终树复审 | -| 3 | `c0192b6c716451d284e2d4b04c71000950413223` | backdrop 集数区域外层滚动和触摸交接 | 已提交未推送;最终树复审 | - -## Latest beta extension used by this merge - -本次实际目标为 `origin/beta@bcfe7b22a05e32913448a228f9513c690bc8233f`,相对当前第一父 `dev4@ff438637b89587cf4f378843338a4122ba07e9d3` 的共同基线 `59fd2688f79d4e6ef46da23a162c8236920629e6` 新增以下 9 个完整提交: - -| # | Full commit | Parent(s) | Functional area | Disposition | -| ---: | --- | --- | --- | --- | -| 1 | `1c1ab72ec8cbfca852e5c3191f4ec8c0eabb1d05` | `adbc9913857d7b1f406ae3ee00143d9e4c41aeda` | 移动端原生增强详情页可用高度 | 已合并;由布局测试覆盖 | -| 2 | `ddb9045561faec417555e764806f66736469c030` | `1c1ab72ec8cbfca852e5c3191f4ec8c0eabb1d05` | 原生增强详情页集数区域外层滚动高度 | 已合并;由布局测试覆盖 | -| 3 | `308694aaadd59d9d1ef230bded83cf84dafa114c` | `e7c1c9cccc7949022cae432085dcbdc2576e3fd4`, `59fd2688f79d4e6ef46da23a162c8236920629e6` | beta 发布集成承载 | 合并承载;处置跟随最终树 | -| 4 | `c0192b6c716451d284e2d4b04c71000950413223` | `ddb9045561faec417555e764806f66736469c030` | backdrop 集数区域外层滚动和触摸交接 | 已合并;由布局测试覆盖 | -| 5 | `0c71cad17573f7fe36458a4785ee969ba36da171` | `308694aaadd59d9d1ef230bded83cf84dafa114c` | 电视端速度加速键失焦/漏释放修复 | 已合并;由布局测试覆盖 | -| 6 | `97e980c8bda8af2187ac7e678ca59d5c78dbd40e` | `c0192b6c716451d284e2d4b04c71000950413223`, `308694aaadd59d9d1ef230bded83cf84dafa114c` | beta 同步复审与最终修复承载 | 最终树已复审 | -| 7 | `9b2f02bd302c5dcb52352716ae2be0b9d84188f5` | `308694aaadd59d9d1ef230bded83cf84dafa114c`, `97e980c8bda8af2187ac7e678ca59d5c78dbd40e` | dev3 PR 合并承载 | 合并承载;处置跟随最终树 | -| 8 | `0c62583d4d9e86b672850d737ca9ef9e57dec3fc` | `0c71cad17573f7fe36458a4785ee969ba36da171`, `9b2f02bd302c5dcb52352716ae2be0b9d84188f5` | beta 分支合并承载 | 合并承载;处置跟随最终树 | -| 9 | `bcfe7b22a05e32913448a228f9513c690bc8233f` | `9b2f02bd302c5dcb52352716ae2be0b9d84188f5`, `0c62583d4d9e86b672850d737ca9ef9e57dec3fc` | beta 最新发布目标合并 | 本次合并目标;已完成最终树复审 | - -## Current behavior and decision - -- 合并方式:当前 `dev3` 作为第一父,`origin/beta` 作为第二父,已执行 `git merge --no-commit --no-ff origin/beta`,Git 自动合并成功,无未合并路径。 -- 已确认的第一轮风险:`IntroSkipPlayback` 使用“片段类型 + provider”作为单集去重键,同一 provider 返回多个同类片段时会误跳过后续片段;续播位置落在片头内部时,自动跳过状态机缺少与片尾等价的续播抑制;`IntroSkipService` 的极端时长差值比较可能发生 `long` 溢出;Activity 销毁时未统一使异步片段查询回调失效。 -- 待定安全项:`Updater.signaturesMatch()` 在候选包签名信息不可读时放行到系统安装器。需要结合包名、版本、文件长度、SHA-256 和系统安装器边界判定是否为可接受兼容策略;在证据不足前不扩大放行范围。 -- no-change 方案会保留当前 dev3,但缺少 beta 的更新校验、实时字幕快捷切换和已修复的播放/详情页行为;盲目替换 beta 会丢失 dev3 的三项移动滚动修复,因此采用保留第一父的合并方案。 -- 当前适配原则:保留 beta 的最终功能设计,只修复可由本地测试证明的状态、边界和生命周期问题;不引入新的播放器架构或依赖。 - -## Review and validation plan - -1. 完成第一轮静态审查和两个独立只读审查意见的核对;Critical/Important 必须逐项验证根因。 -2. 对确认的问题先写最小失败测试,再修改最小生产代码;每个修复后运行受影响的定向测试。 -3. 修复后重新审查完整修复差异,确认没有新的状态/线程/兼容性回归;重复至无未处理 Critical/Important。 -4. 最终验证至少包括:冲突标记扫描、`git diff --check`、片段解析/状态/时间边界测试、实时字幕模型测试、TrackDialog 测试、移动详情布局测试、Mobile/Leanback Arm64 Java 编译,以及当前仓库既有的 MPV asset ELF 门禁(不重建 native)。 -5. 真实设备播放、APK 安装器对不可读签名信息的 OEM 行为若当前环境无法提供,只记录为限制,不用编译结果冒充运行时证据。 - -## Acceptance and rollback - -- 21 个 beta 增量提交和 3 个本地未推送提交均有完整处置,合并树无冲突路径和冲突标记。 -- 同一数据源的多个片段可以独立按顺序处理;续播不会误跳当前片段;异常/极端时间输入不会溢出并产生错误落点;Activity 销毁后异步回调不再更新已销毁页面。 -- 实时字幕模型切换保持播放会话和音频管线,旧请求/旧识别器回调不能改变新状态。 -- 更新校验仍保留包名、版本、文件长度和 SHA-256 门槛;签名不可读的兼容策略必须有明确安全理由和测试证据。 -- Mobile/Leanback 受影响 Java 代码编译通过,定向测试无失败、错误或跳过;未能执行的设备场景明确记录。 -- 提交使用 task guard 原子生成,并立即创建唯一 annotated recovery tag;随后只推送当前分支和新 tag,再执行 `git pull --ff-only`。 - -## Implementation log - -- 2026-09-03 Asia/Shanghai:记录初始工作区 `dev3@c0192b6c716451d284e2d4b04c71000950413223`,保护既有未跟踪 `.claude/`。 -- 2026-09-03 Asia/Shanghai:刷新 `origin/beta`,确认目标 `308694aaadd59d9d1ef230bded83cf84dafa114c`;确认 C6 目标 `c975ae1ed482a4bf47f106f5931bd2392e8ecce3` 为其祖先,完整新增范围为 21 个提交。 -- 2026-09-03 Asia/Shanghai:启动 guard `review-beta-sync-20260903`,范围 `app`、`docs`;首次 bash 调用因环境无 WSL 失败,改用 `G:/Git/bin/bash.exe` 后启动成功。 -- 2026-09-03 Asia/Shanghai:执行 `git merge --no-commit --no-ff origin/beta`,自动合并成功;当前 `HEAD` 未移动,`MERGE_HEAD` 为 beta 目标。 - -## Checkpoint 3: 2026-09-03 dev4 beta merge and E-SP7 review closure - -- Current merge: first parent `ff438637b89587cf4f378843338a4122ba07e9d3`, second parent `bcfe7b22a05e32913448a228f9513c690bc8233f`, merge base `59fd2688f79d4e6ef46da23a162c8236920629e6`; only the assessment index conflicted and was resolved by retaining both C9 and E-SP7 rows. -- E-SP7 review: `a228e988d488f178890b64592f9dd89761f8e011` remains unchanged; `applyVideoLimit()` still uses `setForceHighestSupportedBitrate(false)`, and the beta tree does not modify `ExoUtil.java`, `ExoUtilTest.java`, or E-SP7 runtime code. -- Validation: E-SP7 `ExoUtilTest` passed; Mobile and Leanback Arm64 Java compilation passed; `IntroSkipServiceTest` 22/22 and `VideoActivityLayoutTest` 153/153 passed, with zero failures, errors, or skips. -- Review decision: no Critical/Important issue remains in the final beta delta; existing Gradle deprecation and `CXX5202` 32-bit native-library warnings are pre-existing. -- Remaining limitation: no Dangbei X7 Ultra playback A/B was available, so codec/frame-drop behavior remains a device follow-up and is not claimed as fully device-validated here. -- Next action: run task guard finish, create the annotated recovery tag, and push `dev4` plus the new tag. - -## Checkpoint 2: 2026-09-03 dev4 beta 合并与 E-SP7 复核 - -- 合并树:第一父 `ff438637b89587cf4f378843338a4122ba07e9d3`,第二父 `bcfe7b22a05e32913448a228f9513c690bc8233f`,合并基线 `59fd2688f79d4e6ef46da23a162c8236920629e6`;唯一冲突为总评估索引的 C9/E-SP7 行,已保留两项记录并清除冲突标记。 -- E-SP7 复核:`a228e988d488f178890b64592f9dd89761f8e011` 的 `ExoUtil.applyVideoLimit()` 仍保持 `setForceHighestSupportedBitrate(false)`;beta 暂存树不包含 `ExoUtil.java`、`ExoUtilTest.java` 或 E-SP7 业务代码覆盖。 -- E-SP7 验证:`:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.player.exo.ExoUtilTest --no-daemon` 通过;Mobile 与 Leanback Arm64 Java 编译均通过,Leanback 最终执行 `BUILD SUCCESSFUL in 5m 59s`。 -- beta 受影响测试:`IntroSkipServiceTest` 22 项、`VideoActivityLayoutTest` 153 项,共 175 项,失败 0、错误 0、跳过 0。 -- 评审结论:片段身份改为原始边界稳定身份,电视端速度键在失焦、按键释放和退出时释放,移动详情页外层滚动与 episode viewport 约束接线完整;未发现应在本次合并前修复的 Critical/Important 问题。 -- 现有警告:Gradle deprecation 和仓库已有的 `CXX5202` 32 位 native library 警告,均未由本次 beta 变更新增。 -- 下一步:执行 task guard 原子提交、恢复标签和推送;真实设备播放仍作为后续补验,不阻断本次源码合并。 - -## Checkpoint 1: merged tree awaiting first review - -- Completed: beta 合并完成,无冲突;已核对 C6 历史覆盖边界、21 个 beta 增量提交和 3 个本地未推送提交。 -- Source identities: first parent `c0192b6c716451d284e2d4b04c71000950413223`; beta `308694aaadd59d9d1ef230bded83cf84dafa114c`; merge base `adbc9913857d7b1f406ae3ee00143d9e4c41aeda`。 -- Workspace: branch `dev3`; `HEAD` 保持第一父;`MERGE_HEAD` 指向 beta;`.claude/` 是受保护初始脏路径。 -- Files changed: 合并暂存树涉及 `app`、`docs` 共 27 个有效路径;本地滚动提交与 beta 的两个 `VideoActivity`/测试文件发生最终树交互。 -- Review evidence: 已发现需验证的片段身份、续播、极端时间和 Activity 回调生命周期风险;更新签名容错仍待安全边界核验;两路独立只读评审已发起,尚未返回。 -- Validation: 合并暂存 diff 无 whitespace 错误;尚未运行修复前回归测试。 -- Rollback anchor: 未提交合并可用 `git merge --abort`;不触碰 `.claude/`。 -- Unresolved: 第一轮 RED 测试、评审意见核对、修复、最终测试/编译、commit/tag/push/pull 均未完成。 -- Next action: 添加最小失败回归测试,先证明同源多段片段身份和续播片头抑制缺陷,再实施对应窄修复。 diff --git a/docs/CRASH-SILENT-RESTART-crash-silent-restart.md b/docs/CRASH-SILENT-RESTART-crash-silent-restart.md deleted file mode 100644 index 35a6238987..0000000000 --- a/docs/CRASH-SILENT-RESTART-crash-silent-restart.md +++ /dev/null @@ -1,6 +0,0 @@ -# 崩溃后静默重启 - -- 崩溃页保留原有“重新启动”和“错误信息”入口,并新增独立的“静默重启”按钮。 -- 只有点击“静默重启”时才同步写入一次性标记并重启应用,确保进程立即退出前标记已经落盘;普通“重新启动”维持原行为。 -- 手机端和电视端首页消费该一次性标记后,跳过当次远程点播接口配置加载。 -- 标记消费后立即清除,因此后续正常启动仍按原流程加载接口。 diff --git a/docs/E-ROLLBACK-exo-upstream-mode.md b/docs/E-ROLLBACK-exo-upstream-mode.md deleted file mode 100644 index 379dc628bd..0000000000 --- a/docs/E-ROLLBACK-exo-upstream-mode.md +++ /dev/null @@ -1,85 +0,0 @@ -# E-ROLLBACK-EXO:EXO 播放链路恢复上游模式 - -## Recovery anchor - -- 目标:将当前 `dev4` 中相对 `fish2018/webhtv:main` 的 EXO 播放实现恢复为上游模式;不改动 MPV、IJK、系统播放器或与 EXO 无关的应用功能。 -- 状态:已实施,定向编译与双端受影响单测通过,待任务守卫提交并创建恢复标签。 -- 当前本地基线:`4e303d30d34c499e1676a3481b4b45358618638d`。 -- 上游目标:`784b90420d646eb6c7ddcc63ad622a92c65b02b4`(`upstream/main`)。共同祖先为 `784b90420d646eb6c7ddcc63ad622a92c65b02b4`。 -- 保护 dirty 路径:`build-tv-debug.sh`;不纳入本任务。 -- 已完成证据:EXO engine、`player/exo` 和对应测试的暂存内容相对冻结的 `upstream/main` 无差异;共享 EXO 看门狗解耦及 TV 加载圈回退已在工作区;保护文件 `build-tv-debug.sh` 已取消误暂存且内容保持不变。 -- 未验证编辑:无;任务代码已通过双端 Java 编译和受影响单测,尚未提交/tag。 -- 已知非阻断结果:一次未正确收窄到目标类的 Mobile 全量测试执行了 4238 项并报告 10 项失败;其中 5 项是当前 HEAD 已存在且不在本任务差异内的旧契约/二进制基线失败,5 项对应本任务明确恢复上游模式后移除的本地看门狗/轨道高亮行为。随后按正确任务过滤重跑本任务覆盖的测试并通过,不把全量旧失败宣称为本任务回归。 -- 唯一下一步:使用 `task_guard.sh finish` 原子提交本任务并创建恢复标签。 - -## 用户授权与范围 - -用户明确选择“全部”,授权将 EXO 播放相关逻辑统一恢复到上游 `https://github.com/fish2018/webhtv/tree/main` 模式。 - -本任务的代码范围: - -1. `app/src/main/java/com/fongmi/android/tv/player/engine/ExoPlayerEngine.java`; -2. `app/src/main/java/com/fongmi/android/tv/player/exo/` 全目录; -3. 上述实现对应的 `app/src/test/java/com/fongmi/android/tv/player/engine/ExoPlayerEngineSourceTest.java` 与 `app/src/test/java/com/fongmi/android/tv/player/exo/`;上游不存在的本地 EXO 测试/实现文件随恢复删除; -4. 共享入口中仅服务本地 EXO 看门狗的 wiring(`PlayerManager.java`); -5. 本任务文档和上游评估索引。 - -保留历史 EXO 评估/实施文档作为审计记录,不因代码回滚删除;不恢复 `build-tv-debug.sh`。 - -## 证据与决策 - -### 基线证据 - -- 本地仓库:当前 worktree `dev4`,HEAD 为 `4e303d30d34c499e1676a3481b4b45358618638d`。 -- 上游仓库:`upstream` 指向 `https://github.com/fish2018/webhtv.git`,`upstream/main` 为 `784b90420d646eb6c7ddcc63ad622a92c65b02b4`,读取时间为 2026-09-10(Asia/Shanghai)。 -- 本地代码审计显示直接 EXO 包相对上游有 12 个已修改/新增生产文件;对应测试包含本地新增的 EXO 测试。`ExoBufferingStallWatchdog` 还由 `PlayerManager` 直接持有并调度,不能只恢复 EXO 包而不清理入口 wiring。 -- GitHub 上游目录作为目标树来源;本地已通过 `git fetch upstream` 冻结并使用完整 commit ID,不依赖网页摘要。 - -### 方案比较 - -| 方案 | 决策 | 理由 | -| --- | --- | --- | -| 不变更 | 拒绝 | 无法满足用户“全部改回上游模式”;本地 EXO 自定义策略仍会影响播放。 | -| 整个仓库重置到上游 | 拒绝 | 会误删当前分支的 MPV、IJK、系统播放器、UI 和业务功能,超出用户指定的 EXO 范围。 | -| 直接恢复 EXO 实现/测试,并移除共享入口的 EXO 专属本地 wiring | 采用 | 使 EXO 核心实现与测试精确对齐上游,保留其他播放器和业务功能;改动可由本任务提交和恢复 tag 原子回滚。 | - -### 风险与接受条件 - -- 接受上游模式会失去本地 EXO 停滞看门狗、延迟 Cues、FFmpeg 回退调优、缓存 worker 崩溃恢复、部分 DV/轨道/负载策略和相关诊断增强;这些是此次用户明确选择“全部”所接受的行为变化。 -- 不能留下对已删除本地类的编译引用;`PlayerManager` 必须不再引用 `ExoBufferingStallWatchdog`。 -- EXO 直接代码/测试路径相对 `upstream/main` 应无差异;历史任务文档不作为代码一致性判据。 -- 最小验证:`git diff --check`、EXO 相关路径与 `upstream/main` 的差异审计、低内存门禁后一次定向 Gradle Java 编译和 EXO 单测。 - -## 实施记录 - -- 2026-09-10:已将 EXO engine、`player/exo` 包及对应测试恢复/删除到上游树;下一步清理 `PlayerManager` 对本地停滞看门狗的共享 wiring。 - -## 回滚 - -使用本任务提交对应的 `recovery/E-ROLLBACK-EXO/-` 标签恢复本次原子变更;如需撤销本任务,按该 tag 反向恢复,不触碰保护性 dirty 文件 `build-tv-debug.sh`。 - -## 实施记录(续) - -- 2026-09-10:移除 `PlayerManager` 对本地 `ExoBufferingStallWatchdog` 的 import、字段、调度、回调和超时分支;确认代码中不再存在该本地类、预缓存 worker recovery 类或 TV 端本地 seek-loading 状态符号的引用。 -- 2026-09-10:反向撤销 `4e303d30d34c499e1676a3481b4b45358618638d` 在 Leanback `VideoActivity` 及其来源测试中的 EXO 加载圈改动;保留该提交的任务文档,不回滚同文件的其他业务功能。 -- 2026-09-10 14:47(Asia/Shanghai),恢复会话验证:先检查内存并等待,最近采样可用约 3.2 GiB、无持续 swap I/O;以单 worker、2 GiB Gradle 堆和 `--no-daemon` 执行 `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`。48 秒后 Java 编译失败,准确错误为 `TmdbDetailActivity.java:8527`、Leanback `VideoActivity.java:4369` 引用不存在的 `MediaSourceFactory.isHlsUrl(String)`。完整本机日志:`/tmp/e-rollback-exo-compile.log`。这是本次回退产生的兼容接线缺口,不是环境错误;未继续执行单测或提交。 -- 恢复会话安全核对:`task_guard check`、`git diff --check` 通过;`git diff --cached --exit-code upstream/main -- ` 无差异。原会话的 `finish` 实际被安全门拒绝,没有产生本任务提交/tag;根因为 `.codex/scripts/task_guard.sh` 的删除文件分支使用 `git add -u -- .`(全仓库暂存),会再次纳入受保护的 `build-tv-debug.sh`。不通过重复取消暂存、篡改保护指纹或绕过安全检查来收尾。 -- 2026-09-11 10:40–11:00(Asia/Shanghai):修复两个已证实阻塞:将 `TmdbDetailActivity`、Leanback/Mobile `VideoActivity` 的 HLS 广告反馈判断迁移到共享 `PlaybackResourceClassifier.isHlsUrl(String)`,并将任务守卫删除文件暂存收窄为 `git add -u -- "$path"`;新增 HLS 兼容别名单测。 -- 2026-09-11:任务守卫检查和 `git diff --check` 通过;在确认没有其他构建任务后,`./gradlew --no-daemon --max-workers=1 --console=plain -Dorg.gradle.jvmargs='-Xmx1536m -XX:MaxMetaspaceSize=384m -Dfile.encoding=UTF-8' :app:compileMobileArm64_v8aDebugJavaWithJavac :app:compileLeanbackArm64_v8aDebugJavaWithJavac` 通过。 -- 2026-09-11:同一构建槽位确认后,Mobile 目标测试类(`PlaybackResourceClassifierTest`、`ExoAutomaticVideoConstraintPolicyTest`、`ExoLoadControlPolicyTest`、`PreloadLifecycleTrackerTest`、`TrackUtilTest`、`PlaybackOwnershipSourceTest`、`TmdbDetailActivityLayoutTest`)和 Leanback 目标测试类(前六项)分别运行一次,均 `BUILD SUCCESSFUL`;日志分别保留于 `/tmp/e-rollback-exo-focused-mobile-20260911.log` 与 `/tmp/e-rollback-exo-focused-leanback-20260911.log`。 -- 2026-09-11:首次 `task_guard.sh finish` 暴露删除路径收口缺陷:已暂存删除且工作树不存在的路径不能继续使用 `git add -u`/`git add -A` pathspec;将 guard 分支改为逐路径执行 `git update-index --remove -- "$path"`,仍不触碰保护 dirty 路径。 - -## Recovery anchor(2026-09-12 FFmpeg 模式清理后续) - -- 目标:移除 EXO 已不再消费的用户可选 FFmpeg 模式及其无效 AUTO 重建链;保留 `ExoUtil` 当前上游 `FfmpegRenderersFactory`、真实 FFmpeg 音视频 renderer 和普通解码/内核回退。 -- 当前分支/实施基线:`dev1` / `ccd608c503bed84711bb1bc75875c030b6be5ce5`;实施提交为 `c2dcf52b5676eef00026f55f013eb58ae522804f`。 -- 保护 dirty 路径:无(启动本阶段任务守卫时工作树干净)。 -- 已完成编辑:删除 `PlayerSetting` FFmpeg 模式 API、`PlayerManager` AUTO 模式失败遍历和刷新状态、手机/TV 设置行及资源、备份字段、过时文档和对应契约测试;保留 `ExoUtil` 的 FFmpeg renderer 构造。 -- 未验证编辑:无。 -- 回滚锚点:`recovery/E-ROLLBACK-EXO/20260912151536-c2dcf52b5676`。 -- 已完成验证:`git diff --check`、运行时/API/UI/资源引用审计和修改后 XML 解析均通过;运行时代码与用户可见资源已无 `ffmpeg_mode`、`FFMPEG_MODE_*`、`player_ffmpeg_mode`、`default_ffmpeg_mode` 或 `ffmpegMode` 引用。历史 EXO 评估/审计文档仍保留当时的模式术语作为审计记录;`ExoUtil` 仍保留 `FfmpegRenderersFactory`、`CompatFfmpegAudioRenderer` 和 `FfmpegVideoRenderer`。 -- 2026-09-12:在确认没有实际 `GradleWrapperMain`/编译器任务后,Mobile/Leanback Java 编译均通过;首次包含整个 `VideoActivityLayoutTest` 的测试命令因既有的 `leanbackSpeedBoostReleaseIsGuarded`(`VideoActivityLayoutTest.java:815`)失败而结束,未扩大范围修复。 -- 2026-09-12:按本次改动收窄后的 Mobile/Leanback `PlayerManagerTest`、`PlayerDisplaySettingSyncTest`、`PlayerSettingTest` 均通过;日志为 `/tmp/e-rollback-exo-ffmpeg-mode-cleanup-20260912.log` 与 `/tmp/e-rollback-exo-ffmpeg-mode-cleanup-focused-tests-20260912.log`。 -- 2026-09-12:当前提交相对 `origin/beta` 的全部差异完成首轮 adversarial 复评,未发现需要修复的源码问题;`origin/beta` 已是当前提交祖先,因此无需产生新的合并提交。 -- 2026-09-12:`dev1` 已推送至 `origin/dev1@c2dcf52b5676eef00026f55f013eb58ae522804f`,恢复标签已推送;已创建目标为 `beta` 的中文 PR #260(`EXO:移除已废弃的 FFmpeg 模式设置`)。 -- 当前状态:本阶段代码已提交、复评、推送并进入 PR;历史审计文档中的旧模式描述不属于运行时残留。 diff --git a/docs/E-SP3-exo-seek-preload-isolation.md b/docs/E-SP3-exo-seek-preload-isolation.md index 01ef7bf0bc..0fd57442d5 100644 --- a/docs/E-SP3-exo-seek-preload-isolation.md +++ b/docs/E-SP3-exo-seek-preload-isolation.md @@ -354,15 +354,3 @@ TV-exo-preload: ... mime=application/x-mpegURL - 产物:`app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk`,SHA-256 `da1514a4e826cd8a363051c615bb89ca44d49d861088dcf9acb3f7987ff0c46d`。 - 代码处置:无需修改或回滚 seek 代码、Gradle 配置或依赖版本。 - 下一动作:关闭资源恢复诊断单元并交付 APK。 - -## Checkpoint 16:2026-09-09 TV Exo 加载圈状态闭环补强 - -- 用户现象:TV 版 Exo 启播完成并正常播放后,加载圈偶现残留;拖拽进度后又偶现没有加载圈。 -- 上游复核:本地 `upstream/main` 指向 `fish2018/webhtv` 的 `784b90420d646eb6c7ddcc63ad622a92c65b02b4`;当前上游主线未包含可直接解决该 TV UI 状态竞态的更新。相关本地基线为 `a88a943fc472ce8e691b8d29a9d297033a0f479c`、`c07e2b27eddbbee3240ed25fd6e2c8e5a64c5c7e`、`eabd3fe86de55490167117916236209b863d6340`、`b3fab34cab38f89384b7e1065e27094f07b99ec1`;后续 `fc5b6ba029348c2c06214a80e4c080d6b210269a` 的合并树保留了 owner 约束,但 Leanback 未补齐晚绑定 READY 收口。 -- 根因闭环:TV 缺少 `onControllerReadyReconciled()` 实现时,控制器晚于播放器进入 READY 会错过 `onStateChanged(READY)`,加载圈没有收口;拖拽后 `mR3` 可能在播放器仍暴露旧 READY 状态时抢先收圈。 -- 实现范围:仅 `app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java`;增加 Leanback 晚绑定 READY 收口;增加 seek pending 与 500 ms 最小可见窗口;`mR3`、READY 回调和 seek fallback 共用 `canHideSeekProgress()`,只有当前播放器有效、READY 且已不处于真实加载阻塞时才收圈;保留 owner、空播放器和生命周期保护。未改 Exo AAR、Media3、预载策略、缓冲阈值、decoder、DV/HDR、MPV、IJK、native 或 ABI。 -- 回归测试:新增 `VideoActivityLoadingProgressSourceTest`,覆盖 seek pending 顺序、mR3 抢先收圈防护、READY/加载状态门控、fallback 重试、显式隐藏清理和晚绑定 READY 收口;同步更新 `PlaybackOwnershipSourceTest` 的 TV 断言。 -- 验证:旧实现红灯记录 `/tmp/e-sp3-tv-loading-red.log`(6 项新增断言失败);修复后聚焦 `VideoActivityLoadingProgressSourceTest` + `PlaybackOwnershipSourceTest` 共 21 项通过,Leanback Arm64 Java 编译同次任务 `BUILD SUCCESSFUL`,日志 `/tmp/e-sp3-tv-loading-green4.log`;`git diff --check` 通过。 -- 设备边界:当前 ADB 可见的是 `SM-N9700`、`V1923A`、`HD1910`、`NX627J` 四台设备,尚未取得用户可复现的同一 TV 片源/操作录屏或完成真实 TV 连续启播与拖拽 A/B;因此本 checkpoint 不宣称设备端问题已完全消失。 -- 回滚:回滚本次 TV UI 原子提交即可恢复原有加载圈逻辑;不触碰 E-SP3-A/B、Media3 产物或 native 资产。 -- 下一动作:在可复现该现象的 TV 设备上连续验证“启播 READY 后收圈”和“多次拖拽 seek 先显示、恢复后收圈”;若用户确认体验通过,再执行本任务 guard finish 创建原子提交与 recovery tag。 diff --git a/docs/E-SP7-exo-avc-adaptive-selection.md b/docs/E-SP7-exo-avc-adaptive-selection.md deleted file mode 100644 index b5c89d16c9..0000000000 --- a/docs/E-SP7-exo-avc-adaptive-selection.md +++ /dev/null @@ -1,125 +0,0 @@ -# E-SP7:Exo H.264/AVC 自适应选轨与掉帧修复 - -- 任务 ID:`E-SP7` -- 类别:Exo 性能/播放行为 -- 唯一文档:`docs/E-SP7-exo-avc-adaptive-selection.md` -- 状态:已实施,待真实设备 A/B 验收 -- 当前恢复锚点:源码、定向单测和 Mobile arm64 Java 编译已通过;下一步执行 task guard 原子提交并创建 recovery tag。 - -## 目标与范围 - -用户反馈部分 H.264 视频使用 Exo 播放掉帧,而 MPV 和上游 `fish2018/webhtv` 正常。两张截图显示同一类 MediaCodec 硬解均为 `c2.mtk.avc.decoder`,但本地选择 `1920x1080 / 800Kbps`,上游选择 `1280x720 / 800Kbps`;本地诊断显示掉帧 150、CPU 约 158%,上游掉帧为 0。 - -本任务只修复 Exo 受约束轨道的自适应资格:保留最大分辨率、最大码率、最大帧率和已有自动降档控制,让 Media3 根据带宽选择轨道。无轨道限制时的既有最高码率行为保持不变。 - -明确不修改:Media3 AAR、`third_party/media-lock.json`、FFmpeg、MPV/native/JNI、预载、tunneling、帧调度、Dolby Vision、字幕、音频和其他播放器路径。 - -## 基线与证据 - -- 工作区分支:`dev4` -- 实施前 HEAD:`59fd2688f79d4e6ef46da23a162c8236920629e6` -- 实施前工作区:无脏文件 -- 回滚锚点:`59fd2688f79d4e6ef46da23a162c8236920629e6` -- 受影响源码:`app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java` -- 回归测试:`app/src/test/java/com/fongmi/android/tv/player/exo/ExoUtilTest.java` - -### 上游与平台来源 - -| 来源 | 完整 revision/commit | 证据与结论 | -| --- | --- | --- | -| `Silent1566/webhtv` 本地修复 | `1536c1bcc8d409d6f2479764a8fee20c45fd1fc8` | 将 `applyVideoLimit()` 的 `setForceHighestSupportedBitrate(false)` 改为 `true`,意图解决 HLS 多码率起播落到最低轨;副作用是受约束轨道变为固定选择。 | -| `fish2018/webhtv` `main` | `ec478b0b697422a7785171c7b51a35b7a526564e` | 当前上游 `ExoUtil.java` 在 `applyVideoLimit()` 中保持 `setForceHighestSupportedBitrate(false)`,与用户截图中的 720p 结果一致。 | -| AndroidX Media3 `release` | `2bc207851df311340767e913931ca7b28cab1794` | `DefaultTrackSelector` 的源码将 `forceHighestSupportedBitrate` 排除在自适应资格之外;为 `true` 时,满足约束的轨道会走 fixed selection。 | -| `FongMi/media` H.264 AU 候选 | `aac6ec964681dd0476a33e3ad220ca7b5bf771f6` | 修改 TS 无 AUD 的 AU 边界、recovery point 和关键帧识别,不改变已经启动的 `c2.mtk.avc.decoder` 的轨道选择;本故障不移植。 | - -### 根因判断 - -`ExoUtil.applyVideoLimit()` 同时设置了设备/策略上限和 `forceHighestSupportedBitrate=true`。在 Media3 中,这会让轨道失去自适应资格;当前设备因此在 800 Kbps 码率下固定选择 1080p,而上游选择 720p。1080p 像素量是 720p 的 2.25 倍,和本地高 CPU、连续掉帧相符。 - -这不是 `FongMi/media@aac6ec964681dd0476a33e3ad220ca7b5bf771f6` 所覆盖的 TS sample 边界问题:截图显示视频已经使用平台 H.264 decoder 播放,且没有无 AUD、recovery-point、首帧失败或 seek 错误证据。 - -## 方案决策 - -### 不变 - -维持本地 `true`:无需改动,但会保留受约束 H.264 固定高分辨率选轨和用户反馈的掉帧问题。不接受。 - -### 直接移植 H.264 AU/recovery-point 上游提交 - -整体移植 `aac6ec...`:它能改善特定 TS 无 AUD 流的 sample 边界、关键帧和 seek,但会触及本地 synthesized PUSI/EOF、4-byte start code、M2TS、Dolby Vision 和多个 Media3 AAR 文件;没有当前样片证据证明这些边界是本故障原因。拒绝作为本任务修复。 - -### WebHTV 窄适配,采用 - -只将 `applyVideoLimit()` 中的 `setForceHighestSupportedBitrate(true)` 恢复为 `false`。保留 `buildTrackSelector()` 在轨道限制关闭分支中的 `true`,避免回退本地此前修复的起播选轨行为。已有 `AutomaticVideoConstraintController` 的带宽/重缓冲/掉帧降档继续作为约束层使用。 - -这是一个单参数、可独立回滚的 Exo App 修复,不需要重建 native 或 Media3 AAR。 - -## 接受标准 - -1. `ExoUtilTest` 证明 `applyVideoLimit()` 保持自适应选轨,且源码中不再在该方法内强制最高码率。 -2. Exo Mobile arm64 Java 编译通过。 -3. 同一 Dangbei X7 Ultra、同一 H.264 资源、同一网络和同一播放位置的候选版本不再固定选择截图中的 1080p/800Kbps 组合;decoder 仍可使用 `c2.mtk.avc.decoder`。 -4. 目标样片连续播放 30 秒无持续掉帧增长,掉帧结果达到上游同量级;若设备仍掉帧,必须以诊断数据区分轨道选择之外的原因,不宣称本任务已完全解决所有 H.264 问题。 -5. 在带宽和设备余量足够时,自适应轨道可以向上切换到更高分辨率。 -6. 既有轨道限制、自动降档、无轨道限制的最高码率行为、HEVC/其他格式、MPV 和 IJK 不回归。 - -## 验证计划 - -先运行: - -```text -:app:testMobileArm64_v8aDebugUnitTest ---tests com.fongmi.android.tv.player.exo.ExoUtilTest -``` - -测试先红后改绿,再运行同一 Exo 定向测试和: - -```text -:app:compileMobileArm64_v8aDebugJavaWithJavac -``` - -由于本任务不涉及 AAR、native、ABI 或 JNI,不运行 native 重建和 MPV 资产门禁。真实设备播放需要用户提供或复现同一 H.264 资源;未取得设备结果时,只报告源码/单测/编译证据,不把编译当成掉帧已消失的证明。 - -## 风险、回滚与发布 - -- 主要风险:自适应起播可能短暂选择较低分辨率;这是用带宽适配换取连续播放的预期行为。 -- 主要保护:最大分辨率、码率、帧率约束仍由 `applyVideoLimit()` 设置;自动控制器继续按实际带宽和播放状态收紧约束。 -- 回滚:恢复 `59fd2688f79d4e6ef46da23a162c8236920629e6`,或对本任务原子提交执行 `git revert`;不触碰 Media3/FFmpeg/MPV 资产。 -- 提交前提:定向单测、Mobile arm64 Java 编译和适用的设备证据必须记录在本文;通过后由 task guard 创建一个原子提交及 `recovery/E-SP7/-` annotated tag。 - -## Checkpoint 1:2026-09-03 E-SP7 实施启动 - -- 用户决定:明确批准“开始修复”。 -- 诊断完成:用户截图、本地 `1536c1...`、上游 `ec478b...` 和 Media3 `release` 语义共同指向受约束选轨被强制固定的回归。 -- 当前工作区:`dev4`,HEAD `59fd2688f79d4e6ef46da23a162c8236920629e6`,task guard 已启动,尚无任务代码改动。 -- 下一步:先把既有 `ExoUtilTest` 改成自适应选轨回归断言并运行红灯测试,然后修改 `ExoUtil.java` 单个参数。 - -## Checkpoint 2:2026-09-03 E-SP7 红绿验证完成 - -- TDD 红灯:旧实现上运行 `:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.player.exo.ExoUtilTest --no-daemon`,16 项中仅 `videoLimits_keepAdaptiveSelectionInsideTheConstraints` 失败,失败原因是 `applyVideoLimit()` 仍包含 `setForceHighestSupportedBitrate(true)`。 -- 实现:`ExoUtil.applyVideoLimit()` 将该参数改为 `setForceHighestSupportedBitrate(false)`;`buildTrackSelector()` 无轨道限制分支的 `true` 未修改。 -- TDD 绿灯:同一 ExoUtilTest 定向任务通过,Gradle `BUILD SUCCESSFUL in 58s`。 -- Java 编译:`:app:compileMobileArm64_v8aDebugJavaWithJavac --no-daemon` 通过,Gradle `BUILD SUCCESSFUL in 57s`。 -- 既有警告:Java deprecation 提示仍存在;本任务未新增编译错误。未运行 native/MPV 门禁,因为本任务不修改 AAR、native、ABI、JNI 或 MPV。 -- 当前工作区:分支 `dev4`,task guard `E-SP7/upstream` active;任务源码、测试、文档和评估索引在声明范围内。 -- 未决:真实 Dangbei X7 Ultra 同资源 A/B 尚未执行,因此当前结论限于源码、单测和编译证据,不能宣称设备端所有 H.264 掉帧均已消失。 -- 下一步:执行一次 scoped diff/check 后调用 task guard finish,创建原子提交和 `recovery/E-SP7/-` annotated tag。 - -## Checkpoint 3:2026-09-03 E-SP7 验证与提交收尾 - -- Mobile arm64 Java 编译:`:app:compileMobileArm64_v8aDebugJavaWithJavac --no-daemon`,`BUILD SUCCESSFUL in 57s`。 -- Leanback arm64 Java 编译:`:app:compileLeanbackArm64_v8aDebugJavaWithJavac --no-daemon`,`BUILD SUCCESSFUL in 2m 5s`。 -- 定向测试:`:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.player.exo.ExoUtilTest --no-daemon`,`BUILD SUCCESSFUL in 58s`,16 项测试通过。 -- 红绿证据:修改前的新断言按预期失败;修改 `applyVideoLimit()` 后同一测试通过。 -- 变更审计:`git diff --check` 通过;变更仅限 `ExoUtil.java`、`ExoUtilTest.java`、本任务文档和主评估索引。未改 Media3 AAR、lock、FFmpeg、MPV、native、JNI 或其他播放器。 -- 设备限制:当前 ADB 只有 `emulator-5554` 和 `emulator-5556`,没有 Dangbei X7 Ultra;真实同资源分辨率、decoder、掉帧和 CPU A/B 尚待设备条件具备后补验。 -- 当前状态:源码修复已完成并通过静态/单测/两产品 Java 编译,待 task guard 完成原子提交和本地 recovery tag。 -- 下一步:执行 task guard finish;后续设备 A/B 结果追加到本文,不改变本次源码提交边界。 - -## Checkpoint 4:2026-09-03 beta 合并后复核 - -- 合并基线:当前第一父为 `ff438637b89587cf4f378843338a4122ba07e9d3`,beta 第二父为 `bcfe7b22a05e32913448a228f9513c690bc8233f`;beta 暂存差异未触及 `ExoUtil.java`、`ExoUtilTest.java` 或 E-SP7 业务路径。 -- 代码复核:`applyVideoLimit()` 的 `setForceHighestSupportedBitrate(false)`、最大分辨率/码率/帧率约束和无轨道限制分支均保持原实施边界。 -- 合并后验证:E-SP7 `ExoUtilTest` 定向任务通过;Mobile Arm64 与 Leanback Arm64 Java 编译通过,Leanback 最终执行耗时 5 分 59 秒。 -- 结论:beta 合并未覆盖或削弱 E-SP7,当前没有需要在提交前修正的 E-SP7 相关问题;真实 Dangbei X7 Ultra 同资源 A/B 仍未执行。 -- 下一步:随 C9 beta 合并原子提交创建恢复标签并推送;设备 A/B 结果后续追加,不改变本次源码提交边界。 diff --git a/docs/E-SP8-exo-short-drama-queue.md b/docs/E-SP8-exo-short-drama-queue.md deleted file mode 100644 index 2ac6c907fe..0000000000 --- a/docs/E-SP8-exo-short-drama-queue.md +++ /dev/null @@ -1,425 +0,0 @@ -# E-SP8:Exo 短剧队列连播设计 - -## 0. 状态与 Recovery anchor - -- 状态:**IMPLEMENTING/用户已批准按本计划实施;尚未完成代码、设备验收或正式放量**。 -- 日期:2026-09-08,Asia/Shanghai;本轮起始时间 20:58,预计 21:16 前完成文档。 -- 用户目标:减少短剧自动下一集的重新起播、转圈和画面中断;识别依据使用现有“短剧源设置”,而不是时长猜测。 -- 完成标准:第 11 节四阶段实施及第 12 节验收;请求/解析隔离、单实例有界队列、双端业务事务、受控预载全部接通,定向测试和双端编译通过,真实设备对照证据齐备,原子提交及 annotated recovery tag。实验默认关闭,正式放量保留用户批准门槛;不得以 queue-only 或编译通过替代完整完成。 -- 本地基线:`dev1@ae64c42369a6b2ee660fee86cbe47c40382df667`;起始工作区干净,无受保护的既有脏文件。 -- 实施基线:`dev1@655bf1a61e752dd5c6ccbbc3e92f92e2c6a35c02`;2026-09-08 21:20 首次恢复,工作区干净,无受保护脏文件。前一设计 guard 已 finished,不存在遗留实施进程。 -- 本轮 lane:`upstream`;task guard:`E-SP8`(active)。 -- 本轮允许路径:第 11 节明确列出的 App 文件、对应测试、本文件及索引;实际封闭列表见 guard scope。不改依赖/lock/AAR/native/布局/其他页面,不自动切换分支。 -- 已完成证据:本地识别及切集调用链、请求头工厂、解析任务取消模型、预缓存保护、本地 AAR API/sources、官方播放列表与预加载资料、FongMi 对照源码、维护者讨论。 -- 尚未验证:真实连续切集、各类 LoadControl 的准入组合、Spider 取消/重入、网络争用、字幕/历史时序及设备性能。文档校验不能替代这些验证。 -- 当前改动/验证:仅更新本文件和索引记录实施授权;生产代码尚未修改,未运行新测试或构建。 -- 回滚锚点:实施基线 `655bf1a61e752dd5c6ccbbc3e92f92e2c6a35c02`;后续只回滚 E-SP8 原子单元,不更换依赖产物。 -- **下一步(唯一):实现并验证每项 HTTP 请求/缓存身份隔离及队列取消身份契约。** - -## 1. 设计结论与非目标 - -采用:**现有短剧源设置 + 单个 ExoPlayer + 当前集/下一集有界播放队列 + 下一集预解析 + 受控 playlist preload + 真实媒体项过渡提交业务状态**。 - -成功的自动过渡边界不主动调用播放器的 `stop()`、`clearMediaItems()`、`release()`、`setMediaItem(s)`、`setMediaSource(s)` 或 `prepare()`,也不走 Activity 的完整 `onRefresh()`。这里禁止的是播放器重启,不是禁止取消旧集后台缓存任务。 - -保证范围是同一次前台播放会话内的 App 行为:不主动重建 ExoPlayer、PlayerView、Surface,不主动重置页面。系统销毁 Surface、生命周期重建、播放器错误恢复和显式换源不属于这条成功路径。 - -不承诺所有资源使用同一个 MediaCodec、绝对零黑帧、绝对零音频间隙或跨集连续时间轴。每集仍是独立 MediaItem,进度从该集起点计算。编码、音轨、分辨率及渲染要求变化仍可能导致解码器重配置。 - -非目标:文件合并、本地 HLS/TS 伪直播、FFmpeg 转码、播放器内核迁移、双播放器方案、MPV/IJK/System 队列改造、全局缓存重写、依赖升级、短剧页面重新设计。 - -## 2. 当前实现与缺口(已核对) - -以下代码位置均以本地基线为准,行号仅用于定位。 - -| 位置/符号 | 现有行为 | 本任务处理 | -| --- | --- | --- | -| `app/src/main/java/com/fongmi/android/tv/setting/Setting.java:922–932` | 读写 `short_drama_config`,统一调用 `isShortDramaSiteEnabled(key, name)` | 原样复用,不新增分类配置 | -| `app/src/main/java/com/fongmi/android/tv/bean/ShortDramaConfig.java:52–63,113–120` | 排除站点优先;启用规则匹配站点 key/名称;无自定义规则时使用 `[短]`、`短剧` | 不改识别语义 | -| 移动端 `VideoActivity.java:7679–7694` | `isShortDramaSource()` 判源;`isShortDramaSession()` 锁存短剧界面形态,避免换源后布局突变 | UI 会话标志不等于队列资格 | -| TV 端 `VideoActivity.java:7192–7199` | 短剧模式同样使用源设置 | 两端复用识别策略;不修改布局 | -| 移动端 `VideoActivity.java:3324–3338,4597–4607,7514–7515`;TV 对应切集路径 | 自动下一集最终进入 `onItemClick()`/`onRefresh()`,保存历史后 stop、clear、重新取流;倒序播放使用 `isRevPlay()` 选择相邻集 | 增加受控自动过渡分支;原路径作为回退 | -| `app/src/main/java/com/fongmi/android/tv/player/engine/ExoPlayerEngine.java:981–996` | 启动资源时设置单个 MediaItem、启动 PreCache、prepare、play | 首集保留;自动队列过渡不调用 start/restart | -| `app/src/main/java/com/fongmi/android/tv/player/exo/MediaSourceFactory.java:201–205,268–276` | 创建源前修改共享 `OkHttpDataSource.Factory` 的默认 headers;使用现有 DefaultMediaSourceFactory | 加入下一集前必须解决请求配置的独立所有权 | -| 同文件 `:208–214,250–265` | 保留 `***`/`|||` 的 ConcatenatingMediaSource2、多协议、提取器、共享磁盘缓存;前台缓存源不写磁盘 | 不替换成仅支持 Progressive 的工厂;特殊拼接首版走旧路径 | -| `app/src/main/java/com/fongmi/android/tv/model/SiteViewModel.java:141–155,221–231` | 同类 PLAYER 任务递增 taskId、取消上一个 Future,并投递到前台 LiveData | 下一集预解析不能直接复用这个结果槽,否则会覆盖/取消前台播放请求 | -| `app/src/main/java/com/fongmi/android/tv/player/exo/PreCache.java:149–193` | 绑定当前 MediaItem、磁盘 key、当前路线与生命周期;已有缓冲、seek、内存、系统条件及熔断保护 | 不是现成的“下一集预加载管理器”;真实过渡时需要重新绑定当前项 | -| `app/src/main/java/com/fongmi/android/tv/player/exo/AutoLoadControl.java:185–190,262–266` | 已有 `shouldContinuePreloading` 委托和内存暂停约束 | 保留并补足队列准入,不用默认 LoadControl 覆盖它 | -| `app/src/main/java/com/fongmi/android/tv/player/exo/ExoPreloadTrafficCoordinator.java` | 已统计 CUSTOM/MEDIA3 预载竞争,带会话身份与释放保护 | 可复用统计,**它不是互斥调度器**,不能误认为已经消除竞争 | -| `app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java:8442` 起的 `Player.Listener` | 当前状态、轨道、不连续、错误与诊断回调 | 补媒体项身份桥接和自动过渡提交,不假定已有业务 Listener API | - -本仓库已有的 UI 换源保持修复 `af0bc991d9e281a19238dc292f85d651304de696` 和短剧手势修复 `86d351d54c21c89f72d8559f0283a5887e5b792e` 必须保持。短剧 dock、全屏形态、上下滑切集不能被队列重构覆盖。 - -## 3. 短剧识别与首版准入 - -### 3.1 分类唯一依据 - -```text -Setting.isShortDramaSiteEnabled(currentSiteKey, currentSiteName) - 1. disabledSites 中站点 key 忽略大小写精确命中 → false - 2. enabledSites 非空 → 按自定义规则判断 - 3. enabledSites 为空 → 按默认 [短]、短剧 判断 - 4. 启用规则对站点 key 或名称进行忽略大小写的包含匹配 -``` - -这是站点级分类,不识别每部作品的类型。综合源被配置成短剧源后,其中作品不会因时长不同而自动退出短剧分类;不要擅自加入标题、分辨率、竖屏比例或“少于五分钟”的猜测。清空启用规则会回到默认规则,不代表关闭全部短剧源。 - -### 3.2 分类、资格、时机分离 - -| 决策 | 依据 | -| --- | --- | -| 是否短剧场景 | 当前源设置;UI 锁存只负责界面 | -| 是否允许队列增强 | 下述首版准入条件、实验放量及用户现有预加载设置 | -| 何时解析下一集 | 当前首帧后、前台解析空闲、资源条件许可时尽早一次性解析 | -| 何时下载下一集数据 | 当前播放安全水位、seek 恢复、负载与取消完成状态;时长/进度仅作调度输入 | - -首版准入条件全部满足才启用: - -- 当前源命中短剧配置;Exo 视频点播;存在相邻集;实验开关允许;用户没有关闭相关预加载能力。 -- 已获得或可以安全后台获得普通 HTTP(S) HLS VOD/渐进式 MP4 直链及完整请求配置;URL 后缀不能单独证明协议类型。 -- 不需要 DRM 会话、嗅探/二次解析、内核切换、特殊解密或 renderer/特效重建;不包括直播、RTSP、光盘/ISO、内部多段拼接、音频/阅读器和未验证代理生命周期。 -- 首版使用普通顺序或现有倒序规则;不支持的随机/重复模式继续走旧路径,不修改用户配置来强行准入。 -- 下一集没有必须保留的非零起播点、片头/片尾跳过或其他提前切集语义;在这些语义接通并验证之前,**不进入增强路径**,不静默忽略它们。 -- 当前会话未处于前台恢复、seek 恢复、内核降级或错误熔断;后台解析能够遵守第 5 节取消与线程约束。 - -这是首版功能覆盖限制,不是把这些资源重新分类为“非短剧”。不准入时仍保留短剧界面和原有播放功能。 - -## 4. 架构与数据契约(拟新增,不是当前已存在的 API) - -```text -Mobile / Leanback VideoActivity - ├─ 原首集/手动选集/失败回退路径(保留) - └─ ShortDramaQueueCoordinator(共享业务状态机,拟新增) - ├─ 短剧准入、按既有顺/倒序找相邻集 - ├─ 独立下一集解析结果通道、generation 取消隔离 - └─ PlayerManager 最小队列接口 - └─ ExoPlayerEngine - ├─ item-scoped MediaSource → addMediaSource - ├─ 现有 LoadControl + 有界 playlist preload - └─ 真实媒体项事件 → 当前业务状态提交 -``` - -只新增一个共享业务协调器,避免在手机和电视端复制两套状态机;快照/结果类型优先作为嵌套不可变类型,不预先搭建泛化播放队列框架。 - -建议的引擎契约是 `supportsQueue()`、`appendNext(snapshot)`、`invalidatePending(generation, reason)` 及结构化过渡事件,具体签名在实施前按现有接口收敛。其他内核默认返回 unsupported,不增加实际队列行为;业务 Episode 对象不泄漏进底层引擎接口。 - -| 数据 | 内容与约束 | -| --- | --- | -| 会话身份 | `generation`、配置/站点 key、作品 ID、线路、画质、内核、顺倒序快照;与 UI 短剧会话锁存分开 | -| 剧集身份 | 使用作品/线路/剧集稳定 ID;若无 ID,则使用当前列表快照内不重复身份。不能只用标题或播放 URL | -| `mediaId` | 队列内唯一、可反查剧集,包含 generation;不携带明文 URL、Cookie、Token | -| 已解析项 | 不可变 URL、headers 副本、MIME/路线判断、字幕信息、起播策略、有效期(仅可信字段)、原 PlaySpec 必需语义 | -| 过渡快照 | old/new mediaId、旧集 position/duration 快照、新集 position、reason、generation、事件序号 | -| 幂等键 | `(generation, episodeIdentity)` 防重复入队;`(generation, oldMediaId, newMediaId, eventSequence)` 防重复提交 | - -**播放器队列索引只是瞬时位置,不是剧集编号。** 用 `getCurrentMediaItemIndex()` 与真实 `mediaId` 校验;清理旧项后索引可以归零,禁止 `currentIndex++` 推导业务剧集。 - -所有播放器读写及队列状态提交在 `player.getApplicationLooper()` 上串行执行。耗时解析、历史落盘和网络请求不阻塞该线程;跨线程只传不可变快照,不读取已经换集的 Activity 可变对象。 - -## 5. 下一集解析:独立结果、前台优先 - -1. 首集继续走现有 getPlayer/start/prepare 流程,成功建立会话后才初始化增强状态。 -2. 当前首帧后,在准入和前台空闲条件允许时尽早解析一个相邻集;不强制等到 80%,避免很短的剧集准备过晚。 -3. 复用现有 `SiteApi.playerContent(..., explicitPlayerType)` 语义,但**不得向 `SiteViewModel` 的前台 PLAYER LiveData 投递预取结果,也不得复用其取消槽**。 -4. 后台结果先验证 generation、作品、源、线路、顺倒序及目标剧集,再生成不可变播放快照。预取完成不得选中下一集、更新标题、写当前播放历史或调用 start。 -5. 同时最多一个下一集解析任务;取消/超时后旧结果只能丢弃,不能重新激活队列。下一集网络解析失败不消耗前台播放的重试预算。 - -独立结果通道不等于允许 Spider 并发重入。必须核对相应运行时的线程约束;复用受控执行器而不是每集建线程。手动取流优先,后台任务须可取消或在不阻塞前台的情况下让出资源。对会忽略中断、共享全局解析状态或需 WebView 嗅探的站点,首版不调度后台取流;已有可靠直链可以单独准入。不得为了预解析引入前台排队等待的回归。 - -签名 URL 不猜测失效时间,也不剥离 query。可信有效期不足以覆盖预计使用窗口时不入队;有效期未知仍可能在切集时失败,按第 9 节重新取流一次,不能宣传预解析后必然可播。 - -## 6. MediaSource、请求头与缓存隔离 - -本次发现的实质前置风险是:现有 `createMediaSource()` 修改共享 HTTP 工厂默认属性。在 A 正播放时为 B 改写这些属性,可能使 A 后续创建的请求继承 B 的配置;这属于由当前结构推导出的并发风险,尚不是已复现的串头事故。 - -首版使用 **item-scoped MediaSource**:基于现有 `MediaSourceFactory` 的构造链,为每个队列项持有独立、初始化后不再变更的 HTTP/上游工厂与 headers 副本,再通过 `player.addMediaSource(nextSource)` 追加。共享受管理的 Cache 和连接客户端,但不共享可变请求默认属性。 - -必须保留现有 DefaultDataSource/DefaultMediaSourceFactory、HLS/MP4 类型识别、UA 清理、EOF 恢复、优先级、提取器、轨道设置与缓存会话引用计数。不得创建额外 SimpleCache 指向同一目录,不能因新增工厂增加不配对的 acquire/release。 - -补充约束: - -- A 的分片/Range 请求、B 的 playlist/分片请求分别使用各自快照;取消 B 不改变 A。 -- 预加载与前台消费同一个队列项时使用相同缓存身份。涉及认证隔离的缓存命名空间必须覆盖站点和认证上下文,不能把同 URL 不同身份当成同一资源;不得删签名来追求命中率。 -- HLS 子请求、重定向、CookieJar 和代理也要验证身份边界;headers 副本并不意味着共享 CookieJar 已完全隔离。无法保持这些约束的路线暂不准入。 -- 下一集的资源判断、解码/DV 状态、字节位置统计不能提前覆盖当前集的运行上下文。实际切换时才提交新 PlaySpec/route/trace;依赖共享可变特殊媒体状态的路径先排除。 - -## 7. 预加载策略:不要把入队当作下载完成 - -区分四件事:URL 已解析、MediaSource 已构造、播放器已接受队列项、下一集数据已经可用。`addMediaSource()` 成功只证明追加操作成立,不能记录为 `preload_ready`。 - -### 7.1 方案选择 - -首选当前 AAR 已有的 playlist preload,不引入第二个播放器或 DefaultPreloadManager。内置能力可通过 `ExoPlayer.PreloadConfiguration` 设置目标时长;本地字节码确认支持,默认配置关闭此项,单位是**微秒**。这是配置示意,不是本轮已实施代码: - -```java -// 仅在队列准入、安全调度与用户设置全部允许时配置。 -player.setPreloadConfiguration(new ExoPlayer.PreloadConfiguration(5_000_000L)); -// 非增强会话恢复原配置;首次启动仍沿用现有流程。 -``` - -初始候选目标为下一集 5 秒,实验上限 10 秒,并受用户更严格设置限制;这些是待测参数,不是最佳实践结论。按时长限额不等于按字节限额,HLS 分片粒度、码率、allocator 和在途请求仍需预算控制。 - -### 7.2 与 PreCache 的所有权 - -- 当前集磁盘预缓存继续由现有 PreCache 负责;它不能直接被称为下一集预取器。 -- 下一集额外内存预载由 playlist preload 负责;不再给同一下一集另开 PreCacheHelper/DefaultPreloadManager。 -- 复用 `ExoPreloadTrafficCoordinator` 的会话统计,但补充明确的准入与让出策略:**前台当前集/seek 恢复 > 当前集缓存安全契约 > 下一集额外预载**。 -- 首版不允许额外的下一集预载与活跃自定义磁盘预缓存无约束竞争;先等 PreCache 自然空闲/任务取消确认,再放行。不能仅调用 stop 后立刻认为旧网络任务消失。 -- 不为提前下载下一集降低用户当前集缓冲水位、不删 E-SP3 的 seek 恢复与熔断保护。无法安全获得预载窗口时允许“已排队但未额外预载”,记录降级原因。 -- 默认、增强、自动等所有可达 LoadControl 分支都要挂接相同准入;保留 AutoLoadControl 的内存暂停条件。依赖配置存在不等于整个 App 策略已经接通。 - -### 7.3 安全闸门与资源上限 - -进入 buffering、seek 恢复、后台/暂停策略限制、内存/存储/网络/电量限制、用户关闭预载、全局实验抑制或错误熔断时,停止提交额外预载并按现有生命周期处理在途任务。后台线程只读取线程安全的策略快照,不能读取 Activity。 - -当前集安全水位按**当前媒体项**计算,不能把下一集缓冲计入当前集保护量。队列稳定态至多当前集+下一集;当前集结束后清理已消费项,再准备下下集。不预解析全剧、不同时下载多个后续集。 - -当前前台 MediaSource 的 CacheDataSource 已关闭磁盘写入;新增 playlist preload 不得偷偷开启它。磁盘容量、缓存写入、计量网络和用户暂停预载选择维持原契约。若用户明确关闭预载,首版整体不进入该增强路径,避免通过排队绕过选择。 - -## 8. 队列状态与自动切集事务 - -### 8.1 状态机 - -下表描述相对于当前集的“下一集槽”,额外保存当前播放身份;`QUEUED` 与 `PRELOADING` 不代表已经切集。 - -| 状态 | 触发/动作 | 后继 | -| --- | --- | --- | -| DISABLED | 不满足准入 | 原播放路径 | -| EMPTY | 当前集稳定、可准备相邻集 | RESOLVING | -| RESOLVING | 独立解析;结果通过 token 校验 | RESOLVED/EMPTY/DISABLED | -| RESOLVED | 不可变请求快照与隔离源完成 | 追加后 QUEUED | -| QUEUED | 等待播放器自然过渡;安全窗口内额外预载 | PRELOADING 或保持 QUEUED | -| PRELOADING | 有下一集身份的加载证据;受闸门控制 | QUEUED/TRANSITIONING | -| TRANSITIONING | 播放器当前 mediaId 已变为该项 | 一次性提交后 EMPTY | -| INVALIDATED | 手动操作、配置变化、退出或旧 token | 丢弃旧结果、取消旧后台任务 | -| FALLBACK | 已无法安全自然过渡 | 一次旧路径重播;本 generation 禁止再次自动尝试同项 | - -所有状态变更幂等。收到空 mediaItem、初始化/清空列表事件时不得推进集数;同项 seek、repeat、playlist mutation、remove-old 引发的事件也不能当成“自动下一集”。 - -### 8.2 真实过渡提交 - -正常队列内部切换由媒体项事件驱动,不依赖每集都出现 `STATE_ENDED`: - -1. `onPositionDiscontinuity(oldPosition, newPosition, reason)` 捕获旧集身份与结束位置快照;不能切换后读取 `getCurrentPosition()` 再写到旧集。 -2. `onMediaItemTransition()`/同批次 `onEvents()` 对照播放器真实 mediaId、reason、generation、预期相邻项,形成一次提交;不得假定不同回调总以业务期望顺序到达。 -3. 用旧快照提交上一集历史/远端进度,随后更新新集对应的选中状态、标题、当前历史对象、MediaSession/外部播放状态。 -4. 引擎更新当前 PlaySpec、route、诊断身份和缓存绑定;取消旧 PreCache generation 后以新项重新建立当前集缓存会话,不调用播放器 start/prepare。 -5. 切换字幕、弹幕和 TMDB 集信息;取消旧集异步任务,新回调均校验媒体身份。旧内容清除与新内容绑定按现有字幕/弹幕能力执行,不重建 PlayerView。 -6. 提交完成后移除已消费队列项,再为下下集创建 EMPTY 槽。保留必要的旧历史不可变快照,不保留旧 MediaSource/Activity 引用。 - -保存历史不要求阻塞播放线程等待磁盘或网络完成,但写任务必须携带独立身份,并保持旧集/新集语义顺序。新媒体项的轨道、时长和首帧可能晚于 transition 到达,应按当前身份增量更新,不能拿上一集信息补成下一集的最终状态。 - -### 8.3 与 STATE_ENDED 及手动操作的关系 - -只有“当前会话有效、确有下一项且未落入失败状态”的自动边界由队列协调器消费。队列耗尽、未及时排入下一集或准入失效时,原结束处理仍可推进一次;不得全局屏蔽 STATE_ENDED,也不得仅凭 `nextReady=true` 永久等待。 - -手动选集、上下滑切集、换源、换线路、换清晰度、换内核、顺倒序变化、onNewIntent 和退出页:先使 generation 失效、清理未来项与后台请求,再走现有显式操作。首版**不把手动滑动伪装为自然过渡**。UI 短剧锁存保持原行为,新源的队列资格重新判断。 - -## 9. 错误与回退 - -| 失败位置 | 处理 | -| --- | --- | -| 解析失败/不支持/取消/签名预计过期 | 不影响当前播放;不追加;当前集结束时交给旧流程 | -| 下一集尚未成为当前项时的预载失败 | 能准确归属下一项时取消额外预载、必要时移除该项;不把当前集标为失败,不触发换内核/换源熔断 | -| 错误无法归属媒体项 | 不猜测是“无害预载错误”;走已有播放器恢复规则并禁用本次增强,保留诊断 | -| 已自动进入下一项但起播失败/401/403 | 按真实当前项保存业务身份;对这个目标集重新解析并走一次原 start 路径,不错误跳到下下集 | -| 已排队却未过渡 | 检查实际 timeline、播放状态和错误;仅在前台应继续播放且越过预先定义的等待门槛时一次回退,不在暂停、后台或反复 buffering 中循环 restart | -| 历史/字幕/弹幕新集任务失败 | 不重启正在正常播放的视频;隔离业务错误并提供原有重试/缺省展示 | - -回退可以调用 stop/clear/prepare,但必须标为 `queue_fallback`,不统计为无缝成功。每个 generation/目标剧集最多一次队列到旧路径的接管;之后遵循既有错误处理,禁止死循环、双重推进或静默跳过失败集。 - -## 10. 方案比较与最佳实践审查 - -### 10.1 决策问题 - -在不迁移内核、不损失现有缓存/seek/请求安全契约的条件下,是否可用当前 Media3 的播放列表替代短剧自动切集的完整刷新?备选反假设是:仅复用上游预载或简单追加即可解决问题。源码显示还必须补解析隔离、请求头所有权与业务状态事务,因此不能只改一个播放器管理方法。 - -| 方案 | 正确性/兼容性 | 性能、维护与结论 | -| --- | --- | --- | -| 不改动 | 现有行为稳定,保留全部功能 | 切集仍 stop/clear/prepare;作为基线及回退 | -| 原样采用用户最初 ConcatenatingMediaSource 示例 | 忽略当前多格式源和业务状态;不选择新功能的过时入口 | 不采纳;已有 ConcatenatingMediaSource2 内部拼接功能保留 | -| 原样移入当前 FongMi 的 DefaultPreloadManager 消费路径 | 其 Session/PlaySpec/字幕架构与本地不同;消费仍 setMediaItem/Source + prepare | 有预热价值,但不满足成功边界无刷新,不能作为直接补丁 | -| 仅 addMediaItem,无预解析/请求隔离/状态事务 | 无法保证下一项可播,可能串请求配置或错写历史 | 不完整,不批准作为成品 | -| **WebHTV 窄适配:隔离源 + 动态队列 + 受控原生预载** | 保留现有源设置、多格式工厂、缓存保护及旧路径;补充明确所有权 | **推荐**;先验证队列正确性,再启用有界预载,不升级依赖 | -| 本地 HLS/TS/双播放器 | 改变时间轴、容器或 Surface/解码器所有权 | 超出需求,拒绝首版引入 | - -推荐是“补全并适配”,不是上游整类替换。只排队但未获得预载窗口可以作为运行时降级;不得将阶段性 queue-only 验证称为完整预加载优化完成。 - -### 10.2 固定来源与逐项处置 - -本任务是 App 新需求设计,不是上游提交范围合并;没有新增 cherry-pick 范围。以下完整 revision 均有明确用途,不能由本表推导出依赖升级授权。 - -| 仓库/组件 | 完整 revision | 处置 | -| --- | --- | --- | -| 当前 WebHTV 工作树 | `ae64c42369a6b2ee660fee86cbe47c40382df667` | 设计与回归基线 | -| `third_party/media-lock.json` 声明的 Media3,仓库 `fish2018/webhtv`,分支 `media/release-1.11.0-alpha01-fongmi` | `e3e922d5c01bc0b564849940fe589daf37360d15` | 保留锁定,不升级;以本地 AAR/sources 进一步确认所需 API | -| nextlib,`anilbeesetti/nextlib` | `6ff6cf9d0820382b3c233d018c52e4163b09d345` | 仅记录现有依赖,排除修改 | -| nextlib 对应 FFmpeg,`FongMi/FFmpeg` | `177f090e0503b7e013922ca903bde14b1c375f18` | 排除修改,不触及 ABI/软解产物 | -| 对照项目 `FongMi/TV`,2026-09-08 读取的 `fongmi` 分支快照 | `4afc4473e22a7ed3d98ee12233e0c2a490061000` | 仅参考 ExoPlayerEngine/ExoPlayerSession 预载消费,不移植整个架构 | - -本地坐标:`androidx.media3:media3-exoplayer:1.11.0-alpha01-fongmi`;不是将官方稳定版 `1.11.0` 当成本项目版本。 - -- 本地 AAR:`third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar`;SHA-256 `cfea29681799509923174cac7ce31b7e944e542db9df58fd56b7a86499a5c07e`。 -- 同目录 sources JAR:`media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar`;SHA-256 `83f4f83b4f44e621d52002c161f63fbcb77be6856af4b1e4a4cb0982b04549e1`。 -- `javap` 已确认 `addMediaSource`、`setPreloadConfiguration`、`PreloadConfiguration(long)`、`targetPreloadDurationUs`;sources 中 `ExoPlayer.java:192–210` 确认微秒单位和默认关闭。`LoadControl.java:373` 的接口默认实现不放行,不能忽略自定义实现;`DefaultLoadControl.java:832–839` 先检查前台加载状态。 - -### 10.3 证据登记 - -访问日期统一为 **2026-09-08**。A=直接源码/测试/官方契约;B=维护者解释或成熟项目;D=未经本项目复现的现场线索。所有来源只支持其列出的结论,不证明 WebHTV 性能已改善。 - -| 证据类别/等级 | URL/固定路径与版本 | 支持的结论、适用性及决策影响 | -| --- | --- | --- | -| 本地实现/A | 第 2 节代码,WebHTV 基线 revision | 源设置已有、自动切集刷新、前台解析单槽及共享 headers;决定必须跨引擎/业务桥接窄适配 | -| 精确依赖源码/A | 上述 AAR/sources SHA;ExoPlayer、LoadControl、DefaultLoadControl | 所需 API 已打包,无需升级;原生预载不会自动理解 App PreCache 竞争 | -| 本地测试/A(仅阅读,未运行) | `app/src/test/java/com/fongmi/android/tv/player/exo/AutoLoadControlTest.java`、`ExoPreloadTrafficCoordinatorTest.java`、`PreCacheTest.java`、`MediaSourceFactoryTest.java` | 已有内存暂停、会话隔离及源工厂验证入口;新增用例应补而不替换这些保护 | -| 官方播放列表指南/A | `https://developer.android.com/media/media3/exoplayer/playlists` | 支持播放期间修改列表及 mediaId 映射;自然过渡可报告媒体项事件,决定不用 currentIndex++ | -| 官方预加载说明/A | `https://developer.android.com/media/media3/exoplayer/preloading-media/preloadmanager` | 简单顺序队列适合 playlist preload,复杂候选管理才考虑 DefaultPreloadManager;本次只需一个下一项 | -| 官方技术文章/B | `https://developer.android.com/blog/posts/elevating-media-playback-introducing-preloading-with-media3-part-1` | 内存预载与磁盘缓存不是同一功能,前台加载优先;5/10 秒并非本项目实测最优值 | -| 维护者讨论/B;提问者现场/D | `https://github.com/androidx/media/issues/2804`,读取正文及 2025-09-25/26 评论 | 通过首帧/buffering 时间验证收益,不应因配置调用成功就认定生效;讨论是人工切项案例,不能直接代替自然过渡验收 | -| 成熟相关项目/B | `https://github.com/FongMi/TV/blob/4afc4473e22a7ed3d98ee12233e0c2a490061000/app/src/main/java/com/fongmi/android/tv/player/exo/ExoPlayerEngine.java` 及同目录 `ExoPlayerSession.java` | `usePreloadedMediaSource()` 后仍调用 set/prepare;说明预热和队列自然过渡不同,本地不整类迁移 | -| 历史修复/A | `docs/E-SP3-exo-seek-preload-isolation.md`,Media3 修复 `c9d3bd912b90ec0ca440c28455f3e6d9bba019ea`、App 修复 `17f1a4cfe2547b8f3ddc61fab34212e77ae719ff` | 已有 seek 取消延迟、恢复水位、HLS 请求竞态证据;下一集预载不能绕过这些约束 | - -已使用 GitHub `gh` 阅读固定快照和完整 issue 正文/评论;公开网页读取官方正文,不以搜索摘要作结论。API reference 页面抓取未成功,相关 API 可用性以本地字节码和 sources 为依据;未据此臆造最新版 API 行为。 - -论文/算法 benchmark 不适用:本任务不发明解码、预测或调度算法,当前决策取决于既有 API、App 生命周期和设备对照结果。相关技术文章和现场线索已纳入;缺少本项目可比较性能数据,明确留待第 12 节,不能标注“实测无缝”或“最佳性能”。 - -## 11. 实施范围与阶段(2026-09-08 已批准) - -仅增加一条 Exo 短剧路径。用户以“本文件按计划实施”批准以下实施范围;已再次核对实际分支与脏文件,保留 `dev1`,不默认切换 `fongmi-sync` 或合并其他任务。 - -| 范围 | 最小职责 | -| --- | --- | -| `app/src/main/java/com/fongmi/android/tv/ui/player/ShortDramaQueueCoordinator.java`(拟新增) | 识别/准入、相邻集、解析结果隔离、状态机及业务提交协调 | -| `app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java`、`player/engine/PlayerEngine.java`、`player/engine/ExoPlayerEngine.java` | 默认 unsupported 的最小队列契约、Exo 入队、媒体项身份与当前上下文切换 | -| `app/src/main/java/com/fongmi/android/tv/player/exo/MediaSourceFactory.java` | 每项请求配置隔离,保留现有工厂功能 | -| 同目录 `PreCache.java`、`AutoLoadControl.java`、`ExoUtil.java`、必要时 `AutoTargetLoadControl.java`/`ExoPreloadTrafficCoordinator.java` | 复用当前缓存策略、LoadControl 准入、真实切换重绑定与竞争诊断;不泛化重构 | -| `app/src/main/java/com/fongmi/android/tv/model/SiteViewModel.java`(仅确需时) | 复用解析能力但隔离结果/取消槽;不得更改前台结果契约 | -| `app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` 与 `app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` | 区分自然过渡与显式刷新;复用共享协调器,不改 PlayerView 布局 | -| 上述对应的 `app/src/test/` 测试及唯一任务文档/索引 | 定向回归与证据 | - -若实际调用链需要扩展其他播放器页面、HTTP 层或设置模块,先解释必要性并征得新范围批准,不把它悄悄并入“只改管理类”。特别是 TMDB 内嵌播放入口,首版仅对能走上述两端 VideoActivity 的会话放量;其他入口保持旧行为。 - -阶段顺序: - -1. **契约与请求隔离**:先补队列身份、取消、headers/缓存隔离的可失败测试,核对前台解析及 LoadControl 可达分支;不启用功能。 -2. **队列正确性**:一个当前项+一个下一项,真实过渡只提交一次,两端状态同步与一次回退;实验仍关闭,queue-only 仅用于归因验证。 -3. **受控预加载**:接通用户设置、资源/取消闸门与原有 PreCache 保护;确认真实下一集加载证据及前台不受损。 -4. **对照验收与放量**:双端编译、风险场景、同源 HLS/MP4 性能对照通过后才批准首版启用。一个逻辑单元优先同 guard/原子提交;不能以阶段 2 结束冒充全部完成。 - -实施耗时不在本文虚报人日;用户批准时按当时工作树、热缓存、设备和样片可用性重新给出当前 agent 的分阶段墙钟估算。设备等待单列,文档产出时间不等于功能交付时间。 - -## 12. 验证与验收标准 - -### 12.1 最便宜的确定性验证 - -首批验证状态机/事件顺序和源隔离,不先全 ABI/全播放器构建: - -- 短剧规则:默认关键词、自定义替换、排除优先、综合源、清空回默认;任何 duration 不改变分类。 -- 队列:同集重复回调、旧 generation、移除旧项后索引变化、repeat/初始化/空项、不同时序的 discontinuity/transition/events。 -- 解析:预取不触发前台 observer、不取消手动取流;不可中断 Spider 不准入;用户连续选集后旧结果不复活。 -- 请求:A/B 不同 Referer、Cookie、UA;A 播放时 B 建源/取消,A 后续请求仍正确;同 URL 不同认证不错误命中缓存;HLS 分片与重定向覆盖。 -- 缓存与负载:旧 PreCache 任务退出前不放行新额外任务;seek/内存/后台/用户关闭时抑制;计数释放幂等、不同会话不互相借用状态。 -- 业务:上一集结束位置不写到下一集;新集标题/字幕/弹幕/历史/外部播放状态只提交一次;预加载从不写“已观看”。 - -补充测试应包含共享协调器测试和 Media3 可控时间线/假数据源的引擎桥接测试。仅测字符串/状态纯函数不足以证明真实 Player 回调时序。对已有 `PreCacheTest`、`MediaSourceFactoryTest`、`AutoLoadControlTest`、`ExoPreloadTrafficCoordinatorTest` 做必要定向回归,测试名称以实际新增类为准。 - -代码通过后按修改范围编译 Mobile/Leanback Arm64 Java;本任务不修改 native,不要求重编所有 ABI。真实播放仍需两端代表性验证,手机通过不冒充电视通过。 - -### 12.2 设备场景与正确性红线 - -| 场景 | 验收要求 | -| --- | --- | -| 同源 HLS、同源 MP4,各连续多集 | 播放器/View/Surface 无 App 主动重建;成功自动边界无 stop/clear/set/prepare;集数正确 | -| 不同 CDN/headers、MP4→HLS、AVC→HEVC | 请求不串;允许 decoder 重配置;不宣称此组零黑帧 | -| 弱网、后台/前台、暂停、seek、临近结束手动滑动 | 前台优先;暂停不被后台回调强行恢复;旧队列不干扰手动操作 | -| 下一集超时、404、401/403、签名失效、无法归属的播放错误 | 当前播放不被预取错误误杀;目标集仅接管一次,不跳集、不死循环 | -| 末集、倒序、重复/随机、片头片尾/非零起播点 | 维持既有语义;未支持模式不准入,而不是悄悄关闭用户功能 | -| 普通长视频、音频、直播、MPV/IJK/System | 旧路径不启用队列副作用;挑选受共享接口影响的代表用例回归 | -| 远端历史同步、字幕/弹幕异步迟到 | 身份正确,旧任务不能覆盖新集,不出现预取即上报观看 | - -功能红线:零串集、零串请求身份、零错误历史归属、零重复推进、零生命周期泄漏;任一失败不放量。 - -### 12.3 性能方法与候选目标 - -冻结基线/候选 App commit 和 APK SHA、设备/OS/内核/解码路径、样片、网络、缓存冷热、设置及温控。相同条件各至少 3 轮,每轮至少 30 个自然边界,记录全部数据及 p50/p95/离散程度;变异过大时不宣布改善。 - -主要指标:自然边界到下一集首帧的间隔、buffering 时长/次数、音频中断、掉帧与 A/V 同步、队列/预载命中率、失败回退率、当前集 startup/seek 延迟、PSS/Java heap、网络读量及放弃下一集的浪费量。 - -候选实验目标(测试前固定,非已有结果):同源同编码受控样片自然边界首帧间隔 p95 ≤ 300 ms;若原基线明显高于 300 ms,目标至少降低 30%。基线已经较快时以不退化为主。不得通过降低画质、当前缓冲安全线或取消功能达标。 - -startup/seek、重缓冲和稳定播放质量不得发生可重复的实质退化;超过测量噪声的恶化必须分析,不能用连播收益抵消。首轮可把 `max(50 ms, 基线的 10%)` 作为 startup/seek 告警线而非允许回归额度。内存必须达到稳定平台;连续 100 次切集后不能随集数增长,队列稳定态不超过两项;额外下载只允许一个未来项并遵守既定预算。 - -`onMediaItemTransition()` 不是首帧。优先用带媒体身份的渲染/加载事件归因;若当前版本不能逐项提供可靠首帧事件,应标记指标不可用,并用帧时间戳/录屏及音频采样补证,不能把 transition 时间当作零延迟。`getTotalBufferedDuration()` 或设置了 5 秒预载目标也不能单独证明下一集已预载 5 秒。 - -### 12.4 日志与隐私 - -复用 PlaybackTrace/现有诊断,不逐帧打日志。记录 `queue_eligible`、`resolve_next`、`queue_append`、`queue_drop_stale`、`preload_allowed/suppressed`、`media_transition_commit`、`queue_fallback`、`queue_invalidate`。 - -字段限于 generation、脱敏剧集/mediaId、reason、阶段、耗时、字节/缓冲摘要、错误类别、是否自然过渡及计数。没有加载证据时不要发 `preload_started/ready` 成功事件;无法归属时标记 unknown。禁止明文记录 URL 签名、Cookie、Authorization 或 Referer 凭据。 - -## 13. 风险、兼容性、放量与回滚 - -- 正确性/生命周期:最高风险是旧回调污染、双重推进、旧历史写入新集、预缓存未退出与新任务竞争;由身份、单线程事务和确定性取消测试约束。 -- 安全:请求头副本之外还须验证缓存、CookieJar、代理及日志隔离;来源未知时保守不准入。 -- 性能/功耗:下一集预载可能增加内存、流量与电量消耗;必须保留前台优先和用户限制,以对照结果决定放量,不先承诺提升百分比。 -- 兼容性/质量:首版范围之外不改变行为;同实例不代表同 decoder,不降低既有解码/渲染/字幕能力来换取“无感”。 -- ABI/包体/许可/供应链:本设计不升级 Media3/nextlib/FFmpeg,不改 JNI、SO、AAR、lock 或 ABI;预计只有少量 App 字节码增长,实际 APK 差异实施后测量,不声称包体零变化。引用上游设计不等于复制代码,若后续移植代码须保留对应许可与来源。 -- 维护:一个共享短剧协调器、现有引擎与工厂窄接口;不另建通用预载框架,不把架构较新的 FongMi Session 强行移入本地。 - -放量:开发/实验默认关闭 → 定向测试源启用 → 同源 HLS/MP4 两端验收 → 用户批准后启用首版范围。现有短剧源设置是分类入口,不新增另一份源名单;实验开关是发布/回退控制,不是内容识别。 - -回退:关闭实验策略并让当前会话安全失效,取消未来项及后台任务,当前集继续播放,到下一次显式/自然边界使用旧流程;不要为了关开关立即 stop 当前正常播放。代码回滚使用后续 E-SP8 实施原子提交的 `git revert`,同时回滚其桥接/策略/测试,不回滚其他任务和既有脏文件。不需要替换播放器依赖产物。 - -## 14. 实施前置条件与本轮验证记录 - -以下条件必须在批准实施后的首个单元中解决,不能默认为已通过: - -1. 每项源工厂与所有相关子请求真正隔离,缓存身份和特殊媒体状态不会跨项污染。 -2. 下一集解析结果与前台隔离,且对应 Spider/运行时不产生不可取消阻塞或并发重入;不能证明的站点不放量。 -3. 所有实际可达 LoadControl 分支、PreCache 生命周期及用户/系统闸门有一致准入,额外预載不降低当前播放保护。 -4. 两端真实 Player 回调顺序下,历史/字幕/弹幕/外部进度事务正确,并能一次回退。 -5. 真实样片和设备对照达到第 12 节标准;没有设备数据时只能报告代码/测试状态,不能宣告播放优化完成。 - -本轮已做:只读源码及本地 AAR 字节码/sources 核对、固定上游参考快照、官方资料与维护者讨论阅读;未运行 Android 构建、单元测试或设备播放。文档归档采用一次组合检查:改动文件格式、相对路径/索引互链、来源 revision 格式、未授权状态声明、检查点脚本;具体通过证据写入 task guard 提交说明。 - -本文件是 E-SP8 唯一任务记录。后续批准、实现、测试、修复、完整 commit/tag、放量和回滚都追加于此,不另建同任务的 plan/implementation/fix 文档。本轮文档提交与 annotated recovery tag 由 `E-SP8-DESIGN` task guard 生成;实际完整 ID 以该提交元数据及交付回执为准,不在提交前虚填自身 hash。 - -## Recovery anchor(2026-09-09 10:08 Asia/Shanghai) - -- 目标:按本文件已批准范围完成 E-SP8 Exo 短剧队列连播;当前仍未完成,实验保持默认关闭。 -- lane/scope:`upstream` / `E-SP8`;仅修改本文件允许的 Exo、SiteViewModel、PlayerManager、两端 `VideoActivity`、队列协调器、测试及本任务索引。 -- branch/HEAD:`dev1` / `655bf1a61e752dd5c6ccbbc3e92f92e2c6a35c02`;设计 recovery tag 已存在:`recovery/E-SP8-DESIGN/20260908211614-655bf1a61e75`。 -- protected dirty paths:任务开始时为空;当前未提交任务改动均为 E-SP8 已声明路径,无预存脏文件混入。 -- 已完成证据:MediaSource 前台按请求 headers 建立独立工厂;cache namespace 为 header 的 SHA-256;`PreCacheHelper` 通过 scoped `Cache` 使用同一 namespace;`SiteViewModel` 已有独立串行下一集解析 executor/future/callback,绝不写入前台 `player` LiveData;`ShortDramaQueueCoordinator` 已实现 generation、顺/倒序、当前/下一项有界队列、一次性 AUTO transition、准入拒绝和过期回调丢弃;`PlayerEngine`/`ExoPlayerEngine`/`PlayerManager` 已增加默认 unsupported 的只追加、未来项移除及自然过渡上下文提交契约;`ShortDramaQueueCoordinatorTest`(5 项)与 `MediaSourceFactoryTest`(5 项)已通过 Mobile Arm64 定向单测;`git diff --check`、guard `check`、Mobile Arm64 Java 编译通过。 -- 未完成/风险:两端 `VideoActivity` 尚未接入协调器与独立解析;自然过渡后的历史、字幕、弹幕、外部播放状态事务尚未验证;受控 playlist preload 尚未接通;尚无 SiteViewModel 取消隔离测试、真实 Media3 fake timeline/bridge 测试、Leanback 编译或设备对照;尚未创建 E-SP8 实施 commit/最终 recovery tag。 -- rollback anchor:`655bf1a61e752dd5c6ccbbc3e92f92e2c6a35c02`;保持不改依赖/native/lock。 -- **唯一下一步:在 Mobile 与 Leanback `VideoActivity` 接入默认关闭的短剧 Exo 队列事务;先实现当前项首帧后的下一项独立解析、普通 HLS/MP4 入队、AUTO transition 身份校验与一次性业务提交,失败即回退旧路径。** - -## Recovery anchor(2026-09-11 20:14 Asia/Shanghai) - -- 目标:完成 E-SP8 当前实现的复评、修复、验证,并在合并 `origin/beta` 最新代码后继续复评;实验仍默认关闭。 -- lane/scope:`upstream` / `E-SP8`;当前 guard 仍绑定 `dev1@655bf1a61e752dd5c6ccbbc3e92f92e2c6a35c02`,未提交改动仅在既定 E-SP8 scope 内。 -- 远端基线核对:已 fetch `origin/beta`,当前头为 `be1b02e06b22a4fa2f08c791555536e3e6154c95`;尚未把该头写入本分支,避免在 E-SP8 guard 未关闭时污染其封闭 scope。 -- 复评发现及修复:自然媒体项过渡时,原实现先由 `PlayerManager.commitPlaylistTransition()` 替换当前 `PlaySpec`,再发送上一集停止事件,可能使停止记录读取下一集地址/位置;两端现已在 commit 前发送停止事件,并移除 commit 后的重复发送。 -- 已完成验证:`git diff --check` 通过;Mobile Arm64 定向 `ShortDramaQueueCoordinatorTest`、`MediaSourceFactoryTest`、`MediaSourceFactoryIsolationTest` 通过(2026-09-11 20:12,`BUILD SUCCESSFUL in 46s`);Leanback Arm64 Java 编译通过(2026-09-11 20:14,`BUILD SUCCESSFUL in 40s`)。构建仅报告既有 CXX5202 32 位 native library warning。 -- 尚未完成:E-SP8 实施 commit/tag 尚未生成;尚未在合并 beta 后复评 beta 新增提交与冲突组合结果;尚无真实设备连续切集/网络争用/字幕与弹幕时序证据。 -- rollback anchor:`655bf1a61e752dd5c6ccbbc3e92f92e2c6a35c02`;已另存当前 E-SP8 工作树临时补丁于 `/tmp/webhtv-esp8-before-beta-20260911/`,不纳入仓库。 -- **唯一下一步:用当前 guard finish 提交并标记已验证的 E-SP8 原子改动;随后以新的 beta-sync guard 合并 `origin/beta`,评审合并差异及已提交未推送提交。** - -## Recovery anchor(2026-09-11 23:22 Asia/Shanghai) - -- E-SP8 原子提交已完成:`2b22c5240d52a8c2054299326f44fee6743ab26f`,recovery tag 为 `recovery/E-SP8/20260911201514-2b22c5240d52`。 -- 当前合并任务为 `beta-sync-review-dev1-20260911`(`standard`),在 `dev1@2b22c5240d52a8c2054299326f44fee6743ab26f` 上无冲突合入 `origin/beta@be1b02e06b22a4fa2f08c791555536e3e6154c95`;冲突组合保留 beta 的 EXO 上游恢复/速度选择/HLS 分类改动,并保留 E-SP8 的媒体 ID、独立取址、header-cache namespace 与队列事务。 -- 合并后定向验证:Mobile/Leanback Arm64 Java 编译及播放器/EXO/UI-player Mobile 单测(1263 项)`BUILD SUCCESSFUL in 50s`;Mobile Arm64 Debug APK 组装 `BUILD SUCCESSFUL in 54s`;`git diff --cached --check` 与 task guard check 通过。 -- 设备启动证据:V1923A(API 28)安装并启动 Mobile Debug APK 成功,首页与短剧内容卡片可见、无应用 `FATAL EXCEPTION`。详情页尝试随后被外部 `pid 5484` 执行 force-stop 中断;没有可重复的短剧站源/自然连续切集链路,因此不将该运行记录提升为播放优化或正式放量验收。 -- 当前状态:**E-SP8 代码实现和合并后源码复评通过;实验默认策略及正式放量前的连续切集、网络争用、字幕/弹幕和 Leanback 实机验收仍保持未完成。** -- 回滚:代码层面可使用已有 E-SP8 recovery tag;当前 beta 合并提交完成后另以本 task 的 recovery tag 回滚整合层。 -- **唯一下一步:完成 `beta-sync-review-dev1-20260911` 的 merge commit/tag,推送 `dev1` 并创建目标为 `beta` 的中文 PR。** - -## Recovery anchor(2026-09-12 01:00 Asia/Shanghai) - -- 现场根因:短剧队列资格错误使用 `isDomainEnabled(EXO)`,这会在稳定策略默认关闭内部实验时直接拒绝队列,因此用户无法看到下一集预解析、播放列表追加和受控预载。 -- 修复:新增生产自动优化动作 `EXO_SHORT_DRAMA_QUEUE`,Mobile 与 Leanback 改为通过 `isAllowed(EXO_SHORT_DRAMA_QUEUE)` 准入;安全条件、短剧源识别、Exo、预载设置、协议及恢复位置等既有门槛保持不变。 -- 非目标保持不变:不会把多个剧集文件合并成一个文件;增强路径仍使用两个独立 MediaItem 的有界播放列表。 -- 验证结果:`testMobileArm64_v8aDebugUnitTest` 中的 `PlaybackExperimentPolicyTest` 与 `ShortDramaQueueCoordinatorTest` 通过,Mobile 与 Leanback Arm64_v8a Debug Java 编译通过;Gradle 报告 `BUILD SUCCESSFUL in 20s`,仅有既有 CXX5202 与弃用 API 警告。 -- 唯一下一步:执行 task guard 检查并提交本次准入修复;真实设备连续切集、网络争用、字幕与弹幕时序仍须后续验收。 diff --git a/docs/E-SP9-exo-unsupported-video-soft-fallback.md b/docs/E-SP9-exo-unsupported-video-soft-fallback.md deleted file mode 100644 index 7cf7a7b414..0000000000 --- a/docs/E-SP9-exo-unsupported-video-soft-fallback.md +++ /dev/null @@ -1,44 +0,0 @@ -# E-SP9:Exo 硬解模式禁止自动软解回退 - -状态:已回滚,保持“视频只能人工手动切换硬解/软解”的既有合同(2026-09-20,Asia/Shanghai) - -## 目标与验收 - -- 复评提交 `4a9f0fd14d04aa5f45c8601d9f77334666b435de` 是否违反现有播放合同。 -- 结论:该提交让硬解模式在无硬件候选时自动使用 FFmpeg 软解,属于自动软解回退,与 `C-AVS3` 已确认的“视频只能人工手动切换硬解/软解,绝对禁止自动切软解”合同冲突。 -- 修复:恢复硬解模式不注册任何 FFmpeg 视频 renderer,音频独立回退策略不变。硬件模式无候选时保持原有行为,用户需手动切换软解。 - -## 现场证据 - -- 用户给出的最后可用发布为 `v5.6.0-beta-202609141608`,目标提交 `7e8fdbd4c2c27c141f89ff2e9a84aaf7f52a6966`;首次失效发布为 `v5.6.0-beta-202609160047`,目标提交 `32640b193f7585353d3120c236b932afa1548165`。 -- 两个发布之间的播放器链路差异是 Exo HLS DataSource 包装,入口提交为 `6bc447a14c1334100bd52a3cb5577f4c95a29c4c`;该差异没有修改 `ExoUtil` 的渲染器选择。 -- 5561 现场 URL 为 `http://45.192.97.170:8880/play/1.m3u8`,302 后得到咪咕 H.265 主清单;媒体清单为 4 个 `6s` 分片。 -- 同一现场日志中,Media3 解析出 `video/hevc`、`hvc1.1.2.L153.80`、`1280x720`,但报告 `supported=NO_UNSUPPORTED_SUBTYPE`;AAC 音频被选中并初始化,链路没有视频解码器初始化、视频尺寸或首帧事件。UI 因此一直停留在 shutter,形成“有声音没画面”。 -- `ExoUtil.getVideoRenderMode()` 在硬解时返回 `EXTENSION_RENDERER_MODE_OFF`,`buildVideoRenderers()` 因此直接返回,不注册任何 FFmpeg 视频渲染器。MediaCodec 没有候选解码器时不会产生可归因的播放错误,音频继续推进。 - -## 方案比较与决策 - -- 方案 A(不改 Exo 硬解分支):硬解模式下无硬件候选时保持无声画面;用户手动切到软解即可播放。与既有合同一致。 -- 方案 B(原提交 `4a9f0fd14d04aa5f45c8601d9f77334666b435de`):硬解模式注册 `CompatFfmpegVideoRenderer(systemDecoderFallbackOnly=true)`,无硬件候选时自动由 FFmpeg 软解接管。画面恢复,但属于自动软解回退,违反 `C-AVS3` 已确认合同。 -- 方案 C(提示而不接管):无硬件候选时向用户提示并引导手动切软解。这是可选的产品改进,需要独立的 UI/状态设计,不在本次修复范围。 -- 决策:采用方案 A,回滚方案 B;不引入方案 C。现有 `CompatFfmpegVideoRenderer` 本体的“平台有候选时让轨”合同保留,仅撤销 `ExoUtil` 中的自动注册。 - -## 验证计划 - -1. 运行 `CompatFfmpegVideoRendererSourceTest`,覆盖硬解模式不注册 FFmpeg 视频回退的源码合同,以及 `CompatFfmpegVideoRenderer` 本体的让轨合同。 -2. 运行 `MpvHardwareDecodePolicyTest` 和 `ExoAudioCodecSelectorTest`,确认 MPV 硬解禁止自动软解、Exo 音频独立回退未受影响。 -3. 编译 Mobile ARM64 Debug;如需设备确认,走 `bash scripts/build_arm64_debug_install.sh --serial 192.168.50.3:5561` 覆盖安装,验证硬解模式确实不出现自动软解出帧,手动软解仍可用。 - -## 实施边界与回滚 - -- 只修改 `app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java`、定向源码合同测试和本任务文档/索引。 -- 不修改 Media3 AAR、nextlib、FFmpeg、MPV、native lock、输出面或音频路由。 -- 回滚:本提交的父提交是 `4a9f0fd14d04aa5f45c8601d9f77334666b435de`;撤销本回滚即可恢复该提交的自动软解行为。 - -## 实施记录 - -- 2026-09-20:确认 `4a9f0fd14d04aa5f45c8601d9f77334666b435de` 自动软解回退违反 `C-AVS3` 既有合同;开启 `E-SP9-manual-decode-contract` guard 进行窄回滚。 -- 回滚实现:移除硬解模式下的 `CompatFfmpegVideoRenderer` 注册和对应构造帮助方法,恢复 `if (videoRenderMode == EXTENSION_RENDERER_MODE_OFF) return;`。 -- 定向测试更新:`CompatFfmpegVideoRendererSourceTest` 现在断言硬解模式不注册 FFmpeg 视频回退,同时保留 `CompatFfmpegVideoRenderer` 本身“平台有候选时让轨”的单元合同。 -- 回滚原因:设备的“有声音无画面”不是原提交可绕过的自动软解场景;正确操作是用户手动切换软解。若需要改善硬解无候选的提示文案,应作为独立产品需求评估,不在本任务中改变解码选择。 -- 回滚锚点:本提交的父提交 `4a9f0fd14d04aa5f45c8601d9f77334666b435de`。撤销本回滚即可恢复该提交行为。 diff --git a/docs/E11-exo-compressed-audio-direct.md b/docs/E11-exo-compressed-audio-direct.md deleted file mode 100644 index 3e6ff108b2..0000000000 --- a/docs/E11-exo-compressed-audio-direct.md +++ /dev/null @@ -1,574 +0,0 @@ -# E11 Exo 压缩音频输出与跳转生命周期 - -## Recovery anchor(2026-09-21,首次起播快速恢复实施) - -- Objective / acceptance:首次新视频也能受益;厂商压缩输出已具备启动供数却不推进时,用短观察窗触发 PCM,并复用当前媒体与样本队列。保留开关修复、标准 offload/HDMI、用户解码偏好、音画同步与正常播放性能。 -- User decision:用户明确“根据最佳实践方案进行优化”,随后限定“仅优化,不联机测试。尽快落地最佳实践方案”;2026-09-21 续接时明确“测试忽略”“继续”,因此跳过剩余策略测试,使用已有集成测试与构建结果完成代码交付,不连接设备、不把硬件时延写成已验证。 -- Lane / guard:`upstream` / `E11-first-playback-recovery`;开始实施 17:22 Asia/Shanghai,预计实现 15 分钟、定向故障注入/TV32 构建 10 分钟、闭合 5 分钟,目标约 17:52。 -- Branch / baseline:`main` / `24fa078d2dc8a404fad23fc30972e5fc4a8a1b5b`;回滚锚点 `recovery/E11-audio-passthrough-switch/20260921163519-24fa078d2dc8`。保护预存 `app/.cxx/` 104 个文件;会话恢复后新增的 `.codex-resume/` 与 `codex-resume` 同样保持原样并排除出提交。 -- Scope:`ExoCompressedAudioDirectPolicy.java`、`ExoUtil.java`、新增 `ExoStartupAudioRenderer.java`、对应 policy/renderer 主机测试及 `testLeanback` 恢复集成测试、本文/索引;不改依赖、native、网络或视频选路。 -- Decision:沿用下方已完成的跨项目研究,选择 Media3 现有 recoverable renderer error 的恢复入口;不伪造 AudioTrack DEAD_OBJECT,也不复制整套 renderer factory。用 `ForwardingRenderer` 包装原平台音频 renderer,保留其构造选项/排序。800 ms 是有充分供数和有效 play 意图后的初始观察值,不是无条件起播等待。 -- Startup fill:API 31+ 仅对自定义 vendor 输出,在完整 AU 已交付约 200 ms 音频后,允许降低实际启动门槛至已交付字节数并读回,保持 256 KiB 稳态容量。沿用发货 Media3 的 AAC/MP3 每 AU 帧数定义;未知/失败读回不当作填满,暂停、flush、stop、换路由及旧 attempt 不可触发提前恢复。 -- Source proof:发货 Media3 `e3e922d5c01bc0b564849940fe589daf37360d15` 的 `ForwardingRenderer` 完整委托;`ExoPlayerImplInternal.attemptRendererErrorRecovery → reselectTracksInternalAndSeek → seekToPeriodPosition` 复用当前 prepared MediaPeriod;`ProgressiveMediaPeriod.seekInsideBufferUs` 能在现有样本队列定位。该恢复会 disable/enable renderer,**不是音频独占重启**;缓存不足、live 或 keyframe 不在队列时仍可能重新加载。WebHTV 正常 back-buffer 设置保留关键帧,最终以真实 Media3 故障注入核验同 period/读取复用。 -- Alternatives / risk:维持 10 秒只作通用兜底无法满足首次体验;直接 App prepare 已被日志证明额外消耗 3–9 秒;自建压缩 AU 重放/直接操作内部 renderer 生命周期引入额外拷贝与状态风险,本轮采用现有恢复合同。短启动门槛不缩容量或改变稳态写入节奏;不做跨媒体永久禁用或并行建轨。 -- Completed implementation:policy 已实现完整 AU 供数统计、启动门槛调整与读回、800 ms 窗口、原始播放头/实际路由复核及每 attempt 一次恢复;`ExoStartupAudioRenderer` 包装原平台音频 renderer,在原播放线程发出明确类型的可恢复异常。内部恢复后的 PCM 进度继续用于确认既有失败记忆,不再要求 App 整项重新 prepare。 -- Verification:已有 3 项真实 Media3 播放流程集成用例通过(首次停滞、断点起播停滞、健康直出),TV32 Debug 构建通过(44 秒),APK v2 签名与包/ABI 检查通过。集成用例使用受控 extractor/renderers/audio outputs;不能替代真实解码器或 HAL 验收。策略补跑因临时测试 source set 未发现目标用例而未执行,用户已要求忽略剩余测试,不能记录为全量回归通过。 -- Evidence / artifact:`/private/tmp/webhtv-E11-recovery-wx7chb0j/` 保存 `gradle-tested.log`、3 项用例的 JUnit XML、`gradle-policy.log` 及 APK 检查记录;集成测试快照与当前源码一致。TV32 APK 为 162,518,123 bytes,SHA256 `15c31a01404a1e1678020cecb20df5085508b67ccd880904fb9ef02072654fd5`。 -- Closure / limitations:代码实现和已有构建完成,按用户要求跳过剩余测试,以本 guard 一次原子提交及 annotated recovery tag 收尾,不推送。800 ms 是数据和状态条件成立后的判错窗口,未知门槛/路由、正常缓冲或 seek 等仍保留原通用兜底;不宣称真实 Sony 2 秒恢复或性能不回退已经实测。跨媒体失败记忆扩展和音频独占重启未纳入本次实现。 -- Next action:使用已有证据关闭 `E11-first-playback-recovery` guard,提交本轮源码、测试及任务记录并创建本地恢复标签;不再运行测试或构建。 - -### 首次起播快速恢复交付记录 - -- `ExoUtil` 将同一个 policy 交给 sink 与 renderer 包装,只包装已有平台音频 renderer,保留原构造参数、扩展 renderer 排序、解码器偏好和直通开关。没有修改依赖、native 库、视频选路或网络实现。 -- 自定义压缩输出在完整 access unit 对应至少约 200 ms 音频时,可降低实际启动门槛至已接收数据量,并以读回值为准;不缩小稳态容量。满足供数、播放状态、位置仍为零等条件后观察 800 ms,再检查原始播放头、路由和实际门槛。进度已推进、暂停、flush/stop、输出替换、路由失效或读回未知时不会按旧证据提前切换。 -- 恢复通过 Media3 recoverable renderer error 复用现有 MediaPeriod 和可定位的样本队列,避免 App `startInternal` 重新准备媒体源。该入口会重新启用音视频 renderer,不能称为“只重启音频”;缓存不足、live 或缺少关键帧时仍可能重新加载。已有 10 秒通用检测保留为不满足早期判定条件时的兜底。 -- 3 项集成用例已验证:首次和断点起播的静止输出均切换到 PCM,媒体源 prepare 与 period 创建各一次,恢复不新增数据源打开,首段待播音频会被重放;健康直出不创建 PCM、不重选 renderer。用例还断言受控场景在 2 秒以内进入 PCM,**不等同于实际电视在 2 秒以内恢复出声**。 -- `gradle-policy.log` 的失败是 `No tests found for given includes`,属于临时测试目录配置问题,没有策略断言结果。此前“定向测试通过”应仅理解为上述 3 项集成用例;策略/失败记忆/输出归属的剩余回归依用户最新要求跳过,保留测试源码供后续使用。 -- APK 路径:`app/build/outputs/apk/leanbackArmeabi_v7a/debug/app-leanback-armeabi_v7a-debug.apk`,`com.fongmi.android.tv`,560 / 5.6.0,唯一 ABI 为 `armeabi-v7a`。在本次提交前构建,应按上方 SHA256 识别;没有安装或联机测试。 -- 代码、测试与本记录在 guard `E11-first-playback-recovery` 内原子提交,紧接着创建唯一 annotated local recovery tag;完整提交与 tag 以 guard 的 Git 记录为准。回滚可 revert 该单一提交,恢复到已修好直通开关的 `24fa078d2dc8a404fad23fc30972e5fc4a8a1b5b` 行为。 - -## 历史 Recovery anchor(2026-09-21,音频直通开关修复) - -- Objective / acceptance:关闭 Exo 音频直通后直接使用 decoder + PCM,不再选择 vendor-direct、标准 encoded passthrough 或 compressed offload;保持 PCM 输出归属、隧道 PCM、开启时正常 direct/offload、失败记忆、音视频同步及用户解码偏好。 -- User decision:用户明确要求顺道修复开关;此前首次快速起播研究已完成,新方案未被冒称已实现。 -- Lane / guard:`quick-fix` / `E11-audio-passthrough-switch`。 -- Branch / baseline:`main` / `e9fef2b90ee8aa634327c2c179eb3dc5d22cb1be`;研究 recovery tag `recovery/E11-first-playback-research/20260921160724-e9fef2b90ee8`。 -- Scope:`ExoUtil.java`、`ExoCompressedAudioDirectPolicy.java`、`ExoCompressedAudioDirectPolicyTest.java`、本文/索引;保护既有 `app/.cxx/` 104 个文件。 -- Completed actions:在 renderer 创建时将真实 Exo 设置传给 policy;track selector 的 offload 偏好同步受控。policy 在 offload/format/config/输出创建处检查设置,清理过时 vendor 能力缓存;保留所有输出的原状态包装。5 项新增用例覆盖禁用时零查询/零建轨、标准 encoded/offload、过时配置、PCM/tunneled PCM 和重新开启。 -- Verification:53 项定向用例通过(policy 46、输出归属 7,0 failure/error/skipped),TV32 Debug 构建一次通过,Gradle 用时 41 秒;测试快照与当前源码一致。APK ZIP CRC、armeabi-v7a 唯一 ABI、包标识和 v2 签名通过。本轮未调整任何超时、实际缓冲或媒体重建方式。 -- Evidence cache:`/private/tmp/webhtv-E11-first-playback-khaovka_/`,新 host init/测试快照与完整 Gradle 日志;native 暂存复用上一轮 `/private/tmp/webhtv-E11-admission-c18ee9ju/cxx`,不触碰预存 app/.cxx。 -- Risk / rollback:开启直通的首次坏路径仍可能等待原 10 秒检测,B/C 仍未实现;开关修复单独 commit/tag,可独立 revert。无设备时不宣称 Sony 实播或性能已验收。 -- Next action:关闭本开关修复 guard 后,用最终 APK 在目标 Sony 关闭直通播放原片,核对 decoder/PCM 与实际推进;开启直通的短窗及局部恢复按下方方案另行继续,不能把本轮开关修复当作 B/C 已完成。 - -### 开关修复交付记录 - -- 真实设置到选路:`ExoUtil.buildTrackSelector` 在关闭时使用 `AUDIO_OFFLOAD_MODE_DISABLED`;`buildAudioSink` 把同一 Exo 设置送入 `setAudioPassthroughEnabled`。policy 的标准 offload、encoded format、encoded output config 与最终 AudioTrack 创建均受控;关闭时清除已缓存 vendor 能力,避免旧查询绕过。不是仅修改 UI 标签,也没有全局强制 FFmpeg。 -- PCM 仍经原包装发布/释放 `ExoAudioOutputState`;tunneled PCM、正常开启时的标准 offload/vendor、既有失败记忆与迟到回调保护的测试保持。用户在播放内的配置刷新继续通过已有 `PlaybackPerformanceDialog.refresh` 的 `ConfigEvent.playerPerformance()` 与 callback 处理,本轮不新增热切换状态机。 -- 新增 5 项测试覆盖:关闭时 vendor/offload 查询和 vendor 建轨均零调用;标准 AAC/MP3/AC3/EAC3/DTS/TrueHD encoded 能力被拒绝;先前 cached/standard-offload/tunneled-encoded 配置不能创建;PCM/tunneled PCM 正常委托并维护快照;重新开启恢复候选。其余 41 项 policy 用例和 7 项输出状态用例形成 53 项最终回归。 -- 构建命令:`bash gradlew -I /private/tmp/webhtv-E11-first-playback-khaovka_/switch-tests.init.gradle :app:testLeanbackArmeabi_v7aDebugUnitTest --tests com.fongmi.android.tv.player.exo.ExoCompressedAudioDirectPolicyTest --tests com.fongmi.android.tv.player.exo.ExoAudioOutputStateTest :app:assembleLeanbackArmeabi_v7aDebug --console=plain`。仅执行一次,完整输出 `gradle-switch.log`;对应 JUnit XML 与测试源码快照已比对,没有重跑不相关错误分类/全部 ABI/native 依赖测试。 -- APK:`app/build/outputs/apk/leanbackArmeabi_v7a/debug/app-leanback-armeabi_v7a-debug.apk`,162,518,123 bytes,SHA256 `5a05a6f7009dc1f323d7579053d4ca53437508d56073c57b0d1915a916ca8622`;`com.fongmi.android.tv`,560 / 5.6.0,仅 `armeabi-v7a`。ZIP CRC 和 APK v2 签名通过。证据 `switch-result.json`、`apk-switch-signature.txt`、`apk-switch-badging.txt` 在当前证据目录。 -- APK 在代码提交前构建,诊断基线为研究提交 `e9fef2b90ee8aa634327c2c179eb3dc5d22cb1be` / dirty,应按最终 SHA256 识别。`adb devices -l` 仍无设备,本轮未安装,未验证 Sony 实际秒数或健康设备性能。关闭开关后的控制流已不进入坏 vendor 路线;开启时原 10 秒兜底仍存在,不能声称全部首次起播问题已解决。 -- 本修复源码/测试/本文/索引为一个原子提交,guard `E11-audio-passthrough-switch` 立即创建 annotated recovery tag;具体提交和 tag 以本次 Git 记录为准。保护既有 104 个 `app/.cxx/`,不推送,不发布。 - -## 历史 Recovery anchor(2026-09-21,首次起播与快速恢复再研究) - -- Objective / acceptance:首次播放的选路、实际输出确认和错误切换都计入起播时间;10 秒全局卡死异常不能作为常见音频故障的主恢复机制。保留健康直出、声道/音质、用户偏好、音画同步、隧道、视频手动解码和稳定播放性能。 -- User decision:用户要求继续深度研究跨平台论文、文档、issues 和项目实现,并追加明确实施需求“音频直通开关没起作用,关了还是优先走的直通”。此前 A/B 授权继续有效;新的默认选路/跨媒体经验/音频局部恢复方案在下节分别标出边界。 -- Lane / guard:`assessment` / `E11-first-playback-research`;仅本文和主索引。研究从 15:17 Asia/Shanghai 预计 25 分钟,后因调用链复核及追加开关修复延长;15:52 收敛查询,研究和开关修复的合并目标约 16:15。 -- Branch / baseline:`main` / `123d871c027eb686702766bca995f616e8d2bd1e`;A 已提交,恢复 tag `recovery/E11-audio-startup-admission/20260921150255-123d871c027e`。保护原有 `app/.cxx/` 104 个文件。 -- Completed evidence:A 只有同媒体/音轨/路由确认失败记忆,首次故障仍有 10 秒检测和约 3–9 秒重建成本;`ExoUtil.buildAudioSink/buildTrackSelector` 只把 passthrough=false 转为空 Context,仍安装 vendor policy 且启用 offload,已建立开关失效原因。下节已补读 Chromium/mpv 早期输入重放、GStreamer preroll/候选、Android 启动门槛、Envoy 分层失败隔离、AWS 超时文章及《The Tail at Scale》作者版正文。 -- Evidence cache:`/private/tmp/webhtv-E11-first-playback-khaovka_/`,全文/源码、URL/时间/SHA256 元数据;此前证据继续保留,不重新评估已完成的上游范围。 -- Plan status:研究方案已收敛;本 guard 没有生产改动、构建或设备测试。追加的开关修复将在本研究闭合后用独立 `quick-fix` guard 实施,范围见下节。B 的短窗和 C 的数据保留仍不是已实现能力。 -- Rollback:本文/索引为独立研究提交;当前可执行生产基线仍为 A。追加开关修复独立提交,可单独 revert。 -- Next action:关闭本研究 guard,随即实施已获明确授权的音频直通开关准入修复并运行对应主机回归与 TV32 构建。 - -## 2026-09-21 首次播放必须快速成功:补充证据与修订方案 - -### 纠正目标与当前缺口 - -旧方案 A 提升了准入一致性和部分重复播放的成功率,但没有完成首次起播优化。旧 B 即使把等待从 10 秒变成 2 秒,仍可能叠加日志中约 3–9 秒的媒体重建,不能据此交付“快速起播”。最终验收须覆盖**首次新视频、首次新音频配置、选错路径后的恢复全过程**,同时记录点击到播放的总时延与各阶段时延;不能只报首帧或解码器初始化速度。 - -最新用户追加的开关故障与原日志直接关联。现基线 `ExoUtil.buildAudioSink` 读取 `PlayerSetting.isAudioPassThrough(EXO)` 后只决定 `AudioTrackAudioOutputProvider.Builder` 的 Context 是否为空;之后仍无条件注册 `ExoCompressedAudioDirectPolicy` 的 offload provider、builder modifier 和输出包装。`buildTrackSelector` 又无条件设置 `AUDIO_OFFLOAD_MODE_ENABLED`。因此 `passthrough=false` 不是最终压缩输出的准入条件。修复应让关闭设置直接选择解码后 PCM,保留平台硬件/平台软件/FFmpeg 的现有优先级;这能在控制流上消除该设置下的首次 vendor 试错,尚不能用主机测试宣称 Sony 实际起播耗时已经测得。 - -### 新增来源与适用限制 - -访问日期均为 **2026-09-21**。等级沿用 Skill:A 为正式合同/准确源码,B 为维护者或成熟项目设计,C/D 为类比或未验证现场线索。本轮未选取新的依赖合并提交;下列固定修订全部只作研究引用,不 cherry-pick。 - -| 来源与版本 | 已读证据与等级 | 对 WebHTV 的决定影响及限制 | -| --- | --- | --- | -| [Chromium DecoderStream](https://chromium.googlesource.com/chromium/src/+/c52f26bf4995863398cbdd1a7cabc3dd45aad973/media/filters/decoder_stream.cc) 与 [DecoderSelector](https://chromium.googlesource.com/chromium/src/+/c52f26bf4995863398cbdd1a7cabc3dd45aad973/media/filters/decoder_selector.cc),`c52f26bf4995863398cbdd1a7cabc3dd45aad973` | A/B:`Decode` 在首个成功解码输出前保留 `pending_buffers_`;初始化成功仍不是最终成功。初始解码报错后作废旧异步回调,继续有限候选,向备用 decoder 重放缓冲,首个有效输出才 `FinalizeDecoderSelection` 并清保留数据。另读同修订 `audio_decoder_stream_unittest.cc` 的配置变更/flush 测试。 | 借鉴“暂定选择→实证成功→提交选择”和旧回调隔离/输入重放。该测试不是 AudioTrack 停滞恢复测试,Chromium 的成功点是解码输出,不是扬声器实际发声;本项目必须把确认延伸到音频输出时钟。模板源码也适用于视频,但不据此改变本项目视频手动软解合同。 | -| [mpv vd_lavc.c](https://github.com/mpv-player/mpv/blob/e76a35ec95b27f5cf2d27b043b5e2e0d90e468ae/video/decode/vd_lavc.c),`e76a35ec95b27f5cf2d27b043b5e2e0d90e468ae` | A/B:`send_packet` 在硬解探测期有界保留至多 32 个输入 packet;`receive_frame` 在失败后尝试下一候选,重放 `requeue_packets`;首个交付帧后结束 probing 并释放保留 packet。 | 支持有界保留“已做的工作”,无需从 URL 再启动整个解封装。32 个视频包不是适合 AAC 的容量或时限;此代码不解决 Android 压缩 sink 未报告错误的情况,不能直接移植或声称本项目已能音频局部切换。 | -| [GStreamer preroll](https://gstreamer.freedesktop.org/documentation/additional/design/preroll.html) / [decodebin](https://gstreamer.freedesktop.org/documentation/playback/decodebin.html),访问日官方页面 | A/B:preroll 先准备样本,收到 buffer/GAP/EOS 分别处理;flush/状态变化有明确解除等待语义。候选可按 rank/应用规则过滤与排序,逐个 TRY/SKIP。 | 以实际阶段就绪替代把 READY/首帧当作整条链成功;候选提前准备,不默认同时启动三个占硬件的输出。EOS/短片不能永远等“填够”或被当成坏设备。 | -| [Android 12 AudioTrack](https://github.com/aosp-mirror/platform_frameworks_base/blob/cebf5c06997b64f4e47a1611edb5f97044509d76/media/java/android/media/AudioTrack.java),`cebf5c06997b64f4e47a1611edb5f97044509d76`;[当前正式 API](https://developer.android.com/reference/android/media/AudioTrack#setStartThresholdInFrames(int)) | A:初始 start threshold 默认是实际 capacity;压缩输出这些 API 的 frame 单位是 byte。API 31+ 可单独 `setStartThresholdInFrames`;而 `setBufferSizeInFrames` 明确仅支持 PCM。实际值可能被钳制、随路由变更,必须读回。 | **新增可选方向:分别管理起播门槛和稳态容量**。不要把固定 256 KiB 当作必须填完才配享有早回退的产品条件,也不要通过不支持的压缩 buffer setter 硬缩容量。短启动门槛须根据完整音频 access unit/可播放时长设定并实测 underrun/功耗,不全格式写死字节数。 | -| [The Tail at Scale 作者全文](https://www.barroso.org/publications/TheTailAtScale.pdf),Dean/Barroso,CACM 2013,DOI `10.1145/2408776.2408794` | B,跨场景迁移为 C:本轮已取得并读作者 PDF 正文 pp.74–80,SHA256 `fffb9132a10c001692f3017ad2ef6fd872a96851638554497ebd23dfbefcc0da`。区分单次请求的立即适应与跨请求长期适应;讨论延迟启动备用、取消重复工作及 latency-induced probation;强调竞速仅在拖慢因素不同时影响副本时有效。 | 失败记忆不能代替首次快速恢复;只看均值或少量成功快路径会遗漏长尾。音频的多条路径共用 CPU/HAL,且输出有不可重复的声音副作用,不具备论文副本独立假设,因此不以论文授权无条件三路竞速。旧研究“仅读摘要”限制由本轮补读解除,论文中的网络数值不转成音频阈值。 | -| [AWS Builders' Library:超时、重试和抖动回退](https://aws.amazon.com/cn/builders-library/timeouts-retries-and-backoff-with-jitter/),Marc Brooker,访问日全文 | B/C:从可接受误报率和健康延迟分布选超时;端到端超时必须覆盖实际工作;多层独立重试会放大负载,最高层重试又会浪费已做工作。文章也明确警告断路器的模态行为与额外测试成本。 | 一个播放 attempt 共用恢复预算和恢复 owner;不能每换一条音频路径重新拿 10 秒,也不能音频/engine/网盘各自盲重试。借鉴预算与分层归因,不为单机音频恢复增加指数退避、随机延迟或后台联网。 | -| [Envoy outlier detection](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/outlier),访问日官方页面 | A/B,音频类比为 C:区分请求本身的错误与本地链路错误;有界暂时摘除/恢复;共享实例的失败状态会影响其它调用方;只通过浅层 active health check 可能错误地提前恢复流量。 | 媒体解码/内容错误保留媒体级隔离;只有充分的输出层证据才能提升为 route+format 经验。重新查询“支持”不能清除已实证的失败;恢复资格须由实播重新验证。 | -| [NuvioTV PR #3107](https://github.com/NuvioMedia/NuvioTV/pull/3107),读取 PR 正文及 diff 快照,SHA256 `c1ac441f93f640ad1303828131f34184aa3e41b6e8d27985a4167ece7c4bc066` | C/D:访问时为 DRAFT;报告大压缩 buffer 与某些 IEC AudioTrack 创建耗时,提出按格式 buffer 上限、后台 probe、内容时间 pacing 等多个改变。 | 仅作为现场线索,不作为成熟修复或合并候选。它排除 AAC 的 pacing,涉及不同 HDMI 格式/模式;不能复制 200 ms、buffer 上限、时钟钳制或后台真建轨来修 Sony。按引用快照保留,不把未固定最终 commit 的草稿当成可移植基线。 | - -启动门槛还交叉核对了 `android-12.0.0_r1` 的 [native AudioTrack::setStartThresholdInFrames](https://android.googlesource.com/platform/frameworks/av/+/android-12.0.0_r1/media/libaudioclient/AudioTrack.cpp) 和 [AudioFlinger Track::isReady](https://android.googlesource.com/platform/frameworks/av/+/android-12.0.0_r1/services/audioflinger/Tracks.cpp):setter 调用共享 proxy 并在降低门槛时处理 disabled 状态;服务端按 `min(startThreshold, bufferSize)` 再钳制到实际 capacity 判断填充。这里没有在 setter 处禁止 compressed/direct 的分支。这证明它不同于仅 PCM 可用的 buffer-size setter,但不证明 Sony HAL 无额外缓冲/锁定延迟;只改 API 门槛不能冒称必然有声。 - -本轮普通 Bing RSS 返回与查询不相干的结果,未用作证据;Google 搜索页没有可用结果;GitHub 部分 core API 达到匿名限额。已改用官方 Gitiles、原始固定源码、项目文档、GitHub issue/PR 页面与作者 PDF。未获取的页面不算已审阅。旧 Media3 #2258/#3122/#3269 与 mpv/VLC/Kodi 音频输出证据继续适用;没有证据支持用跨 period 实验开关、任意 ADTS 包装或独立视频时钟解决本例。 - -### 推荐的完整方案 - -1. **用户开关先于能力与性能偏好。** 关闭直通时压缩 passthrough、标准 compressed offload 和自定义 vendor-direct 都不准入;选现有 decoder 输出 PCM。开启时保留正常直通/硬件/DSP 候选和用户软解偏好。开关不改视频选择,不在播放中额外造第二个音频输出。 -2. **先形成候选,再按实际输出提交选择。** 轻量的格式解析、已有能力/经验查询可提前复用;不让常见 AAC 起播等待与它无关的 HDMI 格式探测。一个候选区分“初始化成功、收到样本、满足起播条件、首次实际推进”,最后一项才代表输出链可用。明确初始化/写入/解码错误立即进入下一可用候选,不等全局 stuck 异常。 -3. **把填充与运行分开,并让填充本身有设计目标。** 读取实际 capacity/start threshold、累计完整 AU 的供数和时间戳,区分没数据与有数据不走。API 31+ 对受测 vendor 路径可评估设置较小的 start threshold,同时保留稳态容量;读回实际值,发生路由变化重建门槛证据。普通 AAC 的候选起播数据窗可从约 100–250 ms 的完整 AU 开始设备对照,这是实验范围而非已经安全的全局参数。短片/EOS 使用明确结束语义,不为凑阈值多读几秒或补造静音。 -4. **首次恢复使用短而有条件的预算。** 对本实例、本路由、play 意图有效、无 pause/buffering/seek/EOS、实际供数已足够且应消费的音频,观察 raw head/有效 position 的推进;无进度时投递一次带 attempt/output 身份的恢复事件,在接收线程再次验证。短窗由健康设备冷启动/恢复分布和可接受误报率裁决;建议以 300–800 ms 做故障注入对照、把判断预算控制在 1 秒内。不是把所有 timeout 改成该数值,也不能把未知门槛当作已填满。新起播会产生新 budget,失败候选切换不重置同一 attempt 的总预算。 -5. **恢复尽量不丢已经读到的内容。** 首次成功输出前,只为有风险的 vendor 尝试有界保留压缩 AU 及其时间戳/配置;失败时释放旧输出,向同音轨备用 decoder 重放,视频 renderer 与 media source 保持。确认输出后立即释放保留数据;DRM、seek/flush、切轨、异步回调和队列水位有独立世代,不重复播放声音。先验证发货 Media3 是否能通过现有 renderer/recoverable-error 接口完成;不能就把局部 sink/renderer 扩展作为明确的新阶段,而不冒称 capability invalidation 已实现。**若全媒体重建仍花 3–9 秒,整体速度验收不通过;C 或等效避免重建的方案不再是最终目标的可忽略项。** -6. **分层记忆故障,让新视频也受益。** A 的 exact-media 记录继续用于内容相关或证据不足的失败;另建议 route+模式+编码/profile+采样率+声道+有效属性+firmware/policy version 的短期经验。可重复的输出初始化/模式拒绝可归输出层;零 head 这类存在内容歧义的失败,至少需不同媒体的同配置失败、各自 PCM 成功,才能提升范围。这个“至少两媒体”只是保守候选规则,未标作统计证明。未知路由不合并,不按 Sony 品牌禁用 AAC,不仅去掉 URL key 就扩大旧缓存。过期后只允许下一次自然起播一次试探,健康播放中不切回,不永久拉黑。 -7. **一个恢复控制点与准确归因。** 音频输出失败、codec 失败、无视频关键帧/无视频输出、网络供数不足各有证据,避免错误地重试网盘或切视频软解。视频候选只在用户既定解码模式内处理已有允许的回退;优化本音频问题不扩大视频自动软解。10 秒 Media3 全局检测可以保留作未覆盖故障的最后告警/恢复,但受支持的正常起播和常见坏 vendor 路径不能依赖它。 - -### 备选、产品预算与阶段 - -| 方案 | 对首次错误路径的效果与代价 | 决定 | -| --- | --- | --- | -| 不改 / 只保留 A | 首次仍等 10 秒,换媒体/路由不明可能每次重复;不满足目标。 | 拒绝作为任务完成状态。 | -| 完全退回上游标准路径 | 避免自定义 vendor 风险,但一概移除原 E11 的可用 DSP 路径。 | 不全局采用;关闭用户开关时正应选择标准 PCM 能力。 | -| 所有 AAC 先软解,稍后自动换直出 | 可能快速首播,但改变健康设备功耗/既有偏好,并增加一次运行中切换。 | 不作默认;不把临时规避冒充保留功能的最终方案。 | -| 只把 10 秒改成 2 秒 | 仍受实际填充/重建成本影响,误判可能制造更多重建。 | 不单独交付为最终优化。 | -| 默认并发直通/硬解/软解 | 可能减少独立初始化尾延迟,但共享 HAL、双声音副作用、解码资源和取消复杂度不符合当前性能要求。 | 不默认启用;先准备候选元数据和失败后的输入可重放性。 | -| 受控起播门槛 + 快速实播确认 + 同轨局部恢复 + 分层经验 | 同时处理首次、切换、下一新视频;需要验证 underrun/资源与样本/时钟连续性。 | 推荐,按下面最小单元推进。 | - -**建议产品验收目标(尚无候选实测,不是已达成或跨设备保证)**:在音视频所需样本已可读取的受控场景,健康路径无新增人为等待;已供数的坏音频路径在 1 秒内决定恢复,判断与切换到真实稳定推进合计以 **不超过 2 秒** 为目标。两次候选失败共用这份预算,不能各自获得 2/10 秒。网盘点击到播放另报全量时间,网络等待不能从用户体验指标中删除;同时以分段日志识别是否由 App 额外造成。局部恢复暂未实现时,应明确报告离 2 秒目标还有多少,不能只统计 detector。 - -最小实施顺序: - -- **本轮追加开关修复,已明确授权**:仅 `app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java`、`ExoCompressedAudioDirectPolicy.java`、对应 `app/src/test/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicyTest.java`、本文/索引;独立 quick-fix guard。关闭时在 track selector、offload support、format/output config 和真实输出创建处一致拒绝 encoded 输出,继续用原包装记录 PCM 输出所有权;开启时原能力/失败保护不变。不改默认值、设置 UI、native、网络或视频。 -- **B 已授权方向**:带真实供数/身份/生命周期门控的提前恢复,可先做故障注入和实际 policy/provider 测试;数值必须用 healthy cold-start 与 Sony 验证,不能因暂缺设备就把 10 秒设计成最终默认体验。单独缩时只是过渡候选,不满足总目标即继续推进。 -- **启动门槛调整、跨媒体经验与 C**:本节完成推荐与约束;改变默认尝试范围、共享健康范围或实际 renderer/依赖所有权时,需按具体最小设计另行实施批准。当前用户追加只要求开关修复,不把这些新架构改动混进修复。 - -### 最便宜的决定性验证与回滚 - -开关修复:真实 policy/provider 定向测试证明关闭时 vendor 查询/建轨零调用、offload 不支持、标准 encoded passthrough 同样被拒绝、缓存的 encoded config 不能绕过、PCM 及 tunneled PCM 正常委托且输出快照仍准确、重新开启可选正常 direct;现有失败记忆/生命周期用例继续通过。一次受影响 TV32 主机测试和 Debug 构建,保护已有 native 暂存;无设备时不伪称实播通过。 - -后续 B/C:可控 fake clock/输出重现足量供数但 head=0、刚好低于/达到门槛、慢供数/未写满、head 先走后停、未知 position、pause/resume、seek/flush、EOS、路由变更、退出后迟到通知;验证恢复只一次、旧样本不跨 attempt。局部恢复进一步核验 media prepare 次数/HTTP 新请求/视频 decoder 初始化均不因音频切换增加,重放时间戳不丢不重、AV sync 不劣化。设备对照至少覆盖 Sony AAC、已知健康 vivo vendor、一条健康 HDMI 多声道;冷/暖首次与不同新视频分开,基线/候选交替并保留全部样本,少量样本只报中位数/最差值,不伪报稳定 p99。 - -正常路线的 CPU/内存/功耗、AudioTrack/codec 数量、网络读取、underrun、丢帧、A/V 偏差不能出现可归因于改动的回退。API 31 的 setter 可用并不等于 HAL 兼容/低门槛性能已证明;未知/未测路径保留现行行为直至有证据的独立候选,而不能声称最终任务已经完成。开关修复与后续每个行为独立 commit/tag/revert;无新 ABI/包依赖、不持久化 URL/音轨内容、不新增长期轮询/双 decoder/网络流。本轮不推送或发布。 - -## 历史 Recovery anchor(2026-09-21,起播优化实施) - -- Objective / acceptance:实施获批 A 的直出准入一致、已确认失败配置的短期记忆和准确错误分类;保留正常 DSP、标准 offload/HDMI、用户偏好、音画同步、隧道、视频解码及已有失败回退。B 只在真实启动门槛和状态证据满足后推进,C 暂缓。 -- User decision:恢复后用户明确“继续实施”;不再重复请求同一阶段批准。 -- Lane / guard:`standard` / `E11-audio-startup-admission`。 -- Branch / baseline:`main` / `8d7ccf42b449db6ae12eb04a38f95196ef29c737`;研究已提交并创建 `recovery/E11-audio-startup-research/20260921140026-8d7ccf42b449`。 -- Scope:`app/src/main/java/com/fongmi/android/tv/player/` 下 `exo/ExoCompressedAudioDirectPolicy.java`、`exo/ExoAudioDirectFailureMemory.java`、必要时 `exo/ExoAudioOutputState.java`、`engine/ExoPlayerEngine.java`、`PlaybackErrorClassifier.java`;对应四个测试类与本文/主索引。保护预存 `app/.cxx/` 104 个文件。 -- Completed evidence:恢复与研究文档校验通过,0 error/0 warning;实际发货 Media3 输出提供 `getAudioTrack()`,可读取真实启动门槛;API 33 可按属性查询预期设备,旧 API 仅在唯一输出设备且与实播一致时允许经验命中。多路/未知路由保守不命中。 -- Implementation status:A 的代码和主机验证已完成;仅确认 vendor 失败且同媒体/同输入音轨/同输出路由的 PCM 连续推进后,才写入最多 32 项、10 分钟的进程内记录。媒体完整 URL 与音轨初始化数据仅保留摘要;系统设备连接通知和能力变化使经验失效;旧输出与新 attempt 隔离。B 未开启,C 暂缓。 -- Verification:64 项定向用例通过;随后公共错误分类收窄以保留 IJK 通用超时合同,其 10 项用例重测通过,其余 54 项对应代码未再修改。Leanback armeabi-v7a Debug APK、ZIP CRC、目标 ABI 和 v2 签名验证通过,最终 SHA-256 `4093aeff27e55101e6ba199a78e77fd5d1c4bc608aa8f89aeac7939131b0c4f9`。本机 `adb devices -l` 无设备;Sony 和健康 direct/HDMI 的实播、速度、性能尚未验收。 -- Rollback:A 作为独立代码/测试/文档原子提交,可 revert;不修改依赖、native、ABI 或打包配置。 -- Next action:用本节最终 APK 在目标 Sony 与健康直出设备做同片对照,采集真实 capacity/start threshold、首次推进及恢复日志,再裁决 B 的短观察窗;不需要重复批准已授权的 A/B 范围。 - -## 2026-09-21 阶段 A:准入一致与已确认失败记忆 - -### 最终行为与边界 - -- `ExoCompressedAudioDirectPolicy.effectiveAttributes/supportsBitstream`:保留既有 UNKNOWN → MUSIC 的 vivo 兼容处理,但在能力查询、决策 key、OutputConfig 和建轨之前统一执行;API 33+ 的 non-offload vendor 路径必须包含 BITSTREAM 支持位。API 29–32 保留官方布尔查询。标准 offload、隧道与原始 AAC/MP3 格式白名单保持。 -- `ExoAudioDirectFailureMemory`:只存完整媒体 URL、输入音轨 ID/编码/profile/采样率/声道/CSD 的摘要、有效音频属性、实际设备端口及能力标识;不存 URL/token/CSD 明文,不落盘。容量 32 项、确认起 10 分钟有效,读取不续期;设备/能力变化使世代失效。 -- 只有本实例 vendor 输出有真实停滞或写入失败证据,显式 PCM 重试仍是同一媒体,选中输入音轨一致,PCM 在至少 2 秒的连续观察中推进至少 1 秒,且实际与预期输出设备均和故障时相同,才确认共享经验。未成功恢复、零供数、暂停、跳转/flush、输出释放/替换、不同音轨/媒体及迟到回调均不会形成新经验。 -- 能力查询处命中经验时只跳过相应 non-offload vendor 配置,交给现有解码候选输出 PCM;健康配置不初始化备用解码器、不复制音频、不新增网络流或定时轮询。确认 PCM 的包装只用于明确的恢复过程,确认结束后不继续采集进度。 -- `AudioManager.getAudioDevicesForAttributes()` 在 API 33+ 提供当前预期路由;API 29–32 只接受唯一输出设备。多路、未知路由、非 NORMAL 音频模式或无法订阅设备变化时不共享经验,保留原引擎内失败保护与原 10 秒 stuck 回退。这意味着本机测试不能证明反馈 Sony 必然命中跨 engine 记忆。 -- 系统 `AudioDeviceCallback` 在第一次故障/经验查询时才注册,使用现有主 Looper,进程内只有一个观察者,不持有 engine/Activity。初始已连接设备通知与真正增删分开;已有 provider 的能力变化通知继续使经验失效。AOSP Android 13 `0d3ff311e6e80dee7fe88a2a2cfa272ce231c3c6` 的 `registerAudioDeviceCallback/broadcastDeviceListChange_sync/NativeEventHandlerDelegate` 是此行为的 A 级源码依据,访问 2026-09-21,沿用研究证据目录。 -- `ExoPlayerEngine` 仅在明确音频重试时传递待确认故障,结合当前输入音轨验证恢复;其它 prepare、stop、rebuild 和 release 隔离旧 attempt。故障提交和新播放启动串行化,PCM 最终确认重新核对输出身份,避免跨线程迟到状态污染新播放。 -- `PlaybackErrorClassifier` 将类型明确的 `StuckPlayerException` 标成 `playback-stuck`,renderer 归属仍 UNKNOWN;音频归因由实际 vendor 输出证据决定。收尾核对发现 `IjkErrorMappingPolicy` 也用 `ERROR_CODE_TIMEOUT` 表示原生 timed-out,因此保留未类型化超时的既有分类和 IJK 恢复合同,没有扩大到 IJK 的错误模型改造;MPV 稳定 marker 与明确网络/解析/DRM 分类保持。 -- 真实 AudioTrack 创建处新增 capacity、buffer size、start threshold 和有效门槛的单次日志;压缩输出的单位为 byte。首次写入、首次推进和 PCM 确认各记录一次,日志关闭时不格式化字符串。没有改变实际 buffer、音频数据封装或音画时钟。 - -### 已完成验证 - -临时构建/证据目录:`/private/tmp/webhtv-E11-admission-c18ee9ju/`。host init 仅选择任务内测试源码并启用 Android mock 默认值,原生构建暂存指向该目录的 `cxx`;未修改正式 Gradle 配置,也未重建播放器依赖或其它 ABI。 - -| 测试类 | 最终用例数 | 覆盖 | -| --- | ---: | --- | -| `ExoCompressedAudioDirectPolicyTest` | 41 | 标准 offload/隧道、模式位/属性匹配、真实输出包装、已供数但停滞、正常进度、未知位置、暂停/flush/stop/release、输出/attempt 隔离、不同媒体/音轨/路由、PCM 成功才共享、最终路由查询期间开始新播放 | -| `ExoAudioDirectFailureMemoryTest` | 6 | TTL 不续期、容量淘汰、路由世代/迟到确认、未知身份拒绝、媒体/路由隔离、摘要与音轨/CSD 区分 | -| `ExoAudioOutputStateTest` | 7 | 已有实例归属与交错释放合同 | -| `PlaybackErrorClassifierTest` | 10 | typed stuck 与真实网络区分、既有 IJK 通用超时语义、MPV marker、解析/输出/DRM 与脱敏 | - -- 首轮 62 项通过,Gradle 用时 2 分 29 秒;补齐同能力设备增删与交错确认边界后,64 项全部通过,并完成电视 32 位 Debug 打包,用时 2 分 57 秒。最终保留 IJK 合同只修改错误分类及其测试,重跑该类 10 项并刷新同一 APK,53 秒通过;未重复其余已通过的 54 项检查。 -- 实际构建均用 `bash gradlew -I /private/tmp/webhtv-E11-admission-c18ee9ju/host-tests.init.gradle :app:testLeanbackArmeabi_v7aDebugUnitTest`;最终两次按完整测试类名加 `--tests`,并构建 `:app:assembleLeanbackArmeabi_v7aDebug`。完整输出依次为 `gradle-verified.log`、`gradle-final.log`、`gradle-classifier-final.log`。最初沙箱调用只因用户 Gradle 缓存锁权限失败,授权后才执行实际构建。 -- 最终 APK:`app/build/outputs/apk/leanbackArmeabi_v7a/debug/app-leanback-armeabi_v7a-debug.apk`,162,518,123 bytes;SHA-256 `4093aeff27e55101e6ba199a78e77fd5d1c4bc608aa8f89aeac7939131b0c4f9`。包名 `com.fongmi.android.tv`,versionCode 560 / versionName 5.6.0,native ABI 仅 `armeabi-v7a`;ZIP 全文件 CRC 与 APK v2 签名通过。签名/manifest 记录为临时目录的 `apk-signature.txt`、`apk-badging.txt`。 -- APK 在本次代码提交之前构建,因此调试日志中的 Git 基线是 `8d7ccf42b449db6ae12eb04a38f95196ef29c737` / dirty;以最终 SHA-256 识别候选,不把旧 APK `912276b40ca353704943451eab6ae8ddc33e1a5784193426ffd58f89af3961cf` 当作最终版本。最后一次构建后仅更新任务文档,没有再改生产代码。 - -### 尚未通过的设备门槛与交付 - -`adb devices -l` 没有连接设备,已在工作期间询问目标设备可用性,尚无设备响应。本轮没有安装或实测,不能声称 Sony 起播已缩短、健康 DSP/HDMI 实播已验收或性能已证明不下降。特别是 API 31 的多输出环境可能不命中共享经验,首次播放依然保留原 10 秒检测。 - -B 的早回退没有启用:旧日志只有请求的 256 KiB 和总写入量,没有实际启动门槛、路由变更后的门槛以及健康直出的启动分布。先用本次 APK 采集这些证据,按前述同片对照裁决观察窗;不把 2 秒建议值直接推广到生产。C 音频局部重建仍暂缓。此限制来自实际证据缺口及用户要求保留功能/性能,不是等待重复的实施批准。 - -A 的源码、测试、本文和索引按 `E11-audio-startup-admission` guard 原子提交并创建 annotated recovery tag;具体提交/tag 由 Git 记录确定,回滚 revert 该单提交。保留原有 104 个 `app/.cxx/` 文件,不推送或发布。 - -## 历史 Recovery anchor(2026-09-21,起播速度与成功率研究) - -- Objective / acceptance:为 AAC/MP3 厂商直出形成兼顾起播时间与成功率的决策方案;保留正常直出、标准 offload/HDMI passthrough、音画同步、用户偏好、视频手动解码和既有格式能力。研究须覆盖平台合同、实际发货源码、上游问题、成熟播放器与相关性能实践,并明确实施阶段、验收和回滚。 -- User decision:研究方案形成后,用户于 2026-09-21 明确要求“继续实施”,授权推荐的准入一致、确认失败后有限记忆及具备安全门槛的较早恢复;保留“当前功能不被破坏、性能不会降低”的验收条件。先实施 A;B 仍须满足真实启动门槛与输出状态证据;C 不进入本轮生产变更。 -- Lane / guard:`assessment` / `E11-audio-startup-research`;只修改本文和主评估索引,临时证据 `/private/tmp/webhtv-E11-startup-research.o62jzZ/`。 -- Branch / baseline:`main` / `fa09d226f9f3763bc0f50167d6485178438fad52`;保护原有 `app/.cxx/` 下 104 个未跟踪文件。 -- Recovery reconciliation:2026-09-20 回退修复已提交为 `f836d419518d1a93d4ff77414a88558f3854c7e3`;2026-09-18 所有权/隧道修复为 `ee216d8ba7dee9637e9b79478d819532c846691c`。下方旧 anchor 的待提交措辞仅保留为历史,不是当前下一步。 -- Completed evidence:用户 2026-09-21 第二份日志含 7 次同片播放;5 次 AAC vendor-direct 的音频 head 均为 0,4 次在 10 秒无进度后回退 PCM,1 次在超时前释放;2 次 FFmpeg 音频软解可正常推进。回退已经提高成功率,但首帧至音频推进仍需约 13.3–19.3 秒。 -- Plan status:平台合同、发货源码、上游问题、mpv/VLC/Kodi 和跨领域延迟实践已复核;下节记录推荐方案、拒绝项及分阶段验收。仅本文和索引有研究记录修改,尚无生产代码改动;本轮只执行文档校验,不能记为设备性能验证。 -- Risk / limit:应用日志不能区分平台直出实现、输出封装与播放位置反馈的底层责任;查询支持不等于目标 AudioTrack 模式实播成功。速度目标待候选包与相同设备对照验证。 -- Rollback:本轮仅文档,可整体 revert 本研究提交;后续实施需另开 guard 和独立原子提交,不改现有锁/ABI/native 制品。 -- Next action:校验并关闭恢复的研究 guard,随后为已获批的阶段 A 启动实现 guard;研究提交和 recovery tag 以本次 guard 的 Git 记录为准,不为回填 ID 另开文档提交。 - -## 2026-09-21 起播速度与成功率:证据、决定与实施边界 - -### 问题与实际能力 - -本轮决策问题:如何缩短厂商 AAC/MP3 压缩直出没有播放进度时的等待,同时保留能正常工作的 DSP、标准 offload、HDMI 直通、手动解码及音画同步。推荐补充 WebHTV 的实播健康判断和有限故障记忆,继续使用 Media3 的解码候选和同步机制。没有推荐升级依赖、移植其它播放器的时钟或默认并发运行多个音频输出。 - -当前任务为既有 E11 的 Exo/App 优化;不是另一项上游合并任务,也不更改 E11 的原始 DSP 能力目标。研究开始于 2026-09-21 11:12 Asia/Shanghai,目标约 11:37 完成(日志/调用链约 8 分钟,外部裁决约 10 分钟,文档/校验/收尾约 7 分钟)。本轮允许路径仅本文和主评估索引;预存 app/.cxx/ 104 个文件受保护。最便宜的决定性验证为源码合同对照及一次文档校验,无需构建 APK。 - -### 日志事实与因果边界 - -来源:用户提供的 `webhtv-debug-log (3) (3).txt` 和 `webhtv-debug-log (4) (3).txt`,均在 `/Users/macbookpro/Downloads/`。第二份包含前面的部分记录及后续会话;按 `(processRunId, seq)` / `logSeq` 去重,按事件时间还原,不能把置顶片段的文本位置当作发生顺序。设备为 Sony BRAVIA 4K VH2 / Android 12 / API 31 / armeabi-v7a,片源为同一 Wogg/Quark MP4,AAC-LC 44.1 kHz 双声道。 - -| 播放 trace | 实际音频路径 | 首帧到音频时钟开始推进 | -| --- | --- | --- | -| `p-d8zas-q` | vendor-direct → 平台软件 AAC → PCM | 13.298 秒 | -| `p-dgqaa-r` | vendor-direct → 平台软件 AAC → PCM | 13.958 秒 | -| `p-doclo-s` | FFmpeg → PCM | 0.292 秒 | -| `p-dptk9-t` | FFmpeg → PCM | 0.364 秒 | -| `p-dqqzo-u` | vendor-direct,超时前退出 | 未观察到推进,不计入成功时延 | -| `p-drxix-v` | vendor-direct → PCM | 19.349 秒,其中恢复重新缓冲约 8.53 秒 | -| `p-du2hp-w` | vendor-direct → 平台软件 AAC → PCM | 13.643 秒 | - -五次 direct 都是写入已接受但 raw playback head 为 0;四次出现 `StuckPlayerException: Player stuck playing with no progress for 10000 ms` 后回退,一次提前退出。两次 FFmpeg 解码器初始化为 51/72 ms;回退的平台 `c2.android.aac.decoder` 为 146–213 ms。这不是仅仅等 UI 更新“软解”标签,而是真实输出路径在等待后发生了切换。这里的时间是首帧到日志中的音频推进,不是点击到首帧,也不能当作实测扬声器发声时刻。 - -最后完整会话:10:38:08.829 创建 AAC encoded AudioTrack(encoding=10,offload=false),08.998 首帧;19.210 媒体位置 71.772 s、buffered position 94.714 s,约有 22.942 s 数据在前,下载速率约 4.82–5.05 MiB/s;约 734,864 音频 bytes 已接受、writeErrors=0、head=0。19.232 无进度异常;21.862 软件 AAC 初始化(171 ms);22.127 PCM Track;22.641 音频推进。后续 PCM 连续推进约 95 秒。 - -因此,反复出现的 10 秒静帧主要与厂商直出/输出时钟链路不前进吻合,不能用“单纯下载慢”解释;恢复时的重新取数仍可能受网络影响。日志不能证明设备整体不支持 AAC,也不能在 HAL 解码、原始帧封装、输出模式/属性、启动填充和位置反馈之间最终定责。Android 的声明是格式/模式能力查询,不是每个时刻、每种参数组合的实播保证,不能据此称厂商“虚假声明”。 - -特别注意:现有 256 KiB 是请求的 buffer size,不是已读出的真实 AudioTrack capacity/start threshold。即使接受的总字节数更大,也不能用它替代未采集的 HAL 启动门槛或设备侧声音证据。暂不添加 ADTS,也不凭本日志调整所有 direct buffer。 - -### 本地调用链与已经具备的能力 - -基线 `fa09d226f9f3763bc0f50167d6485178438fad52`;实际发货 Media3 源锁 `e3e922d5c01bc0b564849940fe589daf37360d15`,版本 `1.11.0-alpha01-fongmi`。读取的是仓库内 sources.jar,SHA256 为 `4d158d63ab0a99688880d6acfdc73ed340f09fa9ac9dd1934fbaa0babfcad086`,不是拿互联网最新版替代运行实现。 - -| 位置/符号 | 已有实现、缺口与决定 | -| --- | --- | -| `app/src/main/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicy.java`:`getFormatSupport`、`platformSupportsDirectPlayback`、`createVendorDirectAudioOutput` | 标准 offload/隧道已有保护。API 33+ 把模式位 `!=0` 当成普通 direct;查询使用原始属性,创建时把 UNKNOWN 内容类型变为 MUSIC。应先让查询、决策缓存及实际建轨采用一致的有效属性,并保留模式位。Sony API 31 没有这些细分位,不能声称单改位判断就能修复它。 | -| 同文件:`VendorDirectAudioOutput`、`requestPcmFallbackForStuckPlayback` | 已按实际输出观察写入和位置,2 秒证据窗只供 10 秒 Media3 异常确认;failed set 只活在单个 engine。应保留输出/attempt 隔离,补齐启动门槛、确认后记忆和较早恢复。 | -| `engine/ExoPlayerEngine.java`:`handleError`、`retryAudioOutputWithPcm`、`startInternal` | 上轮已能成功回退。当前通过重新 prepare 当前媒体项恢复,会停止预缓存并重建播放准备过程;额外 3–9 秒不能全归为音频解码慢。 | -| `exo/ExoUtil.java`:`buildAudioSink`、`FfmpegRenderersFactory`;Media3 `MediaCodecRenderer.maybeInitCodecWithFallback` | 已有硬件/平台软件/FFmpeg 候选及 decoder fallback。候选查询和真正实例化是两回事;选中路径后初始化失败再试下一个,不需要默认把所有 codec 同时初始化。保留音频偏好及视频手动选择。 | -| `exo/ExoAudioOutputState.java` | 输出状态按真实实例归属,旧实例 release 不能清新状态。跨 engine 的经验缓存只能存已确认结果,不能替代当前输出所有权。 | -| `player/PlaybackErrorClassifier.java`:`exactStage` | 通用 `ERROR_CODE_TIMEOUT` 被标成 NETWORK_IO;当前日志中的 typed stuck 并非网络证据。将来应先按类型区分;单独 StuckPlayerException 也不一定证明是音频,只有结合本实例证据才称音频输出停滞。其它真正网络错误保持原分类。 | -| `engine/PlaySpec.java`:`getKey`、`getUrl` | 有 key 和 URL,但仅凭字段名不能认定 key 是稳定的单集资源 ID。首版故障记忆可用完整 URL 的进程内摘要作保守媒体隔离;签名变化造成未命中可接受,不擅自去掉查询参数归并不同资源。 | - -音频和视频有各自的样本队列和解码器,设备可以并行执行它们;播放器的 render 调度和同步时间轴仍共同协调。Media3 音频 renderer 提供主媒体时钟,视频按它决定帧何时显示。首帧可以先显示,音频时钟为 0 时后续画面会等待;强行让视频自由前进会把静帧换成音画错位。 - -`AudioOutputProvider.Listener.onFormatSupportChanged` 能把变化传到 sink/renderer/track selector,但当前 `ExoPlayerImplInternal.reselectTracksInternalAndSeek` 会附加当前位置 seek,且同轨同 renderer 的等价 selection 可能根本不重建 decoder。`AudioOutput.isStalled` 的现有 sink 处理是 flush,不代表切到 PCM;直接复用它可能再次打开同一失败路径。这两点是“只重启音频”必须证明的合同,不能只开一个开关就宣称无损恢复。 - -### 外部研究与来源处置 - -访问日期均为 **2026-09-21**,网络读取使用已配置的 `127.0.0.1:7897` 代理。原始全文、API 响应及带 URL/时间/SHA256 的元数据保存在 `/private/tmp/webhtv-E11-startup-research.o62jzZ/`;下面保留可重新访问的定位,不以临时目录存在作为唯一依据。A 为实际代码/正式合同,B 为维护者解释或可核对的案例,C 为未确认线索/跨场景推论。 - -| 来源与固定版本 | 等级、已读取的证据 | WebHTV 适用范围、限制与决定影响 | -| --- | --- | --- | -| [AOSP AudioTrack](https://github.com/aosp-mirror/platform_frameworks_base/blob/cebf5c06997b64f4e47a1611edb5f97044509d76/media/java/android/media/AudioTrack.java),Android 12 `cebf5c06997b64f4e47a1611edb5f97044509d76`;`isDirectPlaybackSupported`、`getStartThresholdInFrames` | A:能力查询不保证此刻有足够资源;streaming 在 play 后仍需达到启动门槛。压缩格式在 buffer/threshold API 中一 frame 按一 byte 计,不能拿这个单位直接除 sample rate 算音频秒数。 | 创建时读取实际 capacity/threshold;不在首个写入后无条件开始 2 秒故障倒计时。路由可能改变门槛;低 API/未知门槛走保守旧兜底。 | -| [AOSP AudioManager](https://github.com/aosp-mirror/platform_frameworks_base/blob/0d3ff311e6e80dee7fe88a2a2cfa272ce231c3c6/media/java/android/media/AudioManager.java),Android 13 `0d3ff311e6e80dee7fe88a2a2cfa272ce231c3c6`;[官方 API](https://developer.android.com/reference/android/media/AudioManager#getDirectPlaybackSupport(android.media.AudioFormat,android.media.AudioAttributes)) | A:OFFLOAD、GAPLESS_OFFLOAD、BITSTREAM 是不同支持位;查询必须针对将用于播放的属性。 | non-offload vendor-bitstream 准入不能用任意非零位替代,标准 offload 继续委托原 provider。仅修合同,不把 API 33+ 差异当作 Sony API 31 根因。 | -| [AOSP NuPlayerDecoderPassThrough](https://android.googlesource.com/platform/frameworks/av/+/android-12.0.0_r1/media/libmediaplayerservice/nuplayer/NuPlayerDecoderPassThrough.cpp),`android-12.0.0_r1`,`aggregateBuffer` / `fetchInputData` | A:此标准 offload 路径可以聚合 extractor access unit 后写入,并非在这里统一追加 ADTS。 | 它使用不同的标准 offload 配置,不能证明 Sony non-offload 能接受当前数据;足以否决“所有 AAC direct 都必须补 ADTS”这类无证据改动。 | -| [Media3 #2258](https://github.com/androidx/media/issues/2258),包括 2025-05-20 至 05-27 维护者及复测评论 | B:HDMI 刷新率/热插拔后能力恢复与 PCM fallback 状态不同步;capabilities receiver 覆盖及 DecoderAudioRenderer 通知缺口已在当时提交。重选可短暂中断。 | 学习路由世代和故障状态失效;不是本轮新移植项。不永久拉黑设备,也不把 capability invalidation 当成音频局部重置证明。 | -| [Media3 #3122](https://github.com/androidx/media/issues/3122);[提交](https://github.com/androidx/media/commit/418aaeaf395751c56be09b561699cb0bef608727) `418aaeaf395751c56be09b561699cb0bef608727` | A/B:短片循环/倍速时下个 period 的供数被视频阻塞,音频未填够而卡住;维护者否决随意切 standalone clock。提交提供实验性 per-stream media progression。 | **已有 API、此例不适用**:发货 sources.jar 已有 `enablePerStreamMediaProgression`,App 未启用;它解决跨媒体项推进,不是有大量当前项数据的厂商 AAC 输出停滞。本轮不启用、不 cherry-pick。 | -| [Media3 #3269](https://github.com/androidx/media/issues/3269),问题及评论 | C:其它 MediaTek/Hisense API 31 设备的 AC3/EAC3 seek 后 playback head 异常,尚无确认的通用修复。 | 提醒 seek/flush/时钟 epoch 风险;触发条件、设备、格式都不同,不能推导 Sony 的根因或照搬社区实验补丁。 | -| [mpv AudioTrack](https://github.com/mpv-player/mpv/blob/e76a35ec95b27f5cf2d27b043b5e2e0d90e468ae/audio/out/ao_audiotrack.c),`e76a35ec95b27f5cf2d27b043b5e2e0d90e468ae` | A:分离 PCM/IEC 输出,处理时间戳/播放头 wrap 与 flush,DEAD_OBJECT 可局部重建 AudioTrack。 | 借鉴实际输出状态和有类型恢复;不是同样的 AAC vendor-direct,不能把其线程/时钟算法复制进 Media3。引用,不集成。 | -| [VLC AudioTrack](https://github.com/videolan/vlc/blob/5751b4a48706db62f74f6c5fffcdc66ca7b9f3ea/modules/audio_output/android/audiotrack.c),`5751b4a48706db62f74f6c5fffcdc66ca7b9f3ea`;`StartPassthrough`、`AudioTrack_Write`、`AudioTrack_ReportTiming` | A:IEC61937 → raw 兼容模式按优先级逐个尝试;写入成功与有效非零 timing 分开;DEAD_OBJECT 尝试重建。旧 Android 阻塞 workaround 另有条件。 | 支持“有序尝试、按实播确认、只恢复失败层”的原则,不支持默认三路同时占用输出。旧系统 workaround 不适用于直接照搬。引用,不集成。 | -| [Kodi AudioTrack](https://github.com/xbmc/xbmc/blob/a2468936c07367799cd82957b858fda35b7e5e58/xbmc/cores/AudioEngine/Sinks/AESinkAUDIOTRACK.cpp),`a2468936c07367799cd82957b858fda35b7e5e58`;`GetDelay`、`AddPackets` | A:区分 PCM/raw/IEC;部分模式按原始 buffer 时长监督播放头,达到条件请求 reopen。明确有 sink 需要额外数据才启动。 | 支持把 buffer 和状态纳入监督;其 watchdog 排除 raw passthrough,不能复制其 400 ms 或两倍 buffer 常量用来判断 AAC。引用,不集成。 | -| [RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html),2017-12,§4–5 | A 规范,跨领域应用为 C:按历史和优先级排序候选、分批延迟尝试、成功后取消其它连接,控制额外负荷。 | 只借鉴有界尝试和失败经验;音频输出有共享硬件/时钟及不可重复消费样本,不能照搬网络竞速或时间参数。 | -| [The Tail at Scale](https://research.google/pubs/the-tail-at-scale/),Dean/Barroso,2013 | C:仅读 Google 摘要,全文镜像 404、CACM 页面 403;未取得并审阅论文全文。 | 不将摘要当作并发解码或具体期限的依据,也不宣称完成论文全文评估。本题的平台/播放器事实与已读 RFC 足以决定是否默认竞速,全文不构成本方案的必需门槛。 | - -证据类别:精确源码/相关提交、正式平台合同、issues/维护者讨论、成熟播放器实际实现均已覆盖;本轮没有移植候选,因此没有要重建的上游测试矩阵。性能/现场报告以用户七次会话及公开 issue 的复现/维护者反馈为依据,不能当作统一基准。未找到直接验证这条 Sony AAC non-offload 链路的论文或受控 benchmark;不制造这种证据。对每个引用的不同模式保留适用限制,研究结论是设计选择,不是性能已经达标。 - -本地相关完整提交处置:`3fdf9f82f37843699a2545ed97d4a2dd17b8ead5`(已有 vendor-direct 能力,保留)、`cf0a5dabc77fb2bbdc3e0f2cc867a9eac86060b8`(已有硬件优先,保留)、`ee216d8ba7dee9637e9b79478d819532c846691c`(已有实例所有权/隧道修复,保留)、`f836d419518d1a93d4ff77414a88558f3854c7e3`(已有 10 秒后 PCM 回退,保留为兜底并补充)。上表 mpv/VLC/Kodi/AOSP 修订仅作固定引用,均不合并;唯一具体评估的 Media3 新特性提交已给出“API 已有、当前问题不适用”处置。 - -### 方案比较 - -| 方案 | 速度、成功率和当前能力影响 | 决定 | -| --- | --- | --- | -| 不改 | 四次恢复证明可播,但首次失败要等 10 秒,新 engine 又重新踩同一路径。 | 不推荐。 | -| 只使用标准 Media3 行为,移除 vendor-direct | 标准 decoder/offload/HDMI 的合同简单;但会丢掉原 E11 在 vivo 等设备验证过的 DSP 入口,也没有自动解决上层重建成本。 | 拒绝整体替换,标准路径作为正常委托和恢复基础。 | -| 所有 AAC 强制 FFmpeg/按 Sony 品牌拉黑 | 可绕过样例问题,但改变正常 DSP、功耗和既有偏好;日志不能支持这种范围。 | 拒绝全局默认。用户已有的手动软解可作为临时规避方式。 | -| 直通、硬解、软解同时运行,最快者胜 | 增加 codec/HAL 争用、内存/CPU、样本副本和取消时序;优先级选中一个“声明支持”路径也不能证明其音频时钟会走。 | 不作为默认方案。轻量能力查询/候选元数据可复用缓存,真正初始化、解码与输出仍保持有界。 | -| 所有超时改成 1–2 秒或把视频切系统时钟 | 可能误判填充、网络、暂停/seek、HDMI 变化;自由视频时钟可能损害 A/V sync。 | 拒绝。 | -| WebHTV 窄适配:查询/建轨一致,确认失败后有限记忆,满足数据及生命周期条件时较早回退 | 正常路径沿用原能力和优先级;失败路径减少无效尝试和等待。需控制误判与记忆范围。 | **推荐,分 A/B 两个可回滚单元实施。** | -| 完整音频局部重建、共享保留现有媒体队列 | 理论上可减少剩余重新 prepare 成本,但标准能力重选可能 seek 或无动作,存在样本丢失/重复、旧回调及同步风险。 | C 阶段条件研究;不阻塞 A/B,也不提前承诺效果。 | - -### 推荐设计及最小阶段 - -**阶段 A:先阻止已确认失败的重复尝试,纠正准入与诊断。建议实施。** - -1. 为 vendor-direct 计算一次有效 AudioAttributes,查询、决策 key、OutputConfig 与建轨用同一结果;保留现有 UNKNOWN → MUSIC 的兼容意图。API 33+ 保留具体支持位,non-offload vendor 路径需要 BITSTREAM;标准 offload 原样委托。API 29–32 的布尔查询只表示可尝试,不能作为已验证成功。保持原格式白名单。 -2. 在真实 AudioTrack 创建处读取并保存实际 buffer capacity、effective start threshold、输出模式及可用的路由身份。API 31+ 有正式启动门槛接口,异常或旧 API 不猜测成确定值。记录 play、首次接受数据、首次有效推进、回退原因及回退后推进的单次时间点;日志关闭时不做逐帧字符串格式化,不新增音频数据复制、常驻计时线程或网络取样。 -3. 首版只增加**进程内、容量有界、短期的失败记忆**,建议最多 32 项、10 分钟有效期(初始设计值,非实测最优)。记录前须满足“本实例 vendor 失败,且同媒体/同输出环境下 PCM 已连续推进”,未成功恢复的不成为跨 engine 经验。新 engine 在相同媒体和配置下可跳过这一路 vendor-direct;原 engine 现有失败保护保留。 -4. key 必须包含媒体身份、音频编码/profile/采样率/声道及初始化数据摘要、有效属性、输出模式、路由世代。首版可用完整媒体 URL 的摘要保守隔离;不存 URL/token 明文、不使用尚未证明唯一的 PlaySpec.key,也不去掉查询参数猜同资源。签名变化未命中仍走正常检测。路由/能力变化(HDMI、蓝牙、音频设备)即清除对应经验;无法识别实际路由就不启用跨 engine 命中。进程结束自然清空,固件/版本变化不会沿用磁盘黑名单。 -5. 网络断流、用户暂停、seek/切轨/退出、无音频、其它 renderer 故障及未确认为该输出的超时不写入经验。有效期结束只在下一次自然起播重新尝试;不在正常播放中强行切回 direct。对健康 direct 不预热备用 decoder、不增加等待。真正标准 offload/HDMI 不受 vendor 故障记忆影响。 -6. 在错误分类处区分 typed playback stuck 与明确的网络 IO;只有带当前 vendor 输出证据的事件才归因于音频。不要把全部 StuckPlayerException 标成音频,也不要通过错误文本猜类型。 - -阶段 A 拟允许的生产路径:`app/src/main/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicy.java`、`exo/ExoAudioOutputState.java`(仅在现有实例快照需要承载新状态时)、同目录新 `ExoAudioDirectFailureMemory.java`、`engine/ExoPlayerEngine.java`、`player/PlaybackErrorClassifier.java`;每个缩写路径均相对 `app/src/main/java/com/fongmi/android/tv/player/`,其中 `player/PlaybackErrorClassifier.java` 的实际路径为该根下的 `PlaybackErrorClassifier.java`。测试限定相应 `ExoCompressedAudioDirectPolicyTest`、`ExoAudioOutputStateTest`、新 `ExoAudioDirectFailureMemoryTest`、`PlaybackErrorClassifierTest`,以及本文和索引。实施前 guard 使用真实完整路径,不按这一段的展示缩写声明目录通配范围。无锁、AAR、JNI、.so、Gradle 或 MPV/IJK 改动。 - -**阶段 B:缩短第一次失败的等待。建议在 A 的实际门槛证据上实施。** - -- 将初次输出分成“填充中 → 可运行但待确认 → 已推进”;只有 active attempt、当前输出、play 意图有效、未暂停/缓冲/seek/释放,且已写入足够越过真实启动门槛的数据、存在应播放的未消费音频时,才累计短期无进度观察。门槛以真实单位判断,不用整体视频 buffered duration 或压缩 bytes/sampleRate 代替音频可播放量。 -- 候选观察窗为满足上述条件后的 2 秒,而非首次写入后 2 秒;数值必须由健康 direct 与 Sony 对照数据裁决。暂停、flush、seek、路由变化及新 output 重置世代,旧事件不得触发新播放回退;EOS/短片和未知数据门槛保留原通用检测,不做激进判断。 -- 复用已有 position/write 调度观察;满足条件后只投递一次带 attempt/output 身份的恢复事件,由现有播放器线程模型处理,接收侧再次验证身份及当前状态。实现时必须证明写入暂时停止后仍能触发,以及退出/新播放不会被迟到通知重启;不在 AudioTrack 回调中阻塞等待另一个线程。 -- 首版沿用已经实测能成功的 PCM 原位重新准备流程,保持播放位置、音轨和用户 play/pause 意图;Media3 10 秒通用 stuck detector 继续作为未覆盖状态的最后兜底。不伪造普通网络超时,也不扩大视频自动软解。 -- 能节省的是已确认输出停滞后的观察时间;若从 10 秒变为 2 秒,理论可少等约 8 秒,但仍有恢复 prepare/网络时间。本日志的 3–9 秒恢复成本尚未消除,不能承诺所有片源在 2 秒内播放。 - -**阶段 C:仅在 B 后仍有明显重建成本时,验证局部音频恢复。暂缓生产实施。** - -先以发货 Media3 的真实 renderer/sink/track selection 做一个可证伪验证,记录 media source prepare 次数、HTTP 请求/读取量、video decoder 初始化次数、音频样本位置及 A/V 偏差,证明 direct → decoder PCM 可以保持媒体队列且没有丢样/重样。若 capability invalidation 会 seek、同 selection 不重建,或须 fork Media3,更新本任务设计并另行批准;不得默认用全局重选或切 standalone clock冒充音频局部恢复。没有这一证据就保留 A/B 的可靠回退。 - -### 验收、性能边界与回滚 - -| 合同 | 最便宜且有决定性的验证 | -| --- | --- | -| A 准入/记忆正确 | 用实际 policy 包装和可控 provider 做定向主机测试:offload-only/bitstream/组合位、属性一致、API 31 布尔查询、健康进度、失败后 PCM 成功才记忆、TTL/容量、不同媒体/格式/路由/engine、旧回调隔离。失效/过期允许新起播尝试;同片同配置可跳过已失败 vendor 路径。 | -| 错误归因 | typed stuck 无网络 cause 不标 NETWORK_IO;未知类型不冒充音频;真正 HTTP/socket/connect 错误和 MPV 现有 marker 维持。 | -| B 不误伤 | threshold 未满足、零写入、慢供数、未知位置、暂停/恢复、seek/flush、EOS、短片、路由变化、被替换/释放的 output 均不得触发快速恢复;持续有足够音频但不推进应只恢复一次。 | -| 真实故障速度/成功率 | 反馈 Sony 同音轨/相同起点,基线和候选交替各至少 5 次(冷启动与重复播放分别记录);记录点击→首帧、首帧→实际音频推进、回退→推进、失败次数及全部样本。样本少时报告中位数/最坏值,不声称统计显著或可靠 p95。先看 A 重复起播不重复 10 秒等待,再看 B 第一次等待减少。 | -| 健康路径与既有功能 | 能正常 vendor-direct 的 vivo/同类设备、一个已有 HDMI AC3/EAC3/DTS passthrough 场景、标准 offload/隧道门禁、平台硬件/平台软件/FFmpeg 偏好、原有 AAC/MP3 及其它格式路由;定向验证暂停、seek、切轨、倍速资格和退出。无需重跑无关全格式/ABI 矩阵。 | -| 性能不回退 | 健康设备相同片源/路线做候选与基线交替;正常路径的 codec/AudioTrack 数量、请求/读取量、CPU、内存、首帧和稳定播放掉帧不出现可归因于改动的增加;A/V sync 不恶化。若波动大到无法裁决,只追加能区分该问题的样本。源码中无双 decoder/新线程并不等于实测性能保证。 | - -每阶段只运行受影响的主机测试与一个 Leanback armeabi-v7a 编译目标;现有同类编译/定向测试最近为约 38–92 秒,不能当作新阶段实际用时。需要设备时才打包对应候选,不提前重建 native 或全 ABI。实施时间按当时设备与 Gradle 状态重新声明;本轮未构建/安装/实测候选包。受影响 Sony 与正常 direct/HDMI 的现场对照未完成前,不能宣称“所有现有功能和性能均已确保”。 - -兼容性/质量:保留当前音画同步、声道/采样率、手动偏好、视频路径、隧道、AV3A 及其它格式;通过同音轨 PCM 恢复,不自动挑低质量替代音轨。维护/生命周期:采用现有 provider 包装和实例世代,故障记忆有界且不持有播放器/Activity。安全/数据所有权:仅进程内技术状态,不持久化用户 URL/token 或上传诊断;不增加网络接口。ABI/体积/许可证/供应链:阶段 A/B 只改 App Java 与定向测试,依赖锁/native ABI/二进制及许可均不变,外部代码只引用原则而不复制到制品。 - -落地顺序为 A → B → 按实測决定是否 C;每阶段独立 guard、验证记录、原子提交和 annotated recovery tag。先用目标设备候选包对照,满足合同再推广;若健康路径降级、音画异常、重复恢复或性能回退,停止推广并 revert 对应单提交,进程内记忆随进程结束清空。生产行为不得为满足速度指标而跳过门槛/路由/所有权保护。 - -本研究单元交付决策记录;文档校验与原子提交/tag 由 `E11-audio-startup-research` guard 收尾,无生产变更或推送。用户已于恢复后明确批准继续实施,后续按 A → 具备证据的 B 推进;C 的局部恢复暂缓。实施自 2026-09-21 13:52 Asia/Shanghai 起预计 50–70 分钟(核对/研究收尾约 8 分钟,实现/定向测试约 35–50 分钟,验证/记录/提交约 7–12 分钟),实际硬件验证另按可用设备记录,不能把主机测试写成设备验收。 - -## 历史 Recovery anchor(2026-09-20) - -- Objective / acceptance:修复 AAC/MP3 厂商压缩直出已接收音频却无播放进度、最终被 Media3 判定超时后未自动回退 PCM 的缺口;仅恢复失败配置,保留视频解码、正常直出、标准 offload、隧道和用户设置。 -- User decision:用户在收到日志/源码定位与最小修复建议后明确要求“修复Bug”。 -- Lane / guard:`quick-fix` / `E11-audio-direct-stall-fallback`。 -- Branch / baseline:`feature/mpv-dv7-fel` / `2623cb812ea842b676bc7d8db699c1a7e70b8e1e`。 -- Scope:`ExoCompressedAudioDirectPolicy.java`、`ExoPlayerEngine.java`、`ExoCompressedAudioDirectPolicyTest.java`、本文与任务索引;保护原有 `app/.cxx/` 下 104 个文件。 -- Evidence:Sony BRAVIA 4K VH2 / Android 12 / armeabi-v7a;反馈日志中 AAC-LC 44.1 kHz stereo 的 encoded AudioTrack 接收 1,929,665 bytes、writeErrors=0、headFrames=0;10 秒后 `STUCK_PLAYING_NO_PROGRESS` / code 1003 被按 FATAL 处理。日志标记的基线 `88aceb110959ff50afc23b10d9b9abe3e0f53255` 与本次基线的策略/引擎文件一致;反馈 APK 标记 dirty,不能仅凭 revision 证明其全部内容。 -- Plan status:类型明确的错误路由、vendor 输出进度观察和 attempt/输出隔离已实现;Leanback armeabi-v7a 编译与 33 项定向测试通过,进入原子提交/tag 收尾。 -- Unverified edits:无未验证代码;任务内两个生产文件、一个测试文件及本文/索引待原子提交。本轮未打包 APK。 -- Risk / limit:本轮依据反馈日志和截图实施,尚未在反馈 Sony 上执行同片源回归;主机测试验证控制流与生命周期,不能冒充 Sony HAL 已通过回归。2026-09-21 合并至 `dev2`(C4 第六轮)后复跑 26 项策略 + 7 项输出所有权测试,全部通过;使用 `unitTests.returnDefaultValues=true` 临时 init 脚本补足 `android.os.SystemClock` 等未打桩 Android API,未修改生产构建配置。 -- Rollback:本次 App 源码、测试、文档作为一个原子提交,可 revert 该提交;不涉及依赖或 native 制品。 -- Next action:使用当前 guard 原子提交并创建恢复 tag;完成后以该提交和 `recovery/E11-audio-direct-stall-fallback/*` 的 Git 记录作为恢复点,不为回填 ID 另开文档提交。 - -## 2026-09-20 压缩音频直出无进度回退 - -### 最佳实践与本地合同复核 - -本次为 E11 已建立的“实际输出失败后恢复同轨 PCM”合同补漏,属于根合同允许的局部纠错。复用现有 Media3 检测器、输出包装和重试入口,不新增定时器、线程、平台兼容分支、解码算法或依赖升级;既有官方 API/issue 研究见下文,不重复泛化搜索。 - -访问日期:2026-09-20。发货 Media3 为 `1.11.0-alpha01-fongmi`,锁定源 `e3e922d5c01bc0b564849940fe589daf37360d15`;以下为 `third_party/maven/androidx/media3/{media3-common,media3-exoplayer}/1.11.0-alpha01-fongmi/*-sources.jar` 中实际源码(A级证据): - -| 来源 | 可核实合同与决定影响 | -| --- | --- | -| `StuckPlayerDetector.StuckPlayingDetector`、`StuckPlayerException` | 只在 `isPlaying()` 且同一 period/广告/媒体位置持续不变时产生 `STUCK_PLAYING_NO_PROGRESS`;暂停/缓冲会撤销检测。按异常类型和 `stuckType` 匹配,不按错误文本或所有 code 1003 猜测。 | -| `ExoPlayerImpl.ComponentListener.onStuckPlayerDetected/stopInternal` | 使用 `ERROR_CODE_TIMEOUT` 包装异常,先异步要求播放线程 stop,再通知 App;音频 pause/release 与 App 收到错误存在竞争。停滞证据必须属于本次输出,并能保留到本次错误处理。 | -| `AudioOutput.write/getPositionUs/play/pause/flush/stop/release`、`AudioTrackAudioOutput` | write 的 buffer.position 增量证明实际接受 payload;position 是输出播放时间。仅观察已有调用,不主动跨线程调用 AudioTrack,不复制音频或改变返回值。 | -| `DefaultAudioSink.getCurrentPositionUs/hasAudioOutputPendingData` | 正常调度已查询输出位置;可在包装层记录进度,无需额外轮询或线程。 | -| 当前 `ExoCompressedAudioDirectPolicy`、`ExoPlayerEngine.handleError/retryAudioOutputWithPcm/startInternal` | 只处理初始化/写入异常;已有同配置禁用、屏蔽 generic passthrough、保留原位置重启逻辑可直接复用。每次 prepare 清除旧输出证据,但保留引擎内失败配置集合,防止反复直出。 | - -没有新的上游提交候选;不改源码锁/补丁/二进制。论文、广泛论坛或性能基准不能改变这条类型明确、数据可观测的错误路由决定;本轮以实际源码、用户日志和可控输出回归验证。保留 E11 既有平台/成熟实现研究及其限制。 - -### 方案、验收与风险 - -- 不改:拒绝,已证实在 no-progress 超时后直接终止。 -- 只用原有 Media3 行为:检测准确,但超时默认终止,不能禁用 WebHTV 厂商直出配置。 -- 对所有超时或所有无硬件音频 codec 强制软解:拒绝,会误伤断流、视频故障及正常 DSP 直出。 -- 采用窄适配:只对实际厂商 direct 输出观察已接受数据和播放位置;在连续播放期间输出至少 2 秒无进度时保留证据,再以现有 Media3 10 秒 `STUCK_PLAYING_NO_PROGRESS` 作为恢复触发。仅标记该 encoding/rate/channel-mask 失败并复用 PCM 原位重试。独立的 2 秒证据窗避免把最近仍在前进的音频归因到播放器停滞,也允许线程通知误差;不提前产生错误。 -- 正常 play/resume 重新开始观察;暂停时不累计新的证据;flush、自然 stop、下一次 prepare 和新输出清除不适用证据。旧输出异步释放不能污染新输出;已确认停滞证据保留到异步错误回调。 -- 正常路径只在现有 vendor-direct write/position 调用中更新少量标量,无新增 buffer、解码器、音频复制或工作线程。标准 PCM/offload/隧道不安装检测包装。 -- 定向测试覆盖真实包装层的成功写入但停滞、正常进度、零写入、暂停/恢复、flush/stop、释放先于错误、新输出/新 attempt 隔离、其它错误类型、配置禁用及一次性消费;同时保留原有直出/失败 PCM/隧道/offload 测试。编译一个受影响 App 变体。 -- 设备验收仍需在反馈 Sony 上用同一片源、音频优先软解关闭,确认发生一次 `playing-no-progress` 回退后 PCM 与媒体进度恢复;正常直出设备及暂停/seek 需按风险验证。主机结果不等同于该设备验收。 - -### 实施与验证记录 - -- `ExoCompressedAudioDirectPolicy` 的 vendor 输出包装只观察已有 write/position 调用;真实 payload 已接受、播放中位置连续 2 秒不变时保存证据。恢复必须同时匹配 Media3 `ERROR_CODE_TIMEOUT` 的 `StuckPlayerException.STUCK_PLAYING_NO_PROGRESS`,不匹配错误文案,也不泛化其它超时。 -- 每次 prepare/stop/rebuild/release 换新 attempt;初始化前捕获 attempt,旧初始化晚完成也不能填入新 attempt。正常输出替换旧证据,旧 release 不覆盖新实例;已证实停滞可跨越 Media3 异步 pause/release,供同一次错误处理消费。 -- `ExoPlayerEngine.handleError` 复用已有配置禁用与 `retryAudioOutputWithPcm()`,保持原位置/播放意图;成功返回 RECOVERED。已核对 `PlayerManager` 调用链,恢复成功后退出错误处理,不进入视频软解或线路重试。 -- JDK 21.0.10 / Gradle 9.5.1:`:app:testLeanbackArmeabi_v7aDebugUnitTest` 及依赖的 App Java 编译成功,Gradle 执行用时 1 分 32 秒。仅执行 `ExoCompressedAudioDirectPolicyTest`(26 项)与 `ExoAudioOutputStateTest`(7 项),合计 33 项,failures/errors/skipped 均为 0。 -- 测试覆盖反馈的 44.1 kHz 与既有 48 kHz 配置;已接受数据但零进度、非零位置后停滞、正常进度、零写入/未知位置、暂停/恢复、flush/stop、先释放后通知、新输出和延迟初始化隔离、其它错误类型、标准 PCM/offload/隧道以及原有写入失败回退均通过。 -- 通过 `/private/tmp/webhtv-E11-audio-stall-zxepv15j/host-tests.init.gradle` 临时限定两个测试类并启用 Android mock 默认值,原生暂存重定向到同目录 `cxx`;没有修改生产构建配置。主机测试使用实际生产包装层和可控 `AudioOutput`,不是真实 AudioTrack/DSP 运行证明。 -- 完整成功日志:`/private/tmp/webhtv-E11-audio-stall-zxepv15j/gradle-verified.log`;JUnit XML:`app/build/test-results/testLeanbackArmeabi_v7aDebugUnitTest/`。首次沙箱调用只在 Gradle 缓存锁处被拒绝,授权后完成唯一一次实际编译/测试;未重复成功检查。 -- 本次基线和提交/tag 由 `E11-audio-direct-stall-fallback` guard 维护;同一原子提交含源码、测试及本文/索引,不另建 E11 跟进文档。 - -## 历史 Recovery anchor(2026-09-18) - -- Objective / acceptance:修复 seek 后旧音频输出释放清空新状态、输出未知时参与自动调速、厂商压缩输出破坏隧道配置三项合同;不改变视频手动硬/软解选择,保留标准直通/offload、非隧道 vendor-direct 和既有音频失败回退。 -- User decision:用户已明确“实施修复”;无需新增设备日志作为代码修复前置条件。 -- Lane / guard:quick-fix / `E11-seek-output-consistency`。 -- Branch / baseline:`feature/mpv-dv7-fel` / `ad6f68d5120d49345e5c3b147e1580f9cc280c5f`。 -- Scope:`ExoAudioOutputState`、`ExoCompressedAudioDirectPolicy`、`ExoNetworkGuardEligibility`、`PlaybackAnalyticsListener`、`ExoPlayerEngine`,对应三个定向测试,本文与任务索引。 -- Protected:原有 `app/.cxx/` 下 104 个 dirty 文件;无依赖、native、构建脚本或视频解码选择修改。 -- Completed evidence:已对比两个发布版本、当前实际 Media3 源码和 App 调用链;已确认现有测试将隧道单边关闭和 UNKNOWN 调速作为预期,需按音视频整体合同纠正。 -- Implementation:三项生产修复已完成;每个引擎按实际 AudioOutput 实例持有状态,旧释放仅能清本实例;UNKNOWN 阻止自动调速;标准 tunneling/offload 配置不再被 vendor-direct 缓存覆盖。 -- Verification:Mobile ARM64 App Java 编译及三个定向 JVM 测试类通过,共 25 项,失败/错误/跳过均为 0。覆盖输出实例交错释放、当前输出释放、实例隔离、隧道/标准 offload/cache 交错以及未知输出调速门禁。没有受影响用户设备,不能把本机检查表述为所有 seek 卡死已实机解决。 -- Unverified edits:无未验证的代码修改;尚未验证受影响设备的实际 seek 场景,本轮未打包或安装 APK。 -- Rollback:完成时使用 guard 原子提交并立即创建 annotated recovery tag;必要时 revert 本次单提交即可,依赖制品不变。 -- Next action:由当前 guard 原子提交并创建恢复 tag;提交/tag 以该逻辑提交的 Git 记录为准,不为补写哈希另开提交。 - -## 2026-09-18 跳转后画面停滞:复核、决定与实施计划 - -### 版本和证据 - -访问/核对日期:2026-09-18。两次前序分析已经完成,不重复网络研究或编译。 - -| 来源与版本 | 等级 | 核实结论与决定影响 | -| --- | --- | --- | -| 旧版 `v5.6.0-202609051525` → `ec478b0b697422a7785171c7b51a35b7a526564e`;截图版 `v5.6.0-202609061650` → `784b90420d646eb6c7ddcc63ad622a92c65b02b4` | A,实际 Git 内容 | 旧 tag 指向 8 月 31 日代码,期间加入音频策略;不能按发布日期推定只有一天代码差异,也不能仅凭这些差异确认用户卡死根因。 | -| WebHTV `cf0a5dabc77fb2bbdc3e0f2cc867a9eac86060b8` | A,实际 diff | 音频硬解优先引入点;本轮保留该选择策略,不把视频切到软解或整体取消音频硬解。 | -| WebHTV `3fdf9f82f37843699a2545ed97d4a2dd17b8ead5` | A,实际 diff | vendor-direct 能力声明/建轨强制 non-tunneling 的引入点;修正其与实际隧道配置的协商。 | -| WebHTV `37995ff14016fd5a26fdae2b482f08470aa6a162`、`37888d8b9d99da29f9ecfc3cd1f5eba458e09ee0` | A,实际 diff | 按 AudioTrack 配置匹配释放回调的全局状态,后来被网络保护用于控制调速;同配置新旧输出无法区分。 | -| 当前 `ad6f68d5120d49345e5c3b147e1580f9cc280c5f` 的 `third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/*-sources.jar`:`DefaultAudioSink`、`AudioTrackAudioOutput`、`MediaCodecAudioRenderer`、`DefaultMediaClock` | A,实际发货源码 | seek 会 release AudioOutput;AudioTrack 真正释放/通知异步完成,新输出可先初始化;Media3 用音频 renderer 时钟同步视频。采用实际输出对象的所有权,不再让延迟的统计回调决定运行状态。 | -| 同一 Media3 源码:`DefaultTrackSelector.maybeConfigureRenderersForTunneling`、`MediaCodecAudioRenderer.onEnabled/supportsFormat` | A,成熟项目代码 | 隧道同时配置音视频;音频渲染器在启用后按 sink 实际能力决定 bypass/decoder。隧道请求必须交给标准 provider,禁止 vendor-direct 把已请求的隧道单边清掉。 | -| E11 历史官方 API、设备实验和 issue 资料(下文保留) | A/B,既有研究 | 保留按实际 encoding/sample rate/channel mask 查询的非隧道 direct;不扩大格式白名单、不改建轨线程或音频样本处理。 | - -本轮属于已建立生命周期和隧道合同的局部纠错,适用根合同的窄修复研究豁免;没有新增上游候选、算法、架构或 native 兼容路径。上述实际源码已足以决定修法,论文/广泛论坛搜索不能改变实例所有权和配置一致性的结论,故不新增这类搜索。 - -### 当前调用链与缺口 - -1. `ExoPlayerEngine` 持有每个引擎独立的 `ExoCompressedAudioDirectPolicy`;`ExoUtil.buildAudioSink` 已将它包在所有实际 AudioOutput 创建路径上,因此不需要增加另一套播放器或修改 Media3。 -2. 旧 `PlaybackAnalyticsListener.onAudioTrackReleased` 仅比较 encoding/rate/channel/tunneling/offload;同配置 A → B 后,A 的延迟回调可能清空 B。该全局快照由 `ExoPlayerEngine.getAudioPlaybackDiagnostics` 读取并传给 `PlayerManager.getNetworkProtectionEligibility`。 -3. `ExoNetworkGuardEligibility` 对 UNKNOWN 放行;状态不确定时不能决定对压缩输出调速。 -4. vendor-direct 缓存只按 encoding/rate/channel 记录。仅在能力查询时判断隧道还不够:最终 OutputConfig 与建轨/modifier 也必须保护 tunneling/offload,避免旧查询缓存覆盖标准输出。 - -### 方案比较与选择 - -| 方案 | 决定 | -| --- | --- | -| 不改 | 拒绝:保留可证明的状态归属错误与单边隧道配置。 | -| 继续按配置猜测回调所属,或延时清理 | 拒绝:同配置无法区分新旧;任意延时也不能证明实例归属。 | -| 直接照搬 Media3 内部监听器 | Media3 内部按监听器实例隔离旧事件的原则正确,但 App Analytics 回调只给配置;须在现有输出 provider 处取得实例所有权。 | -| 每个引擎跟踪实际 AudioOutput + 身份比较释放;未知状态门禁;隧道委托标准 provider | 采用:在初始化/释放边界处理,借助既有 provider 包装,不增加线程、样本复制、逐帧状态统计或全局禁用正常能力。 | -| 整体回滚音频功能、升级 Media3、增加通用卡死重启 | 不采用:会扩大行为/验证范围,且现有证据不能支持针对所有设备的重启策略。 | - -### 实施与验收 - -- 新 `ExoAudioOutputState` 在实际创建成功后发布不可变快照,每次创建产生独立身份;release 只清除本实例仍持有的快照,并继续委托原始释放。状态归属单个引擎,读取为原子快照,旧异步 Analytics 回调仅保留日志。 -- `ExoPlayerEngine` 读取自身 policy 的输出快照;移除全局 Analytics 输出状态及按配置匹配的释放更新。 -- UNKNOWN 输出阻止网络保护自动调速;保留 PCM、既有 offload、手动倍速和其它既有门禁的处理。 -- FormatConfig 请求 tunneling 时不探测/启用 vendor-direct,标准 provider 决定支持和输出配置;最终建轨与 builder modifier 也尊重 OutputConfig 的 tunneling/offload,缓存不能越权覆盖。 -- 测试真实输出包装的交错 release、同配置新身份、当前实例 release 和引擎隔离;测试标准隧道保留、unsupported 返回、已缓存 direct 后的隧道/offload,以及既有非隧道 direct/失败 PCM 回退。 -- 成本仅为每次输出创建/释放的不可变快照与原子更新;不改逐帧/音频数据内容、ABI、JNI、包内 .so、网络请求或音视频解码选择。 -- 验证结果与提交/tag 在本节收尾记录;受影响设备缺少实机复现条件属于结论限制,不反复向用户索取,也不因此留下已获批代码修复未完成。 - -### 本轮验证结果(2026-09-18) - -- JDK 21,`:app:testMobileArm64_v8aDebugUnitTest` 及依赖的 `compileMobileArm64_v8aDebugJavaWithJavac` 成功,最终 Gradle 用时 38 秒。 -- `ExoAudioOutputStateTest` 7 项、`ExoCompressedAudioDirectPolicyTest` 13 项、`ExoNetworkGuardEligibilityTest` 5 项,合计 25 项全部通过,无跳过。 -- 临时 host init 只选择上述三个任务内测试类,并启用 Android mock 默认值;不把它当作真实 AudioTrack、硬件隧道或受影响设备实测。生产构建配置未修改。 -- 首次执行被沙箱阻止写 Gradle 缓存锁;授权后编译暴露一处已删除局部变量仍被日志语句引用,改读实际 OutputConfig 后完成上述验证。未重复已通过的检查。 -- 完整成功日志:`/private/tmp/webhtv-E11-seek-output-verification/gradle-fixed.log`;JUnit XML:`app/build/test-results/testMobileArm64_v8aDebugUnitTest/`。原生构建暂存目录沿用既有隔离 init,原有 `app/.cxx/` 保留。 -- 本轮 App 代码修复/定向验证完成;guard ID `E11-seek-output-consistency` 的原子提交与 annotated `recovery/E11-seek-output-consistency/*` tag 为恢复点。没有设备证据支持“所有 seek 卡死均已解决”的结论。 - -## 原始 E11 记录(从历史提交恢复) - -## 历史 Recovery anchor(2026-09-02,非当前状态) - -- Objective: Exo 对设备 Audio HAL/DSP 明确支持的普通压缩音频优先发送原始 access unit 到 AudioTrack,失败后自动回退同一音轨 PCM;播放参数只显示当前实际输出链路。 -- Acceptance: AAC/MP3 代表样本在 vivo V2453A 上出现 WebHTV UID 的压缩 `DIRECT/COMPRESS_OFFLOAD` AudioFlinger Track;不支持的声道/采样率保持 PCM;初始化或写入失败只触发 Media3 可恢复回退;pause、seek、flush、切轨和 A/V 同步正常;无双解码、逐帧额外复制或新增线程。 -- Branch / baseline: `feature/mpv-audio-fallback-policy` / `d41155f16cd81f1354672a5479743462fc168ed9`. -- Protected pre-existing dirty paths: `app/.cxx/` 下 69 个 task guard 记录的既有生成文件。 -- Approved scope: `ExoCompressedAudioDirectPolicy.java`、`ExoUtil.java`、Exo 运行输出快照/映射、聚焦测试、本文和主索引。 -- Status: approved and implementation active. -- Rollback anchor: revert the atomic E11 commit or restore the recovery tag created at closure. -- Next action: run the final E11 task-guard closure with the recorded unit-test, build, and V2453A cold-start evidence. - -## 用户目标与批准 - -用户要求解决三播放器“设备有音频硬件能力但实际始终软解 PCM”的完整问题,并已批准全部阶段实施。E11 是第一独立回滚单元,只处理 Exo;MPV native、IJK 和通用能力页分别在后续任务完成。 - -## 现场根因 - -- vivo `V2453A` / Android 15 / API 35 的 91 个 MediaCodec decoder 中没有真实硬件音频 decoder;E10 和 P3-4 正确回退 FFmpeg/平台软件 PCM。 -- 设备 Audio policy 同时声明 `compress_offload_out`,支持 MP3、AAC LC/HE、FLAC、ALAC、APE、Vorbis,flags 为 `DIRECT|COMPRESS_OFFLOAD|NON_BLOCKING|GAPLESS_OFFLOAD`。 -- 标准 offload API 对 AAC/MP3 返回不支持,但 `AudioManager.getDirectPlaybackSupport()` 返回 bitstream support;独立压缩 AudioTrack 即使调用 `setOffloadedPlayback(false)`,AudioFlinger 仍实际打开 AAC LC 的 type 4 OFFLOAD 线程。 -- 当前 `ExoUtil.buildAudioSink()` 未启用 audio offload preference;默认 `DefaultAudioOffloadSupportProvider` 只依据标准 offload API,因此不会进入设备已有的厂商 direct-bitstream DSP 路径。 - -证据目录:`/private/tmp/android-device-test-P3-4-20260901-rootcause/`、`/private/tmp/android-device-test-P3-4-20260901-aac-live2/`;独立探针:`/private/tmp/AudioTrackProbe.java`、`/private/tmp/AudioOffloadInventory.java`。 - -## 最佳实践证据 - -访问日期均为 2026-09-01。 - -| 来源 | revision / URL | 等级 | 支持的结论与 WebHTV 影响 | -| --- | --- | --- | --- | -| Android `AudioManager.getDirectPlaybackSupport` | `https://developer.android.com/reference/android/media/AudioManager#getDirectPlaybackSupport(android.media.AudioFormat,%20android.media.AudioAttributes)` | A,官方 API | direct support 必须按实际 encoding、sample rate、channel mask 和 audio attributes 查询;不能把 MIME 或 policy 白名单单独当作运行能力。 | -| Android `AudioTrack.Builder.setOffloadedPlayback` | `https://developer.android.com/reference/android/media/AudioTrack.Builder#setOffloadedPlayback(boolean)` | A,官方 API | 标准 offload 是 AudioTrack 构建属性;厂商可能在普通 direct bitstream 请求上隐式选择 DSP,因此必须以实际压缩 Track/运行回调验收。 | -| WebHTV 锁定 Media3 | `1.11.0-alpha01-fongmi` AAR;`DefaultAudioSink`、`AudioTrackAudioOutputProvider`、`MediaCodecAudioRenderer`、`TrackSelectionParameters.AudioOffloadPreferences` | A,实际发货二进制 | 已实现压缩 access-unit 帧数、非阻塞写入、播放头、pause/seek/flush、路由回调和 offload 初始化/写入可恢复错误;WebHTV 只需补能力判定与选择策略,不应重写热路径。 | -| AndroidX Media issue #2258 | `https://github.com/androidx/media/issues/2258` | B,上游设备问题 | 路由/显示切换可能使压缩输出退回 PCM;参数面板必须取当前 AudioTrack 配置,不能取预先能力或设置值。 | -| vivo V2453A Audio HAL 与独立探针 | 上述本地证据,设备 build/API 35 | A,目标设备实测 | 标准 offload false 与实际 DSP direct/offload 可同时存在;厂商适配应以官方 direct support 为门禁,并让 AudioTrack 初始化作为最终裁决。 | -| P3-4 / E10 本项目实现 | `d41155f16cd81f1354672a5479743462fc168ed9`、`cf0a5dabc77fb2bbdc3e0f2cc867a9eac86060b8` | A,本项目代码与真机证据 | MediaCodec 能力与 Audio HAL 能力是两个维度;实际输出失败必须回退,能力页和运行面板必须分离。 | - -### 证据类别说明 - -- PR/issue/revert:issue #2258 证明输出路由可在运行时改变;未发现可直接覆盖 vivo 非标准 direct-bitstream offload 的通用上游修复。 -- 成熟相关实现:Media3 本身是本阶段采用的成熟实现;mpvRex、SaltPlayerSource 没有比 Media3 更完整的视频容器 compressed AudioTrack 时钟/seek 合同,因此不复制其输出层。 -- 论文/基准:本阶段不改变编解码算法;学术论文不适用。性能以零双解码、零新增热路径复制和真机 CPU/AudioFlinger 证据验收。 - -## 方案比较与决定 - -| 方案 | 正确性 | 性能 | 风险 | 决定 | -| --- | --- | --- | --- | --- | -| 不改 | 所有普通压缩音频继续软件 PCM | CPU/功耗较高 | 不满足目标 | 拒绝 | -| 仅启用 Media3 默认 offload | 标准 API 正常设备有效,vivo 仍被判不支持 | 成熟稳定 | 覆盖不足 | 拒绝 | -| App 自建 packet/AudioTrack 管线 | 可完全控制 | 容易重复复制、时钟和生命周期代码 | 回归面大 | 拒绝 | -| Media3 管线 + 标准 offload/direct 双证据 provider | 复用成熟 encoded 管线,设备明确支持才启用,失败可恢复 PCM | 稳态无额外解码/复制 | 厂商 direct 模式需真机严验 | 采用 | - -## 实施设计 - -1. `DefaultTrackSelector` 保留 `AUDIO_OFFLOAD_MODE_ENABLED`,仅供标准 Media3 offload 使用;厂商 direct 不再伪装成 offload。 -2. 新 provider 先保留标准 passthrough/offload 判断;标准输出不支持时,仅对 Media3 已能计算 encoded sample frames、Android encoding 有效、系统 direct playback 明确支持的格式返回 `FORMAT_SUPPORTED_DIRECTLY`,使 `MediaCodecAudioRenderer` 走 encoded bypass。 -3. direct 查询使用真实 sample rate/channel mask/audio attributes;未知声道、超出 policy 的 AAC 5.1 等不强行启用。 -4. 厂商 direct 输出使用 encoded、non-offload `AudioOutputProvider.OutputConfig`,AudioTrack builder 最后固定 256 KiB 缓冲并设置 `setOffloadedPlayback(false)`;vivo HAL 可隐式路由 DSP,同时该路径不受 Media3“仅纯音频允许 offload”的限制。 -5. 厂商 direct AudioTrack 初始化或写入失败时,在本次 player 会话中拉黑该 encoding/sample-rate/channel-mask;Media3 重新选择同轨 decoder + PCM,不持久化错误状态。 -6. 运行面板以 AudioTrack 初始化快照中的实际 encoding/output mode 为事实;能力或设置只用于选择,不能直接生成“硬件直出”文案。 - -## 性能与兼容合同 - -- 不并行创建软件 decoder,不双解码,不预热,不增加线程。 -- 不进入 PCM buffer 内容处理,也不复制数据;encoded write 只增加异常边界,正常成功路径没有额外分配;能力查询只发生在格式支持/AudioTrack 配置阶段。 -- 标准 Media3 offload 设备保持上游行为;厂商 direct 适配只在标准 provider 不支持且系统 direct support 明确成立时生效。 -- 倍速、音频处理器、karaoke/音效等需要 PCM 的场景由 Media3 support/selection 约束自动保留 PCM。 -- HDMI 原码直通和普通压缩 DSP 直出是不同语义;E11 不改变既有 AC3/DTS/TrueHD passthrough 决策。 - -## 验收、发布与回滚 - -1. 聚焦单测覆盖标准支持优先、direct fallback、未知格式拒绝、encoding 白名单和同配置失败后不重试。 -2. App Java 编译与 Mobile arm64 Debug APK。 -3. V2453A 播放 AAC/MP3 stereo:AudioFlinger 必须显示 WebHTV UID 的压缩 DIRECT/OFFLOAD Track,面板显示实际硬件直出;AAC 5.1 必须保持 PCM。 -4. pause/resume、seek、切轨/切集、路由变化和至少一次失败回退;不得循环重建或 A/V 漂移。 -5. 比较同一输入 CPU/温度和 prepare-to-audio;稳态不得比 PCM 路径增加可见负担。 -6. E11 为 App-only 单提交,不更新 Media3 AAR/native;异常时回滚该提交即可恢复默认 PCM/直通行为。 - -## 实施记录 - -- 2026-09-01 13:18 CST:恢复 `feature/mpv-audio-fallback-policy@d41155f16cd81f1354672a5479743462fc168ed9`,确认仅 `app/.cxx/` 为既有受保护生成目录,V2453A USB 在线。 -- 2026-09-01:完成锁定 Media3 二进制 API/字节码、Android 官方 API、目标设备 Audio policy/AudioFlinger/独立探针和 issue #2258 复核;用户已批准实施。 -- 2026-09-01 16:41 CST:ADB 直接启动 `AAC_LC_2.0_48kHz.mp4`,provider 多次判定 `vendor-direct`,但 Media3 仍创建 `c2.android.aac.decoder` 和 PCM AudioTrack。锁定 Media3 字节码确认 `DefaultTrackSelector.maybeConfigureRendererForOffload` 在任何非音频 renderer 被选中时禁止 offload,因此原实现只可能覆盖纯音频,不能满足视频播放目标。 -- 2026-09-01 16:49 CST:ADB 直接启动 MP3 纯音频,Media3 选中 vendor offload 后 AudioTrack 初始化失败并自动回退 `c2.android.mp3.decoder` + PCM;独立 `app_process` 探针确认同一 AAC/MP3 配置采用 non-offload direct AudioTrack 和 256 KiB 缓冲均能成功打开。实施设计据此修正为 encoded bypass + non-offload direct AudioTrack。 -- 2026-09-01 18:04 CST:使用手机测试库 AAC 视频连续完成 v4/v5 命令行回放。Media3 provider 在 encoding=10、48 kHz、stereo、256 KiB、session=0、non-tunneling、non-offload 下仍返回 `status=-38`;同一设备、同一 WebHTV UID、同进程名的独立探针在完整 AudioAttributes、重复 builder setter、32 次 direct-support 查询后均成功创建 AAC OFFLOAD 线程。已否定 tunneling、session、virtual-device Context、扩展 AudioAttributes、setter 顺序及 capability-query 竞争假设,下一步仅替换 vendor-direct 的建轨动作,仍由 Media3 AudioTrackAudioOutput 管理时钟与生命周期。 -- 2026-09-01 18:59 CST:连续播放失败根因进一步确认:vendor-direct 建轨异常后虽然配置已加入失败集合,但 provider 仍透传底层标准 passthrough/direct 支持,Media3 没有重新选择 PCM decoder。采用同一 Exo 引擎内复用 direct policy、由 `ExoPlayerEngine.handleError()` 原地重启当前 item 一次、并对失败配置强制返回 encoded 不支持的方案,避免新增播放器实例和重试循环。 -- 2026-09-01 22:39 CST:V2453A 冷启动播放手机测试库 `A11_MP3/MP3_2.0_44.1kHz_128kbps.mp3`。首次 vendor-direct 初始化返回 `status=-38` 并上报 `ERROR_CODE_AUDIO_TRACK_INIT_FAILED`;同一 PID 随后创建 `c2.android.mp3.decoder`,未发生用户二次点击。 -- 2026-09-01 23:00 CST:为避免 3.7 秒 AAC 样本在恢复窗口结束前自然结束,临时生成 59 秒 AAC LC 2.0 副本(仅 `/private/tmp`,未进入测试库/Git)。首次播放在 `mediaPos=0.00` 报 direct 初始化失败,随后同一 PID 创建 `c2.android.aac.decoder`;AudioFlinger 记录 PID 23905、Track 3367、PCM 16-bit、48 kHz、stereo,播放器保持 `VideoActivity`。 -- 2026-09-01 23:02 CST:V2453A 冷启动播放 `A01_AAC/AAC_5.1_声道.mp4`。AudioFlinger 记录 PID 24878、Track 3368、PCM 16-bit、44.1 kHz、channel mask `0x3F`(6 声道);该声道组合未进入 vendor-direct,直接走 decoder + PCM。 -- 2026-09-01 23:06 CST:确认短 AAC 样本的首次 2 秒抓取不作为验收证据;其后长样本已闭合首次自动回退、解码器初始化和 PCM 输出链路。 - -## 测试库扩展(2026-09-01) - -依据 V2453A 的 `compress_offload_out` profile(MP3、AAC-LC/HE、FLAC、ALAC、APE、Vorbis),在本机与手机 `/storage/emulated/0/Download/影音测试库/` 各补充以下 6 个可追溯样本。每个文件已用 `ffprobe` 验证编码/采样率/声道,并完成手机回读 SHA-256 比对;这些样本用于后续 Exo/MPV/IJK 能力覆盖和回退测试,当前 E11 的 Exo 直出验收仍限定 AAC/MP3。 - -| 文件 | 实际格式 | 来源 | SHA-256 | -| --- | --- | --- | --- | -| `A01_AAC/AAC_HE_V1_2.0_44.1kHz.aac` | HE-AAC v1,44.1 kHz,2.0,ADTS | `https://samples.ffmpeg.org/A-codecs/suite/AAC+/WishI-48kSBR.aac` | `5326480b94f1828fdfce398a6bf528308e7152dbf44b9d4838f4633efaf5a414` | -| `A01_AAC/AAC_HE_V2_2.0_44.1kHz.aac` | HE-AAC v2,44.1 kHz,2.0,ADTS | `https://samples.ffmpeg.org/A-codecs/suite/AAC+/WishI-48kSBRPS.aac` | `91f9eba3b402b755d05dfc503220ba4b85ca877c95f16464117241b7852bbd46` | -| `A12_ALAC/ALAC_2.0_48kHz.mov` | ALAC,48 kHz,2.0,MOV | `https://samples.ffmpeg.org/A-codecs/lossless/ALAC/ALAC_24bits2.mov` | `3ddb9d38a4ec51cde8be6c5840cb622f77d20aa7a4652b613d7b49ab181e9e55` | -| `A12_ALAC/ALAC_5.1_48kHz.mov` | ALAC,48 kHz,5.1,MOV | `https://samples.ffmpeg.org/A-codecs/lossless/ALAC/ALAC_6ch.mov` | `944a78472074f6c0a4df74d0126eede3581f70cd171943dfe45c93a3c0e8dc35` | -| `A13_APE/APE_2.0_44.1kHz_sh3.ape` | Monkey's Audio,44.1 kHz,2.0 | `https://samples.ffmpeg.org/monkeyaudio/sh3.ape` | `9b8e89b81a87001648d58dc9ef440a5b9b8c214a4df07bd22776da1ff6e32004` | -| `A14_Vorbis/Vorbis_2.0_44.1kHz_160kbps.ogg` | Vorbis,44.1 kHz,2.0,160 kbps,Ogg | AndroidX Media3 `2bc207851df311340767e913931ca7b28cab1794` `media.exolist.json`; `https://storage.googleapis.com/exoplayer-test-media-1/ogg/play.ogg` | `d5bdb7257d6b9bb2d22c005685e4fa0984db32ac1963b792414916ee79352f62` | - -`samples.ffmpeg.org` 是 MPlayer/FFmpeg 测试样本集合,新增样本仅用于本地/测试设备验证,不对外重新分发。 - -### 单测门禁修复:夹具脱离 android.os.SystemClock(2026-09-21) - -- 问题:合并 `f836d419518d1a93d4ff77414a88558f3854c7e3` 后,以仓库真实单测配置(未开启 `unitTests.returnDefaultValues`)执行 `:app:testMobileArm64_v8aDebugUnitTest` 得到 4765 项 / 12 失败 / 1 跳过,失败全部在 `ExoCompressedAudioDirectPolicyTest`:夹具用 media3 `PlaybackException` 的公开构造函数,其内部调用 `android.os.SystemClock.elapsedRealtime()`,该 native 方法在 JVM 单测桩上抛出 `Method ... not mocked`。上游基线不含本地仓库的严格桩配置,因此该依赖在本地才暴露。 -- 修法:测试夹具改为经 `playbackError(errorCode, cause)` 构造 `TestPlaybackException`(`PlaybackException` 的带时间戳 protected 构造函数,显式 timestamp 0),只影响测试代码;生产路径 `requestPcmFallbackForStuckPlayback(PlaybackException)` 及其读取的 `errorCode`/`getCause()` 语义不变。 -- 拒绝的替代:全局开启 `testOptions.unitTests.returnDefaultValues = true`,会把所有单测的未实现框架方法静默降级为默认值,放宽既有门禁。 -- 验证(真实构建配置,无临时 init 脚本):`:app:testMobileArm64_v8aDebugUnitTest` 全量 4765 项(失败 0、错误 0、跳过 1)`BUILD SUCCESSFUL`;`:app:testLeanbackArmeabi_v7aDebugUnitTest` 以 `com.fongmi.android.tv.player.exo.*` 与 `FlagSelectionListenerTest` 过滤执行 75 个测试类共 553 项(失败 0、错误 0、跳过 0)`BUILD SUCCESSFUL`,其中含被修复的 `ExoCompressedAudioDirectPolicyTest` 26 项。 - -### 切换播放器后厂商直出初始化失败导致延迟卡顿(2026-09-21) - -- 用户现象:0904 版本音乐来回切换播放器顺畅;0921 版本才恢复音乐播放,但 MPV -> EXO 后 EXO 可显示首帧却不出声、界面持续缓冲,按下一曲或退出重开才恢复;MPV 也存在切换后长时间等待。两份用户日志:`webhtv-debug-log (21).txt`、`webhtv-debug-log_3.txt`。 -- 现场证据:`webhtv-debug-log_3.txt` 中 `p-6mbtnm-4` 于 `14:33:03.382` 开始连续 `AudioTrack init failed 0 Config(48000,12,10,262144/131072/100000)`,`p-6mcun2-7` 于 `14:33:52.065` 以 44100Hz 重复同一失败;两次都只记录 sink 回调与首帧,之后没有 `onPlayerError`、没有 PCM fallback,直到用户清播放或退出。对照初始起播的 `p-6mbmjg-2`,Media3 在 `14:32:54.448` 最终上报 `ERROR_CODE_AUDIO_TRACK_INIT_FAILED`,随后现有逻辑成功执行 `fallback=pcm`。 -- 根因:锁定 Media3 `1.11.0-alpha01-fongmi` 的 `DefaultAudioSink` 对非 offload 的 `AudioOutputProvider.InitializationException` 不立即上抛,而是放入 `PendingExceptionHolder`;其计时起点被全局 `pendingReleaseCount` 阻塞。切内核时旧 `AudioTrackAudioOutput.release()` 走异步释放,若 `onReleased` 未及时回到旧的 playback thread,新 sink 的初始化失败会被无限期延迟,App 层 `ExoPlayerEngine.handleError()` 因此没有机会执行已有 PCM 回退。0904 基线没有这套 vendor-direct 失败路径,所以切换不触发该等待。 -- 本地源码复核:`ExoCompressedAudioDirectPolicy.getAudioOutput()` 已在厂商直出初始化异常时执行 `disableVendorDirect()` 并设置 `pendingPcmFallback`;但 `ExoPlayerEngine` 只在最终 `PlaybackException` 到达时消费该请求,和日志断层完全对应。 -- 修法:策略层新增每输出 attempt 仅一次的 `InitializationFailureListener`,在初始化异常现场通知引擎;`ExoPlayerEngine` 用 attempt generation 与 request sequence 去重,将 PCM 重启投递到主线程,并在 `handleError()` 保留原最终错误回退作为兜底。`startInternal()`、`release()`、`rebuild()`、`stop()` 推进 generation,避免旧切换的延迟回调误重启新播放;`resetOutputProgress()` 清理本 attempt 的通知与请求状态。 -- 拒绝的替代:等待 Media3 固定延迟上抛会把用户可见卡顿保留;把 vendor-direct `OutputConfig` 伪装成 offload 会改变 Media3 的 offload/gapless/回退语义;修改锁定 AAR 需要额外二进制重建与更宽回滚范围,均不符合本次局部修复合同。 -- 验证:`:app:testLeanbackArmeabi_v7aDebugUnitTest --tests com.fongmi.android.tv.player.exo.ExoCompressedAudioDirectPolicyTest` 通过(含新增“初始化失败必须通知一次”用例),主源码 Java 编译通过。随后以 `scripts/build_arm64_debug_install.sh --serial 192.168.50.3:5557` 覆盖安装 mobile/arm64-v8a Debug 到 dev2,安装成功且未卸载原包;安装后 EXO 正常 PCM 播放会话中 `OMX.google.aac.decoder`、`audio.output.playhead` 持续前进。 -- 设备验证边界:dev2 对同一个 HLS AAC 样例返回 `exo-audio-direct: ... reason=no-direct-support`,没有进入用户 Sony 日志中的 `reason=vendor-direct` 失败分支,因此本轮不能把“原设备切换已通过”冒充为已验证结论;需要在会触发 vendor-direct 初始化失败的设备/片源上复测 MPV -> EXO 和 EXO -> MPV,预期日志应在首次 `disable ... reason=initialization` 后立即出现 `fallback=pcm`,而不是等待最终 `onPlayerError`。 - -### dev5 未推送改动复评与 beta 交付准备(2026-09-21) - -- 冻结基线:`dev5@8325175e633951edc4c54c67671d239b0567b2fa`;`origin/beta@150e29340200a1cb2173880a3ef7b92651bc2879`;`upstream/main@8e4d9333de8ea7346491e71a0b1ab6858a852298`。远端 beta 和 fish2018/main 均为当前 HEAD 的祖先,合并树没有待解决差异。 -- 首轮审查范围:相对 `origin/beta` 的 5 个上游 Exo 修复提交、双父 merge、本地立即初始化失败回退接线、失败记忆、启动停滞 renderer 包装及对应测试。逐项检查了 attempt generation、初始化失败去重、失败配置禁用、PCM 确认与 TTL/容量清理,以及直通关闭时 format/config/output 三层准入;没有发现需要修改生产代码的缺陷。 -- 定向门禁:`bash ./gradlew --console=plain :app:testLeanbackArmeabi_v7aDebugUnitTest --tests com.fongmi.android.tv.player.exo.ExoCompressedAudioDirectPolicyTest --tests com.fongmi.android.tv.player.exo.ExoAudioDirectFailureMemoryTest --tests com.fongmi.android.tv.player.exo.ExoStartupRecoveryIntegrationTest --tests com.fongmi.android.tv.player.PlaybackErrorClassifierTest :app:compileLeanbackArm64_v8aDebugJavaWithJavac :app:compileMobileArm64_v8aDebugJavaWithJavac` 通过:81 项中 0 failure、0 error、1 项显式 `@Ignore`;Leanback/Mobile ARM64 Java 编译均通过,Gradle `BUILD SUCCESSFUL in 37s`。 -- 第二轮审查:对照仓库锁定 Media3 `1.11.0-alpha01-fongmi` 源码确认,`ExoStartupAudioRenderer` 发出的 `isRecoverable=true` renderer error 会进入 `ExoPlayerImplInternal.attemptRendererErrorRecovery()` / `reselectTracksInternalAndSeek()`,复用当前 prepared period;`DefaultAudioSink.flush()` 只释放旧输出,不会清除已记录的失败证据。结合测试结果再次核对未推送差异,未发现新增问题,因此没有为“修测试而修代码”的无效改动。 -- 交付边界:本轮没有连接模拟器或目标电视,不把主机控制流、Java 编译或受控 Media3 集成测试扩大为真实 HAL/Sony 播放验收;未改依赖锁、native 资产、AAR 或 MPV。未推送改动通过本次 guard 原子记录后推送 `dev5`,并以 `dev5 -> beta` PR 交付。 diff --git a/docs/E4-LIBASS-exo-ass-rendering.md b/docs/E4-LIBASS-exo-ass-rendering.md deleted file mode 100644 index 4d3b7a8d4f..0000000000 --- a/docs/E4-LIBASS-exo-ass-rendering.md +++ /dev/null @@ -1,799 +0,0 @@ -# E4-LIBASS:Exo ASS 特效字幕方案与最佳实践评审 - -## Recovery anchor - -- 当前目标/授权(2026-09-19):用户明确要求“修复这个bug,同样的视频mpv可以正常处理,exo没理由不能”,批准第 17 节 HDR/DV 独立 SDR 字幕层的窄修复与原片验证。 -- 当前单元:`E4-LIBASS-hdr-fix-20260919`,`upstream`;分支 `feature/mpv-dv7-fel`,基线/回滚锚点 `e85dc87988bbe8e3d67509426cb5e1d1a2cee3b7`。scope 为 Exo ASS Java、对应测试/debug fixture、独立 ASS JNI 与配套产物、本文件/索引;保护初始 `app/.cxx/` 的 104 个文件。 -- 已完成:确定 `ExoAssSession.admittedLocked()` 显式排除 Dolby Vision MIME、非 SDR transfer 和 BT.2020;核对当前 SSA packet 桥、独立 Surface 宿主和 native 颜色代码,完成第 17 节的窄方案取证。截图中的 Exo 为普通白字,MPV 保留大小、粗体和黄色英文;两图对白时间不同,不能当逐像素基准。 -- 原片证据:vivo V2453A `10CF6H1D2L0009S` 的正常 `VideoActivity` 播放《伦敦陷落》;旧包日志确认 `media3-ssa` 输入、3840×2160 HEVC 硬解、实际输出 BT.2020/PQ(standard=6/transfer=6)、Surface dataspace `0x11c60000`。这些是安装前实测;不将它们冒充候选包的输出复测。 -- 实施进度:新增 `AssVideoPolicy`,HDR/DV/BT.2020 使用原始 SDR RGB;保留 SDR 视频的旧矩阵行为及 DRM/未知 transfer/rotation/tunneling/宿主限制。JNI 在已有 colorSpace 参数为 0 时跳过历史视频 YCbCr 转换,不改 API 或依赖;代码、JNI 与测试包已构建/验证并安装。 -- 验证/验收:7 项定向 Android instrumentation 均已执行,其中 6 项 HDR/颜色/JNI 修复相关用例通过(7.185 秒);另 1 项真实 Exo 字幕生命周期用例因既有测试夹具硬编码启动包名与当前 `applicationId` 不一致而未能启动。native/APK 来源、API 24、16 KiB 和库字节核验通过。用户明确确认“可以了,打tag”,按实际观察验收闭合;取消未执行的额外截图/原片性能/相邻用例,不声称这些检查已完成。 -- 时间/证据:08:29 Asia/Shanghai 开始,原目标 09:04–09:09 因解锁等待和构建环境处理延后,09:12 已告知剩余 15–20 分钟。证据在 `/private/tmp/exo-ssa-20260919/`;Gradle 联合构建 2 分 33 秒。首次沙箱缓存锁失败与后续成功日志均保留。 -- 唯一下一步:按 `E4-LIBASS-hdr-fix-20260919` guard 原子提交任务文件并立即创建本地恢复 tag;无需追加测试或研究,不推送。 - -## 17. HDR/DV 下 SSA 样式回退诊断与修复(2026-09-19,已批准实施) - -### 17.1 已证实的代码条件与推断边界 - -当前 `ExoAssSession.createIfEnabled()` 已在 64 位进程创建必需会话,`ExoUtil` 在追加副字幕 renderer 前给主 TextRenderer 安装 ASS observer;不是旧实验构建开关重新出现。`AssPacketInput` 保留 Media3 SSA sample 的 ReadOrder、Layer、Style、正文和覆盖标签。 - -`ExoAssSession.admittedLocked()` 则仍拒绝 `VIDEO_DOLBY_VISION`、PQ/HLG 等非 SDR transfer 和 `COLOR_SPACE_BT2020`。不满足条件时,`AssSurfaceHost.update()` 不创建独立字幕层,现有 SubtitleView 继续显示兼容 Cue。因此即使 SSA 内容正确,HDR/DV 片源也不会启用原字体/完整特效。该限制从原型保留至当前,是明确的能力缺口;截图片名不是实际视频元数据,仍需同片同轨验证。 - -仅删除上述条件不构成完整修复:`third_party/exo-ass-native/exo_ass.cpp:rgba()` 只区分 BT.601 与其余值,后者均按 BT.709 做历史 YCbCr 兼容转换。它不能正确表达 HDR 独立 SDR 字幕的颜色策略。当前 native 已使用独立 RGBA8/EGL Surface,并不把字幕烧入视频。 - -### 17.2 决策证据 - -访问日期为 2026-09-19;下列 revision 均为参考或保持,无待合并上游提交批次。 - -| 来源/版本 | 等级、支持的判断 | 适用性与决定影响 | -| --- | --- | --- | -| 当前 WebHTV `88aceb110959ff50afc23b10d9b9abe3e0f53255`;`ExoAssSession`、`AssSurfaceHost`、`AssPacketInput`、`exo_ass.cpp` | A:实际 admission、输入、显示和颜色处理 | 限定修复在现有 Exo ASS 链;保留主/副轨职责、时钟、字体与异常回退 | -| 锁定 libass `89cc0f4e450d64f74281a17d7f11ed05229665e8` 的 [`ass_types.h`](https://github.com/libass/libass/blob/89cc0f4e450d64f74281a17d7f11ed05229665e8/libass/ass_types.h),本地 `build/exo-ass-native/sources/libass/libass/ass_types.h:157`,identity 与 lock 一致 | A:HDR 上的字幕应视为 SDR;精确匹配 HDR 画面颜色没有标准,呈现方选择 SDR 色彩空间;YCbCr 兼容由调用方负责 | HDR 分支采用独立 SDR 字幕颜色,不能把视频 BT.2020/PQ 直接当现有 SDR 矩阵参数;不升级 libass | -| [Android mixed SDR/HDR composition](https://source.android.com/docs/core/display/mixed-sdr-hdr),页面更新 2026-06-17 | A:SurfaceFlinger/HWC 合成及逐层 SDR 白点/调暗;设备配置影响亮度和功耗,显示验证具有设备差异 | 保持视频 Surface 与独立字幕层;真机必须确认视频仍为 HDR/DV,字幕不过亮、透明区正常;不宣称所有旧电视一致 | -| FongMi/mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`,`sub/sd_ass.c:mangle_colors` | A/B:按字幕矩阵、视频矩阵及兼容选项决定转换;basic 模式只处理 BT.601 到 BT.709 的历史转换 | 参考分离呈现与旧颜色兼容的处理;不复制 MPV 会话或替换 MPV 库,不把它的截图视为 Exo 验证 | -| [Media3 #2383 维护者讨论](https://github.com/androidx/media/issues/2383#issuecomment-2872355740)、[libass-android 作者现场报告](https://github.com/androidx/media/issues/2383#issuecomment-2880916225) | B/D:effects 内混合的亮度调整;作者报告部分设备的 effects 路径改变 HDR/DV 输出 | 确认避免为本修复改走视频 effects;报告不是当前独立 Surface 必然失败的证据。相关 API/问题讨论、成熟项目和现场证据均已覆盖 | - -沿用第 10 节的 ASS 排版、字体、时间与合成资料,不重复研究已验收的渲染内核。本次不提出新的栅格化算法或性能优化,新增论文/通用博客不会改变 admission 与独立 SDR 呈现的决定;设备亮度/合成成本由实测解决,不以论文或截图替代。 - -### 17.3 方案与最小实施范围 - -| 方案 | 结果与取舍 | 决定 | -| --- | --- | --- | -| 不改动 | 保留原型边界,HDR/DV 继续失去完整字幕样式 | 不满足本问题的修复目标 | -| 只删除 HDR/DV 排除条件 | 可进入 libass,但原 BT.601/BT.709 转换会错误接收 HDR 元数据,未解决显示验收 | 不采用 | -| 原样引入第三方 effects 路径 | 需要改视频管线,扩大 DV、厂商解码/呈现和维护风险 | 不采用 | -| 适配现有独立字幕层 | 放行已支持 SurfaceView 上的非加密、非 tunneling HDR/DV;HDR 字幕作为 SDR RGB 呈现,SDR 视频保留既有矩阵行为 | 已批准实施,验证与用户验收见第 17.5 节 | - -拟修改路径:`app/src/main/java/com/fongmi/android/tv/player/exo/ass/`、对应 ASS 单测/设备测试、必要的 debug fixture、`third_party/exo-ass-native/` 中 JNI 源码和配套 arm64 产物/manifest/provenance,以及本文件/评估索引。采用现有 render 参数的明确 HDR/SDR 颜色契约,避免改写用户字幕脚本。依赖版本、Media3 AAR、MPV、视频解码/输出选择、32 位支持、TextureView、DRM 与 tunneling 均不属于本单元。独立 native 构建已有 warm cache,可用 `scripts/build_exo_ass_native.py --jni-only --install` 重建必要 JNI;实施前确认精确文件 scope。 - -不新增字幕线程或额外逐帧中间缓冲;开启 HDR 上原本未运行的 libass 必然增加其排版/合成成本,不能称为零开销。沿用本任务普通字幕一核 CPU ≤10%、render+upload p95 ≤8 ms 的验收预算;仅当使用已获批复杂压力样本时才适用第 13 节的 40%/16.67 ms 预算。独立 Surface 的设备合成成本和视频丢帧同时记录;源码、JNI、manifest/provenance 与 App 适配整体回滚。 - -### 17.4 验收、时间与回滚 - -1. 先在原片确认视频 Format、同一主字幕轨、字体和 libass 由 COMPAT 进入 ACTIVE;同时间对照字体、字号比例、颜色、描边与位置,不能用两句不同对白做像素比较。 -2. 定向覆盖 HDR10/HLG/DV admission 和原 SDR 颜色;native 固定颜色/透明度输出应证明 HDR 路径不误套旧视频矩阵,SDR 回归保持。保留原有 DRM/tunneling/宿主限制与异常回退。 -3. 只构建 arm64 JNI 和手机 App/测试 APK,核对 ELF/API/16 KiB、manifest 和 APK 字节一致性;以现有核验脚本执行必要产物检查,不重建 Media3/MPV 或全 ABI 矩阵。 -4. 设备原片检查视频 HDR/DV 模式、可见字幕颜色/亮度、暂停/seek、字幕开关和 Surface 重挂;同片同设置测量基线/候选的字幕耗时、CPU、视频丢帧,噪声敏感指标至少三组。字幕通过不能以改视频为 SDR、换解码器或降低画质换取。 - -预计批准并解锁后 25–40 分钟:局部实现和定向测试 8–12 分钟、warm JNI/Gradle 构建 3–6 分钟、设备原片与性能验证 11–18 分钟、原子提交/tag 约 3 分钟。实际开工时更新当地完成时间;原片网络/设备等待单列。最小回滚为整体撤销该次已验证实现提交,恢复本评估基线的 App/JNI 配套状态。未完成必要实机验收时不得宣称 HDR/DV 已修复。 - -### 17.5 实施与闭合结果 - -- `AssVideoPolicy` 负责 HDR10/HLG/DV/BT.2020 的独立 SDR RGB 选择;SDR 显式矩阵和缺省分辨率推断不变,未知 transfer、旋转、加密及现有宿主限制保留。`ExoAssSession` 使用该策略;`AssNative.COLOR_SPACE_SDR_RGB=0` 与 JNI 的受控分支配套。 -- 独立 JNI 仅重编本地 wrapper,未升级依赖,未改 Media3/MPV 或视频输出路径。`libexo_ass.so` 仍为 2775600 字节,SHA-256 `31e04a1d26c606dd2f5df0b0b81f2916ed0b29c13b3415515a77cff540e83cc2`;manifest/provenance 与源输入一致,API 24、ARM64、16 KiB LOAD/ZIP、动态依赖和 JNI 导出核验通过。普通视频原有 YCbCr 兼容仍由测试确认。 -- 一次联合 Gradle 构建通过:`:app:assembleMobileArm64_v8aDebug`、`:app:assembleMobileArm64_v8aDebugAndroidTest`,2 分 33 秒。采用既有隔离 CMake staging init script,未改初始 `.cxx`;日志 `gradle-build-approved.log`。先前沙箱禁止写 Gradle wrapper 锁文件,获准后使用现有 JDK 21/cache 完成构建,未改产品构建配置。 -- 7 项定向设备测试均已执行,其中与本次修复直接相关的 6 项通过:4 项视频/颜色策略、1 项固定 ASS 绘图的原始 RGB/预乘 alpha 与 HDR↔SDR 暂停时间点重绘、1 项官方 blur/transform 帧。另 1 项真实 Exo 生命周期用例因既有测试夹具硬编码启动包名 `com.fongmi.android.tv`,而当前 `applicationId` 为 `com.silent.android.webhtv`,未能启动 Activity;该失败不由本次 HDR/JNI 改动引入,详见本轮 C4 记录。测试覆盖与原片用户验收分别记录,不以准入测试声称各 HDR 格式均做过真实视频显示测量。 -- 修复 APK 已安装,SHA-256 `506daab799b6c3b42f3a8cb6e1e4c7b451658b5ca1f1739a957bb4866a2c3ff9`;测试 APK `ac81b4b41da076957e3e9db13066c6f4479121eb3a0f8fbceab4eb1804b60da2`。原安装包另存临时目录,SHA-256 `495157f6c45a515278bf5f74ad4dc9f09edd30522025887981e85e8162c05b50`;安装助手已完成 OEM 确认。 -- 用户随后明确“可以了,打tag”。按显式闭合要求立即归档,不再启动原片配对性能、额外图像或其他设备/媒体验证;没有失败的必需检查。原片日志中的频繁网络缓冲不归因于本次字幕修改,也不据此宣称性能已量化。双 ABI、TextureView、DRM/tunneling 等未获批能力不扩展。 -- 提交由 `Task-Guard: E4-LIBASS-hdr-fix-20260919` 定位,恢复 tag 前缀 `recovery/E4-LIBASS-hdr-fix-20260919/`,不推送。回滚整体撤销该提交中的 Java/JNI/manifest/provenance 与配套文档,恢复基线 `e85dc87988bbe8e3d67509426cb5e1d1a2cee3b7` 的配套状态。 - -## 上一单元 Recovery anchor:双字幕 - -- 当前目标/授权(2026-09-18):用户明确要求“exo实现双字幕,对标mpv播放器”,直接实施此前评估的 MPV 默认双字幕行为:主字幕保留当前 ASS/libass 字体和特效,副字幕默认置顶、使用普通字幕样式;两路独立选择/关闭,共享一个 ExoPlayer 的媒体时钟。第 2、10 节原先不包含双字幕的边界被本次授权扩展,其他历史限制不变。 -- 当前单元:`E4-LIBASS-dual-subtitles`,`upstream`;分支 `feature/mpv-dv7-fel`,基线/回滚锚点 `e2f39f240743ba4f8adf75bc6599f4ef7899d48a`。guard 保护原有 `app/.cxx/` 104 个文件;仅修改 App 的 Exo 字幕适配、既有播放器接口/管理与共用播放页/选轨页、对应测试、本文件和索引,不改 Media3/MPV/FFmpeg/独立 ASS JNI 产物或锁。 -- 决定/进度:第 16 节完成源码、官方接口、维护者讨论和 MPV 实现取证。`DualSubtitleTrackSelector`、`ExoSubtitleSession`、`SecondarySubtitleCues` 和共用 UI/engine 接线已实现;主 ASS observer 只接原主渲染器,副轨绑定实际 stream 与媒体代次拒绝旧回调。13 项本机检查通过,包含真正 ExoPlayer/合并 MediaPeriod/SubtitleView 接线;最终手机 Release/Debug/测试 APK 和电视 32 位 Java 编译通过,产物见第 16.5 节。 -- 验证:真实选轨/Renderer 合约检查,覆盖默认关闭、同时选中、主副独立关闭、冲突拒绝、切源/旧回调、暂停/偏移/seek;代表性手机播放覆盖主 ASS 与副文本共存和宿主重挂。手机/电视公共 Java 接线编译,单 ARM64 APK;不重跑所有 ABI/native 矩阵。 -- 时间/设备:2026-09-18 08:06 Asia/Shanghai(UTC+8)开始,约 2 小时,目标 10:06;取证 10 分钟、实现 65 分钟、构建/验证 35 分钟、归档 10 分钟。08:06 ADB 无设备,已请求用户连接,同时继续实现。 -- 恢复进度(11:57 Asia/Shanghai):上一轮 10 项选轨/Cue 合约全部通过,测试体 15.437 秒,Gradle 命令总耗时 1 小时 28 分 8 秒;期间会话中断,原 10:06 目标已超时。已停止扩展研究,剩余目标约 30 分钟;ADB 仍无设备,实机同屏尚未验收。 -- 归档:使用本单元 guard 一次提交源代码、测试和两份现有文档,并创建 `recovery/E4-LIBASS-dual-subtitles/` 前缀的本地注释 tag;提交以 `Task-Guard: E4-LIBASS-dual-subtitles` 定位,不推送。12:27 因打包尚未完成延后收尾,12:30 最终构建成功;没有追加研究或重复已通过的检查。 -- 唯一下一步:实体手机连接后安装本轮 APK,执行现成的内嵌/外挂 ASS 双字幕实机同屏场景。当前没有设备,不将本机检查冒充实体设备的字体、Surface 或性能验收。 - -### 上一阶段:常规构建必需功能(已归档) - -- 当前目标/授权(2026-09-15):用户指出自行构建 debug 包又使用系统字体,并明确“这是一个必须的功能,根本不存在关闭的场景”。删除 ASS 实验构建开关,使常规手机/电视 arm64 debug/release 使用此前已验收的渲染接线;不要求额外 Gradle 参数。 -- 当前单元:`E4-LIBASS-required-build`,`quick-fix`;分支 `feature/mpv-dv7-fel`,基线 `248a947ba8dcd834e386cba25e4de83984ebc1ab`。仅修改 `app/build.gradle`、`ExoUtil.java`、`ExoAssSession.java`、本文件及评估索引;初始 `app/.cxx/` 的 70 个文件继续保护。 -- 原因与决定:`exoAssPrototype` 默认 false,同时控制 BuildConfig、JNI 打包、外挂标记及会话创建。历史验收包额外传 true,普通构建未启用。按第 15 节删除配置和运行时对此配置的依赖;已有能力判定、惰性 worker、失败回退保持。 -- 验证状态:不传 ASS 参数的手机/电视 arm64 debug 增量构建通过(1 分 23 秒);两包 BuildConfig 均无实验字段,编译字节码保留会话创建,DEX 含 ASS 会话,JNI 字节与仓库一致且 ZIP 16 KiB 对齐。此修复不重建 JNI,不将已有字体/性能验收冒充本轮复测。证据为 `/private/tmp/exo-ass-required-build.log` 和 `/private/tmp/exo-ass-required-artifacts.json`。 -- 边界/回滚:现有独立 JNI 只有 arm64;32 位、HDR/DV 等既有能力缺口仍未实现。回滚本次原子提交即可恢复基线构建策略,取消开关不再作为当前回滚方法。 -- 唯一下一步:由 `E4-LIBASS-required-build` guard finish 将已验证的 5 个任务文件原子提交并创建同名前缀的本地恢复 tag,不推送。 - -### 历史阶段恢复记录(以下开关策略属于旧提交) - -- 目标/状态:阶段 1 外挂原型已归档;2026-09-15 实际容器 ASS 路径经修复后,用户确认“可以了,打tag”,按实际播放验收并归档。采用已有 Media3 SSA sample 的兼容桥,仍默认关闭、arm64/SDR/SurfaceView;精确 MKV duration/未缓存长事件 seek、双 ABI、HDR/DV、旧电视和产品化仍属后续阶段。 -- 授权:2026-09-14 用户在复评后要求“在不破坏现有功能、性能的前提下,实施方案”,授权当前推荐的阶段 1;阶段 2–4 尚未授权。 -- Lane/scope:upstream,guard `E4-LIBASS-stage1`。范围为 Exo ASS 新目录及测试、`ExoUtil`、`ExoPlayerEngine`、`PlayerManager`、公共 `PlaybackActivity`、App 构建/混淆/调试测试入口、独立 native 构建/锁/产物、TextRenderer 补丁及对应 exoplayer Maven 产物、两份现有任务文档。精确路径由 guard scope 记录。首轮证据在 `/private/tmp/webhtv-libass-research-20260914/`,复评证据在 `/private/tmp/webhtv-libass-review-20260914/`。 -- 分支/实施基线:`feature/mpv-dv7-fel`;复评基线 `845ce82c64b16c94a1db7a61ed6bd38d2d1d35ac`。用户确认 MPV 修复后单独提交 `5cde3c015258f620f264d5f3ffe0a437c2ea3d48`,恢复 tag `recovery/E4-LIBASS-mpv-font/20260914222255-5cde3c015258`;当前 Exo guard 以该提交为基线,原有 Exo 改动完整接续。 -- 保护:开始时仅 `app/.cxx/` 未跟踪,70 个文件由 guard 保护,不纳入本任务。 -- 稳定 ID:`E4-LIBASS`;历史 `E4-2` 是 Cue layer/collision/margin 适配,不重编号、不将其冒充 libass 接入。 -- 进度:输入单测 6/6、原 TextRenderer/附件/关闭路径及默认包装检查通过;暂停延迟、正常系统 provider、附件优先和完整生命周期修复已通过。最终缓存/调度版本的官方 blur/karaoke/重建与完整生命周期 4 项复测通过(7.012 秒);用户原字体画面、实际匹配日志和原 ASS 开销已核实。三对性能、8 次释放在用户明确接受的复杂动画预算下通过,原始失败结果保留。MPV 已独立归档。 -- 最便宜的决定性验证:真实 TextRenderer + fake SampleStream 的关闭、有效输入、offset/延迟、seek/流结束契约;随后进行官方固定语料、GL/生命周期/失败注入及三次配对播放测量。 -- 本次时间目标:上海时间约 18:40–20:50;接线/实现 65 分钟、单 ABI/Java 构建 20 分钟、真机/性能 35 分钟、归档 10 分钟。复用已完成研究与可溯源缓存,避免重新进行广泛评审。 -- 网络:系统 HTTP/HTTPS 代理为 `http://127.0.0.1:7897`;后续网络取证显式使用该代理。初次 GitHub 发现查询在代理识别前直连成功,单独记录。 -- 回滚:关闭实验/恢复兼容字幕;源代码、补丁、Java 产物、JNI 与锁作为同一提交整体回退至实施基线;不推送。 -- 当前变更:所有改动限 guard scope。主要符号为 `TextRenderer.Observer/Stream`、`ExoAssSession`、`AssSurfaceHost`、`AssInput`、`AssNative`;native 源在 `third_party/exo-ass-native/`。构建/运行证据集中于 `/private/tmp/webhtv-libass-stage1/`,App CMake 使用独立 `build/exo-ass-app-cxx`,初始 `.cxx` 保持保护。 -- 用户追加:要求一并修复 MPV 的 ASS 原字体问题,并明确以内嵌/外挂的成熟开源处理机制为依据。MPV 文件已单独提交,退出当前 Exo guard;字体接线增加 `MediaSourceFactory.java`、`DolbyVisionP81ExtractorsFactory.java`,保留初始 70 个脏文件。事实、固定源码与修复决策见第 13 节。 -- 恢复 tag:用户要求先打 tag 时尚有失败门槛,因此仅对已提交基线创建 `recovery/E4-LIBASS-font-baseline/20260914-2040`,指向 `845ce82c64b16c94a1db7a61ed6bd38d2d1d35ac`;不含本次未提交实现,不代表原型验收通过。 -- 边界/风险:复杂旋转、缩放和模糊仍有实测 CPU 成本,不宣称任意脚本零开销。验收设备为 vivo V2453A/API 35,视频对照为 AVC/PCM SDR;用户原字体片段不代表原 4K 视频的解码性能。双 ABI、完整 MKV 事件、HDR/DV、旧电视仍未验收。前期多生成的 68 个 `.cxx` 文件已移至临时目录,初始 70 个文件未动。 -- 最终单元:源/补丁/锁/Java 与 JNI 产物/测试/两份文档作为同一提交,由 `Task-Guard: E4-LIBASS-stage1` 及 `recovery/E4-LIBASS-stage1/` 本地注释 tag 定位,不推送。最新安装 APK SHA-256 为 `50033c50d547ebff5aba19736a572be36802b649b4c1947f55c9c65ba9a2c76e`,对应下方最终产物表。 -- 当前接续:`E4-LIBASS-runtime` guard,基线 `e7c0cdf0d6dafd5679425f045192e708e92dcfed`,恢复 tag `recovery/E4-LIBASS-stage1/20260915042400-e7c0cdf0d6da`。新证据位于 `/private/tmp/webhtv-libass-live-20260915/`;生产改动尚未验证,精确范围由当前 guard 记录,70 个原始 `.cxx` 文件继续保护。 -- 接续进度:`AssPacketInput`/会话/JNI/字体接线已实现;输入单测 14/14 通过(54 秒构建),独立 JNI 与 API/来源/许可证/导出检查通过,arm64 App/测试 APK 构建通过(1 分 25 秒)。JNI SHA-256 `476b3e048fff1002cbd25a328340637f0cb40fdec6a6f2f6033fc7548ac80157`。设备 6 项中 5 项通过;容器生命周期的首轮失败已确认是旧绘图素材的兼容 Cue 基线为空,素材已修正并重新打包。用户随后确认实际播放正常并要求 tag,按显式验收立即收尾,不补跑该项或追加截图;不将未复测项目写成通过。 -- 唯一下一步:执行 `E4-LIBASS-runtime` guard finish,将本次源/测试/产物/记录作为一个原子提交并立即创建本地恢复 tag,不推送。 - -### 阶段 1 验证口径(运行前冻结;复杂动画预算经用户明确修订) - -- 同一 vivo V2453A/API 35、同一 AVC/立体声 PCM SDR 样片、同一播放器设置,交错测量关闭/开启共三对;每次完整播放,记录启动、seek、掉帧、underrun、CPU/PSS、render/upload/swap 和温度。样片改 PCM 的现有 AAC 输出环境原因见第 13 节,生产音频策略未改。启动和 seek 的三次中位数增量分别不超过 `max(50 ms, 基线 10%)`、`max(30 ms, 基线 10%)`;不得新增 underrun,不得三对中两对出现额外视频掉帧。 -- 常规/用户 OP 样本:libass render + GL upload 的 p95 总和不超过 8 ms,worker CPU 不超过一核的 10%。2026-09-15 用户明确选择“保留完整特效,接受复杂字幕的实测 CPU 开销”,接受将连续旋转/缩放/blur 的 `animated.ass` 压力样本单列为一核 CPU ≤40%、render + upload p95 总和 ≤16.67 ms;此前已向用户说明这两个具体上限。swap 单列,不以等待时间冒充 CPU 工作。整进程 PSS 增量 ≤64 MiB,预热后末次与首次释放态增量 ≤8 MiB。原始 10%/8 ms 的压力样本失败记录保留,不改写为原门槛通过。 -- 固定官方字体/viewport/时间的 blur+kf PNG 采用预先选定容差:前景并集 RGBA 平均绝对误差 ≤1.5,任一通道误差 >16 的前景像素比例 ≤2%。屏幕可见、暂停延迟、Surface 重建、关轨、失败回退和最终资源释放独立验收。 -- 字符集限制:无 BOM 的短 GB18030 文本可能被现有 `UniversalDetector` 误判;20:13 对旧失败样本的独立探针得到 KOI8-R,对完整代表性样本得到 GB18030。原型保持既有检测策略,不宣称可可靠推断任意短文本编码;BOM/UTF-8 和完整代表性 legacy ASS 分别测试。 - -## 研究问题与证据标准 - -1. 是否已有可复用、维护中的 Media3/libass 扩展,而非仅编译脚本或演示? -2. 如何保留 ASS 原始事件、头部、字体附件和时间轴,避免普通 Cue 转换丢失动画/卡拉 OK 信息? -3. 时钟、seek/flush、选轨、暂停/倍速、字体、渲染线程和 Surface 生命周期应如何分工? -4. CPU 位图叠加、GPU 合成、WASM/WebView 或视频烧录各有什么实际限制? -5. 当前 WebHTV 的本地 Media3 补丁、网络/预加载、HDR/DV、双 ABI 与现有字幕行为如何保留? - -证据分类:A=源码/测试/官方契约;B=维护者解释或成熟项目实践;C=有方法的独立技术报告;D=单篇帖子或未经复现的经验。论文与宣传性性能数据不会直接转化为本项目性能保证。 - -## 1. 结论与建议 - -当前构建策略以第 15 节为准:用户已明确 ASS 是必需功能,删除实验构建开关。下述阶段 1 的默认关闭建议保留为历史设计记录。 - -**可以自行实现,推荐复用成熟的 libass 排版/特效内核,自行完成 Exo 接入和呈现适配。** 不必等待 Media3 官方合并 libass,也不必重新发明 ASS 解释器。 - -目标是让 Exo 播放时支持字幕组常见的定位、移动、变换、卡拉 OK、描边/阴影/模糊、矢量绘图、裁剪、分层及嵌入字体。这里的“完整特效”以选定 libass 版本及测试语料为基准,不能扩大为全部 VSFilter 历史行为、所有字体格式或所有电视均完全一致。 - -最直接的开源起点是 **peerless2012/libass-android**;最有价值的实际消费者是 **Jellyfin Android TV**;跨平台设计参照是 **GStreamer、VLC、Kodi、JASSUB**。这组证据能支撑可行性和设计选择,不能代替 WebHTV 的性能及 HDR/DV 实机验收。 - -建议批准时只先实施阶段 1:默认关闭的外挂 ASS 原型,验证 libass、字体、复用既有承载方式的透明层、准确时钟及兼容字幕回退。复评将接入方式收敛为“保留真实 TextRenderer + 本地窄观察接口”,见 6.1;代价是首阶段需要重建受影响的 Media3 Java 产物,而不只是加 App 类。对于视频为 SurfaceView 的路线,优先复用现有字幕 SurfaceView 的承载契约。其余阶段逐阶段批准,不自动进入 MKV 提取器或生产默认行为修改。 - -### 1.1 复评发现及决策变化 - -原方案的 libass 内核、独立叠加层和分阶段方向成立,但以下缺口必须补齐,才能据此实施。 - -| 优先级 | 已核实的缺口 | 本次决定 | -| --- | --- | --- | -| P1,编码前 | 实际 Media3 默认不在提取阶段解析外挂字幕;只注入 parser factory 可能完全不走预期路径 | 从当前选中的 TextRenderer 输入接入;不全局翻转字幕解析模式,见 2.2、6.1 | -| P1,编码前 | raw ASS 带合成 Dialogue 前缀;原始 BlockDuration 已被写成百分之一秒,不能冒充原始 MKV chunk | 外挂完整文件、Media3 封装事件、原生 MKV 事件三种输入显式区分;精确 MKV 输入留给阶段 2 | -| P1,编码前 | NoSampleRenderer 不接收文本延迟消息;包装 TextRenderer 会绕开 RendererHolder 的类型特判 | 保留 TextRenderer 实例及其原状态机,在实际 offset/延迟已知的位置输出观察数据 | -| P1,原型验收 | 无操作 parser、隐藏 Cue 和“异常时回退”之间缺少可执行的恢复路径 | 保留兼容 Cue、保存当前有效输出,成功接管才抑制重复显示;输入超限或原生层可返回失败时恢复,见 6.4 | -| P1,MKV 验收 | “seek 后重放长事件”不是缓存自然能保证的能力,尤其首次跳到未读区间 | 明确已缓存/未缓存两类;缺少覆盖证明时不声称完整支持,见 6.2 | -| P2,产品化前 | 色彩矩阵、晚到字体、API 24 与现有 CMake API 26 差异、实际图像基准还不够具体 | 补 libass 官方契约和回归语料;基础 native 门槛前移至首次构建,见 6.3、9、11 | - -这些是对方案和接口的审查结论,不是已复现的 WebHTV 故障,也不是对开源项目整体质量的评价。 - -## 2. 当前 WebHTV 的实际基础 - -| 已有能力与落点 | 对本次设计的约束 | -| --- | --- | -| [ExoUtil.java](../app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java) `setPlayerView`:启用 embedded styles,但关闭 embedded font sizes;已有用户字幕位置/字号设置 | 普通字幕继续沿用原行为;ASS 原样模式必须明确用户字号/位置覆盖规则,不能无条件破坏脚本坐标 | -| 同文件 `buildPlayer`、`buildRenderersFactory`:自定义 `FfmpegRenderersFactory`、软解/硬解选择、音频输出和动态调度 | 只能装饰既有工厂;不能换成扩展示例中的默认播放器工厂 | -| 同文件约 212 行已注册 `PlaybackAnalyticsListener` 为 `VideoFrameMetadataListener`;[PlaybackAnalyticsListener.java](../app/src/main/java/com/fongmi/android/tv/player/exo/PlaybackAnalyticsListener.java) `onVideoFrameAboutToBeRendered` 更新帧率、帧调度/seek 诊断 | 如使用帧时间做字幕同步,应共享分发并保留原监听者;再次直接 `setVideoFrameMetadataListener` 会覆盖既有接线 | -| [MediaSourceFactory.java](../app/src/main/java/com/fongmi/android/tv/player/exo/MediaSourceFactory.java) `createUpstreamDataSourceFactory`、`getExtractorsFactory`、`createMediaSource` | 保留 OkHttp 请求头、EOF 恢复、预加载优先级、缓存、APE、拼接播放及仅加载选中轨道策略;外挂字幕也走现有数据源契约 | -| [DolbyVisionP81ExtractorsFactory.java](../app/src/main/java/com/fongmi/android/tv/player/exo/DolbyVisionP81ExtractorsFactory.java) `wrap`:Matroska raw subtitle、远程 deferred Cues、DV7/P8.1 参数和视频包装 | 直接替换为第三方 `AssMatroskaExtractor` 可能丢失这些参数和后续视频适配;应在现有链路加最小输入钩子 | -| 锁定的 Media3 `SsaParser` 及 `SsaParserTest` 已有样式、位置、layer、重叠与字体样式测试;历史 E4-2 为 layer/collision/margin 适配 | 当前不是“完全不支持 ASS 样式”;缺口是完整运行时特效、字体与帧呈现。不能把 E4-2 重新包装成新任务 | -| MPV 的独立 native 链已经带 libass 与字体库 | 可用作视觉参考;不能从 `libmpv.so` 内部借私有符号/JNI 给 Exo,不能把 MPV 播放成功当作 Exo 验证 | - -本地版本依据:[media-lock.json](../third_party/media-lock.json) 的 Media3 `1.11.0-alpha01-fongmi`,源码提交 `e3e922d5c01bc0b564849940fe589daf37360d15`;nextlib `1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3`,提交 `6ff6cf9d0820382b3c233d018c52e4163b09d345`。**Media3 实际产物还叠加 lock 中的补丁和 artifact overrides,不能仅凭裸提交判断发布行为。** 首轮读 Git 对象;复评进一步读取本地 Maven sources JAR,核得 exoplayer SHA-256 `83f4f83b4f44e621d52002c161f63fbcb77be6856af4b1e4a4cb0982b04549e1`、extractor SHA-256 `ec22c28c9fef1f4fdb54b495da919a706d4a28b781ac6701790a259beee6dedd`,均与 lock 一致。此处是源码身份核验,没有重新编译或验证二进制运行行为。 - -`/Users/macbookpro/Desktop/github/media` 的当前 checkout 是 `3c2cbe8ac742c2fe15eff52f03eeb3b1b648848d`,不能与锁定产物混为一谈。锁定源码也确认存在 `NoSampleRenderer.onRendererOffsetChanged(offsetUs)` 和 `onPositionReset(...)`;前者契约明确规定从 renderer position 减去 offset 得到媒体位置。实现时仍需对实际依赖进行编译验证。 - -### 2.1 用户补充:已有 MPV 字幕 SurfaceView 能否给 Exo 复用 - -**可以复用显示承载和部分合成实现;目前它还不是一个任意播放器可直接调用的完整 ASS 服务。** 初稿只提独立层,没有充分纳入本地已实现的这部分,现修正推荐优先级。 - -核验到的真实链路: - -1. [MpvPlayer.java](../app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java) 约 2827 行 `createOsdSurfaceView()` 创建普通 `SurfaceView`,设置 `setZOrderMediaOverlay(true)`、`PixelFormat.TRANSLUCENT`、不接收焦点/点击,并放到视频 SurfaceView 的同一父容器。它依赖 `requiresOsdSurface()` 的 direct-output/ISO 条件,并非所有 MPV 输出都走此层。 -2. `osdSurfaceCallback`、`reconcileOsdSurface()` 管理尺寸与挂接;[MPVLib.java](../app/src/main/java/is/xyz/mpv/MPVLib.java) 的 `enqueueOsdSurface` 经 [render.cpp](../third_party/mpv-player-jni/src/render.cpp) 和 [request.cpp](../third_party/mpv-player-jni/src/request.cpp) 设置 MPV 的 `android-osd-wid`。JNI 明确要求 MPV 已初始化,没有“提交 ASS_Image 给任意 Surface”的接口。 -3. 固定 MPV 源码 `video/out/vo_mediacodec_embed.c` 在 `draw_frame` 用视频 PTS 调 `android_osd_overlay_render`,在 `flip_page` 呈现 OSD;本地另有 optional-OSD/timed-release 补丁,不能把裸源码的前置条件当成当前成品所有行为。 -4. `video/out/android_osd_overlay.c` 已实现 `ANativeWindow`、RGBA EGL window surface、GLES2 shader、预乘 alpha 混合、纹理上传、change-id 缓存、清屏与释放。其 `android_osd_overlay_render()` 调用的是 `osd_render(ctx->vo->osd, ...)`,只请求 `SUBBITMAP_BGRA`,然后上传 MPV 打包后的彩色字幕图块。 - -| 现有部分 | 复用判断 | 必须保留/补上的边界 | -| --- | --- | --- | -| 透明 SurfaceView 创建、层级、尺寸、非交互属性 | 可提取最小通用承载逻辑,Exo/MPV 分别使用 | 生命周期回调应交给当前生产者;避免为 Exo 创建两个重叠字幕层 | -| EGL/GLES 合成、clear、缓存和行拷贝思路 | 有真实复用价值,可评估抽取后重新编译 | 目前依赖 MPV 的 vo/OSD/日志/分配器;拆依赖和许可成本应与采用 libass-android 合成器比较 | -| 字幕图像接口 | 需要适配 | MPV 合成器消费 packed BGRA;libass 输出 mask+color。可先转换有界图块验证正确性,再决定是否保留 BGRA 或改 mask shader;不能把每帧整屏 RGBA 转换当默认 | -| MPV 选轨、字体、ASS 状态与时钟 | 不能直接挂给 Exo 使用 | 属于 MPV 的 demux/OSD 实例;Exo 自己提供 raw events、字体、选轨和媒体时钟 | -| `MpvOsdSurfacePolicy` 与 native 挂接队列 | 借鉴已有回归用例,按内核保留策略 | “当前片首次用过就保留”“先拆 video 再拆 OSD”解决 MPV 特定问题,不宜全量搬进通用宿主 | - -`MpvOsdSurfacePolicyTest` 已有选轨、隐藏、首用后保留及 Surface 销毁顺序测试,本轮只读取,未重跑。Surface 归属切换必须先让旧生产者停止并解除连接,再交新生产者;不能让 MPV 和 Exo 的 EGL 同时连接同一个 Surface。复用代码/宿主并不要求跨播放器切换时永远保留同一个 Surface 对象。 - -推荐的概念分工: - -```mermaid -flowchart LR - M[MPV 字幕与 OSD 状态] --> MG[现有 MPV 合成适配] - E[Exo raw ASS 与时钟] --> L[独立 libass 会话] - L --> EG[字幕图块合成适配] - MG --> H[可复用的透明 Surface 承载契约] - EG --> H -``` - -图中两条路径按当前播放器择一连接。最小原型先复用 SurfaceView 的承载契约和必要代码;Exo 使用自己的 GL 生产者,首阶段不修改 MPV Java/JNI/native、不共享 EGL context、不抽取公共合成模块。优先参考 libass-android 的 mask 合成;MPV packed BGRA 合成器作为对照。后续只有维护或性能证据支持时,才另行决定公共代码抽取。 - -成熟方案与本地方案的对应:Jellyfin/libass-android 是“Exo 提取输入 + 独立 libass + 独立 EGL/GLES + 透明 TextureView”;现有 MPV direct-output 是“MPV 的字幕/OSD + 独立 EGL/GLES + 透明 SurfaceView”。共同点是字幕单独产生图像并合成,SurfaceView 与 TextureView 只是输出承载的不同选择。 - -AOSP 文档确认 SurfaceView 可直接作为 EGL/GLES 输出,单独交给 SurfaceFlinger 合成;TextureView 内容先进入应用 UI 合成,更方便 View 变换。额外图层是否使用硬件 overlay 取决于设备,不能保证新增 SurfaceView 总是更快。现有代码仅处理底层视频为 SurfaceView:若 Exo 选择 TextureView,必须另验 Z-order/UI 遮挡,必要时选透明 TextureView 字幕宿主;也不能承诺两个独立 Surface 严格原子同帧呈现。 - -### 2.2 复评补核:实际发布代码决定接入位置 - -以下 Media3 文件指 2 节核对 SHA-256 的 sources JAR 内相应类,不是外部 checkout 的 HEAD。 - -| 实际调用链/符号 | 核实结果与实施约束 | -| --- | --- | -| `ExoUtil.getMediaItem/buildSubtitleConfigs` → `DefaultMediaSourceFactory.createMediaSource` | 默认 `parseSubtitlesDuringExtraction=false`,外挂走 `SingleSampleMediaSource`,合并成文本轨道后由 TextRenderer 解码。不能只调用 `setSubtitleParserFactory` 就认为完成接线,也不应为 ASS 全局改变其他格式的解析时机 | -| `DefaultRenderersFactory.buildTextRenderers` → `new TextRenderer(output, looper)` | TextRenderer 是 `final`;具有接收 `SubtitleDecoderFactory` 的公开构造函数,且本地 `legacyDecodingEnabled=true`。这是最小解码扩展点,但它单独不提供完整呈现时钟/period 生命周期 | -| `RendererHolder.setCurrentStreamFinalInternal` / `hasReachedServerSideInsertedAdsTransition` | 对真实 `TextRenderer` 做 `instanceof` 判断,处理 final stream end 和流切换。通用 Renderer 包装器会改变这些语义;“委托了所有接口”仍不等于无回归 | -| `ExoPlayerImplInternal` → `RendererHolder.setTextOffsetMs` → `TextRenderer.handleMessage` | 延迟消息只发给 `TRACK_TYPE_TEXT`;`NoSampleRenderer` 的类型是 NONE。正值延后,渲染查询时间为 `positionUs - textOffsetUs`;不能从 UI 线程轮询或漏接该设置 | -| `MatroskaExtractor.writeSubtitleSampleData/commitSampleToOutput` | `FLAG_EMIT_RAW_SUBTITLE_DATA` 只表示未转成 Cue,仍输出 `Dialogue: 0:00:00:00,,<原始 chunk>`;初始化数据为 `[合成 Format 行, CodecPrivate]`,duration 被量化至 10 ms。原始 ReadOrder 还在,但需要识别封装;直接喂整包 libass 或将 initData[0] 当 CodecPrivate 都不对 | -| 同一 MatroskaExtractor 的 ContentEncoding 分支 | 本地已接受文本 zlib,处理解压并在提交时裁切 NUL;还保留 header stripping 相关路径。第三方 #85 的再次探测解压不能直接移植;阶段 2 钩子应取得明确编码处理后的有效字节和原始 duration,对不支持的组合明示失败 | -| [PlaybackActivity.java](../app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java) `attachSurface/detachSurface/resetVideoSurfaceForDecoderSwitch/syncVideoSurfaceSize` | 公共 Activity 负责手机/电视宿主挂接;`setRender` 会更换底层 View,Surface buffer 尺寸也可能与 View 布局不同。这里接 host 生命周期,不能只在 `ExoUtil.setPlayerView` 一次性创建层 | -| [ExoPlayerEngine.java](../app/src/main/java/com/fongmi/android/tv/player/engine/ExoPlayerEngine.java) `rebuild/release` | 播放器重建和 Activity 配置变化不是同一生命周期。会话属于 engine/player,Surface 属于当前 Activity;detach 只释放显示资源,engine release 才关闭会话;rebuild 必须失效旧回调 | -| [PlayerManager.java](../app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java) `setTextOffsetMs`,`PlayerEngine.supportsSecondarySubtitle` | 复用当前延迟设置。原 ASS 阶段不包含 Exo 双字幕;2026-09-18 用户另行授权后,按第 16 节增加两路字幕,保留原有选轨及 MPV 能力 | - -选轨身份必须包含 player/session、`MediaPeriodId`、轨道标识及 stream generation;不能只用 `Format.id`,也不能仿照候选实现截取冒号后的 ID 来做全局匹配。拼接播放、外挂合并源、后台预加载和旧 decoder 回调都可能重用局部 ID。网络读取仍使用当前播放支路的 DataSource/OkHttp/headers/cache;不另开 native HTTP,也不把预加载优先级的 helper 当作前台字幕数据源。 - -## 3. 核验过的开源实现与成熟度 - -### 3.1 首选参考:libass-android 与 Jellyfin Android TV - -`peerless2012/libass-android` 不只是 NDK 编译脚本:包含 `ass`、`ass-kt`、`ass-media`,覆盖 JNI、外挂解析、Matroska 字幕/字体、选轨、渲染工厂、Canvas/GL 输出。包装代码是 MIT;其 native CMake 静态链接 libass、FreeType、HarfBuzz、FriBidi、fontconfig、expat、libunibreak 等,依赖许可证需分别处理。 - -在固定版本 Jellyfin Android TV 中,`ExoPlayerBackend.kt` 实际创建 `AssHandler(AssRenderType.OVERLAY_OPEN_GL)`,在 `enableLibass` 开关下包装 extractor/renderers factory,并把 `AssSubtitleView` 加入字幕视图。版本目录引用 `io.github.peerless2012:ass-media:0.5.1`。这验证了有真实项目接入;未据此推断全部发布版本均默认开启或所有设备均稳定。 - -扩展提供的模式必须区分: - -| 模式 | 实际用途 | WebHTV 决策 | -| --- | --- | --- | -| `CUES` | 向传统 Cue/SubtitleView 输出;项目表格明确不支持动画,也是便捷构建入口的默认值 | 不能用它宣称完成 ASS 特效接入 | -| `EFFECTS_CANVAS` / `EFFECTS_OPEN_GL` | 在 Media3 视频 effects 流程合成,可随视频帧处理 | 会介入视频处理,HDR/DV 及缓冲/时序成本另行验证;本次不优先 | -| `OVERLAY_CANVAS` | 独立视图,可显示动画 | 当前部分工作会阻塞 UI;仅可作为有预算的兼容回退参考 | -| `OVERLAY_OPEN_GL` | 独立渲染线程和透明 TextureView,GPU 合成 mask | 参考其分层方式;本地优先评估复用字幕 SurfaceView,具体见 2.1;不等于 GPU 完成全部 ASS 排版/模糊运算 | - -必须改造或核验的具体问题: - -1. `render/AssRenderer.kt` 写死 `positionUs - 1000000000000L`。应读取实际 renderer offset,并处理拼接媒体、非零起播和 discontinuity;不能靠调一个固定字幕延迟掩盖。 -2. `kt/AssPlayer.kt` 的便捷构建会设置新的 MediaSourceFactory;`withAssMkvSupport()` 会替换 MatroskaExtractor。不能直接覆盖 WebHTV 自定义工厂。 -3. `extractor/AssMatroskaExtractor.kt` 反射读取 `extractorOutput`、`subtitleSample`,并按附件长度分配字节数组。对 Media3 版本、混淆和异常附件均敏感;长期适配优先明确的受控源码扩展点。 -4. `executor/AssExecutor.kt` 存在 8 ms future 等待路径;异步任务对象和 busy/lastFrame 状态跨线程使用。需要改为清晰的所有权、有界排队和过期结果丢弃。这是源码风险判断,本轮没有复现竞态。 -5. 源码默认 glyph cache 为 10000、bitmap cache 为 128 MB、`maxRenderPixels=0`。这不是低内存电视的已验证预算。 -6. 已读取的 `lib_ass_media` 单测仍是 `assertEquals(4, 2 + 2)` 模板;不能用“仓库有 tests 目录”证明时钟、生命周期或画质回归覆盖。 -7. 维护者/用户记录了 Mali-470 缺少 `GL_EXT_unpack_subimage`、alpha 混合、Android 7 JNI local-reference 溢出、快速移除视图和暂停 resize 问题。部分补丁已合并,但必须核对选定版本;不能把 closed 一律当 merged。 -8. #71 的约半秒特效字幕延迟仍为 open 用户报告;#85 压缩 ASS 载荷修复为 closed 但 API 返回 `merged_at=null`。这些是必须纳入语料的风险,不据此声称所有场景都会失败或已修复。 - -### 3.2 第二参考:LumeraD3v/assrender - -有更短的 Media3→libass→Bitmap overlay 实现,包装代码 Apache-2.0,适合理解最小接线。但不建议整包引入: - -- 提取器也使用反射;`AssHandler` 使用主线程 Handler、`player.currentPosition` 和约 30 fps 循环,无法直接满足逐帧贴合或重负载要求。 -- 实际 `CMakeLists.txt` 编译了 `subtitle_pipeline.c` 与 direct 路径,并链接 avformat/avcodec/avutil/swresample 等 FFmpeg 库。README 的“direct 路径不需要 FFmpeg”不等于当前包没有这些依赖。 -- open issue #3 报告旧 Media3 1.5.1 AAR 在 1.9.0 出现 `AbstractMethodError`;open PR #1/#2 涉及默认字体、日志和缺失选轨接线。这些报告不能自动当成主线已修复。 -- README 声称 Exo 会移除所有样式,与本地 SsaParser 已有功能不符,未采信该概括。 - -### 3.3 跨平台参考真正能借什么 - -| 项目 | 已读的实际实现 | 可借鉴内容与边界 | -| --- | --- | --- | -| GStreamer `assrender` | `gstassrender.c`:视频和字幕 segment 都转为 running time,再喂事件、渲染、合成 | 跨时间域归一化、队列/锁、seek 后 segment 变化;不能照抄其管线 API 到 Exo | -| VLC | `modules/codec/libass.c`:`ass_add_font`、按 PTS/duration 喂 chunk、按呈现时间调用 libass、检查 change 标志 | 字体附件、时间单位、只在必要时重建区域;复制代码前须审核该文件许可证 | -| Kodi | `DVDSubtitlesLibass.cpp`:字体、事件、锁、flush,以及 seek 后重叠事件排序注释 | 把逆向 seek、ReadOrder 和重叠字幕做成真实回归用例;注释不是所有现版本都会错的证明 | -| JASSUB | `JASSUB.cpp`、`worker.ts`、`webgl2-renderer.ts`:libass WASM 在 worker 渲染 mask,GPU 合成 | 分离布局/栅格化与合成、worker 所有权、空帧清屏、预乘 alpha、字体加载;现代 WebGL2/OffscreenCanvas 等要求不适合默认塞入老电视 WebView | -| FFmpeg subtitles filter | `vf_subtitles.c`:按视频帧 PTS 调用 libass,并把 mask 混入 AVFrame | 可用作离线参考画面;烧录必须介入视频帧处理,播放未必需要重新编码,但硬件帧下载/上传与处理成本不能忽略 | - -JASSUB 的 `rawRender` 区分“无需更新”的 null 与“需要清空”的空数组,其 worker 执行 GPU 绘制;WebHTV 也必须区分无变化、空画面和失败。只缓存最后一帧会让字幕在结束/关轨后残留。 - -## 4. 官方提案、文档与论文如何影响决策 - -Media3 [PR #2324](https://github.com/androidx/media/pull/2324) 是已有 libass 提案,但作者注明不再继续,维护者表示维护成本高、预计不会合并。提案列出仅 MKV、缺外挂、注入点临时、逐帧问题和 4K `draw_ass_rgba` 太慢等缺口。它是很有用的失败边界说明,不是可直接依赖的官方扩展。 - -[Media3 #2042](https://github.com/androidx/media/issues/2042#issuecomment-2595058592) 的维护者解释:解析发生在提取阶段,此时无法知道稍后的最终视频显示尺寸;需要更晚的自定义字幕显示组件。[#2289](https://github.com/androidx/media/issues/2289#issuecomment-2766645727) 则解释默认文本回调有播放器循环、线程切换和 UI 重绘延迟,并非逐帧呈现系统。后续维护者建议可实验使用 VideoFrameMetadataListener,但未承诺更改普通字幕时间戳语义。因此,单换 SubtitleParser 无法完整解决问题。 - -Matroska 的 ASS 规范规定:header/styles 在 CodecPrivate;事件 Block 保存 `ReadOrder,Layer,Style,Name,MarginL,MarginR,MarginV,Effect,Text`,时间与持续时长来自容器。外挂完整 ASS 与 MKV chunk 必须采用不同的输入方法,不能从已经扁平化的 Cue 反向恢复。 - -阅读的图形学论文是 Chris Green(Valve,2007)《Improved Alpha-Tested Magnification for Vector Textures and Special Effects》:SDF 可改善文字/矢量纹理缩放和边缘效果,但不负责 ASS 事件语义、复杂脚本 shaping、字体匹配或播放器同步;文中也讨论单通道距离场的转角问题。**它支持未来局部 GPU 优化的研究方向,不支持本轮重写 SDF ASS 引擎,也不是 Android 性能数据。** 本轮没有获得能直接证明某个 ASS Android 接入方案最优的对照论文,不把不相关论文数量当结论强度。 - -技术博文 [Text Rendering Hates You](https://faultlore.com/blah/text-hates-you/) 对样式、换行、shaping、字体 fallback、亚像素/透明度及大字形缓存的耦合有具体解释。它进一步支持复用 libass + FreeType/HarfBuzz/FriBidi,而不是在 Java/Canvas 中逐个补标签。博文是工程解释,不是本项目 benchmark。 - -## 5. 方案比较与明确取舍 - -| 方案 | 正确性/质量 | 性能与兼容性 | 维护成本 | 决定 | -| --- | --- | --- | --- | --- | -| 不改,沿用 SsaParser/Cue | 保留现有普通字幕;完整动画等缺口仍在 | 无新增 native/GL 成本 | 最低 | 保留为默认及回退基线,不能满足本需求 | -| 原样采用 libass-android 便捷 builder | 内核成熟,但本地工厂、时钟与布局可能错配 | Media3 1.8.0 参考源码与本地 1.11.0 fork 不同;旧 GPU、预算需验证 | 初次接入低,后续隐性成本高 | 拒绝原样套用 | -| **libass + TextRenderer 窄观察接口 + 独立字幕层** | 保留真实 TextRenderer 与兼容 Cue,从选中轨道取得输入/offset/延迟;libass 解释原始内容 | 复用 SurfaceView 承载方式,独立 native/GL;双路解析的额外成本须测 | 中等,需维护一个受控 Media3 补丁和对应 Java 产物,不重构 MPV | **推荐,分阶段原型** | -| 仅 App 的自定义 SubtitleDecoderFactory + NoSampleRenderer | 可截获选中 ASS,避免提取器反射 | 仍须额外解决 period 绑定、延迟消息、动态调度和回退;无样本 renderer 本身不能消费字幕 | 少一次 Media3 产物修改,但生命周期桥接更多 | 作为对照方案;不把固定 offset 或 UI currentPosition 轮询作为最终设计 | -| 视频 effects 内合成 | 更容易围绕视频帧生成字幕画面 | 介入 video pipeline;HDR/DV、tunneling、附加处理和延迟需单独验证 | 中高 | 独立层达不到明确时序目标时再评估 | -| WebView + JASSUB/WASM | 复用 libass,可参考已有浏览器产品 | WebView/worker/WebGL 版本、JS/JNI 时钟、内存与故障面增加 | 中高 | 借鉴设计,不作为 Android TV 首选 | -| FFmpeg filter 烧录 | 成熟 libass 输出,可生成参考片段 | 改变视频帧处理和硬件路径;可能引入下载/上传,不能宣称零成本 | 高 | 离线对照;不作默认播放架构 | -| 自研 Java/Canvas 或 GPU/SDF ASS 引擎 | 标签组合、shaping、历史兼容需重新验证 | 单项 GPU 优点不等于整体更快 | 最高 | 首轮拒绝 | - -不需要让 Exo 通过 MPV 播放视频,也不需要把 libplacebo 作为 ASS 语义引擎;它们可提供合成设计或参照画面,但无法替代原始事件、字体与时钟接入。 - -## 6. 推荐数据流与关键契约 - -```mermaid -flowchart LR - A[现有数据源与提取器] --> B[真实 TextRenderer 的选中字幕输入] - B --> C[有界事件队列] - C --> D[字幕工作线程中的 libass] - B --> Q[保留兼容 Cue 与单一显示仲裁] - E[TextRenderer 时钟 offset 延迟 generation] --> D - F[视频 viewport 与帧时间] --> D - D --> G[有所有权的 alpha mask 与颜色] - G --> H[独立透明 GL 字幕层] - A --> I[原有音视频播放链路] - I --> J[原有视频 Surface] - H --> K[系统合成显示] - J --> K -``` - -### 6.1 接入方案:保留 TextRenderer,增加可选观察接口 - -**这是待实施的 WebHTV 适配设计,并非 Media3 已存在的 API。** 在本地 `TextRenderer` 增加默认 null/no-op 的观察接口,由 `ExoUtil.buildRenderersFactory` 的既有 Ffmpeg 工厂接线;保持真实类型、解码器、Cue 解析、选轨、final stream、原有构造函数及非 ASS 行为。观察接口只接收以下数据,不允许回调阻塞播放器线程: - -| 观察数据 | 触发位置及约束 | -| --- | --- | -| stream/format/reset/end | `onStreamChanged`、`onPositionReset`、disabled/release 与最终流边界;带真实 `MediaPeriodId`、stream offset 和 generation。读取中的下一个 period 与正在显示的 period 分开记录 | -| 选中 ASS 样本 | 成功的实际 sample read,跳过 peek/omit-data、EOS 和不支持的加密输入;有界复制有效 offset/length,保留时间、格式、初始化数据及输入类型。不得在样本已经转成 Cue 后还声称得到原始 ASS | -| clock/config | 在 TextRenderer 已知实际 stream offset、字幕延迟和播放状态的位置生成快照;包括暂停/缓冲、倍速、文本延迟变化。回调只发布轻量数据,不执行 native parse/render/GL | -| 兼容 Cue | 保留原输出,记录当前 generation 的最新 CueGroup;由一个显示仲裁点决定当前显示兼容 Cue 或 ASS 层,具体见 6.4 | - -可在公开 `SubtitleDecoderFactory` 扩展点做 decoder 侧复制,以减轻播放线程工作,但必须使用上述同一 stream 身份和时钟契约,保留委托 decoder 的 flush/release、charset 与队列规则。是否需要 decoder 钩子由一次最小实现决定,不并行维护两套输入路径。禁止使用全局 AssHandler 收集所有 extractor 的事件;预加载和未选中轨道不应创建 libass 会话。 - -观察接口补丁只作用于受影响的 `media3-exoplayer` Java 产物及必要接线;不改变音视频 renderer、LoadControl 或默认字幕解析开关。测试必须覆盖观察接口关闭时的既有行为,以及 stream 切换/flush/最终结束。编译成功不能代替这些契约测试。 - -### 6.2 输入、时间与 seek - -- 外挂完整 ASS:经现有授权数据源读取有界字节,确定编码后交给 `ass_read_memory`/对应完整文件接口。无需把本地 URI 当任意文件路径开放给 native。 -- 外挂保留 BOM/UTF-8/UTF-16/GB18030 等当前字节安全补丁已有行为;编码转换只做一次,先在有效长度内规范化到 UTF-8,再处理字符串边界,不能在 UTF-16 原始字节上遇零截断。新增 libass 副本/解析有大小限制,不把这个限制误说成已经解决旧 SingleSample loader 自身的峰值内存问题。 -- MKV:原始 CodecPrivate 走 `ass_process_codec_private`,每个解压后的完整事件走 `ass_process_chunk`,使用真实 PTS/duration(微秒到毫秒明确转换)。保持 ReadOrder、Layer、Style、覆盖标签及逗号正文;不能同时混用手动 event 修改破坏 libass 的 chunk 去重契约。 -- 适配类型明确区分 `FULL_SCRIPT`、`MEDIA3_SSA_SAMPLE` 和 `MATROSKA_ASS_CHUNK`。阶段 1 只接管 FULL_SCRIPT;阶段 2 在当前 Matroska 源码增加默认关闭的明确输出契约,携带真实 `blockDurationUs`、CodecPrivate 和附件归属,经过 SampleQueue/选轨或等价受控通道再进入会话。若仅剥离现有 SSA_PREFIX,只能得到量化后的 duration,必须标为兼容桥,不能通过“精确 MKV 事件”验收。 -- 原始数据钩子应在 Cue 扁平化之前,且在容器 ContentEncodings 正确处理之后。NUL、zlib、buffer offset/limit 都有实际缺陷报告;不把 backing array capacity 当有效样本长度,不靠探测两个 zlib 字节替代完整容器语义。 -- 字体附件可能早于或晚于轨道/样式到达。按会话登记、内容哈希去重,字体集变化后触发正确的 font lookup/cache 更新;限制数量、单体/总字节,区分缺字、缺字体和未选中轨道。 -- 不把“字体文件名”当唯一字体 family 身份,不假定系统一定有某个字体路径;配置可验证的 fallback,覆盖 CJK、阿拉伯/RTL、组合字符。默认不联网下载字体;新增本地/在线字体来源需单独定义授权、缓存和隐私行为。 - -- 推荐观察接口直接采用 TextRenderer 当前 offset:`t_ass_ms = floor((rendererPositionUs - streamOffsetUs - textOffsetUs) / 1000)`;`textOffsetUs > 0` 表示晚显示。同样本绑定的时间已被 `BaseRenderer.readSource` 加过 stream offset,归一化时只减一次;原始提取器的 period 时间不能再减一次。未知时间、溢出和已失效 period 不参与计算。若使用 NoSampleRenderer 对照实现,须显式补齐延迟传递;不能假设它会收到文本消息。 -- onPositionReset、媒体切换、选轨、关轨、release 都推进 generation;重建必要事件状态并清除旧帧。外挂完整轨道与流式 MKV 的 seek 策略不同:前者已有完整事件,后者必须保证落点前开始、落点后仍活跃的长事件可以重放。 -- `ass_flush_events`/prune 不能机械地在每次 seek 都调用;要与样本重新投递、ReadOrder 去重及回看缓存一起设计。无界保留所有直播事件也不可接受。 -- 外挂完整轨道在 seek 时保留事件并重新查询时间;只清除过期显示/调度结果。流式 MKV 按“period + track + header 身份”保留有界原始事件缓存;重建 track 时重放尚覆盖目标时间的事件,保持 ReadOrder 去重。缓存未覆盖的首次远跳,不能靠固定几秒回看保证任意长事件:阶段 2 必须证明容器索引/受控预读可找回这些事件,或将该输入明确留在兼容路径,不能宣称 A08 已通过。回退到旧路径也不等于补齐了旧路径本来缺失的事件。 -- 普通时间同步可用 Exo 媒体时钟驱动;贴画面招牌使用 `presentationTimeUs` 与 `releaseTimeNs` 的配对关系研究帧调度。复用现有帧监听分发,回调内只发布时间信息;不要同步解析、渲染或等待 native。 -- 上述 PTS 与 releaseTimeNs 是一组映射,**不能直接相加**。以对应视频帧 PTS 查询字幕,按 releaseTimeNs 安排提交;后者与 `System.nanoTime()` 同时间域,不与 elapsedRealtime 微秒直接混算。seek/速度变化/Surface 更换使旧映射失效;没有可信帧回调的路径回到媒体时钟,仍需报告呈现精度等级。 -- 暂停/缓冲应冻结字幕时间,倍速跟随媒体时钟;暂停时 resize/字体更新仍可重绘。不能把“回调刚发生”当作视频或字幕已经被用户看见。 -- 独立 Surface/TextureView 之间并不天然原子呈现。即使取得视频 PTS,也不能承诺字幕与硬件视频扫描输出严格同帧;这一点必须用实际显示结果验证。 - -### 6.3 渲染、字体、几何与资源 - -- 一个字幕会话拥有 libass library/renderer/track;parse/render/font mutation/release 串行化或使用明确共同锁。JNI/GL/UI 的所有权、销毁顺序和 generation 校验必须写清,不能只依赖 GC/finalize。 -- 待渲染时间最多保留最新请求;正在运行的一帧不能靠清空 Java 队列强制中断 native。超时仅丢弃过期结果并禁止继续积压;如一次 native 调用可长时间卡死,进程内 timeout 不能保证安全取消,需作为独立风险处理。 -- “保留最新请求”只适用于时钟/重绘,不适用于头部、字幕事件和字体。数据队列须有序且按字节计费,满时退出本轨特效模式并恢复 Cue,不能丢掉中间事件继续标为正常。reset/release 是可靠控制消息;结果至少校验 session/stream generation、Surface epoch、layout epoch 和 font epoch。 -- `ASS_Image` 是按顺序合成的一组 8-bit alpha mask、RGBA 颜色和位置,不是完整 RGBA 视频帧。图像可能宽/高为零,末行只保证 `stride*(h-1)+w` 可读;跨线程输出要复制到有界自有缓冲,或在明确锁定生命周期内完成上传,不能悬挂引用 renderer 管理的内存。 -- 合成需正确处理 ASS 颜色低字节的透明度约定、mask coverage、预乘 alpha 和图层顺序。不能给已预乘结果再乘一次 alpha;色彩矩阵与 HDR 字幕亮度另测。 -- 使用 `detect_change` 区分位置/内容变化,静态字幕不重复上传;空帧、关轨和 Surface 重建均要正确清屏。 -- `detect_change` 只描述同一 libass renderer 的上次结果;GL context 丢失、新 Surface、viewport/字体变化都须强制重传或重绘。不能拿“内容未变”跳过新 Surface 首帧。空画面是有效结果,解析/GL 失败是另一种状态。 -- GLES2/旧 Mali 不保证 `GL_EXT_unpack_subimage`;可使用有界紧密行拷贝/可用上传路径,并测试 alpha bitmap 回退。不能仅因主流手机支持某扩展就调用它。 -- 用真实显示 viewport 对齐脚本坐标,包括 PlayRes、视频 storage size、SAR/DAR、黑边、缩放裁切、旋转和 UI 布局。ASS 原样模式中强制居中或一刀切缩放字号都会改变特效。 -- 具体配置是 `ass_set_storage_size` 使用未作像素拉伸的视频存储尺寸,`ass_set_frame_size` 使用字幕目标尺寸,`ass_set_margins`/pixel aspect 对应真实显示矩形和裁剪;脚本 LayoutRes 会覆盖部分推导。不能把 `SurfaceHolder.setFixedSize` 的 buffer 大小当成屏幕 Viewport。宽高为 0 时等待布局,尺寸/旋转变化推进 layout epoch。 -- 字体按“脚本指定 family + 本媒体附件 → 明确配置的系统 provider/fallback”解析,保留字体内部名称与 TTC face 信息。初始字体先装入再配置 renderer;晚到字体在串行线程重建字体选择/必要的 renderer 并使旧图块失效。`ass_fonts_update()` 在已读 API 中已弃用且是 no-op,不能用它宣称完成更新;`ass_clear_fonts()` 仅在关联 track/renderer 全释放后安全。 -- 原样模式不擅自打开 `ASS_FEATURE_WRAP_UNICODE` 或 WHOLE_TEXT_LAYOUT,不改写事件/样式数组;这些扩展可能改变 VSFilter 的换行/RTL 布局。字幕字号/位置设置继续在兼容模式生效;原样模式保留脚本排版和通用时间延迟。若以后增加“只覆盖普通对白”,可借鉴 MPV selective override,但须单独验收其启发式误判。 -- libass 不自动处理 `YCbCr Matrix` 色彩兼容。SDR 合成需按 `ass_types.h` 的 header matrix 与视频 matrix/range 处理 RGB,`None` 不转换;不能对 mask 或预乘后的颜色重复转换。HDR/DV 将字幕视为独立 SDR 图层,亮度/色域由呈现路径验证,不套用 SDR 视频矩阵去变换 HDR 视频,也不宣称 Android 各设备一致。 -- 普通 Cue 仍交给原 SubtitleView。只有当前已成功接管的 ASS 轨道隐藏其重复文字;不能隐藏整个包含字幕/交互的容器而让 SRT、图形字幕或控件失效。 - -### 6.4 显示仲裁与可恢复失败 - -会话状态为 `COMPAT → PREPARING → ACTIVE`;可检测失败转 `FALLBACK`,关轨/释放转 `DISABLED`。PREPARING 继续显示兼容 Cue;只有当前 generation 的 native 结果有效、宿主可用并已完成首次有效提交后,才由单一仲裁点切换。字幕当前恰为空时也要有明确 READY/空帧确认,不能把没有图像误认为加载失败。 - -首阶段保留原 SsaParser/Cue 的工作结果,保存当前有效 CueGroup;切换回退时先清除新图层,再恢复与当前时间一致的 Cue,并继续原输出。这样避免为回退重建播放器、重新请求媒体或 seek。代价是选中 ASS 时存在兼容解析和 libass 解析的额外工作,必须计入性能对照;未经证据不删除这条恢复路径。该方式回到的是现有部分 ASS 能力,不保留完整动画效果。 - -加载失败、输入/队列超限、字体配置失败、GL 初始化失败等可返回错误只降级本轨、本会话,不自动切 MPV、软解、视频 Surface 或 tunneling。原生 SIGSEGV、严重 OOM 和无法返回的 native 调用不能靠 Java 异常处理保证恢复;发布前靠选版、边界检查、目标语料及 native 检测降低风险,不宣称进程内完全隔离。 - -宿主 detach 时清屏并串行拆除 EGL/ANativeWindow 连接,保留仍由 PlaybackService 持有的会话输入;重新 attach 按新 Surface epoch 重绘。engine rebuild/release 先失效 token、停止新任务,在所有者线程依次处理在途结果、GL、track/renderer/library 与 JNI 引用;不在 UI 或播放器线程等待 native 完成。释放的最终完成须可观察,不能只 post 一个任务就宣称资源已释放。 - -## 7. 性能预算:目前是设计目标,不是测试结果 - -独立字幕层能减少对既有视频管线的侵入,但 libass 的 shaping、栅格化、复杂 blur/clip 和大量事件仍消耗 CPU,上传/合成仍消耗 GPU 与内存。复杂脚本没有“任意设备全速”的保证。 - -一个 3840×2160 RGBA 画面约 31.6 MiB,双缓冲约 63.3 MiB,尚未计 native mask、glyph cache、GL texture 和上传暂存。60 fps 每次搬运整帧的理论像素字节量约 1.99 GB/s(十进制),不等于实测带宽,但足以说明默认整屏 Bitmap 路线不合适。 - -阶段 1 可从以下**待测起始配置**比较,不直接写成产品默认: - -| 项目 | 原型预算/衡量方式 | -| --- | --- | -| 缓存 | 试验 32 MiB libass bitmap cache;字体附件总额可先限 32 MiB/单体 8 MiB;测 CJK 与多字体语料后调整,记录拒绝原因 | -| 分辨率 | 同时比较原分辨率与显式 1080p 上限;降采样会影响精细描边和招牌,不能在“原样”模式悄悄降低质量 | -| 调度 | 最多 1 个在途渲染和 1 个最新请求;更新频率受显示刷新率约束,静态内容按 change 标志复用 | -| 时延 | 记录字幕排版/render/upload/swap 的 p50/p95/max、超期帧和 queue age;目标是正常语料工作 p95 小于一个显示刷新周期,复杂语料单列 | -| 播放影响 | 同设备、同文件、同播放设置对照关闭/开启 ASS,比较首开、seek、视频丢帧、音频 underrun、PSS/native/GL 内存与热状态 | -| 生命周期 | 连续 seek/换轨/换集后内存应进入有界平台,不随操作持续增长;缓存可保留,泄漏不可用“有缓存”解释 | - -预算还要区分 libass bitmap cache、glyph、字体字节及解析结构、事件、JNI 自有副本、GL atlas/纹理和在途上传;`ass_set_cache_limits` 不限制全部会话内存。实现前冻结单样本、外挂文件、队列总字节、图块数量/面积及纹理池上限,分配前检查乘法/长度溢出。调度必须保留当前 dynamic scheduling:不能按“每三次 renderer 回调一帧”推断视频帧率,也不能为无字幕会话增加常驻 10 ms 唤醒。GPU 合成前的 libass 栅格化仍是 CPU 工作。 - -选择一个 64 位设备和一个代表性的 32 位/旧 GLES2 电视覆盖 ABI 与 GPU 差异;阶段 1 先验证一个可用 ABI。性能差异需要稳定配对样本和多次短测,不能用一次均值保证无回归。没有达到预算时,应明确拒绝该语料的特效模式或让用户选择降级,不能拖垮音视频后继续显示“正常”。 - -性能验收分两组:关闭功能/非 ASS 的路径不得加载新 native、创建 GL 会话或新增周期唤醒;开启 ASS 的路径在同设备/样片/设置下至少做三次配对短测,保留首开/seek 的中位数与范围、逐帧 render/upload 的 p95、视频丢帧和音频 underrun。先记录基线波动并冻结可接受阈值,新增音频 underrun、稳定可复现的视频/seek 回退、持续内存增长直接否决。不能把 CPU/GPU/内存增加一概叫“零开销”,也不能用自动降低分辨率或关特效来取得原样模式的通过结果。 - -## 8. 最小分阶段实施与回滚 - -以下为研究提出的实施单元。**2026-09-14 复评后阶段 1 已获实施授权,阶段 2–4 尚未授权。** 每阶段继续使用本任务唯一文档;开始时根据工具链、缓存、可用设备和 ABI 重新给出当前代理的实际墙钟估算。 - -| 阶段 | 独立交付 | 最便宜的决定性验证 | 回滚 | -| --- | --- | --- | --- | -| 1:外挂 ASS 原型 | 默认关闭;保留真实 TextRenderer,增加可选观察接口并重建受影响 Java 产物;一个 ABI 的独立 JNI/libass/GL;外挂输入、fallback 字体、准确 offset/延迟、显示仲裁和生命周期;先限 SDR + 视频 SurfaceView | observer 关闭/选轨/seek/流结束契约测试;官方语料固定时间点对照;动画/暂停/延迟/字体/Surface 重建;主动注入可恢复失败;与关闭字幕基线配对;该 ABI 的 ELF/API/16 KiB/加载检查 | 会话开关关闭立即恢复原路径;代码/补丁/Java 产物/JNI/锁作为同一兼容单元回退;MPV 未改 | -| 2:MKV 与字体 | 当前 Matroska 源码内补受控 raw ASS/附件契约,明确到选中 SampleStream 的传递方式;原始 duration、字节边界、ReadOrder、附件和 seek 覆盖;保留 DV/deferred Cues/网络行为 | CodecPrivate/chunk 字节与时间、zlib/header stripping、NUL、非零 buffer offset、乱序附件、未缓存长事件 seek;未选轨/预加载不得创建会话;existing Matroska DV/seek 定向回归 | 关闭 MKV 接管并撤回该输入契约的源/Java 产物;外挂原型可独立保留 | -| 3:电视兼容与性能 | 扩至双 ABI、旧 GPU、4K/HDR/DV、视频 TextureView/LUT、tunneling/安全 Surface 能力判定及性能预算;保持已有解码和视频输出选择 | 两类代表设备配对;真实显示对齐;无 row-length 扩展路径;新增 ABI 重复其基础 ELF/API/16 KiB/加载检查;不以 arm64 结果代表电视 32 位 | 不满足能力或验收的组合保持兼容字幕;按会话停用新层,必要时原子回退该阶段产物与锁 | -| 4:产品化 | 清晰的 ASS 原样/兼容选择、异常回退、诊断导出、可灰度开关;完善字幕偏好规则 | 字幕与现有播放设置回归、恢复流程、素材质量报告;用户可见结果验收 | 关闭新功能并保留旧设置/旧路径;不能静默切 MPV 或改变解码器 | - -依赖源码、NDK、构建参数、每 ABI `.so`/AAR SHA-256 与许可证必须随实现锁定。本轮下载的研究源码不是已批准的生产依赖;不修改 MPV/FFmpeg/libplacebo 锁来“顺便升级”。 - -阶段 1 的拟议落点是 `ExoUtil`/独立 Exo ASS 会话、`ExoPlayerEngine`、公共 `PlaybackActivity`,新增独立 native 构建/锁/JNI,以及 TextRenderer 观察接口的 Media3 补丁、受影响本地 Maven 产物和定向测试;这些是未来 scope,当前 guard 仍只允许两份文档。先做 observer 的选择/时钟/关闭行为 fixture,再接 libass 和 Surface。原样模式暂不接管 MKV、视频 TextureView/HDR 等未验收组合;不能自动修改视频设置来满足字幕原型条件。 - -阶段 2 开始前必须冻结原始 duration/附件如何穿过当前 SampleQueue/选轨的具体接口及测试样本,并解决 A08 的未读长事件覆盖问题。它们是明确的后续设计/验收门槛,不能用阶段 1 成功跳过;没有这些证据时建议暂缓阶段 2 的完整能力承诺。阶段 3 只扩大已验收范围,基本内存安全和 native 包装检查不推迟到该阶段。 - -## 9. 验收清单与故障证据 - -| 场景 | 必须证明的行为 | -| --- | --- | -| A01 外挂基本 ASS/SSA | 样式、位置、字体和时间与固定 libass 参考一致;编码边界清晰 | -| A02 动态标签 | `move`、`t`、淡入淡出、旋转/缩放、karaoke 在中间时间点仍正确,不能只拍起止帧 | -| A03 绘图/剪裁/混合 | `p`、`clip/iclip`、blur、outline/shadow、多层和重叠 alpha 正确 | -| A04 字体 | MKV 多字体、缺字体 fallback、CJK、RTL、组合字符;字体早/晚到达皆可解释 | -| A05 MKV 字节与时间 | header、ReadOrder、Layer、逗号正文、offset/length、PTS/duration 保真;压缩遵守容器语义 | -| A06 异常输入 | NUL、无效/巨大附件、截断、无效时间、极端标签有界处理,有原因日志且不拖垮播放 | -| A07 非零起播/拼接 | 正确使用 stream/period offset;连续换集、拼接媒体无固定偏移错误 | -| A08 seek | 前后 seek、连续 seek、落在长事件中间、不重新读到旧 block 的回看场景无漏字/重复/旧帧 | -| A09 暂停/缓冲/倍速 | 字幕媒体时间正确冻结/推进;暂停时 resize、字体完成加载仍能重绘 | -| A10 轨道切换与关闭 | ASS↔SRT/WebVTT/图形字幕、关字幕、不同语言轨道切换无重复渲染或残留;原阶段只验收一路,新增双字幕按第 16 节验收;MPV 既有能力保持 | -| A11 显示几何 | 黑边、非方形像素、裁剪/缩放、窗口变化、电视 overscan 布局下字幕映射正确 | -| A12 生命周期 | 快速 add/remove、Surface 重建、后台/前台、释放期间回调不访问已销毁 native/GL 对象 | -| A13 旧 GPU / ABI | armeabi-v7a 与 arm64-v8a;缺少 row-length 扩展的 GLES2 上传路径和输出正确 | -| A14 SDR/HDR/DV | 视频颜色/模式无意外变化,字幕亮度与 alpha 合理;分别覆盖既有 Surface/TextureView 与受支持播放路径 | -| A15 负载 | 4K 大招牌、模糊、多事件和长时播放有界;对照视频丢帧/音频 underrun 和热状态 | -| A16 呈现时间 | 普通字幕统计延迟/抖动;贴画面招牌对照视频帧和实际屏幕,单独报告不能做到严格同帧的设备 | -| A17 回退 | 选中 ASS 但初始化/字体/GL 失败时明确回退原因;不自动换播放器或悄悄关闭当前音视频能力 | -| A18 无字幕及普通字幕 | 功能关闭时无 native/GL 常驻成本;现有字幕字号/位置、网络、选轨、DV、软硬解和诊断链不回归 | - -### 可直接采用的成熟测试基座 - -新增核验 **libass/libass-tests** `10edd9ecd8054f2c4678d36b379d8372d0e573c6`。其 regression 使用 `compare`、固定字体和参考 PNG,禁用系统字体 provider;另有 crash 语料,建议 ASan/UBSan。已读顶层/回归 README、`regression/blurs/blur+t.ass` 和 `regression/karaoke/357-k-and-kf-desynced.ass`:前者在一条字幕内组合不同强度 blur 与 transform,后者组合 k/kf/fade,适合检验“只有首帧正确”的假实现。参考图、字体与字体许可随选定测试版本固定,不能只复制 ASS 文本。 - -| 验证层 | 最小决定性用例与失败判据 | -| --- | --- | -| Media3 接线 | 真实 TextRenderer + fake SampleStream/clock:默认 observer 关闭、只选中轨道、正负延迟、非零 offset/period、peek 不重复输入、旧 decoder flush 后结果被拒绝、final stream 结束;不写只检查方法被调用的镜像测试 | -| 原始输入 | 同一脚本的完整 ASS、Media3 封装和 MKV 原始事件独立 fixture;精确检查 header/ReadOrder/正文逗号/PTS/duration;同一库对照无法发现两边都喂错数据,所以不能只做截图 | -| 布局/像素 | 先跑官方 blur/karaoke 小组,再以固定字体/viewport/时间生成独立参考,比较自有 GL 合成;ARM 浮点/SIMD 允许预先定义的容差,不能要求不同架构所有像素哈希一致,也不能事后再生成“正确答案”掩盖差异 | -| 时钟/屏幕 | 标签位于视频帧边界前后的样片、暂停 resize、倍速和字幕延迟;分别记录媒体时钟、提交时刻和实际屏幕。独立层不能严格同帧的组合不得标为帧级贴合通过 | -| 恢复/释放 | 注入字体/GL/队列失败,确认原 Cue 恢复、音视频不停;detach 后 attach、engine rebuild、关轨期间在途帧均不可复活旧内容; native 复制/边界逻辑用选定小语料做 ASan/UBSan | - -本轮只读取上述语料与说明,**未运行 libass-tests 或新接口 fixture**;实施时从相关小组开始,不无差别运行全库或全 ABI 矩阵。 - -画质比较必须固定 libass 版本、字体文件、字体解析日志、脚本/样本、播放时间及 viewport;只拿不明字体配置的 MPV 截图作“正确答案”会误判。可用离线 FFmpeg/libass 生成参考,差异需要区分文字排版、栅格化、色彩和实际呈现时刻。 - -建议诊断至少记录 session/track/generation、输入字节/事件/字体计数、实际 font family、媒体时间和 offset、renderer size/viewport、render 耗时、mask 数/面积、upload 字节、清屏/过期丢弃/回退原因。字体名、媒体 URI 等按现有日志策略脱敏,不导出完整字幕正文或凭据。 - -“已解析”“产生 mask”“GL swap 已提交”“视频帧回调”都是分层证据,不等于用户实际看到了字幕。故障报告要指出最后正常层、缺失证据与最小验证步骤,例如在同时间点捕获独立字幕层与屏幕结果。受保护 Surface 或 HDR 屏幕录制可能改变/隐藏输出,必要时采用外部拍屏,不用录屏成功代替真实显示验证。 - -## 10. 来源、固定版本与证据等级 - -访问日期均为 **2026-09-14(Asia/Shanghai)**。源码是读取实现,不是运行验证;issue 是作者/维护者报告,不是本项目复现。官方规格记 A,源码本身记 A(只能证明实现方式),维护者解释/产品实践记 B,技术论文或工程博文按其可支持的范围记 C,未经独立复现的单条问题/宣传记 D。 - -### 固定源码清单 - -| 来源 | 完整 revision | 本轮处置 | -| --- | --- | --- | -| [peerless2012/libass-android](https://github.com/peerless2012/libass-android/tree/04dcc7d49cfe35076fce5eea81c5918f381caa47) | `04dcc7d49cfe35076fce5eea81c5918f381caa47` | 候选参考;优先适配,不原样集成 | -| [Jellyfin Android TV](https://github.com/jellyfin/jellyfin-androidtv/tree/3d087faa00e79044016b14f8b227affe5942af7e) | `3d087faa00e79044016b14f8b227affe5942af7e` | 实际消费者参考;未移植 | -| [LumeraD3v/assrender](https://github.com/LumeraD3v/assrender/tree/3542e5a2f099664d47832d03043bd499c1504a1a) | `3542e5a2f099664d47832d03043bd499c1504a1a` | 最小结构参考;不建议整包采用 | -| [libass](https://github.com/libass/libass/tree/b2fe9d8770678a7b5271387d38c20657ebf3429a) | `b2fe9d8770678a7b5271387d38c20657ebf3429a` | API/许可参考;生产选版待阶段 1 决策 | -| [JASSUB](https://github.com/ThaUnknown/jassub/tree/656371af1c904be59a1008dcdb93f18dfe5e23d0) | `656371af1c904be59a1008dcdb93f18dfe5e23d0` | worker/GPU 设计参考;不引入 WASM | -| [GStreamer](https://github.com/GStreamer/gstreamer/tree/bb387b3c7bea6d275d20b13af8c622bd465f7c69) | `bb387b3c7bea6d275d20b13af8c622bd465f7c69` | segment/同步/合成参考 | -| [VLC](https://github.com/videolan/vlc/tree/c666634229ca28354fd4fc1bdf8c43a8a232644b) | `c666634229ca28354fd4fc1bdf8c43a8a232644b` | 时间/字体/区域缓存参考 | -| [Kodi](https://github.com/xbmc/xbmc/tree/334195075cd7183f6787aecd6a74ea377dd8f731) | `334195075cd7183f6787aecd6a74ea377dd8f731` | seek/锁/事件参考 | -| [本地锁定的 FongMi/mpv](https://github.com/FongMi/mpv/tree/cca559b41ceb0bb7731cf6ef2e1f33276cd30c42) | `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` | 已有 OSD Surface/EGL 复用评估;不修改原 MPV native 链 | -| [libass/libass-tests](https://github.com/libass/libass-tests/tree/10edd9ecd8054f2c4678d36b379d8372d0e573c6) | `10edd9ecd8054f2c4678d36b379d8372d0e573c6` | 官方图像/异常语料参考;本次未执行,不引入生产依赖 | -| 本地 `/Users/macbookpro/Desktop/github/FFmpeg` | `85c0e1a333444cfe2f5491a0c4262bbc7c92f719` | 只读 `libavfilter/vf_subtitles.c`;不视为本地已发布依赖版本 | -| 本仓库锁定的 Media3 源码 | `e3e922d5c01bc0b564849940fe589daf37360d15` | 本地契约;保留且不升级 | -| 本仓库锁定的 nextlib | `6ff6cf9d0820382b3c233d018c52e4163b09d345` | 依赖边界;本轮无新修改候选 | -| 现有 MPV 锁定 libass | `89cc0f4e450d64f74281a17d7f11ed05229665e8` | 已有另一条二进制链;不与 Exo 研究版本混同 | - -以上是本轮固定证据版本清单,不是要求合并的 commit range。没有选择上游历史提交批次,因而不重做既有全量上游 ledger。各行处置均为参考/保留/候选,未执行 cherry-pick 或依赖更新。 - -### 可追溯证据表 - -下表的文件路径位于上表对应固定 revision;issue/PR 与网页为访问日快照,无不可变版本号。 - -| ID / 来源 | 等级与支持的判断 | 对 WebHTV 的适用性、限制与决策影响 | -| --- | --- | --- | -| S01 [libass `ass.h`](https://github.com/libass/libass/blob/b2fe9d8770678a7b5271387d38c20657ebf3429a/libass/ass.h),已读 ASS_Image、render、chunk、ReadOrder、prune、font API | A:输入/时间单位、alpha mask/stride、变化提示与事件契约 | 直接决定 JNI、队列、缓存和渲染边界;API 存在不等于性能已测 | -| S02 [Matroska SSA/ASS](https://www.matroska.org/technical/subtitles.html#ssaass-subtitles) | A:CodecPrivate、Block 字段、容器时间与 ReadOrder | 决定 raw 输入;不可从 Cue 复原完整脚本 | -| S03 [Aegisub ASS 标签文档](https://aegisub.org/docs/latest/ass_tags/) | A:move/transform/karaoke/drawing/clip 等语义说明 | 作为语料设计依据;实际兼容仍以选定 libass 与样本为准 | -| S04 [Media3 支持格式](https://developer.android.com/media/media3/exoplayer/supported-formats) + 锁定 SsaParser/测试 | A:支持 SSA/ASS 容器/解析以及部分样式 | 不将“支持格式”推断为完整特效;保留已有测试和功能 | -| S05 [Media3 #2324](https://github.com/androidx/media/pull/2324),[维护者结论](https://github.com/androidx/media/pull/2324#issuecomment-2828026034) | B:作者停止工作、维护成本、提案缺口 | 不等待其合并,不当成熟官方模块 | -| S06 [Media3 #2042](https://github.com/androidx/media/issues/2042#issuecomment-2595058592) | B:解析时无法获得最终显示尺寸 | 解析和显示拆分;自定义显示层 | -| S07 [Media3 #2289](https://github.com/androidx/media/issues/2289#issuecomment-2766645727),[帧监听建议](https://github.com/androidx/media/issues/2289#issuecomment-2775666590) | B:普通文本路径不保证逐帧精度;帧时间可供实验 | 单独验收呈现延迟;保护现有视频帧监听 | -| S08 [Media3 #2383](https://github.com/androidx/media/issues/2383#issuecomment-2872355740),[补充说明](https://github.com/androidx/media/issues/2383#issuecomment-2886390997) | B:TextOverlay 与 Bitmap/CanvasOverlay 的 HDR 亮度/API 处理存在差异 | 这是当时 effects 路径讨论,不推断目前所有版本或独立 Android View 都存在相同限制;HDR/DV 仍须实测 | -| S09 [libass-android `lib_ass_media`](https://github.com/peerless2012/libass-android/tree/04dcc7d49cfe35076fce5eea81c5918f381caa47/lib_ass_media) | A:已读 AssPlayer、AssRenderer、AssExecutor/Task、AssHandler、AssTrackOutput、AssMatroskaExtractor、ParserFactory、配置和模板测试 | 最直接可复用结构;固定偏移、反射、工厂替换、预算是明确适配点;README 的速度/内存宣传未复现 | -| S10 [Jellyfin `ExoPlayerBackend.kt`](https://github.com/jellyfin/jellyfin-androidtv/blob/3d087faa00e79044016b14f8b227affe5942af7e/playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt) 与版本目录 | A/B:实际选择独立 OpenGL overlay,依赖 ass-media 0.5.1 | 提升集成可行性的可信度;不证明 WebHTV 可原封不动复用 | -| S11 libass-android [#70](https://github.com/peerless2012/libass-android/pull/70)、[#76](https://github.com/peerless2012/libass-android/pull/76)、[#79](https://github.com/peerless2012/libass-android/pull/79)、[#80](https://github.com/peerless2012/libass-android/pull/80)、[#82](https://github.com/peerless2012/libass-android/pull/82)、[#87](https://github.com/peerless2012/libass-android/pull/87) | B:已合并的生命周期/混合/老 GPU/JNI/resize 修复记录 | 转成回归清单;本轮没有重演报告设备的故障 | -| S12 libass-android [#55](https://github.com/peerless2012/libass-android/issues/55)、[#71](https://github.com/peerless2012/libass-android/issues/71)、[#85](https://github.com/peerless2012/libass-android/pull/85) | D:NUL、延迟、压缩事件的具体报告;#85 closed 且未合并 | 输入与时间边界仍需语料验证;不把报告当统一根因或修复证明 | -| S13 [assrender 源码](https://github.com/LumeraD3v/assrender/tree/3542e5a2f099664d47832d03043bd499c1504a1a/assrender/src/main),[#1](https://github.com/LumeraD3v/assrender/pull/1)、[#2](https://github.com/LumeraD3v/assrender/pull/2)、[#3](https://github.com/LumeraD3v/assrender/issues/3) | A/D:实际 CMake、反射和 render loop;字体/选轨/AAR 问题报告 | 仅取窄参考,不能把 open PR 当已解决或把 README 当包依赖清单 | -| S14 [GStreamer `gstassrender.c`](https://github.com/GStreamer/gstreamer/blob/bb387b3c7bea6d275d20b13af8c622bd465f7c69/subprojects/gst-plugins-bad/ext/assrender/gstassrender.c),[插件文档](https://gstreamer.freedesktop.org/documentation/assrender/index.html) | A/B:segment running time、事件队列和合成 | 借鉴不同时间域转换与锁;非 Exo 现成适配 | -| S15 [VLC `libass.c`](https://github.com/videolan/vlc/blob/c666634229ca28354fd4fc1bdf8c43a8a232644b/modules/codec/libass.c) | A/B:字体、PTS/duration、change-aware 重绘 | 稳定的职责划分参考;复制需逐文件许可证审查 | -| S16 [Kodi `DVDSubtitlesLibass.cpp`](https://github.com/xbmc/xbmc/blob/334195075cd7183f6787aecd6a74ea377dd8f731/xbmc/cores/VideoPlayer/DVDSubtitles/DVDSubtitlesLibass.cpp) | A/B:字体/事件/锁/flush,seek 顺序注意事项 | 决定 seek 回归语料;不因 GPL 项目可读就视为可无条件复制 | -| S17 [JASSUB 源码](https://github.com/ThaUnknown/jassub/tree/656371af1c904be59a1008dcdb93f18dfe5e23d0/src) 与 README | A/B:WASM libass、worker、GPU mask 合成、预乘 alpha、现代浏览器要求 | 借鉴线程/数据所有权与合成;“快于 native”等宣传未用作实测证据 | -| S18 [Green 2007 论文原文](https://cdn.akamai.steamstatic.com/apps/valve/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf),已读 5 页 | C:SDF 字形/矢量纹理放大及限制 | 只影响远期局部优化选择;不证明完整 ASS 引擎或 Android 播放性能 | -| S19 [Text Rendering Hates You](https://faultlore.com/blah/text-hates-you/),已读 shaping/fallback、透明合成与大字形部分 | C:文字处理的实际复杂性 | 支持复用成熟文字栈;不是 ASS 标准或性能 benchmark | -| S20 本地固定 FFmpeg `libavfilter/vf_subtitles.c`,`filter_frame` / `overlay_ass_image` | A:按视频 PTS 调 libass 后混入 AVFrame | 离线参考与 burn-in 代价判断;未构建或烧录测试 | -| S21 本地 `MpvPlayer.createOsdSurfaceView`、JNI `enqueueOsdSurface`、`MpvOsdSurfacePolicyTest`;[固定 native OSD 实现](https://github.com/FongMi/mpv/blob/cca559b41ceb0bb7731cf6ef2e1f33276cd30c42/video/out/android_osd_overlay.c) 与 `vo_mediacodec_embed.c` | A:已有透明 SurfaceView、独立 EGL/GLES、BGRA 图块和 MPV OSD 耦合 | 改变本地实施优先级为复用承载/评估合成抽取;不把已有层等同于通用 ASS 服务 | -| S22 AOSP [SurfaceView/GLSurfaceView](https://source.android.com/docs/core/graphics/arch-sv-glsv)、[TextureView](https://source.android.com/docs/core/graphics/arch-tv) | A:两类宿主的合成与生命周期机制,SurfaceView 可接 EGL | 支持原型优先使用已有 SurfaceView 设计;Z-order、硬件 overlay 数量和实际收益仍需设备验证 | -| S23 本地 `third_party/maven/androidx/media3/{media3-exoplayer,media3-extractor}/1.11.0-alpha01-fongmi/*-sources.jar`,SHA-256 见第 2 节;`DefaultMediaSourceFactory`、`TextRenderer`、`RendererHolder`、`BaseRenderer`、`NoSampleRenderer`、`MatroskaExtractor` | A:实际发布源码包含本地默认解析模式、类型特判、文本延迟及 SSA_PREFIX/10 ms duration 语义 | 决定保留真实 TextRenderer 和窄观察接口;源码身份已核对,不代表新增接口存在或已编译 | -| S24 [libass `ass_types.h`](https://github.com/libass/libass/blob/b2fe9d8770678a7b5271387d38c20657ebf3429a/libass/ass_types.h),结合 S01 | A:YCbCr Matrix 由调用方处理、HDR 字幕按 SDR 考虑、LayoutRes、font update no-op、扩展换行兼容边界 | 补齐色彩/字体/布局策略;Android HDR 的最终显示仍需设备验证 | -| S25 [固定 MPV `sub/sd_ass.c`](https://github.com/FongMi/mpv/blob/cca559b41ceb0bb7731cf6ef2e1f33276cd30c42/sub/sd_ass.c),`assobjects_init`、`filter_and_add`、`reset`、`configure_ass`、`mangle_colors` | A/B:先登记字体、chunk 时间/去重、条件 flush、样式覆盖和色彩处理 | 成熟原生实现补强生命周期/字体/seek 取舍;只参考设计,不复制 MPV 会话或 GPL 代码 | -| S26 [libass-tests README](https://github.com/libass/libass-tests/blob/10edd9ecd8054f2c4678d36b379d8372d0e573c6/README.md)、[回归说明](https://github.com/libass/libass-tests/blob/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/README.md)、[blur+t](https://github.com/libass/libass-tests/blob/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/blurs/blur%2Bt.ass)、[karaoke](https://github.com/libass/libass-tests/blob/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/karaoke/357-k-and-kf-desynced.ass) | A:固定字体、图像容差、异常语料及实际动态标签 | 用成熟语料取代仅列 A01–A18 的泛化清单;无本轮运行结果,字体许可要随引入核验 | -| S27 本地 `PlaybackActivity.attachSurface/detachSurface/syncVideoSurfaceSize`、`ExoPlayerEngine.rebuild/release`、`ExoUtil.buildRenderersFactory`、`app/build.gradle`,复评基线 `bc2b3b284de87ada937b4ba3564f6fb12aa8a956` | A:服务/宿主生命周期分离、公共双端接线、固定 buffer 与 View 尺寸差异、现有 native API 26 参数 | 明确 App 改动位置、epoch 和独立 API 24 构建要求,不改动原视频 native 目标 | - -证据类覆盖:官方规格/API、确切源码/现有测试、PR/issue/维护者讨论、成熟相关项目、技术论文/博文/现场报告均已覆盖。本任务不是挑选具体上游 revert,未发现需要把某个 revert 作为方案前提;也未把尚未阅读正文的搜索结果、HN 模糊匹配或 404 的 libass wiki 页面列为证据。 - -## 11. 许可证、来源与可复现边界 - -- libass 主库 ISC 不等于整个字幕包都是 ISC。FreeType、HarfBuzz、FriBidi、fontconfig 等需要逐项记录许可证与版本,确认 LGPL 相关发布义务和静态链接策略。 -- libass-android 包装 MIT、assrender 包装 Apache-2.0;JASSUB 顶层 LICENSE 为 MIT,而 package 元数据明确列出 WASM/native 依赖的多种许可证。VLC/Kodi 优先借设计和语料思路,复制文件前审核其具体许可。 -- 新字幕 native 使用独立构建入口和独立 lock,不从 MPV 运行实例或私有符号获取 libass。可参考已有 MPV 的字体依赖源码版本,但 Exo 必须显式固定自己的 libass、FreeType、HarfBuzz、FriBidi、fontconfig/XML、libunibreak 输入与许可证,不能随 MPV lock 更新而隐式改变。新增依赖不包含 FFmpeg、mpv 或 libplacebo;选择性代码复用不等于整包引入 libass-android 的预编译库。 -- 不复用不明来源 prebuilt,不让新库解析到 MPV/nextlib 同名依赖;独立 JNI 名称、隐藏非必要导出、C++ runtime 去重、ABI/API 与 16 KiB ELF/ZIP 对齐从**阶段 1 首个 ABI**开始检查。`app/build.gradle` 当前 NDK 为 r29,但现有 CMake 参数为 API 26;新 ASS 库需按 App minSdk 24 独立构建,不能盲目挂到 API 26 目标,也不因此修改既有视频目标。其余 ABI 未构建时只能是受门控原型,不能作为完整发布包。 -- 包体报告分别记录每 ABI 新增压缩/未压缩库大小、APK 增量及字体/provider/cache 开销;字体和每个静态依赖分别保留许可/来源,不能用 MIT 包装许可覆盖 LGPL 等义务。实施前冻结依赖清单,选版仍是原型准备动作,本次没有升级或生成任何依赖。 -- 下载归档只用于阅读,没有执行外部项目脚本或 native 文件。取证显式使用 `http://127.0.0.1:7897`;复评的新增请求均为公开 GET。首轮记录的认证取证属于此前会话,不作为本轮新授权使用。 -- 三份源码归档 SHA-256:peerless `538984edf6480bef0c7605292e913f39e96d967ad69fc8802e5aad05c7c5f490`;assrender `918fefb558c4acf48ee271ac34beb7897e4cfb7c9df9890f4d718aba8c388532`;JASSUB `fe101651b866f746c799fe9820ea8def46bfd4a8e5ffc6ba5155eb547904cbfa`。原始快照位于 Recovery anchor 的临时目录;长期依据是本文件的固定 commit URL 与访问日期。 - -## 12. 复评阶段的完成边界 - -首轮已完成可行性与跨平台研究。复评补齐实际发布源码证据,修订输入/时钟接线、显示回退、seek 边界、字体/色彩/几何、生命周期、native 基础门槛及成熟测试语料。采用窄 TextRenderer 观察接口是结合当前 fork 的设计建议,其开销和完整性仍由阶段 1 证明;阶段 2 的精确输入传递和未缓存长事件恢复保持显式门槛。文档归档进行一次范围内的结构、链接、完整 revision 与索引核验,结果记录于提交的 `Verification` 字段;guard 检查保护路径、原子提交及 tag。 - -本轮没有构建 APK/AAR/so,没有安装设备,没有运行 A01–A18 或 libass-tests,也没有测得 WebHTV 性能改善。建议仅实施可回退的阶段 1 原型;完整产品准入仍需后续阶段证据。首轮归档为 `bc2b3b284de87ada937b4ba3564f6fb12aa8a956`;本次文档提交用 `Task-Guard: E4-LIBASS-review` 定位,本地恢复 tag 使用同名任务前缀,不推送远端。 - -## 13. 阶段 1 实施与用户字体问题(本阶段已验收) - -本节覆盖复评之后获批的实现,不能将第 12 节历史状态当作当前结果。独立 native 固定 libass `89cc0f4e450d64f74281a17d7f11ed05229665e8`(与已有可溯源源码版本一致,重新独立编译),未采用研究用参考 head,也没有链接 MPV/FFmpeg。完整依赖、许可、工具链、构建输入和产物见 `third_party/exo-ass-lock.json`、`third_party/exo-ass-native/build-provenance.json`;生成脚本和 verifier 随本任务归档。 - -### 用户样本与因果证据 - -- 用户给出的 `S02E02认真又真实的分歧点.ass` 为 UTF-8 BOM、40369 字节、422 个 Dialogue,SHA-256 `3348b91d75605a69f7f8fc687b444fffe7e48c693ceac3602f9502c92f7a8495`。`OP_CN` 指定 `WEOYTLVC`,另有多组重命名字体;文件没有 `[Fonts]` 数据。只凭视频文件名把当前字幕推断为内嵌的早先说法已被用户纠正,后续以外挂 ASS 为准。用户媒体不纳入仓库测试资产。 -- 完整读取 `显示信息.lua`(108 行):启用时用 `sub-ass-force-style` 写入颜色/粗体/描边等,但没有 `FontName`;关闭时清空此项。它没有下载或提供字体。用户报告启用后原字形恢复,与下面 MPV 覆盖项被替换的源码路径一致;不据此推断 Exo 具有同样根因。 -- MPV `MpvPlayer.applySubtitleStyle()` 原来无条件写入 `sub-ass-style-overrides=FontName=sans-serif,...`,即使未开启系统字幕样式也如此。本次删除应用自动生成的 ASS 全局覆盖,保留普通字幕 `sub-*`、字号/位置和 `sub-ass-override=scale`,并保留用户 mpv.conf/Lua 对覆盖列表的所有权。无 MPV native、锁或 ABI 修改。 -- Exo 的实际发布 `MergingMediaPeriod.onPrepared()` 会把 **Format ID** 改成 `:`。原型只接受字符串开头为自有标识,导致合法外挂 ASS 未进入 libass。现在只为准入识别合法数字前缀;stream/period/generation 仍用完整身份,不能把去前缀后的字符串作为跨会话缓存键。 - -### 针对此次问题深入读取的成熟实现 - -访问日 2026-09-14,沿用第 10 节固定 revision,以下均为实际正文/源码证据,未重复拉取无关历史。 - -| 来源 | 已读代码及结论 | 本地决定 | -| --- | --- | --- | -| MPV `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` | `options/options.c` 将 `sub-ass-force-style` 映射到 `sub-ass-style-overrides`,带 `UPDATE_SUB_HARD`;`DOCS/man/options.rst` 的 embeddedfonts、style-overrides、override 明确区分脚本样式和普通字幕样式;`sub/sd_ass.c` 的 `assobjects_init/add_subtitle_fonts` 在建 track 前登记字体并应用显式覆盖 | 修复 App 错误下发的 FontName,不另造字体替换机制、不用定时 Lua“修正”结果 | -| libass-android `04dcc7d49cfe35076fce5eea81c5918f381caa47` | `AssMatroskaExtractor.binaryElement` 读取附件 MIME/name/data → `AssHandler.addFont`;`pendingFonts/createTrack` 覆盖字体先到的情况;`Ass.kt` 将 library/font/track 调用置于同一锁 | 字体数据必须有明确来源、先登记后匹配;适配现有 Media3/DV 提取链,不能仅更改默认 family | -| Jellyfin Android TV `3d087faa00e79044016b14f8b227affe5942af7e` | `ExoPlayerBackend` 实际使用 `withAssMkvSupport`、`AssSubtitleParserFactory`、`AssRenderersFactory` 和 `OVERLAY_OPEN_GL` | 对照真实消费者的完整接线;保留 WebHTV 现有工厂和字幕回退契约 | -| VLC `c666634229ca28354fd4fc1bdf8c43a8a232644b` | `modules/codec/libass.c` 的附件 MIME/扩展名识别、`ass_add_font`、`ass_set_fonts_dir`,默认 `ass_set_style_overrides(NULL)` | 系统 provider 是兜底,附件是字体数据,不能用一个固定系统 family 覆盖脚本所有样式 | -| 本地已发布 Media3(源码基础 `e3e922d5c01bc0b564849940fe589daf37360d15` + lock 补丁) | `SingleSampleMediaSource` 保留 SubtitleConfiguration.id,`MergingMediaPeriod` 再加数字前缀 | 修复外部 ASS 准入,补直接/合并/嵌套合并和普通容器 ID 的边界测试 | - -取舍:不改动会保留已证实的 MPV 字体覆盖和 Exo 准入遗漏;直接替换为第三方整套播放器工厂会丢失本地视频、音频与网络适配;采用成熟 libass/字体登记机制,并对现有调用链做上述窄修正。匹配不到原字体的数据缺口必须单列,不能通过换一个相近系统字体来宣称恢复。用户实际字体附件仍待取得并验收。 - -### 已完成验证与剩余门槛 - -- `AssInputTest` 5/5 通过(新增合并 ID 测试后需再跑);Mobile 主代码和 Leanback Java 编译通过。 -- `verify_exo_ass.py --sanitize` 通过:944 个无关 Media3 class/resource 保留,源/补丁/lock/metadata/校验和一致;独立 `.so` API 24/AArch64/16 KiB,限定系统 DT_NEEDED、8 个 JNI 导出;36 个恰好末行大小的 mask 分配和无效尺寸用例通过 host ASan/UBSan。native SHA-256 `291a38b0b8c43dfb20897a111a53bd3867101cfd182bd25c505373a62592bcba`。 -- 实验 APK 含匹配的 2762848 字节 `.so`,未压缩且 ZIP 数据偏移 16384 对齐;默认 APK 不含该库且 BuildConfig=false。两份增量构建 APK 的总大小受 ZIP 空洞/布局影响,不把 APK 总差值当净增量;ZIP 有效压缩条目增量为约 2.76 MB。 -- vivo V2453A/API 35/Adreno 首次 instrumentation 10 项:官方 blur、karaoke、unchanged/context/empty 三项通过;真实 TextRenderer 的字节/offset/延迟/seek/end、异常隔离、reading/displaying period 三项通过。失败四项:一项 fake SampleStream 没把返回 FORMAT 的 peek 算入计数,已修 fixture;三项 Activity 启动等待超时,改用已可成功启动的 shell 路径和 ActivityMonitor,待跑。 -- 暂停调延迟后的兼容 Cue、真实外挂/用户字体、MPV 原字体与普通字幕、Surface/释放、三对性能仍未通过。阶段仍未完成,不能把已打的基线 tag 或已编译 APK 表述为最终交付。 - -### 22:23 起:已确认 MPV 归档,继续 Exo 字体链路 - -用户确认“mpv正常了,exo还不行”并要求先打 tag。MPV 修复已独立提交/tag(见 Recovery anchor),未夹带未验证的 Exo 实现;原 guard 状态保存在 `/private/tmp/webhtv-libass-stage1/guard-before-mpv-font-20260914-2222`。恢复 Exo guard 时仅显式接续任务已有文件,70 个原始 `.cxx` 文件继续受保护。 - -最新构建的 `AssInputTest` 6/6、Mobile APK/test APK、Leanback Java 通过;默认 observer 测试已通过。Activity shell 启动问题已解决,三项播放用例现在实际进入播放,但被音轨初始化阻塞:`AudioTrack init failed Config(48000,4,10,...)`,包含关闭 ASS 和无字幕两条基线。正在用立体声样片区分设备/现有直通设置与字幕问题,不改生产音频策略。 - -用户后续明确要求修复内嵌/外挂字体,授权补齐字体附件部分;完整 MKV ASS 事件/时长/seek 的阶段 2 仍未展开。固定 Media3 已将 EBML 元素回调设为 protected,可采用 libass-android 的附件子类模式,完全保留现有 raw subtitle、deferred Cues、DV7 参数与外层视频包装,无需新增 extractor Maven 补丁。新增接线范围仅为 `MediaSourceFactory.java`、`DolbyVisionP81ExtractorsFactory.java`;附件类、会话和 JNI 均位于原 scope。 - -决定:仅对当前前台完整外挂 ASS 创建每媒体字体集合;预加载继续使用原默认工厂,拼接流仍使用兼容路径。读取 Matroska Attachment 的 name/MIME/data,容忍字段顺序,按内容哈希去重,限制 64 个字体、单体 16 MiB、总量 32 MiB。字体不写到共享文件目录,不改变原内部 family。初始字体先 `ass_add_font` 再建 renderer/track;晚到字体在同一 worker 重建 libass 状态并失效旧图块。新媒体/释放使旧集合失效;关字幕仅释放 native,允许本视频重选时复用附件。异常只回退字幕,不中断音视频。 - -相比不改动,修复仅有系统 provider 的字体缺口;相比原样移植,避免反射、全局跨 extractor Handler、字段顺序假设和无界附件分配。验收覆盖附件真实字形、字段乱序/重复/限额、晚到重建/跨视频隔离、普通播放回退及性能;关闭原型或回退本 Exo 单元即可撤销。北京时间 22:23 续作估计 25–35 分钟,预计 22:48–22:58;后因回退和字体 provider 故障超时,停止可选研究,继续处理确定性缺陷。 - -23:00 后新增实际证据:从刚播放媒体的 Exo 缓存头(823428 字节)提取出 8 个字体附件,内部相关命名与用户 ASS 的 `WEOYTLVC` 等相符,其中 WEOYTLVC 对应华康金文体 W3;总字体数据 811760 字节。用户字幕依然是外挂,容器同时有内嵌轨道不改变这一事实。用户字体/缓存及仅供复现的重封装文件都保存在 `/private/tmp/webhtv-libass-stage1/` 和设备 cache,不纳入 Git。 - -附件字段乱序/去重/旧媒体关闭、production JNI 附件字形与固定参考一致、关闭 ASS、无字幕播放四项已通过。第一次字体测试没有要求无附件时的输出非空,后来证实系统 provider 缺陷;该结果只能证明附件字形,不能证明正常系统兜底下的优先关系,已补非空断言待跑。暂停回退测试失败:观察路径 `setTextOffsetUs` 清空 Cue,而暂停时 `renderFromSubtitles` 不再产出,已在 observer 存在且仍是同一 displaying stream 时从现有 Subtitle 立即计算当前 Cue;默认 null observer 分支保持原样,补暂停正/负时间边界测试。原播放 fixture 改用立体声 PCM,两个 AAC 版本都被相同的现有直通设置拒绝,生产音频代码未动。 - -字体 provider 第二处确切构建缺陷:锁定 fontconfig `a4e25ec391d417e4bca052fbfa5cd7ce5f7fd39e` 的 `src/fcxml.c:FcConfigParseAndLoadFromMemoryInternal` 设置 SAX1 `startElement/endElement`;独立 libxml2 2.15.3 的 `minimum=true` 却关闭 SAX1,使 `` 等配置未正确装载。已读取 libxml2 `meson.build:114–115` 和 `parser.c:xmlDetectSAX2`,并核对现有 MPV `buildscripts/scripts/libxml2.sh` 明确 `-D{push,reader,sax1,iso8859x,pattern}=enabled`。决定只补 `-Dsax1=enabled` 并重建独立依赖,版本不变;新增“缺少附件时仍有可见系统兜底”像素断言,不能用全透明结果通过字体差异比较。 - -最近一次新 APK 安装被 OEM 返回 `INSTALL_FAILED_ABORTED`,设备已切到其他应用;已询问是否用户主动取消,设备操作等待回复。主代码/两个 APK/Leanback 和暂停 Cue 的 Media3 重建均已通过编译,最新 APK 尚未安装验收。宿主 FFmpeg 不含 subtitles filter,离线参考命令未成功;不把失败参考列为验收结果。 - -### 当前候选与恢复入口(00:26 已恢复安装和定向复测) - -- MPV 已归档,Exo 全部代码/产物仍未提交,guard 基线为 `5cde3c015258f620f264d5f3ffe0a437c2ea3d48`。失败门槛未关闭,不为 Exo 创建实现 tag。 -- 已重建 SAX1 libxml2 和独立 JNI;`verify_exo_ass.py` 通过,944 个无关 Media3 class/resource 字节保持。最终 `.so` 为 2772512 字节,SHA-256 `ffea1ff04cf262b739c9fcc1a69c08b3c336884a3a5dd9c082979ddec93ae2f6`;最终 AAR `ddfe4d5f7f4afe96347f38f980b3861cdd0432179ff4d976bc7067096df673bd`,sources JAR `80971da199285f9f6c1507c2ace66b313258532be6bae44dd751ff8b88862ac6`。 -- 最后一次 Mobile APK 构建通过(31 秒);APK 中的 native 字节与上述产物一致,未压缩、ZIP 数据偏移 16 KiB 对齐。最终源码差异空白检查通过。原 host ASan/UBSan 的 mask 逻辑未再修改,不重复运行。 -- 候选 `/private/tmp/webhtv-libass-stage1/exo-ass-font-candidate.apk`,SHA-256 `11724d7a7473254dbcf5f92260987ecb9d5e1aa006b4755cd99340745b14e7d6`;测试 APK 同目录 `exo-ass-font-candidate-test.apk`,SHA-256 `54789c76c0be9760b8ecedf8568848f86c97182ad013491e26a47e3c0af5e150`。 -- 两次 OEM 安装拒绝日志为 `paused-cue-install.log`、`sax1-install.log`,均 `INSTALL_FAILED_ABORTED: User rejected permissions`;已停止重试。设备留在用户正在使用的应用,未强制切换、卸载或改安装安全设置。临时 ExoPlayerImplInternal/ExoAssNative/ExoAssTest 日志标签已恢复,用户全局 `log.tag` 未改。 -- 恢复后仅跑新增/修正用例:`AssAttachmentTest#testAttachedFaceWinsOverSystemFallback`、`TextRendererObserverTest#testPausedObserverDelayRedeliversCompatibleCue`、`TextRendererObserverTest#testDefaultObserverOffPreservesCueAndSingleConsumption`、`AssPlaybackTest#testPauseDelaySurfaceFallbackSeekTracksAndRelease`。再运行原 ASS/原字体画面与三对性能验证;未受影响的像素组不重跑。 -- 用户字体复现资源已就绪:设备 cache 中 `exo-ass-user-fonts.mkv` 为自生成视频/PCM + 原 8 字体附件,`exo-ass-user.ass` 是用户原文件未改字节;本机同名用途文件和字体在临时目录,首条 OP_CN 从 75.09 秒开始。它用于验证字幕字形,不代表用户原 4K 视频解码/性能验收。 - -### 2026-09-15 设备恢复后的实际结果 - -用户“手机连上了”后已成功安装上述原候选和测试 APK;安装证据 `resumed-install.log`、`resumed-test-install.log`。`instrumentation-resumed.txt` 记录 4 项定向测试全部通过:`AssAttachmentTest#testAttachedFaceWinsOverSystemFallback`、`TextRendererObserverTest#testPausedObserverDelayRedeliversCompatibleCue`、`TextRendererObserverTest#testDefaultObserverOffPreservesCueAndSingleConsumption`、`AssPlaybackTest#testPauseDelaySurfaceFallbackSeekTracksAndRelease`,耗时 6.393 秒。此次才关闭先前系统 provider/暂停 Cue 的失败门槛;附件优先断言同时要求系统兜底产生非空像素。 - -原 ASS 的临时绝对 `file://` 路径在 `ExoUtil.buildSubConfig → UrlUtil.convert → Server /file → Path.local` 中被按应用既有本机文件地址规则处理,造成试验字幕未读入;字体附件本身已到达。将调试输入改为应用已有 FileProvider 的 `content://com.fongmi.android.tv.provider/cache/exo-ass-user.ass` 后,同一候选无代码改动即正常。这是试验资源寻址问题,未修改生产本机文件策略。 - -`user-font-content-metrics.json`:75.500 秒暂停,`ACTIVE`、`scriptCount=1`、`fontCount=8`、`fontBytes=811760`、`nativeAlive=true`、`compatibleVisible=false`,无音频 underrun/视频丢帧。`user-font-content.png` 已实际查看,底部“给这循环往复的每日”与用户原图均为同一细长特效字形,上方日文也保留原字形。这里只证明该原 ASS/字体的实际可见效果;静止画面的 render p95 不能代替完整性能配对。 - -00:36 左右设备再次从 ADB 列表消失,尚未取得本次实际字体选择日志和性能配对;没有为 Exo 创建实现提交或 tag。当前轮从 00:25 起估计 12–18 分钟(安装/定向 3 分钟,实样/性能 8–12 分钟,归档 2 分钟),原目标 00:37–00:43;USB 中断会增加必要等待,恢复后只继续未完成测量。 - -00:44:42 的 ADB 列表仍为空;先前安装/测试执行会话已确认正常结束,没有后台安装等待。性能脚本已备好,使用 24 秒 AVC/PCM 固定资产、三对交替的独立进程启动、原型关闭/开启对照、`/proc//task/*/stat` 的 ExoAss worker CPU 增量、播放中 PSS、8 次重建/关轨释放及最终 worker 消失检查;尚未运行,不能预写通过。预计设备恢复后 4–6 分钟完成剩余测量和归档。临时 `ExoAssNative`/`ExoAssTest` 日志标签应在取证结束后恢复为空;USB 中断前未能读取最终标签状态。00:34 实读全局 `log.tag=I`,未修改,不按旧会话记录覆盖为其他值。 - -### 00:58 恢复:原字体日志通过,性能门槛实际失败 - -`user-font-final-logcat.txt` 在 75.500 秒记录 `WEOYTLVC → DFPJinWenW3-GB`、`TPDAXLCR → DFKinBun-W3-WIN-RKSJ-H`,对应原中/日文字体;14 styles/422 events、8 个附件进入当前 libass。临时 ExoAssNative/ExoAssTest 标签随后成功恢复为空,未改全局日志属性。 - -USB 再次短暂重连后,改为手机本地 shell 连续测量,保留原已完成的第一组关闭结果;证据为 `performance-pairs.json`、`performance-device/`,不是新的源码语料或生产设置。三对均无视频丢帧/音频 underrun,但原样模式 render p95 为 9.110/10.277/10.050 ms,upload p95 为 2.774/3.182/2.954 ms;PSS 增量 108918/78181/63518 KiB,首组实际 worker CPU 35.94%,不满足既定门槛。第二/三组出现约 6 秒 Android 前台启动延迟,5 秒 CPU 窗口落在首帧之前,所得 0% 无效,不作为性能通过证据。释放态 PSS 已进入平台,但最后仅按线程名称观察 1 秒仍有同名线程;需以实际 HandlerThread 和 native owner 的完成状态判断,不能直接定性永久泄漏。 - -继续在原授权的性能验收范围内作窄适配:libass-android 固定 revision `04dcc7d49cfe35076fce5eea81c5918f381caa47` 的 `AssSubtitleTextureView.kt:AssRenderThread` 使用默认优先级 HandlerThread;本原型人为设为 Android BACKGROUND,拟改为 DEFAULT,仍在独立 worker 上串行执行,不挪到 UI/播放线程。已读锁定 libass `ass_render_api.c:ass_set_cache_limits` 和 `ass_render.c:check_cache_limits`:bitmap 参数是位图与 composite 的合计预算,glyph 参数限制 outline 数量;将试验值 2000/32 MiB 缩为 512/8 MiB,淘汰缓存,不修改像素、字体、字幕分辨率或更新频率。取舍是缩短历史 glyph 复用范围,是否增加排版开销由同一配对验证否决,不先宣称达标。 - -补充诊断使用 HandlerThread 的存活状态/TID;调试 close 在保持前台的异步等待中记录 releaseComplete 和线程退出后再 finish,避免以 Activity 关闭后固定等待 1 秒作为 native 释放证据。生产释放仍不阻塞 UI/播放线程,既有 instrumentation 同时断言实际线程退出。修正测量在真实 playing/ACTIVE 后采 CPU,关闭组保持同样播放窗口。 - -本轮日志还暴露 libass 的 Meson `vcs_tag` 从源码归档向上误读 WebHTV 外层 Git tag。固定源码输入没有改变;构建脚本仅将 `CONFIG_SOURCEVERSION` 标记为 lock 中完整 libass commit,并在产物验证中要求该字节串,避免把外层 tag 当库版本。该修正与本轮必要的 native 缓存调整一起重建,不另开依赖升级。约 01:31 起估计 12–18 分钟完成窄修正/构建/受影响组/配对,目标约 01:43–01:49;尚无新的验证通过结论。 - -### 最终验证、用户决定与归档 - -缓存上限已改为 512 个 glyph/8 MiB 位图与 composite 合计,worker 使用普通优先级;保留原字体、分辨率、动画和更新频率。普通优先级没有显著降低本压力脚本 CPU,不将它表述为已证实的 CPU 优化。新增诊断记录实际 HandlerThread TID/存活状态,测试在前台异步等待 native 和线程真正结束后记录关闭;生产释放仍不阻塞 UI/播放线程。 - -构建/安装与产物核验通过。`perf-instrumentation.txt` 的官方 blur、karaoke、unchanged/context/empty、完整暂停/延迟/回退/seek/换轨/重建/最终释放 4 项全部通过,7.012 秒;实际 worker 退出也进入断言。输入与未改动的 TextRenderer/默认包装检查沿用前述通过证据,没有重复运行无关检查。 - -最终三对测量使用已开始播放/原样 ACTIVE 的窗口,按诊断给出的精确 TID 计算 CPU,不再把首帧前 0% 算入。原始证据 `performance-device-v2/`、`performance-v2.json` 保留最初预算下两个失败项;下表记录实际结果及用户明确接受后的结论: - -| 项目 | 实测 | 结论 | -| --- | --- | --- | -| 首开中位数(关闭 → 开启) | 599 → 643 ms,增加 44 ms | 通过原门槛 | -| seek 中位数(关闭 → 开启) | 218 → 245 ms,增加 27 ms | 通过原门槛 | -| 视频丢帧/音频 underrun | 六次均为 0 | 通过 | -| 复杂动画 worker CPU | 一核 36.65%、35.48%、35.60% | 用户接受复杂动画 ≤40%;原 10% 门槛未通过 | -| 复杂动画 render + upload p95 之和 | 12.255、11.897、12.574 ms | 用户接受复杂动画 ≤16.67 ms;原 8 ms 门槛未通过 | -| swap p95 | 1.781、1.715、1.617 ms | 单列,未混入上述计算耗时 | -| PSS 增量 | 28174、56393、60779 KiB(最大 59.35 MiB) | ≤64 MiB,通过 | -| 8 次重建/关轨释放 | 预热后末次比首次 +1906 KiB(约 1.86 MiB) | 有界平台,≤8 MiB,通过 | -| 最终所有者释放 | 六次播放及最后循环均 releaseComplete=true、nativeAlive=false、workerAlive=false | 通过 | - -为判断压力样本成本来源,使用已安装 NDK 的官方 Simpleperf 采样,不改生产库:`cpu-profile.data` / `cpu-profile-report.txt` 中约 63% 的 worker 采样调用栈经过 `ass_render_frame`,主要是 composite、blur、bitmap shift/rasterizer;另有 Adreno 纹理上传和提交开销。符号来自相同对象/静态库的仅供分析重链接,不安装或发布。不会把 libass 的必要栅格化说成可通过改默认字体消除的开销,也不据此替用户放宽门槛。 - -用户随后明确选择“保留完整特效,接受复杂字幕的实测 CPU 开销(推荐)”。选择前已说明复杂动画 CPU ≤40%、render + upload p95 ≤16.67 ms 的具体调整,常规样本继续按 10%/8 ms。这是显式接受的验收取舍,没有降低字幕质量,也没有将最初失败测量删除或改写。 - -用户原 ASS/原 8 附件的 OP 片段另作定向实测,`user-ass-performance.json`:约 75.7–81.2 秒,5.125 秒采样,一核 CPU 6.63%,render p95 0.873 ms、upload p95 1.806 ms,合计 2.679 ms,无视频丢帧/underrun,当前原样 ACTIVE,符合原 10%/8 ms 预算。此为原文件与原字体的代表片段,不代表整集或原 4K 解码负载。 - -| 最终产物 | SHA-256 / 状态 | -| --- | --- | -| `libexo_ass.so`(2772464 字节) | `1b1d13c42da24c2e9b3652085f53b6876bdb6e06b226ed5701e250428f9df915`;API 24、arm64、16 KiB ELF、8 个 JNI 导出;内嵌源码标记等于锁定 libass 完整 commit | -| exoplayer AAR | `ddfe4d5f7f4afe96347f38f980b3861cdd0432179ff4d976bc7067096df673bd` | -| exoplayer sources JAR | `80971da199285f9f6c1507c2ace66b313258532be6bae44dd751ff8b88862ac6`;944 个无关 class/resource 保留 | -| 已安装实验 APK(173057779 字节) | `50033c50d547ebff5aba19736a572be36802b649b4c1947f55c9c65ba9a2c76e`;native 字节匹配,未压缩且 ZIP 偏移 151420928 满足 16 KiB 对齐 | - -验证细节见 `perf-artifact-verification.json`、`perf-apk-build-2.log`、`perf-install.log`、`perf-test-install.log`。时间目标明显超出,期间经历 USB 中断、失败门槛修正及构建/安装等待;不以耗时为理由省略失败验收。临时字体日志属性已恢复,用户全局日志属性未改;用户字体、截图、缓存媒体和 profiler 文件只保留在临时目录/设备,不纳入 Git。 - -本单元通过 guard 原子提交并立即创建本地注释恢复 tag;提交由 `Task-Guard: E4-LIBASS-stage1` 定位。回滚可关闭实验构建开关恢复原兼容字幕,或整体撤回本单元的源/补丁/锁/Java/JNI 产物;MPV 已确认的恢复锚点独立存在。本阶段不发布到远端,也不宣称后续内嵌事件、电视 ABI、HDR/DV 和产品化已完成。 - -## 14. 正常播放页的容器 ASS 兼容桥(2026-09-15,用户实际播放已验收) - -### 实际故障与授权范围 - -用户再次报告当前播放底部仍是系统字体,并授权监听当前手机;接续此前“不管是内嵌还是外挂”均需按成熟实现修复的要求。只补齐当前已经由 Media3 交付的 SSA sample,不借此展开新的精确 Matroska 输出协议、双 ABI 或 HDR。 - -04:49–05:00 的应用日志和只读 JDI 会话检查保存于 `initial-logcat.txt`、`app-debug-log.txt`、`current-session.txt`。实际为正常 `VideoActivity`,Exo/HEVC 3840×2160、SurfaceView、tunneling=false、无 DRM,颜色元数据未声明 HDR。`EXO_ASS_PROTOTYPE=true`,会话为 `COMPAT`,`script=null/scripts=0/frames=0/nativeAlive=false`;选中 `Format.id=4`、`text/x-ssa`、非空初始化数据,播放请求 `PlaySpec.subs.size=0`,字体集为 0。这些是本次运行事实,不否定用户先前提供的独立 `.ass` 文件,但证明当前播放器没有加载那份外挂文件。 - -根因有两处同属输入接线:`ExoAssSession.isExternal/onSample` 仅接受完整外挂;`MediaSourceFactory.createMediaSource` 只在外挂列表含 SSA 时启用字体附件提取。早先原字体画面使用原 ASS/字体的独立复现 Activity,不能据此称正常播放入口已经修复。 - -### 成熟源码与决策 - -沿用第 13 节的固定研究证据,本轮于 2026-09-15 直接阅读并核对以下源码;无依赖版本升级,无新增第三方源码复制: - -| 证据 | 支持的具体处理 | 本项目适配与限制 | -| --- | --- | --- | -| A:libass-android `04dcc7d49cfe35076fce5eea81c5918f381caa47` 的 `lib_ass_media/.../text/AssTrackOutput.kt`、`AssHandler.readTrackDialogue` | 从 SSA sample 前两个逗号取得相对结束时间,剩余 ReadOrder/Layer/样式/文本原样交给 libass chunk API | 在真实 TextRenderer 选中的输入上执行;不复制反射读取 extractor 私有缓冲区或绕过选轨的结构 | -| A:当前 Media3 `e3e922d5c01bc0b564849940fe589daf37360d15` 的已发布 extractor sources JAR,`MatroskaExtractor.SSA_DIALOGUE_FORMAT/SSA_PREFIX/commitSampleToOutput` | 初始化数据为特定格式行和 CodecPrivate;sample 为 `Dialogue: 0:00:00:00,duration,ReadOrder,Layer,...`,缺 duration/laced 字幕保持既有处理 | 严格识别该格式,保留 ReadOrder 和全部 ASS 文本;duration 仍为当前输出的 10 ms 精度,不声称恢复原始 blockDurationUs | -| A:锁定 libass `0a0221a1347e2f1e07c395263540026e9a0aa7c7` 的 `libass/ass.h`、`ass.c:ass_process_codec_private/ass_process_chunk` | 先装字体、创建 track、加载 CodecPrivate;每包传开始毫秒/时长;按 ReadOrder 去重;不能与后续 `ass_process_data` 混用修改事件 | 所有 JNI 调用仍在同一 worker;有界缓存已收到的包供晚到字体/Surface/轨道重建重放,seek 保留已缓存长事件并去重 | - -官方 Matroska ASS 映射、libass API 文档、成熟 VLC/MPV 实现、上游问题/维护讨论和渲染成本依据继续引用第 5、9、13 节;本轮不改变其结论。新问题仅是上述已发布 sample 的接线,论文和新的广泛性能文献不能改变输入格式决策,不再扩展检索。 - -比较:不改动会让当前字幕永久走系统 Cue;原样移植第三方 extractor/全局 Handler 会绕过本地选轨和已保护的视频路径;采用其相同的 sample 拆分和 libass chunk API,在现有会话内接入是最小修复。完整新 Matroska packet/duration 契约继续独立评审,本轮不修改 Media3 Maven 产物或提取器输出 ABI。 - -### 实施与验收约束 - -- 默认构建开关、SDR/SurfaceView/非 DRM/非 tunneling 门槛保持;选中完整外挂继续原路径,选中明确识别的 Media3 SSA sample 才走新输入。其他字幕不建立 native worker。 -- 每选中 stream 独立包缓存,最多 20000 个 ReadOrder、总计 8 MiB、单包不超过 4 MiB;不丢中间包或以最新包覆盖历史。异常/超限恢复仍在更新的兼容 Cue。stream/媒体切换清除旧事件和字体;晚到字体重建后重放同一 stream 已有事件。 -- 原始 ReadOrder/Layer/样式和文本字节保留;开始时间从 renderer sample time 减真实 stream offset,再转换毫秒。字幕延迟只在渲染时钟扣一次。duration 沿用当前 Media3 精度;未读取过的 seek 前长事件不冒充已恢复。 -- 字体附件不再依赖外挂机会;每媒体集合、去重、64 个/16 MiB 单体/32 MiB 总量限制不变。预加载、拼接流仍沿旧工厂;MPV 和视频/音频逻辑不变。 -- Native 只增加 CodecPrivate/chunk 入口并重建独立 JNI;锁定依赖不变,源码/输入/许可证/ELF/APK provenance 一并验证。修正许可证拷贝的行尾空白规范,使 guard 空白检查与记录的许可证校验值一致。 -- 定向验收:sample 原文/时长/offset/重复/限额单测;分包与整份脚本在同一官方动画/卡拉 OK 时间点的像素对照;内嵌选轨、暂停延迟、seek/重建、回退和 worker 释放;外挂与关闭路径回归;最终正常 `VideoActivity` 原视频的原字体匹配日志和可见截图。 -- 性能预算沿用用户已接受的普通一核 10%/8 ms、复杂一核 40%/16.67 ms;本轮关注新输入的分配/队列开销,不重跑未改变的整个图形性能矩阵。原视频网络缓冲单独记录,不与字幕处理混淆。 -- 回滚:关闭实验开关,或整体撤回本 guard 的 App/JNI/产物/任务文档到 `e7c0cdf0d6dafd5679425f045192e708e92dcfed`;保留此前独立 MPV 恢复 tag。 - -执行目标:北京时间 05:14 开始接入,源码和定向单测约 10–15 分钟、JNI/arm64 App 构建约 4–6 分钟、设备与原视频验证约 10–15 分钟、归档约 2 分钟,预计 05:44–05:54。 - -05:50 定向设备结果:6 项中 5 项通过,包括分包官方 blur/karaoke 的 8 帧像素对照、重复 ReadOrder/重放、JNI 边界、外挂完整生命周期及关闭/无字幕。容器测试已 `ACTIVE input=media3-ssa packets=4 fonts=1`,但断言兼容 Cue 非空失败。关闭 libass 的同一素材也为 `cueCount=0`;已发布 `SsaParser.createCueFromDialogueInfo` 明确忽略以 `m `/`M ` 开头的纯绘图文本,而测试素材最后一个同时间包正是绘图。分类为测试素材不满足回退前提:仅将其绘图包排到文字包前,保留四个事件、Layer、时间和全部特效,保持非空断言;只复测原失败的容器生命周期,不重跑已通过的五项。输入/图形实现不因本项改动。原时间目标因此延长约 8–12 分钟。 - -最终验收:用户在安装本次修复后明确确认“可以了,打tag”。依照显式闭合要求立即提交/tag,取消上述尚未执行的素材修正后复测及额外正常页日志/截图采集。原失败、基线分类及已通过的 5 项结果完整保留,未把用户确认表述为自动化测试全部通过。 - -最终 APK `/private/tmp/webhtv-libass-live-20260915/exo-ass-runtime-final.apk`:173057932 字节,SHA-256 `07cd2260dbf07134c62c146192c677a526ccdcd24a29ea2f7df710ff8d304885`;JNI 为 2775600 字节,SHA-256 `476b3e048fff1002cbd25a328340637f0cb40fdec6a6f2f6033fc7548ac80157`,APK 中未压缩且 ZIP 偏移 151420928 满足 16 KiB。素材修正后的增量打包通过(20 秒),APK 字节/对齐验证通过。提交由 `Task-Guard: E4-LIBASS-runtime` 定位,恢复 tag 使用同名前缀;不推送远端。 - -## 15. ASS 作为常规构建必需功能(2026-09-15) - -用户自行构建 debug 包出现系统字体。源码证据(A,2026-09-15):`app/build.gradle` 的 `exoAssPrototype` 默认 false,控制 `EXO_ASS_PROTOTYPE` 和 `third_party/exo-ass-native/prebuilt` 打包;`ExoAssSession.createIfEnabled` 因此不创建会话,`ExoUtil.buildSubConfig` 也不标记完整外挂 ASS。此前交付构建显式传 `-PexoAssPrototype=true`,绕过了缺陷;未检查用户自行构建的 APK,不把源码判定写成其 APK 实测。 - -用户已明确要求必需功能,批准消除构建差异。本次属于既有设计的局部接线修复,沿用第 5、13、14 节已完成的 libass/成熟消费者研究和设备字体验收,不引入上游改动、依赖升级或新原生实现,因此无需新增外部检索。 - -方案比较:保持现状会继续产生缺失功能的普通包;仅将属性默认改 true 仍允许 Gradle 属性静默关闭必需功能;采纳窄适配,删除属性和 BuildConfig 字段,无条件登记现有 JNI 目录,并移除两处运行时构建标志判断。功能启用不依赖 debug/release、手机/电视类型或调用者的本地构建参数。 - -现有 `Process.is64Bit()` 是原生 ABI 能力边界,独立 JNI 当前只有 `arm64-v8a`。已有 SDR/SurfaceView/DRM/tunneling 判定和异常回退继续保护播放;字幕选轨关闭仍是用户播放控制。只有有效的已选 ASS 才启动 native worker,复用此前已验收的字体、特效、时钟及资源生命周期,不改变其渲染成本。32 位/HDR 等缺口没有因删除构建开关自动完成。 - -验收:不传 ASS 属性,构建手机/电视 arm64 debug;核对生成的 BuildConfig 不再包含实验字段、两处 Java 接线编译通过、APK 含与仓库一致的 `libexo_ass.so`。构建时隔离 App CMake 暂存目录,保护初始 `.cxx`。公共 source set 与公共 Java 接线同时适用于 release;不以 debug 打包宣称完整 release 混淆或新设备性能验收。普通包新增此前遗漏的 2775600 字节 JNI,和历史正常包采用同一产物及许可证,不重建 native。 - -回滚:整体撤销 `E4-LIBASS-required-build` 原子提交,基线为 `248a947ba8dcd834e386cba25e4de83984ebc1ab`;不再提供实验参数作为产品开关。提交由 `Task-Guard: E4-LIBASS-required-build` 和 `recovery/E4-LIBASS-required-build/` 本地注释 tag 定位,不推送远端。 - -最终验证:`bash ./gradlew --init-script /private/tmp/webhtv-libass-stage1/isolate-cxx.gradle :app:assembleMobileArm64_v8aDebug :app:assembleLeanbackArm64_v8aDebug --console=plain` 通过,1 分 23 秒(15 项执行、128 项复用),未传 ASS 属性。两包均无实验 BuildConfig 字段,`javap` 确认 `createIfEnabled` 包含进程 ABI 判断及会话构造,DEX 含会话类;原生库均为 2775600 字节,SHA-256 `476b3e048fff1002cbd25a328340637f0cb40fdec6a6f2f6033fc7548ac80157`,未压缩且 ZIP 数据偏移满足 16 KiB。来源/既有 native 实现保持一致;恢复文档检查通过,未进行安装、设备复测或 release 混淆构建。 - -| 本轮无参数 APK | 字节数 | SHA-256 | -| --- | --- | --- | -| `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk` | 188388733 | `1c36fff34fc684548c4bdffea18a1e5d4416ccd1c82ed4b14474976e33b0999a` | -| `app/build/outputs/apk/leanbackArm64_v8a/debug/app-leanback-arm64_v8a-debug.apk` | 176524458 | `b2cf27f168dc3aebb7762a90b055204f610c494c9b02beb02d8bb2bdd691164c` | - -## 16. Exo 双字幕,对齐 MPV 默认行为(2026-09-18) - -### 16.1 决策、授权和证据 - -用户在此前评估后明确要求实施。决策问题限定为:能否保留原音视频选轨、ASS observer 和单媒体时钟,通过现有 Java 扩展接口同时选择并显示两路字幕?本次不升级依赖,不扩展到两路 libass 原样渲染或副字幕单独的设置页。 - -证据均于 2026-09-18 阅读实际内容,网络经 `http://127.0.0.1:7897`。网络原文留在 `/private/tmp/webhtv-exo-dual-research-20260918/`。 - -| 证据类别/级别 | 固定来源 | 支持的结论与 WebHTV 决定 | -| --- | --- | --- | -| 实际依赖源码 A | `third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/*-sources.jar`,SHA-256 `4d158d63ab0a99688880d6acfdc73ed340f09fa9ac9dd1934fbaa0babfcad086`;锁定基线 `e3e922d5c01bc0b564849940fe589daf37360d15`,含既有本地补丁 | `MappingTrackSelector.selectTracks` final,同类文本组只分配给第一个匹配 renderer;`DefaultTrackSelector` 的后处理也只保证一路文本。`TrackSelector` 本身支持独立实现,`TrackSelectorResult` 可按 renderer 返回多路固定 selection。采用组合并保留原 delegate,不复制整个选择器或更改文本 track type。 | -| 上游测试 A | `fish2018/webhtv@e3e922d5c01bc0b564849940fe589daf37360d15:libraries/exoplayer/src/test/java/androidx/media3/exoplayer/trackselection/MappingTrackSelectorTest.java` | `selectTracks_multipleVideoAndAudioTracks_mappedToSameRenderer` 证明仅加同类型 renderer 不会自动分轨;metadata 特例不能移用于字幕。新增针对两路文本的真实选择结果检查。该对象在本仓库可读取,另一个 media checkout 不含此对象,未以其当前源码替代。 | -| HLS 实际源码 A | 同版本 `media3-exoplayer-hls` sources JAR,`HlsSampleStreamWrapper.bindSampleQueueToSampleStream` | 一个 sample queue 已被绑定时,第二次绑定返回 fatal;因此不能把同一字幕 TrackGroup 中的两个自适应版本当作双字幕。新增按 TrackGroup 冲突保护,而非仅比较单条 Format;两路不同的字幕组仍正常选择。 | -| 官方文档 A | [Media3 Track selection](https://developer.android.com/media/media3/exoplayer/track-selection),页面更新 2026-09-08 | `TrackSelectionOverride` 只匹配相同 TrackGroup,`setTrackTypeDisabled(TEXT)` 禁用的是整类;因此副轨状态不能塞进主轨的 `setOverrideForType`,也不能让主字幕关闭直接关掉第二个 renderer。delegate 参数继续表达主轨,副轨单独保存。 | -| 维护者讨论 B / 使用场景报告 C | [ExoPlayer #5192](https://github.com/google/ExoPlayer/issues/5192),尤其 [erdemguven 建议](https://github.com/google/ExoPlayer/issues/5192#issuecomment-444564606) 与 [ojw28 解释](https://github.com/google/ExoPlayer/issues/5192#issuecomment-446011361) | 实际读完正文和 7 条评论。建议多个 TextRenderer/TextView,改 mapping 或定义新类型;将 forced 合并进 optional 的内容侧方案不适用于用户自由选择两种语言。报告中的同时核对字幕场景支持独立轨道需求,不是性能证明。 | -| 成熟项目源码/官方说明 A/B | `mpv-player/mpv@cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`,`player/loadfile.c:mp_switch_track_n`、`options/options.c:mp_subtitle_shared_sub_opts`、`sub/sd_ass.c:configure_ass`、`DOCS/man/options.rst` | 主/副 track slot 分开;同一轨不能同时占两槽;副轨默认 `secondary-sub-ass-override=strip`、置顶、默认关闭。对齐当前 App 的这组默认行为。MPV 原生可另设 override=no,不能宣称其不支持两路 ASS 原样。 | -| 当前 App 合约 A | `ExoUtil.buildPlayer/withAssObserver`、`ExoPlayerEngine.startInternal/rebuild/release`、`TrackUtil.setTrackSelection`、`TrackDialog`、`PlaybackActivity.attachSurface/detachSurface`、`AssSurfaceHost.update` | 双字幕入口已存在,但 Exo 未声明能力;主 ASS ACTIVE 隐藏原 SubtitleView。副轨必须独立显示,不能共用 `Player.Listener.onCues` 或主 ASS observer;宿主绑定在共用 Activity,手机/电视一致。原音视频自动策略继续持有相同 DefaultTrackSelector。 | - -论文/算法基准不适用:本阶段不发明字幕排版、时间同步或渲染算法,沿用已发布的 TextRenderer/Cue/SubtitleView;没有需用论文判定的新算法或数值性能承诺。独立博客不再作为决策门槛,实际维护者讨论、同场景报告和两套播放器源码已能确定路线。对性能和生命周期的结论仍以本地合约/代表性验证为限。 - -### 16.2 方案比较与实施设计 - -| 方案 | 正确性、维护与成本 | 决定 | -| --- | --- | --- | -| 不改动 | 保留所有行为,但 Exo 只能一路字幕 | 不满足需求 | -| 原样采用历史上游建议,修改 MappingTrackSelector 或创建新的 TEXT_SECONDARY 类型 | 可以分轨,但会扩展 Media3 公共类型、renderer/source 兼容边界,并需维护 Java AAR/锁/补丁;覆盖选择器容易丢掉音视频后续修复 | 不直接移植 | -| 预先合并两份字幕/另起完整播放器 | 合并难以覆盖内嵌、直播和轨道切换;第二个播放器增加 IO、音视频资源与时钟偏差 | 不采用 | -| WebHTV 窄适配 | 原选择器只处理原 renderer 列表,其音视频配置/selection 原样保留;新增真正的 TextRenderer 只消费用户指定副轨。最终 Tracks 合并选中标记,角色分别查询,单独 SubtitleView 置顶 | **采用,用户已授权实施** | - -- `DualSubtitleTrackSelector` 组合原 DefaultTrackSelector,转发参数、音频属性、能力变化、初始化/释放和 selection activation;副轨使用固定 selection,禁止将多索引 ABR 误当同时显示。选择结果按激活时间更新,不能把提前选好的下一 period 当成当前显示轨。 -- 主轨保持原 Player/Cue/ASS 路径。副 TextRenderer 使用相同渲染时钟、offset、seek/flush 和延迟消息;不包裹 renderer、不改全局时钟、不新建播放器或定时拉取字幕。 -- 副轨用独立会话与主线程 SubtitleView,移除文本嵌入样式、居中置顶、多条同时出现的文本合并换行;图形 Cue 保留位图及相对布局后整体向上放置。ASS 字体原样仅由主 libass 提供。显示层不抢焦点、无点击,不占控制器布局。 -- 角色选择、显示回调和宿主属于各自生命周期;切源清空副轨,释放使旧回调失效;暂时 detach 只移除 View,重新 attach 恢复当前 Cue。缺失或不支持的副轨不影响音视频/主字幕;同轨冲突不重复解码。 -- 复用主/副字幕切换按钮和列表。两路的选中标记单独查询,主轨关闭后副轨可继续显示;副轨关闭不改变主轨和主 ASS worker。 -- 性能边界:关闭副轨时无副字幕 decoder、worker、额外 HTTP 或逐帧 UI 更新;开启只增加一路必要字幕解码和 Cue 更新。音视频选择对象、tunneling、硬/软解手动选择、直通、自动视频约束和原生库保持既有实现。不以源码分析保证所有设备的 CPU 数字。 -- 来源/ABI/安全:不更改第三方二进制和许可证;调用已有不稳定 Java API,依赖版本不动。仍由当前 extractor/decoder 处理不可信字幕,不另造解析器/网络/字体读取链。新增测试只覆盖本次合约,避免改变全局测试设置。 - -### 16.3 接受条件、验证与回滚 - -1. 内嵌或已添加外挂的两条不同字幕可同时选中,主/副角色与 UI 一致,默认副轨关闭;主字幕的字号/原样 ASS 规则不回归。 -2. 主、副可分别关闭;同轨冲突拒绝;切源、播放器重建/切换、停止/释放无残留副字幕。暂停、前后 seek、现有字幕偏移不产生累加漂移。 -3. 真正 TextRenderer 的固定选择、Cue 输出与当前媒体时间契约通过定向检查;检查副轨开关前后音视频 selection/configuration 等价,关闭副轨无需活跃 decoder。 -4. 代表性手机播放检查主 ASS 原字体/特效和副文本同屏、独立开关、暂停跳转、宿主重挂。手机/电视公共代码编译;不把 ARM64 手机播放冒充电视硬件或全部格式验收。 -5. 在上述证据记录完成后,任务文件一次原子提交,创建唯一 annotated 本地恢复 tag。基线为 `e2f39f240743ba4f8adf75bc6599f4ef7899d48a`,整体 revert 本阶段提交即可撤销双字幕接线;原 Media3/JNI/MPV 产物没有需要配套回退的变更。不推送。 - -### 16.4 实施与验证记录 - -- 已实现:新增上述 3 个字幕类,组合原选择器并保留真实 TextRenderer;主/副状态独立,副显示层惰性创建;字幕 token + stream Format 校验挡住切轨/切源后的旧 Cue;共享手机/电视播放页挂接;原 MPV 的同轨双槽拒绝规则也用于列表排除。`TrackUtil` 对当前点击优先使用原生 track id,持久化偏好仍回退原描述匹配。 -- 定向用例:`DualSubtitleTrackSelectorTest` 7 项,包含真实 DefaultTrackSelector 音视频配置等价、tunneling/参数透传、延迟激活、主副开关、同轨组保护、旧 token 和缺失/DRM;`SecondarySubtitleCuesTest` 3 项,包含无样式多语言文本堆叠、位图区域相对布局与空 Cue;`DualSubtitlePlaybackTest` 的两个实际 Exo/ASS 生命周期场景已编译,尚未运行。 -- 构建:首轮 Java 编译发现 TextOutput 的 CueGroup 方法是抽象方法,已修正为空输出 lambda;没有改动第三方接口。随后 `:app:assembleMobileArm64_v8aDebug :app:assembleMobileArm64_v8aDebugAndroidTest :app:compileLeanbackArmeabi_v7aDebugJavaWithJavac` 通过,2 分 41 秒。使用既有 `build/avs3-native/app-build.init.gradle` 隔离 CMake。原日志为 `build/exo-dual-subtitles/compile-mobile.log` 和 `build-debug-tests.log`。 -- 设备:08:27 ADB 仍无设备;现有模拟器是 x86_64 TV,不能代替 ARM64/libass 真机验收。本机已缓存 Robolectric 4.16,先以独立、未改变产品依赖的验证 harness 运行 Java/Android 合约,实际字体/Surface 场景等手机连接后继续。此时不宣称播放验证通过。 -- 本机结果:`DualSubtitleContractHostTest` 10/10(测试体 15.437 秒,Gradle 1 小时 28 分 8 秒);`DualSubtitleRendererHostTest` 2/2(测试体 5.289 秒,Gradle 29 秒);`DualSubtitlePlayerHostTest` 1/1(测试体 6.867 秒,Gradle 21 秒)。三个 wrapper 均调用本次 `androidTest` 中的实际用例;Robolectric 4.16/Android API 28 仅在 ignored `build/exo-dual-subtitles` harness 注入,不改变 App Gradle/依赖锁。 -- 真实渲染器检查:两路独立原始 ASS/SRT 输入,主 observer 不接副样本;同一时钟下暂停改延迟、前后 seek、独立关闭通过。补上来源代次防护:仅有 Format 相同不足以识别同格式的前后两个媒体,副 observer 记录 reading/previous 两个实际 Stream 对应的 mediaGeneration;旧来源回调不得使用新请求代次,提前读取下一 period 时仍可显示当前字幕。 -- 完整 Java 接线检查:同一真实 ExoPlayer、两个 SingleSampleMediaSource/合并 MediaPeriod、两个 TextRenderer 和真实 PlayerView/SubtitleView;默认副 renderer disabled,选择后两路同时输出,主/副独立关闭、前后 seek、主 SubtitleView 置 INVISIBLE 后副层仍可见、detach/attach 通过。此处只检查与原 ASS 接管相同的 View 可见性操作,没有加载 native libass 或声称完成实体设备的字体/Surface 验收。 -- 原始失败保留:本机播放器用例起初按未合并 ID 查找素材,实际 `MergingMediaPeriod` 已给 Format.id 加 `0:`/`1:` 来源前缀;改为按 fixture label 找到真实 Format 后仍用真实 ID 执行产品选轨。另一次 host-only 编译遇到 `Shadows.shadowOf` 重载引用当前 SDK 已移除的 FingerprintManager,改用 `Shadow.extract`;host 主线程时钟由 wrapper 显式推进。上述修改均仅在验证代码,不改生产逻辑或降低断言。 -- 证据:`build/exo-dual-subtitles/contract-tests-passed.xml`、`renderer-tests-passed.xml`、`host-player-tests-track-identity.log` 和对应 Gradle XML;首次素材定位失败与 SDK 接口编译失败日志均保留。12:14 ADB 仍为空;实体设备的两个 `DualSubtitlePlaybackTest` 场景尚未运行。 - -### 16.5 最终构建、使用与验证边界 - -- 最终联合构建一次通过(3 分 53 秒):`:app:assembleMobileArm64_v8aRelease`、`:app:assembleMobileArm64_v8aDebug`、`:app:assembleMobileArm64_v8aDebugAndroidTest`、`:app:compileLeanbackArmeabi_v7aDebugJavaWithJavac`。使用已有 `build/avs3-native/app-build.init.gradle` 和 `-PfastRelease=true`,未改变产品构建文件/依赖/原生库;日志 `build/exo-dual-subtitles/final-build.log`。 -- 两个 App APK 的 DEX 均含 `ExoSubtitleSession`;打包的 `lib/arm64-v8a/libexo_ass.so` SHA-256 均为 `476b3e048fff1002cbd25a328340637f0cb40fdec6a6f2f6033fc7548ac80157`,与仓库已验收原库逐字节一致。核验记录 `build/exo-dual-subtitles/final-artifacts.json`。 -- 使用:Exo 播放页打开字幕轨道,点击“副字幕”后选择另一条内嵌或已添加外挂轨;副字幕默认在顶部显示普通文本,主字幕保留原 ASS 样式/字体。主、副各有关闭选项,默认不启用副字幕;同一轨道不能占两个角色。手机/电视使用同一个选轨入口。 -- 限制如实保留:本轮 13 项是本机 Android/真实 Media3 合约与 View 集成检查;没有实体手机连接,未安装 APK,也未执行原生 ASS 同屏、真实视频掉帧/CPU 或电视遥控体验验收。未升级成两路 libass 原样渲染,未增加副轨独立样式设置;历史 ARM64 ASS 能力边界仍见前文。 - -| 产物 | 字节数 | SHA-256 | -| --- | ---: | --- | -| `app/build/outputs/apk/mobileArm64_v8a/release/mobile-arm64_v8a.apk` | 150060204 | `495157f6c45a515278bf5f74ad4dc9f09edd30522025887981e85e8162c05b50` | -| `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk` | 181893921 | `ce9d02c97edcbd595f40db5ae9fa8f28fa9b4dce750ef0b1cddd0471e35183a0` | -| `app/build/outputs/apk/androidTest/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug-androidTest.apk` | 1370334 | `05073818d8e0edbeb02dc86388d88c5e70ef2a69ec000481303681eecba0ae28` | diff --git a/docs/E9-3-exo-dv5-vulkan-renderer.md b/docs/E9-3-exo-dv5-vulkan-renderer.md index 92ec651030..734240433d 100644 --- a/docs/E9-3-exo-dv5-vulkan-renderer.md +++ b/docs/E9-3-exo-dv5-vulkan-renderer.md @@ -1,48 +1,708 @@ -# E9-3:Exo DV5 GPU 映射默认准入 +# E9-3: Exo DV5 Vulkan renderer -## Recovery anchor +状态:实施已获用户授权,先实现默认关闭的硬解 GPU 映射原型;真机验收前不替换现有生产路径。 -- 目标:按用户 2026-09-12 的明确要求,默认注册满足 native 能力条件的 Exo DV5 GPU 映射 renderer,不开启其他播放实验。 -- 分支/回滚基线:`feature-menu` / `969261479167bca3f8f16f11551de7dcc9290112`。 -- Lane/guard:`quick-fix` / `E9-3-DV5-DEFAULT`。 -- 范围:`ExoUtil.java`、`ExoDv5GpuRendererFactory.java`、`ExoDv5GpuRendererTest.java`、本文及任务索引。 -- 保护:任务开始前已有的 `app/.cxx/` 35 个未跟踪缓存文件,不修改或提交。 -- 状态:默认注册已实现;DV5 renderer、映射策略及实验策略定向单测通过,Mobile/Leanback arm64 Java 编译通过。未重建 native,未打包或安装,当前偏色场景尚未真机复验。 -- 下一动作:由当前 guard 原子提交已验证的本轮改动并创建本地注释恢复 tag,不推送。 +## 1. 目标与边界 -## 历史记录与当前证据 +- 目标:在没有原生 Dolby Vision Profile 5 显示能力、但普通 HEVC MediaCodec 能输出可采样 10-bit `AHardwareBuffer` 的设备上,让 Exo 使用硬解并通过 Vulkan/libplacebo 恢复正确色彩。 +- 保留:Exo 的解封装、网络、音频、字幕、轨道、时钟、帧释放、掉帧统计和 PlayerView 协议。 +- 不做:P5->P8.1 码流伪转换、把 P5 冒充 HDR10 后 Surface 直出、复用 `libmpv.so` 私有状态、DV7 FEL 双层合成、DRM 安全视频输出。 +- 首个实施单元:独立 `VideoSink`/native renderer 原型,默认关闭,仅 Profile 5、非 DRM、Vulkan 能力通过时可被显式启用。 -原 E9-3 文档随主线的根目录任务文档清理被删除,本轮沿用同一任务 ID 和文件名恢复当前记录,不另建平行任务。完整历史保存在 `9fcab83f9084446566240a8e8f5233d87d0274cc:docs/E9-3-exo-dv5-vulkan-renderer.md`。 +## 2. 基线 -- `e6a0ee439028f5274f7dc51c7b15770b3afe2ba6` 修复 RPU pivot 差分解释及帧元数据匹配;`6a3ddd266a94a6b984099876631cc6260e77b776` 修复 DV5 停用时的 Surface 释放。该链路不是把 DV5 简单改标 HDR10,而是 HEVC MediaCodec + AImageReader/AHardwareBuffer + 独立 Vulkan/libplacebo 映射,输出 SDR。 -- 本轮修改前,`ExoUtil.FfmpegRenderersFactory.buildVideoRenderers()` 把 `PlaybackExperimentSetting.isDomainEnabled(EXO)` 传给 DV5 工厂,而 `PlaybackExperimentPolicy.State.stable()` 默认总开关为 false,因此普通配置不会注册该映射 renderer。本轮移除这一实验门控,默认按 native probe 注册;实验总开关本身不变。 -- 原生优先、Profile 5、非 DRM、硬件 HEVC 等判断位于 `ExoDv5GpuRenderer`;API/native 库/Vulkan/AHB 条件由 `ExoDv5Native.probe()` 保留。本轮不修改它们。 -- 历史验证覆盖 V2453A 和 `P5_Dolby_Amaze.mkv`,不代表全机型或所有 RPU 状态已验证。本轮未取得当前偏色场景日志,不能断言默认关闭是当前设备偏色的唯一原因。 +- App:`742de1b08b01e6022517f6200a7502d1326301c9`,分支 `exo-dv5`,开始时工作树干净。 +- Media3:WebHTV `e3e922d5c01bc0b564849940fe589daf37360d15`,版本 `1.11.0-alpha01-fongmi`。 +- MPV 实证链:MPV `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`、FFmpeg `04482c8d13ac27b2a9fe93f5d388929eef8af5f4`、libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`。 +- 设备证据:用户确认当前 WebHTV MPV 的 `MediaCodec + Vulkan + gpu-next` 在目标设备播放 DV5 时颜色正常,至少不再紫绿。 +- 当前 Exo 缺口:`ExoUtil.DolbyVisionHdr10FallbackRenderer` 把 Profile 5 改报普通 HEVC/HDR10 并直接输出 Surface,没有进行 Dolby IPT/RPU 映射。 -## 本轮设计决定与边界 +## 3. 决策证据 -沿用历史 E9-3 的 MPV/libplacebo、Android AHardwareBuffer/Vulkan 和 Media3 VideoSink 研究;本轮仅调整已建立设计的局部默认注册条件,不新增渲染算法、依赖、ABI 或原生资产,因此不重复外部综述或原生构建。 +访问日期均为 2026-08-27。 -| 方案 | 决定 | -| --- | --- | -| 不改 | 默认仍无法选择已实现的 DV5 映射,不满足用户要求 | -| 打开全局/Exo 实验总开关 | 会同时影响其他实验,超出本轮行为范围 | -| DV5 工厂默认按 native probe 注册 | 采用;与其他实验开关解耦,renderer 仍只认领适用的非 DRM DV5 硬解内容 | +| 证据 | 等级 | 支持的结论 | 局限/影响 | +| --- | --- | --- | --- | +| Android `MediaFormat.KEY_COLOR_TRANSFER_REQUEST` 官方 API 文档 | A | API 31+ codec 可接受 transfer/tone-map 请求;请求必须在配置后验证 | 只适用于设备已有可工作的 DV decoder,不解决普通 HEVC decoder 的 raw DV5 GPU 映射 | +| Android NDK `AImageReader` / `AHardwareBuffer` 与 Vulkan Android external-memory API | A | codec 可输出到 `AIMAGE_FORMAT_PRIVATE` reader,buffer 可用 GPU sampled usage 导入 Vulkan | 具体 external format、10-bit 精度、fence 和驱动行为依设备而异 | +| Media3 `VideoSink` / `MediaCodecVideoRenderer.Builder.setVideoSink`,WebHTV 锁定线 `e3e922d...` | A | 可保留 Media3 renderer 的 codec/时钟/掉帧逻辑,仅替换 codec 输入 Surface 和最终渲染 | `VideoSink` 是 Unstable API,升级 Media3 时必须有编译契约测试 | +| FongMi/media `0cefd3ceec27444cf8faf02486b472bab39109fe` | A | Profile 5 不能作为标准 HDR10 BL;有 DV codec 时优先请求并验证 codec tone-map | GPU fallback 必须位于 codec tone-map 之后,不能替代原生能力 | +| FongMi/FFmpeg `eb107bbafe37442065e42b4f2d410f371b758143` 与 `15b73698835285d68f9615691dd4dfc04422f28e` | A | MediaCodec 硬解链需保留逐帧 RPU/解析元数据,才能在 GPU 阶段应用 DV5 映射 | Exo 原生 MediaCodec 不会自动提供 FFmpeg `AVFrame` side data,需在输入 AU 上自行提取 | +| FongMi/mpv `c7fef70644b3d506340e113689a5923f324c861d` 及当前锁定树 | A | `gpu-next` 能对 raw MediaCodec DV5 帧应用 GPU mapping | mpv 的 VO/queue/decoder 状态机不能直接嵌进 Exo | +| 当前 `hwdec_aimagereader.c` 与 Vulkan direct/stable 实现 | A | 使用 PTS 匹配 AImage;DV5 采样必须保持 full range、RGB identity 和原始 Y/Cb/Cr 分量语义 | 需要保留 AImage 到 GPU fence 完成,不能在回调中提前释放 | +| libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5` | A | 支持 `PL_COLOR_SYSTEM_DOLBYVISION`、RPU matrix/reshape、tone mapping 和 Vulkan 输出 | 必须独立构建/链接,不能依赖 `libmpv.so` 内部生命周期 | +| FongMi/media `7d0d1e3c572aee885ffbbfd6d8317f1f3a581910` | B | GLES/libplacebo renderer 提供了 Media3 Surface/lifecycle 参考和 Dolby metadata 映射参考 | 该提交是 FFmpeg 软件解码,不满足本任务硬解目标,只取 renderer 生命周期设计 | +| mpv-player/mpv issue #10287 与 mpv-android issue #1081 | B | 普通 MediaCodec-copy/错误 Surface 转换会紫绿;Android 硬解 GPU DV 路径具有设备依赖 | issue 不是规范,不能替代本项目真机验收 | -不改原生杜比优先级、普通视频/DV7 路径、现有失败回退或色彩算法;不声称修复所有设备/素材的偏色。新默认值会让更多满足现有条件的设备实际选择该路径,因此仍需用户用原场景复测。回滚本轮单一提交即可恢复实验门控,不涉及 native 成套回滚。 +论文类证据不适用:本任务的决定因素是 Android codec/BufferQueue/Vulkan ABI 和实际开源实现,不是新的色彩科学算法;Dolby 专有规范也不提供可用于 Android 公共实现的完整生产契约。 -## 验证计划 +## 4. 方案比较 -- 工厂测试:默认稳定配置下完整 probe 可准入,缺失/失败 probe 不准入;实验总开关保持关闭。 -- 回归现有 DV5 路由/RPU 提取/帧调度测试及 `PlaybackExperimentPolicyTest`。 -- 使用 JDK 21 跑 Mobile arm64 定向单测并编译共享入口的 Leanback arm64 Java;不打完整 ABI 矩阵,不重建 native,不安装手机。 -- 通过后记录结果,并由同一 guard 原子提交及创建本地注释恢复 tag,不推送。 +### A. 不改 -## 实施与验证结果(2026-09-12) +- 优点:无新增 native 风险。 +- 缺点:无 DV5 decoder 的设备继续紫绿,当前 HEVC/HDR10 伪装语义错误。 +- 结论:不能满足目标。 -- `ExoDv5GpuRendererFactory.shouldCreate()` / `create()` 删除实验开关参数,仅按非空且可用的 native probe 准入;`ExoUtil` 对应删除实验域参数,保留原注册顺序和失败处理。 -- 更新工厂测试,确认稳定配置的 Exo 实验域仍关闭,但完整 probe 可准入;失败或缺失 probe 仍拒绝。 -- JDK 21 下 `ExoDv5GpuRendererTest`、`ExoDv5GpuMappingPolicyTest`、`PlaybackExperimentPolicyTest` 三个定向测试类通过;Mobile arm64 与 Leanback arm64 Debug Java 编译通过。 -- 同一次 Gradle 验证结果:`BUILD SUCCESSFUL in 1m 5s`,`84 actionable tasks: 8 executed, 76 up-to-date`。完整日志:`/private/tmp/webhtv-dv5-default.Td3uKE/verification.log`。 -- 未执行 externalNativeBuild,未改依赖、native 资产或其他实验策略,未生成/安装新版 APK。手机现有安装包不受源码改动影响,原视频色彩表现需在新版包中复测,不能把编译/单测通过当作已解决当前设备偏色的证明。 -- 提交和恢复 tag 由 `E9-3-DV5-DEFAULT` guard 的 finish 记录;回滚本轮原子提交即可恢复旧门控。 +### B. P5->P8.1/RPU-only 转换 + +- 优点:Extractor 层改动小。 +- 缺点:只改 RPU,不能把 P5 的 Dolby IPT 基础层转换成 HDR10 基础层。 +- 结论:拒绝。 + +### C. 原样移植 MPV 播放器或调用 `libmpv.so` 内部 libplacebo + +- 优点:目标设备已有成功实证。 +- 缺点:会引入第二套 demux/clock/track/lifecycle;`libmpv.so` 的 libplacebo 符号和上下文不构成稳定 Exo API;二进制与回滚耦合。 +- 结论:拒绝原样集成,只移植已经验证的 AImageReader/Vulkan/DV 表示规则。 + +### D. WebHTV 适配的 Media3 `VideoSink` + 独立 native Vulkan/libplacebo + +- 优点:保留 Exo 主体;codec 输出 Surface、帧时序和显示 Surface 有清晰所有权;可以按能力和内容窄启用;MPV/Exo 二进制独立回滚。 +- 缺点:需要新的 native 资产、RPU 提取、PTS 配对、Vulkan/AImageReader 生命周期和真机矩阵。 +- 结论:推荐并实施。 + +## 5. 推荐架构 + +```text +Media3 Extractor / SampleStream + | + | HEVC access unit + input PTS + v +MediaCodecVideoRenderer (ordinary HEVC decoder view for P5) + | + | codec output Surface + v +ExoDoviVideoSink.getInputSurface() + | + v +AImageReader PRIVATE + GPU_SAMPLED_IMAGE + | + | AImage timestamp -> input PTS metadata map + v +AHardwareBuffer -> Vulkan external image + | + | RGB_IDENTITY, full range, raw Cr/Y/Cb mapping + v +libplacebo Dolby Vision reshape + tone map + | + v +PlayerView output Surface +``` + +### 5.1 Media3 ownership + +- 用 `MediaCodecVideoRenderer.Builder.setVideoSink()` 接入,不复制 `MediaCodecRenderer` 状态机。 +- `VideoSink.handleInputFrame()` 保存 renderer 提供的释放 handler;到计划释放时调用 handler,将 codec buffer 送进 AImageReader Surface。 +- AImage timestamp 必须与 codec buffer PTS 精确匹配;seek/flush/stream change 清空帧、RPU 和 handler 队列。 +- tunneling 在该路径禁用;自定义 GPU 渲染与 tunneling 不兼容。 + +### 5.2 RPU 所有权 + +- 在 MediaCodec 输入 AU 入队前扫描 HEVC NAL type 62,保留原样 AU 给 decoder,同时把 RPU 副本按 input PTS 送入 native metadata queue。 +- MP4 length-prefixed 与 Annex-B 都要支持;解析失败为单帧失败,连续失败触发整次播放受控回退。 +- RPU 解析和 `pl_dovi_metadata` 生成在 native 层完成;Java 不复制 Dolby 结构体。 +- 不把 ExoplayerHdrUtils 0.4.0 当作 metadata API:其公开 JNI 只提供 frame transform/profile 信息,没有输出可供 libplacebo 使用的 RPU 映射结构。 + +### 5.3 Native 资产 + +- 新库独立命名为 `libexo_dovi_renderer.so`,两 ABI 成套构建。 +- 只包含本路径需要的 AImageReader、Vulkan、libplacebo、RPU 解析和同步代码;不链接 mpv demux/player/VO 状态机。 +- 首选 Vulkan direct AHardwareBuffer sampling;失败可回退 stable GPU conversion;均失败则声明能力不可用并回到下一 Exo renderer/MPV。 +- 禁止运行时链接 `libmpv.so` 的内部 libplacebo 状态。 + +### 5.4 策略顺序 + +1. 显示和 decoder 均支持原生 DV5:原生 DV。 +2. API 31+ DV decoder 接受 `KEY_COLOR_TRANSFER_REQUEST`:codec tone-map。 +3. 非 DRM、普通 HEVC decoder、Vulkan/AHB probe 通过且用户/实验允许:本 E9-3 GPU mapping。 +4. 否则:MPV `gpu-next` 或服务端转码;不得再把 Profile 5 无条件冒充 HDR10。 + +## 6. 安全与兼容约束 + +- DRM:`cryptoType != NONE` 一律不声明支持;安全 decoder/secure Surface 不进入自定义 GPU。 +- API:最低运行 API 26,Vulkan external-memory/AHardwareBuffer 能力逐项探测;仅检查 Vulkan 版本号不够。 +- 精度:源 buffer 必须证明为可用的 10-bit/external format;不允许静默降为 8-bit 后仍标记成功。 +- 生命周期:Surface replacement、后台/前台、seek、flush、换集和 release 必须停止接收回调并等待/取消有限 fence。 +- 队列:输入 RPU、codec handler、AImage 三者均有上限;按 PTS 丢弃 stale 项,不允许无界积累。 +- 故障:初始化失败在 codec 启动前返回不支持;运行时连续映射失败报告可分类错误,由 App 只重建一次并回退。 +- 输出:SDR 屏默认输出 BT.709 SDR;HDR10 屏可后续增加 BT.2020 PQ,首个单元不同时扩展两种输出策略。 + +## 7. 实施阶段 + +### E9-3a:契约与能力门控 + +- 新增纯 Java 的 Profile 5/DRM/API/Vulkan 路由策略、失败原因和单测。 +- 新增 `VideoSink` 壳和 native capability bridge;native 不可用时不得抢占 track。 +- 将现有 Profile 5 HEVC/HDR10 fallback 收窄为最后的显式兼容选项,不在本单元直接删除。 + +### E9-3b:硬解 Surface 与帧同步 + +- 独立 native 库创建 AImageReader Surface。 +- 实现 MediaCodec handler -> AImage timestamp 的有界配对、flush/release。 +- 先以不做 DV mapping 的诊断图/帧计数验证 10-bit AHB 导入和稳定出帧。 + +### E9-3c:RPU 与 libplacebo 映射 + +- 输入 AU 提取 RPU并按 PTS入队。 +- libplacebo raw DV representation、reshape、BT.709 SDR 输出。 +- 目标 DV5 样片与 MPV 同帧截图/色彩对照。 + +### E9-3d:受控接线 + +- 默认关闭的实验开关、设备失败记忆和一次性回退。 +- 通过设备矩阵后才允许自动策略选择;未通过时生产默认仍走原生 DV/codec tone-map/MPV。 + +## 8. 验收标准 + +- 自动化:Profile 5/7/8、DRM、API、Vulkan probe、renderer 优先级、flush/PTS queue 的单测。 +- 编译:Media3/App Java 编译;两个 ABI native clean build;ELF `SONAME`/`DT_NEEDED`/版本标记校验。 +- 设备:目标设备同一 DV5 文件确认 `MediaCodec` 硬解、Vulkan renderer、生效 RPU 数、无紫绿;与 MPV gpu-next 截图对照。 +- 生命周期:起播、seek、暂停恢复、前后台、换集、Surface 重建、连续 20 次退出进入,无黑屏/死锁/use-after-free。 +- 性能:4K 代表片连续 10 分钟,无持续队列增长;掉帧、GPU 帧时和功耗不显著劣于同设备 MPV 路径。 +- 负向:DRM、8-bit AHB、缺少 Vulkan external format、RPU 缺失/损坏均不错误宣称成功。 + +## 9. 回滚 + +- 每个子阶段独立 commit/recovery tag。 +- E9-3a 回滚只移除策略/壳,不改变 Media3 AAR 或 MPV。 +- E9-3b/c 回滚删除独立 native AAR/`.so` 和 renderer 注册;现有 Exo、nextlib、MPV assets 保持原版本。 +- E9-3d 运行时可通过关闭实验开关立即停止选择该路径,无需删除媒体数据库或配置。 + +## 10. 当前检查点 + +- 已完成:方案、证据、替代比较、验收和回滚边界;用户已明确要求按最佳实践继续实现。E9-3a 已新增纯 Java 路由/能力策略,覆盖原生 DV、API 31+ codec tone-map、实验性 GPU mapping、显式旧 HDR10 兼容和不支持结果;现有 P5 兼容 renderer 已接入该策略但保持原默认行为。 +- 未完成风险:尚未用目标设备验证 Exo 自建 AImageReader Surface 是否得到与 MPV 相同的 10-bit external format;这是 E9-3b 的硬门槛。 +- 下一动作:完成 E9-3a 的定向 JVM 验证并提交恢复点,然后为 E9-3b 声明 native/Java/build 的独立范围。 + +## 11. E9-3a 实施记录 + +- 代码:新增 `ExoDv5GpuMappingPolicy`。GPU 路由要求 Profile 5、非 DRM、API 26+、非 tunneling、普通 HEVC 硬解器、独立 renderer native 库、Vulkan/AHardwareBuffer probe 和实验开关全部通过;原生 DV 和 API 31+ 已接受的 codec tone-map 优先。DRM 即使允许旧兼容也不会进入 GPU 或旧 HDR10 fallback。 +- 接线:`ExoUtil.shouldUseDolbyVisionHdr10Fallback` 的 Profile 5 分支改由策略返回值决定。E9-3a 没有注册不存在的 `VideoSink`,现有非 DRM Profile 5 默认仍进入旧兼容 renderer,等待 E9-3b/c 真机成立后再调整生产优先级。 +- 测试:独立 JUnit 4.13.2 执行 `ExoDv5GpuMappingPolicyTest`,9 个用例通过;`:app:compileMobileArm64_v8aDebugJavaWithJavac` 成功,证明 App/Media3 接线可编译。 +- 已知验证阻断:`:app:testMobileArm64_v8aDebugUnitTest` 在执行测试前的 `processMobileArm64_v8aDebugResources` 失败,缺少既有 Material/Media3 style/attr/color 资源。本单元未修改资源或依赖,故没有扩大范围处理该基线问题,也不把新增 JUnit 结果表述为 Android Gradle 测试任务通过。 +- 回滚:回退 E9-3a commit/tag 即恢复原 Profile 5 fallback 判断;没有 native 资产、Media3 AAR、设置或数据库变更。 +- 下一动作:启动 E9-3b 独立实现单元,先完成 AImageReader/`AHardwareBuffer` capability bridge、`VideoSink` Surface 生命周期和有界 PTS 配对;DV 映射仍保持关闭。 + +## 12. E9-3b 实施记录 + +- 代码:新增独立 `libexo_dovi_renderer.so` CMake target(使用锁定 NDK r29,arm64-v8a 与 armeabi-v7a),并通过 `ExoDv5Native` 暴露能力探测、AImageReader Surface、有限 expected-frame 队列、AHardwareBuffer 使用/高位深统计和释放接口。 +- Media3:新增 `ExoDv5VideoSink` 与 `ExoDv5GpuRenderer`,使用锁定 Media3 `VideoSink`/`MediaCodecVideoRenderer.Builder.setVideoSink()` 契约;视频帧 handler 的释放时间仍由 Media3 控制,AImage 时间戳按 presentation PTS(微秒转纳秒)配对,避免把渲染 deadline 错当 codec PTS。Renderer factory 仅支持显式诊断创建,当前未注册到生产 renderer 列表。 +- 能力门控:native probe 要求同一 Vulkan 物理设备同时具备 Vulkan 1.1、`VK_ANDROID_external_memory_android_hardware_buffer`、`VK_EXT_queue_family_foreign`、sampler YCbCr conversion,并成功创建逻辑设备取得 AHB 导入函数;AImageReader 必须是 `AIMAGE_FORMAT_PRIVATE + AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE`。不满足时不会声明可用。 +- 测试/构建:`:app:compileMobileArm64_v8aDebugJavaWithJavac` 成功;`:app:externalNativeBuildMobileArm64_v8aDebug` 与 `:app:externalNativeBuildMobileArmeabi_v7aDebug` 成功,产物分别确认 ELF 64-bit AArch64 与 ELF 32-bit ARM。新增纯逻辑测试覆盖显式 opt-in、完整 probe、队列时序和 PTS 纳秒换算;Android Gradle 单测仍受既有资源链接错误阻断。 +- 当前限制:此单元只验证 codec→AImageReader/AHardwareBuffer 通路并统计 buffer,不导入 Vulkan 图像、不调用 libplacebo、不输出 PlayerView,也不提取/解析 RPU。因此不能宣称 DV5 色彩已恢复;这些属于 E9-3c/3d。 +- 回滚:回退 E9-3b commit/tag 可移除 CMake/native 目标与诊断 sink,既有 Exo/MPV 二进制和默认路径不变。 +- 下一动作:启动 E9-3c,接入独立 libdovi/RPU parser 与 libplacebo mapping API;先定义逐帧 metadata 所有权和 malformed-RPU 回退,再实现 Vulkan external image 到输出 Surface 的最小帧路径。 + +### E9-3b 检查点 + +- 完成:E9-3b diagnostic Media3 VideoSink and independent AImageReader/Vulkan capability bridge compile for arm64-v8a and armeabi-v7a; ELF SONAME, JNI exports and DT_NEEDED verified; production renderer remains unregistered. +- 分支/基线:`exo-dv5` / `7f665b7d858454fa19919b1e0b69b0f239ac9587`。 +- 已改路径:`app/build.gradle`、`app/src/main/cpp/**`、四个 `ExoDv5*` Java 文件、新增测试和本任务文档;无起始脏文件。 +- 验证:App Java、两 ABI CMake build 成功;ELF64 AArch64/ELF32 ARM、`libexo_dovi_renderer.so` SONAME、七个 JNI 导出和 `libandroid/libmediandk/libvulkan` 依赖已确认。 +- 验证补充:使用 JDK 21 执行 `bash gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.player.exo.ExoDv5GpuRendererTest --no-daemon`;任务在既有 `processMobileArm64_v8aDebugResources` 资源链接错误处终止,未进入测试执行。该错误涉及缺失的 Material/Media3 资源,本阶段未改动资源或依赖。 +- 未解决风险:目标设备尚未运行 capability probe/codec Surface 出帧;AImageReader 回调销毁只完成静态审计,尚无设备压力验证;本阶段不具备显示输出和 DV mapping。 +- 回滚锚点:`7f665b7d858454fa19919b1e0b69b0f239ac9587`。 +- 下一动作:Run ExoDv5GpuRendererTest with Gradle JDK 21, finish release-lifecycle review, then commit and tag E9-3b. + +## 13. E9-3c 依赖本地化检查点 + +- 检查点摘要:E9-3c dependency provenance and missing armv7 shaderc resolved; isolated r29 build succeeded +- 下一动作:Copy the two-ABI libplacebo/shaderc closure into third_party/exo-dv5-native and link it from local CMake + +## 14. E9-3c 依赖本地化实施记录 + +- 已完成:在当前仓库新增 `third_party/exo-dv5-native`,复制 libplacebo 7.375.0/API 375 的 arm64-v8a、armeabi-v7a 静态库,以及使用 NDK r29/API 24 独立生成的两 ABI shaderc 组合归档;构建和运行时不再读取 `main-2` 路径。 +- CMake:`app/src/main/cpp/CMakeLists.txt` 通过 `${CMAKE_CURRENT_LIST_DIR}/../../../../third_party/exo-dv5-native` 引入 ABI 对应归档,链接 `libdl`、`libm`、Android/MediaNDK/Vulkan,并启用 libplacebo 的静态 pthread 配置。 +- Native 探针:`exo_dovi_renderer.cpp` 调用 `pl_version()` 并只在 API 375 库可用时上报 `CAPABILITY_LIBPLACEBO_375`;Java 能力门控已将该位列为必需能力。 +- 资产哈希:见 `third_party/exo-dv5-native/MANIFEST.sha256`。来源版本为 libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`;shaderc 使用 NDK `29.0.14206865` 的 staged source,armv7 归档在独立临时输出目录生成。 +- 验证:`JAVA_HOME=.../Contents/Home bash gradlew :app:externalNativeBuildMobileArm64_v8aDebug :app:externalNativeBuildMobileArmeabi_v7aDebug --no-daemon` 成功;两 ABI 均完成 CMake 链接。该验证只证明 native 资产和 JNI 探针可编译,不证明 DV5 已完成 Vulkan 图像导入、RPU 映射或色彩恢复。 +- 未完成:libplacebo Vulkan 外部图像导入、PlayerView 输出和逐帧 Dolby Vision RPU 映射仍属于后续功能单元;当前 renderer 仍保持诊断/显式 opt-in,默认播放行为不变。 +- 回滚:回退本单元提交即可移除本地依赖和 CMake 链接,恢复 `c94ba7c9464f4cb43df351e471e8aa2df7132178` 的诊断底座。 +- 下一动作:在独立后续单元中补齐 Vulkan/AHardwareBuffer 到输出 Surface 的实际渲染,再进行目标设备 DV5 色彩验收。 + +## 15. E9-3c-render 检查点 + +- 检查点摘要:libdovi-3.3.2 builds for both Android ABIs in isolated output; current libplacebo archive lacks libdovi mapping support; no incomplete renderer was committed +- 下一动作:Start a new native asset unit to vendor libdovi C API and both ABI archives before wiring Vulkan renderer +- 已验证:`libdovi-3.3.2`(commit `4fd2b2235c9f93582dd4a00e65ee34a07800afd7`)使用 NDK `29.0.14206865`、Android API 26 linker 在独立临时目录成功生成 `aarch64-linux-android` 与 `armv7-linux-androideabi` 静态库;未写入其他工作区。 +- 已验证:当前本地 libplacebo 归档是 `pl_has_dovi=1` 但 `pl_has_libdovi=0`;因此仅链接现有归档不能解析 RPU,也不能生成 `pl_dovi_metadata`。现有 ExoplayerHdrUtils JNI 只提供 profile/frame info,不能替代该接口。 +- 未完成:本单元没有提交伪 Vulkan 输出或不完整 RPU 映射;当前 `VideoSink` 仍是诊断底座,生产路径和默认播放行为不变。 +- 原因:要继续实现必须新增独立 libdovi C API 头/双 ABI归档,并将 `RpuDataMapping` 完整映射到 libplacebo 的 `pl_dovi_metadata`;这是新的 native 资产和 ABI 单元,不能在没有完整头文件/真机输出验证时冒充完成。 +- 回滚锚点:`893a2cca7b56f953976d74d52317862bca6deb6c`。 +- 下一动作:启动新的独立 native 资产单元,先把 libdovi C API(含 mapping structs)和双 ABI 归档本地化,再接入 Vulkan renderer。 +- 目标:把 Exo DV5 renderer 所需的 libplacebo/shaderc 双 ABI 静态闭包复制进当前仓库,禁止运行时或构建时引用 `main-2` 绝对路径。 +- 分支/基线:`exo-dv5` / `c94ba7c9464f4cb43df351e471e8aa2df7132178`;恢复时工作树无预先存在的脏文件。 +- 已确认来源:libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`(7.375.0/API 375),arm64-v8a 与 armeabi-v7a 各自独立构建;shaderc 来自 Android NDK `29.0.14206865` 随附源码,组合归档包含 239 个对象。 +- 已完成证据:`main-2` 缓存中存在双 ABI `libplacebo.a` 和 arm64 `libshaderc.a`;armv7 shaderc 缓存缺失,已用同一 NDK r29/API 24 在独立临时输出目录成功生成,没有写入 `main-2` 的 `obj/libs/prefix`。 +- 当前限制:尚未把资产复制到 `third_party/exo-dv5-native`,尚未接入当前仓库 CMake,也尚未完成双 ABI 链接验证;不能据此声称 DV5 色彩映射已经可用。 +- 回滚锚点:`c94ba7c9464f4cb43df351e471e8aa2df7132178`;删除本单元新增的本地依赖目录并回退 CMake/本文档即可。 +- 下一动作:复制双 ABI libplacebo/shaderc、公共头和许可证,写入来源/哈希清单并完成当前项目双 ABI native 链接。 + +## 16. E9-3c-libdovi 实施记录 + +- 来源与构建:本地化 `libdovi` 3.3.2,源码 commit + `4fd2b2235c9f93582dd4a00e65ee34a07800afd7`;`dolby_vision` crate 使用 + `features=capi`、`crate-type=staticlib`、NDK `29.0.14206865`、Android API + 26 分别生成 `aarch64-linux-android` 与 `armv7-linux-androideabi` 归档。 + C API 头由相同源码和 cbindgen 0.29.4 生成,归档、头和 MIT 许可证均由 + `third_party/exo-dv5-native/MANIFEST.sha256` 锁定。 +- 本地闭包:CMake 仅从仓库相对路径按 `${ANDROID_ABI}` 导入 + `libdovi.a`,不读取或复用其他工作区。native capability probe 实际调用 + `dovi_parse_unspec62_nalu` 的错误输入路径并释放 opaque 对象,因此双 ABI + 链接必须解析真实 parser/free symbols,而不是只检查文件存在。 +- 语义校正:libdovi 3.3.2 的 conversion mode 2 才是 P8.1 no-op mapping, + mode 4 是保留 mapping 的 P8.1;mode 3 是静态 P8.4。E9-3 不依赖任何 + P5→P8.1 伪 base-layer 转换,而是保留原始 Profile 5 IPT base layer,供 + Vulkan/libplacebo 结合逐帧 RPU 做 reshape。 +- 限制:本单元只建立可重现的 parser ABI 依赖。尚未把 RPU 从 Media3 + access unit 传入 native,也尚未创建 Vulkan swapchain/AHB external image + 和显示输出,因此不能宣称设备色彩已经恢复。 +- 回滚:回退本单元提交和恢复标签即可删除 libdovi 资产、头、许可证、 + CMake 导入和 capability parser probe;上一阶段 libplacebo 诊断底座不变。 +- 下一动作:覆盖 `MediaCodecRenderer.onQueueInputBuffer` 提取 NAL type 62, + 以 PTS 有界排队到 native 并完成 metadata 生命周期,然后接入 Vulkan 输出。 + +## 17. E9-3d-vulkan 实施检查点 + +- 当前基线:`exo-dv5` / `3df132a68e1aa67ce54b61cb091c41f88ceba00e`; + 当前单元只修改 native renderer 与本文档,无预先存在的用户脏文件。 +- 已完成代码:创建 libplacebo Vulkan instance/device/swapchain,导入 + `AImageReader` 的 `AHardwareBuffer`,按 Profile 5 raw IPT 语义设置 full-range + RGB-identity YCbCr sampler 与 Cr/Y/Cb 分量映射,解析 libdovi RPU 并调用 + `pl_render_image` 输出到 PlayerView Surface。 +- 已验证:`externalNativeBuildMobileArm64_v8aDebug` 编译成功;该结果仅证明 + arm64 native 源码和链接闭包成立,不代表设备播放或色彩验收成功。 +- 收尾风险:补齐 foreign queue ownership 的 semaphore 契约、失败路径回收、 + libplacebo log 销毁、seek 后旧 RPU 状态清理,并依据 AHB format feature + 选择采样 filter;随后执行 arm64-v8a/armeabi-v7a 双 ABI clean native build。 +- 回滚锚点:`3df132a68e1aa67ce54b61cb091c41f88ceba00e`。 +- 下一动作:核对 vendored libplacebo API 375 的 release/hold 同步契约并完成 + 上述最小安全修复。 +- 检查点摘要:arm64 native Vulkan renderer build succeeded; patched semaphore ownership, failure cleanup, log release, seek RPU reset, and AHB sampling feature gating +- 下一动作:Run dual-ABI native build, inspect result, then finish E9-3d-vulkan atomically. + +### E9-3d-vulkan 完成记录 + +- Vulkan 输出:native renderer 现已创建 Android `VkSurfaceKHR`、libplacebo + Vulkan device/swapchain/renderer,并把 `AImageReader` 的 + `AHardwareBuffer` 作为 external-memory `VkImage` 导入。输出 Surface 替换时 + 串行销毁并重建 Vulkan 资源,release 会先停止 image listener 并等待当前 + callback 退出。 +- DV5 映射:AHB sampler 强制使用 `RGB_IDENTITY + ITU_FULL`,libplacebo plane + 按 `Cr/Y/Cb` 解释 raw Profile 5 分量;输入 AU 的 NAL type 62 RPU 经 libdovi + 解析为每帧 `pl_dovi_metadata`,交给 `PL_COLOR_SYSTEM_DOLBYVISION` 的 + `pl_render_image` 完成 reshape/tone-map。 +- 同步与回收:external image 在 `VK_QUEUE_FAMILY_FOREIGN_EXT` 与 libplacebo + 之间用 `pl_vulkan_release_ex`/`pl_vulkan_hold_ex` 转移 ownership;hold 使用 + libplacebo 创建的 binary semaphore,并在 `pl_gpu_finish` 后销毁 semaphore、 + wrapper、VkImage 和 imported memory。失败路径会在回收前尝试重新取得 + ownership;Vulkan/log/swapchain/renderer 均有配对释放。 +- 格式门控:要求 AHB Vulkan format 支持 sampled image;YCbCr conversion + 仅在 format features 声明支持时使用 linear chroma filter,否则降为 nearest, + 并传递 separate-reconstruction-filter 能力。seek/flush 同时清空 pending RPU + 和 last-DV metadata,避免沿用上一时间线的元数据。 +- 验证:使用 JDK 21、NDK `29.0.14206865` 执行 + `:app:externalNativeBuildMobileArm64_v8aDebug` 与 + `:app:externalNativeBuildMobileArmeabi_v7aDebug`,两 ABI 均 `BUILD SUCCESSFUL`。 + 此结果证明 native 源码、libplacebo/libdovi/shaderc 本地闭包和双 ABI 链接成立, + 不证明目标设备已经正常出帧、颜色正确或性能达标。 +- 真机门槛:renderer 虽已具备受控注册条件,但内部实验默认关闭;没有目标设备 + DV5 播放、截图、logcat、seek/lifecycle 证据前,不开启自动生产选择,也不宣称 + 色彩或性能验收通过。 +- E9-3d-wiring:native capability bit 已按 libplacebo 7 的最低 Vulkan + 1.2 要求校正,renderer 名称由 diagnostic 改为 experimental,统计新增 + `renderedFrames` 与 `renderFailures`。自定义 renderer 仅在现有内部实验总开关 + 与 Exo 域开关同时开启、native probe 完整通过时加入列表;它位于列表最前, + 但对检测到“DV 显示能力 + 硬件 DV decoder”的设备主动返回 unsupported,保留 + 原生 DV。其余 Profile 5 才走普通硬件 HEVC decoder + VideoSink/Vulkan 路径, + 并显式关闭 tunneling。sink 初始化会补传已经设置的 PlayerView Surface,避免 + Surface 先到导致 native renderer 黑屏;native 首个成功 present 会触发 Media3 + 首帧通知,连续 3 次 Vulkan 渲染失败则上报 video-frame-processing error,避免 + 永久静默黑屏。 +- 回滚:回退本单元提交/恢复标签即可恢复到仅有 RPU 输送和本地头文件的状态; + vendored native 依赖与现有 Exo/MPV 默认路径不受影响。 + +## 17. E9-3c-renderer RPU 接线记录 + +- Media3:`ExoDv5GpuRenderer` 覆盖 `onQueueInputBuffer`;该 hook 在锁定版 + Media3 的 `DecoderInputBuffer.flip()` 之后、`MediaCodec.queueInputBuffer()` + 之前执行。扫描器不修改原 ByteBuffer,支持 Annex-B 3/4 字节起始码和 + 1/2/4 字节长度前缀,只复制 HEVC NAL type 62。 +- JNI/native:每个 RPU 带原始 `buffer.timeUs` 送入 native;libdovi 必须 + 成功解析 header(`rpu_type == 2`)和 mapping 才进入 16 帧有界队列。 + 统计有效 RPU、malformed RPU、队列丢弃和当前 pending;flush/seek 同时 + 清除 expected-frame 与 RPU 队列,避免旧 metadata 污染 seek 后帧。 +- Surface:`VideoSink.setOutputSurfaceInfo/clearOutputSurfaceInfo` 已转发到 JNI, + native 使用 `ANativeWindow_fromSurface` 获取独立引用,并在 Surface 替换、 + clear 和 renderer release 时成对释放;当前尚未用该 window 创建 swapchain。 +- 验证边界:App Java 与 arm64-v8a/armeabi-v7a native 构建通过。NAL 扫描 + 测试新增 Annex-B、四字节长度前缀和输入 position 不变断言;Android Gradle + 单测任务仍受既有资源链接基线错误约束。 +- 当前限制:本单元完成 AU→RPU parser 与 Surface 所有权接线,但 AHB 尚未 + import 为 Vulkan external image,RPU mapping 也尚未转换成 + `pl_dovi_metadata` 并送入 `pl_render_image`,所以仍不能宣称色彩恢复。 +- 下一动作:本地化完整 libplacebo API 375 公共头,然后实现 Vulkan context、 + Android swapchain、AHB external-format import、raw DV component mapping 和 + 同步 `pl_render_image` 输出。 + +## 18. E9-3d libplacebo 公共头本地化 + +- 第一批公共头来自已锁定并已链接的 libplacebo 7.375.0 commit + `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5` 同一源码树,包含 Vulkan、 + GPU、swapchain、renderer、colorspace、log/cache/common 和核心 shader API。 +- 这些头只复制到当前仓库 `third_party/exo-dv5-native/include/libplacebo`; + App CMake 和 native 源码不引用 `main-2`,另一工作区仅作为本地复制来源。 +- 第一批是资产恢复点,不改变运行时或生产 renderer 选择;第二批将补齐 + renderer include 闭包后,由实际双 ABI 编译验证头与静态库 API 375 匹配。 +- 第二批补齐 `renderer.h` 的实际传递闭包:dispatch、dither、gamut/tone + mapping 以及 custom/ICC/LUT shader API。未复制 D3D、OpenGL、FFmpeg、 + dav1d 等本实现不使用的公共头,保持 Exo Vulkan 资产范围最小。 +- 下一动作:在 native renderer 同时 include `vulkan.h` 与 `renderer.h`,以 + 双 ABI 编译确认头/归档契约,再实现 context、swapchain 与 AHB import。 + +## 19. E9-3d-device 目标设备验证 + +- 设备与产物:V2453A(Android API 35,序列号 `10CF6H1D2L0009S`)安装 + `app-mobile-arm64_v8a-debug.apk`;验证基线为 + `c943d7659bf9f03d2cf3cdaa670d5b82231168ca`。 +- 路由证据:开启内部播放实验与 Exo 域开关后,目标 DV5 样片实际选中 + `MediaCodecVideoRenderer-DV5-Vulkan`;native capability probe、Adreno + Vulkan 初始化和普通 HEVC `c2.qti.hevc.decoder` 创建均成功。 +- 失败分类:本次不是音频输出失败。`AudioTrack` 已完成初始化,Media3 最终 + 上报的是 `ERROR_CODE_VIDEO_FRAME_PROCESSING_FAILED`,异常链为 + `MediaCodecVideoRenderer-DV5-Vulkan -> VideoSinkException -> + DV5 Vulkan rendering failed`。应用当前把 output stage 显示成泛化的音视频 + 输出提示,容易让设备侧反馈误判为音频故障。 +- 真正阻断:native `renderImage()` 连续三帧返回 `false`,但当前失败出口没有 + 阶段日志,尚不能区分 AHB properties、external image 创建/绑定、 + `pl_vulkan_wrap`、swapchain、`pl_render_image`、submit 或 foreign queue hold。 +- 原始证据:设备完整 logcat 保存于 `/tmp/e9-dv5-full-logcat.txt`;关键错误位于 + 2026-08-27 20:07:31,renderer format 为 `dvhe.05.09`、3840x2160。 +- 验证结论:构建与 renderer 选择成立,但设备出帧和色彩验收失败;不得将本 + candidate 宣称为 DV5 正常播放。 +- 回滚锚点:`c943d7659bf9f03d2cf3cdaa670d5b82231168ca`。 +- 下一动作:为 `renderImage()` 增加有界的首个失败阶段与 AHB/Vulkan 格式日志, + 在同一设备复现后只修复首个确定失败的 native 契约。 + +## 20. E9-3d-render-fix 实施检查点 + +- 根因:目标设备返回 Android Vulkan `externalFormat` 时,旧实现仍把 + `formatProps.format` 写入 `VkImageCreateInfo.format` 和 libplacebo wrap。 + Android external-format image 必须使用 `VK_FORMAT_UNDEFINED`,实际格式由 + `VkExternalFormatANDROID.externalFormat` 描述;设备日志中的 + `Unsupported VkFormat: 1000330000...` 与此契约错误相符。 +- 修复:`renderImage()` 在 external format 非零时同时使用 + `VK_FORMAT_UNDEFINED` 创建/包装 image;保留 concrete format 路径不变。 + 所有失败出口增加首个阶段、`VkResult`、AHB geometry/format/usage、Vulkan + format/externalFormat/features、PTS 和 RPU 状态日志。 +- 误报修复:Media3 音频错误仍保持原 `OUTPUT` 分类;视频帧处理错误仍属于 + output stage,但 `PlayerManager` 根据精确错误码显示独立的“音频输出失败” + 或“视频输出失败”,不再把 DV5 Vulkan 错误提示成音频故障。 +- 验证:JDK 21 下 `:app:externalNativeBuildMobileArm64_v8aDebug` + `:app:externalNativeBuildMobileArmeabi_v7aDebug` 与 + `:app:compileMobileArm64_v8aDebugJavaWithJavac` 均 `BUILD SUCCESSFUL`。 +- 当前状态:尚未完成修复后目标设备播放验收;下一动作是用无构建缓存的 + arm64 debug APK 安装到 `10CF6H1D2L0009S`,复现同一 DV5 样片并检查首个 + `render failed stage=` 日志、`renderedFrames` 和错误提示。 + +### E9-3d-render-fix 设备复测检查点 + +- 检查点摘要:external-format 修复已安装;普通视频回归已排除;DV5 精确复测待执行。 +- 下一动作:启动应用,从当前历史列表点击 `P5_Dolby_Amaze.mkv`,读取首个 + `ExoDv5 render failed stage`。 +- Next action (guard): launch app and tap P5_Dolby_Amaze.mkv from history, then inspect first ExoDv5 render failed stage +- 分支/基线:`exo-dv5` / `38a4fdb84f3c28391c43f1a98f866960981046aa`; + 当前未提交范围仍为 native external-format 修复、精确输出错误文案与本文档, + 没有新增其它播放器行为。 +- 设备状态:V2453A(Android API 35,`10CF6H1D2L0009S`)在线;当前工作区 + `app-mobile-arm64_v8a-debug.apk` 已于 2026-08-27 20:46:29 重新安装。 +- 已排除:用户复核后确认普通视频播放正常,不存在实验 renderer 抢占非 DV5 + 轨道的回归;继续只处理 `dvhe.05.09` 样片。 +- 未完成:安装后的 DV5 样片尚未完成一次有效复测,因前一次自动点击命中了 + 历史列表中的其它条目,没有产生新的 `ExoDv5` 日志。 +- 下一动作:重新启动应用,读取当前历史列表 UI 坐标后精确点击 + `P5_Dolby_Amaze.mkv`,保存完整 logcat 和屏幕证据,并按首个 + `render failed stage=` 决定唯一下一处 native 修复。 + +### E9-3d-render-fix FIFO 时间戳修复检查点 + +- 已确认 RPU 解析成功,但目标设备的 `AImage` timestamp 是 monotonic clock, + 与媒体 PTS 不在同一时间域;旧代码精确比较两者导致每帧都误报 `missing-rpu`。 +- 已改为按 MediaCodec 输出顺序 FIFO 关联已排队的媒体 PTS,保留 AImage + timestamp 仅用于队列为空时的诊断;arm64 debug APK 已于 22:37 构建完成。 +- 工作区仍只包含既有 E9-3d native 修复、输出错误分类和本文档修改;Gradle + 生成的未跟踪 `app/.cxx` 已移至 `/tmp/e9-app-cxx-20260827-2237`,不纳入任务。 +- Rollback anchor:`38a4fdb84f3c28391c43f1a98f866960981046aa`。 +- Next action: install FIFO candidate and reproduce P5_Dolby_Amaze.mkv once, then fix only the first new native failure stage + +### E9-3d-render-fix 色彩修复检查点 + +- 设备复测已确认 FIFO candidate 可以稳定出帧,但画面接近全黑。 +- 根因已收敛到 RPU 状态映射:libdovi 的压缩 DM 对象不携带新的静态颜色 + 矩阵,当前实现却把其中的零字段写入 `pl_dovi_metadata`;同时没有继承 + `use_prev_vdr_rpu_flag` 指向的 reshape 状态,也没有向 libplacebo 传递 + RPU 的源 PQ 亮度元数据。 +- 修复边界:只调整 `exo_dovi_renderer.cpp` 的 RPU 状态继承、Profile 5 + 标准静态矩阵回退和 HDR 元数据映射,不改变 Exo/MPV 路由或其它格式行为。 +- Next action: implement compressed-DM inheritance and Profile 5 luminance mapping, then build and install one arm64 APK + +### E9-3d-render-fix 黑屏语义复核检查点 + +- DV5 black-output review: libplacebo/mpv standard path applies P5 RPU reshape; hand-mapped coefficient normalization matches upstream, so disabling mapping is unsupported. Current decisive uncertainty is Android external-format sampler channel/range contract versus first-frame RPU state. +- 已核对 libplacebo 7.375.0 的 `pl_map_dovi_metadata()` 与 shader:P5 的 RPU + mapping 会在颜色矩阵前执行;libdovi C API 的整数/小数系数组合与 FFmpeg + signed fixed-point 语义一致。`profile5.bin` 的零 luma 曲线只是单帧 fixture, + 不能据此把所有 P5 mapping 当成错误。 +- 下一动作:检查已保存的最新设备日志和 Android external-format 通道契约, + 只修复一个确定的 native 输入解释错误,然后执行 arm64 native build。 +- Inspect saved latest device log and external-format channel contract, then apply one minimal native fix and arm64 build + +### E9-3d-render-fix 当前候选构建检查点 + +- 当前 DM/RPU 色彩修复候选已通过 `:app:externalNativeBuildMobileArm64_v8aDebug` 与 `:app:assembleMobileArm64_v8aDebug`,APK SHA-256 为 `956e972b272ca219c4c48233000280af75fa5598171118fc0bb19ee970a0f5b7`。 +- 目标设备 `10CF6H1D2L0009S` 当前未连接,尚不能把编译成功表述为色彩修复通过。 +- 下一动作:设备上线后覆盖安装该 APK,播放 `P5_Dolby_Amaze.mkv` 并只读取 `ExoDv5`/renderer 定向日志。 +- Checkpoint: arm64 DV5 DM/RPU color candidate built successfully; device offline; app/.cxx preserved under /tmp +- Next action: install candidate on 10CF6H1D2L0009S and capture focused ExoDv5 playback logs + +### E9-3e HDR swapchain 日志根因与修复 + +- SurfaceFlinger 的目标设备现场状态确认 Exo DV5 Surface 使用 + `RGBA_1010102`,但 dataspace 仍为 `V0_SRGB`;同一设备声明支持 HDR10/PQ, + 最大亮度 800 nit。`color_bits=10` 只选择位深,不能替代 HDR 色彩空间声明。 +- 根因是 Exo Vulkan 路径没有像 mpv gpu-next 一样调用 + `pl_swapchain_colorspace_hint`,libplacebo 因而按默认 sRGB 创建 swapchain。 +- 最小修复是在已完成 DV reshape 的 BT.2020/PQ source 上调用 colorspace hint, + 再 resize/acquire swapchain;同时有界打印前四帧 source/target primaries、transfer、 + luminance 与输出位深,供设备日志和 SurfaceFlinger dataspace 双重确认。 +- 回滚锚点:`recovery/E9-3d-render-fix/20260828004539-eb1f827bfc7b`。 +- 设备验证:arm64 native 构建与 APK 打包成功,APK SHA-256 为 + `75d7cee2f3da73133ed8be65ab7ba184a4e4518ac9cecfd0ede17acab51b238b`; + 已覆盖安装到 V2453A 并精确播放 `P5_Dolby_Amaze.mkv`。 +- 日志结果:Exo 仍使用 `c2.qti.hevc.decoder`,RPU 为 Profile 5 且 mapping/DM + 均有效;前四帧 source/target 均为 BT.2020/PQ、10 bit、0.005-3999.7 nit, + 无 `render failed` 或 `VideoSinkException`。 +- 系统输出结果:SurfaceFlinger 将视频层标记为 `BT2020_PQ`,已从修复前的 + `V0_SRGB` 切换为 HDR 输出。日志层面的错误色彩空间根因已修复;不以截图或 + 主观画面对比替代用户的最终视觉验收。 + +### E9-3f RPU 时间戳域修复 + +- 复测日志发现恢复播放时首个渲染 PTS 为 `1000000000000`,而 pending RPU + 从 `1000055767000` 开始,差值约 55.8 秒,连续三帧因此命中 `missing-rpu`。 +- Media3 会通过 `VideoSink.setBufferTimestampAdjustmentUs()` 对输出帧做起始位置 + 修正;旧实现只修正 AImage timestamp,RPU 和 native FIFO 的媒体 PTS 仍使用原值。 +- 修复:RPU 入队、`nativeQueueFrame` 的 presentation PTS、AImage timestamp + 统一使用同一调整后的时间戳,覆盖 seek/恢复播放场景,不改变播放器路由。 +- 下一动作:编译安装并从历史入口再次播放 `P5_Dolby_Amaze.mkv`,确认不再出现 + `missing-rpu`,且 Exo 仍持续出帧。 + +### E9-3f 当前复测检查点 + +- 时间戳修复候选已通过 `:app:assembleMobileArm64_v8aDebug`,APK SHA-256: + `cd115cefb8acc2890488dc5de039d4fefd12cc2666520fb48a0c84147fde9c99`。 +- 已覆盖安装并启动到目标设备 `10CF6H1D2L0009S`;尚未播放样片。 +- 由于 guard 要求更换诊断路线,下一步只做一次精确历史条目播放并抓取 + `ExoDv5`/`VideoSink` 日志,判断时间戳修复是否消除 `missing-rpu`。 + +### E9-3f 失败复测结论 + +- 当前候选无效:首批 RPU 被调整为 `-432999` 至 `-299999` 微秒,自定义 + DV5 decoder 约 300 ms 后释放,随后普通 Exo renderer 才尝试连接仍由 Vulkan + 持有的 Surface 并报 `Failed to connect to surface`。没有发生 MPV 切换。 +- 已否定的假设:不能对 `DecoderInputBuffer.timeUs` 和 VideoSink 输出帧 PTS + 同时直接叠加 `bufferTimestampAdjustmentUs`;Media3 的输入时间戳已经包含 stream + offset,这种处理会制造负 RPU PTS,且未证明两个回调属于同一原始时间域。 +- 当前未提交候选不得完成或打标签。下一动作是读取首次 decoder 释放前后的完整 + 播放异常,确定 sink 退出原因,再用单一明确映射修正 RPU/帧配对。 +- Guard checkpoint message: 当前候选无效。 +- Guard next action: 读取首次 decoder 释放前后的完整播放异常,确定 sink 退出原因,再用单一明确映射修正 RPU/帧配对。 +- Checkpoint: 已撤销重复叠加 `bufferTimestampAdjustmentUs` 的无效候选;当前仅增加 + renderer stream/disable、sink adjustment 和首四帧 PTS 的有界日志,以确定首帧前 + custom renderer 被释放的直接原因。 +- Next action: 构建安装一次 arm64 debug APK,精确复现并读取 `ExoDv5` 定向日志。 + +- Compaction recovery: branch/HEAD and three task-owned dirty files match E9-3f checkpoint; generated app/.cxx is the only guard violation. +- Next action: Preserve app/.cxx outside worktree, capture one PID-bound all-buffer logcat reproduction, and fix only the first confirmed failure. + +- Checkpoint: A fresh device log confirms Exo's custom renderer receives valid Profile 5 + RPU metadata and renders without failures, but the target SurfaceView is not retained by + SurfaceFlinger as an HDR layer (`mIsHdrLayerPresent=false`). The renderer was advertising + a 4000-nit BT.2020/PQ target, so the compositor can display PQ values as SDR, producing the + reported near-black image. The current bounded fix changes only the swapchain hint to the + explicit sRGB color contract; DV5 IPT/RPU mapping and Exo routing remain unchanged. +- Next action: Build the arm64 debug APK, install once, replay `P5_Dolby_Amaze.mkv`, and read + only `ExoDv5` logs to verify the target contract is sRGB and rendering remains failure-free. + +### E9-3g SDR output contract correction + +- Root cause: the compatibility renderer advertised the decoded DV source itself as the + swapchain target (`BT.2020/PQ`, 0.005-3999.7 nit). That bypassed libplacebo output tone + mapping and depended on Android retaining and correctly mapping the custom Vulkan child + surface as HDR. The user-visible result on V2453A was an almost-black picture. +- Fix: retain raw Profile 5 sampling and per-frame RPU reshape, but request an explicit + sRGB/BT.709 swapchain target. libplacebo now performs the DV-to-display tone mapping before + presentation; Exo routing, MediaCodec selection, RPU parsing and component mapping are + unchanged. +- Build: `:app:assembleMobileArm64_v8aDebug` passed with JDK 17. APK SHA-256: + `cf01c3fc4ebbbce8833f7d066f23ac2a175731db9ae481f7a25038583139961b`. +- Device: installed on V2453A (`10CF6H1D2L0009S`) and launched + `P5_Dolby_Amaze.mkv`. The renderer remained Exo with `c2.qti.hevc.decoder`; RPU parsing and + initial frame scheduling succeeded, with no MPV switch, `VideoSinkException`, native render + failure or crash in the focused run. +- Output contract: SurfaceFlinger reports the active 3840x2160 video layer as + `dataspace=V0_SRGB`, display `colorMode=SRGB`, and SDR white point about 400 nit. This proves + the renderer no longer presents 4000-nit PQ as its final output and now performs GPU tone + mapping. Per the user's instruction, no screenshot or subjective image comparison was used; + final visual color acceptance remains user-observed. +- Rollback: revert this unit's commit/tag to restore the previous HDR10 swapchain hint without + changing the earlier RPU queue and timestamp fixes. + +- Replan: vivo shell `logcat` and application-UID `run-as logcat` both omit the App's Java/native playback logs even though the installed APK hash matches the local candidate; repeated logcat capture cannot identify the disable cause. +- Next action: replace the existing bounded `Log.i` probes with an App-private diagnostic file, reinstall once, and read that file after one P5 reproduction. + +- Checkpoint: App-private diagnostics prove the custom renderer parses 26 RPU units and receives one codec frame, but is stopped before AImage acquisition. Locked Media3 `VideoSink` sources require adding `bufferTimestampAdjustmentUs` inside `handleInputFrame` and using the resulting frame PTS for scheduling and the input Surface timestamp; the raw buffer PTS remains the RPU/native match key. +- Next action: store raw and adjusted PTS in `PendingFrame`, use adjusted PTS for scheduling/AImage timestamp, rebuild, install, and replay P5 once. + +- Checkpoint: a direct P5 episode click starts at zero and proves adjusted frame/position scheduling is correct. Three frames reach AImageReader and match RPU, but all native renders fail only after the candidate passed media PTS `0/17/33 ms` as `MediaCodec.releaseOutputBuffer` timestamps instead of absolute monotonic release times. +- Next action: keep adjusted frame/position scheduling and raw RPU matching, but restore monotonic `releaseTimeNs` for codec/AImage release, then rebuild and replay P5 from zero once. + +- Checkpoint: monotonic codec release timestamps now let three AImages reach native matching, but all three fail at `missing-rpu`. The decisive evidence is `parsedRpu=47`, `pendingRpu=16`, `rpuQueueDrops=17`: `pendingRpus` incorrectly shares the 16-entry output-frame bound, so decoder input pre-roll discards the earliest RPU metadata before the first output frame arrives. +- Next action: separate the RPU metadata queue bound from `expectedFrames`, preserve enough decoder reorder/pre-roll metadata, then rebuild and replay P5 from zero once. + +### E9-3f RPU 预滚队列修复与设备验收 + +- 根因:`pendingRpus` 错误复用了 `expectedFrames` 的 16 帧上限;MediaCodec + 输入预滚在首个 AImage 输出前已解析 47 至 49 个 RPU,旧策略持续丢弃队首, + 导致首帧对应的最早元数据被删除并命中 `missing-rpu`。 +- 修复:为 RPU 元数据使用独立的 256 项有界队列;队列极端溢出时保留最早、 + 即将被输出帧消费的元数据并丢弃最远未来项。`expectedFrames` 的 16 帧输出 + 匹配边界保持不变,flush/seek 仍同时清空两类队列。 +- 构建:`bash gradlew :app:assembleMobileArm64_v8aDebug` 成功;生成的未跟踪 + `app/.cxx` 已整体保留到 `/tmp/e9-cxx-20260828-2BckBs/app-cxx`。 +- 真机验收:候选 APK 已覆盖安装到 V2453A(`10CF6H1D2L0009S`),从选集入口 + 将 `P5_Dolby_Amaze.mkv` 从 0 秒播放约 42 秒。最终 stats 为 + `renderedFrames=2450`、`renderFailures=0`、`matchedFrames=2451`、 + `unmatchedFrames=0`、`rpuQueueDrops=0`、`malformedRpus=0`;日志无 + `missing-rpu`、`VideoSinkException` 或 `AndroidRuntime` 崩溃。 +- 色彩输出日志:source/target 均为 BT.2020/PQ,0.005 至 3999.7 nit、10 bit。 + 路径仍为 `MediaCodecVideoRenderer-DV5-Vulkan`,没有自动切换 MPV。 +- 原始证据:`/tmp/e9-dv5-rpu-queue-final-private.log` 与 + `/tmp/e9-dv5-rpu-queue-final-logcat.log`。 +- Next action: create the atomic E9-3f commit and annotated recovery tag. + +### Checkpoint 2026-08-28: external research batch, mpv backends all pass + +- 用户补充的设备事实:同一目标设备、同一 DV5 样片,MPV `gpu-next` 的 + `direct`、`stable`、`legacy` 三种 Vulkan AImageReader 后端均能稳定还原色彩。 +- 这条事实显著降低了“Android external-format、AHardwareBuffer 通道顺序、 + YCbCr sampler/range 或单一 Vulkan 后端”的可能性;三种后端共享的上游 + `mp_image -> libplacebo` Dolby Vision 表示更值得优先对照。 +- 已核对本地锁定 MPV `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`、FFmpeg + `04482c8d13ac27b2a9fe93f5d388929eef8af5f4`、libplacebo + `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5` 及本地 stable override;MPV + `raw_dovi` 会保留原始 DV representation 和三分量语义,stable 另用固定 + 输出池/fence 保证 AImage 生命周期。 +- 外部 issue:mpv #10287 记录无 DV license 的 Snapdragon 设备在 + `mediacodec-copy` 下出现紫绿,而软件/gpu-next 映射正常;mpv #10700 记录 + P5 的错误颜色与 tone-mapping/backend 输入表示有关。这些支持“必须保留 + raw DV + RPU GPU mapping”,不支持 P5 直接改标 HDR10/P8.1。 +- 本轮仍为 assessment;未修改生产代码,保护脏文件 + `app/src/main/cpp/exo_dovi_renderer.cpp`。下一动作:抓取锁定 MPV direct、 + FFmpeg DOVI 状态更新和 libplacebo frame mapping 的完整源码,逐字段对照 + Exo 当前实现,形成推荐/否决矩阵。 +- External research confirms same DV5 sample is correct with MPV gpu-next direct, stable, and legacy; prioritize shared FFmpeg/libplacebo RPU representation and preserve existing dirty native file. +- Fetch locked MPV direct, FFmpeg DOVI state, and libplacebo mapping sources into /tmp and compare exact fields + +### Checkpoint 2026-08-28: narrow the remaining fault domain + +- Authority remains assessment-only for this batch; no production code has been changed. +- User-confirmed evidence: the same device and Profile 5 sample produce stable correct color + with MPV gpu-next `direct`, `stable`, and `legacy`. This rules against a defect unique to + one MPV AHardwareBuffer import backend and makes the shared FFmpeg/libplacebo Dolby Vision + frame representation the primary reference contract. +- Current strongest hypothesis: Exo's hand-built `pl_dovi_metadata` and simplified RPU + carry-forward/frame-selection semantics diverge from FFmpeg's stateful `DOVIContext`. + Counter-hypothesis: Exo feeds equivalent metadata and the remaining fault is output target + or resource lifetime. The next source comparison must distinguish these before any edit. +- Protected pre-existing dirty path remains + `app/src/main/cpp/exo_dovi_renderer.cpp`; do not overwrite or commit it during assessment. +- Next action: compare the locked FFmpeg DOVI parse/state functions and MPV/libplacebo frame + handoff field-by-field with Exo's current RPU parsing and render path, then record a + recommendation and acceptance/rollback boundary. + +### Checkpoint 2026-08-28: MPV 三后端证据后的有限对照 + +- 用户确认同一样片在 MPV gpu-next `direct`、`stable`、`legacy` 均稳定正常;因此不再把单一 + Vulkan/AHardwareBuffer 后端作为首要假设。 +- 锁定源码对照显示 MPV raw DV 路径使用 `pl_map_avdovi_metadata` 的完整 FFmpeg + `AVDOVIMetadata`,并读取 AImage dataspace;Exo 当前使用 libdovi 结果手工维护 + `pl_dovi_metadata`,且固定 RGB identity/full YCbCr 采样。这是当前最强的可证伪差异。 +- 未新增生产代码;受保护脏路径仍为 `app/src/main/cpp/exo_dovi_renderer.cpp`,本文件为任务记录。 +- 下一动作:完成不超过一批的外部主源码/issue核查,并在设备重新在线时仅读取当前 APK 的 + ExoDv5 私有日志,确认 target/dataspace/RPU 状态后再决定是否编辑代码。 + +### Checkpoint 2026-08-28: Exo 与 MPV 共享语义的最终收敛 + +- 用户确认同一样片在 MPV `direct`、`stable`、`legacy` 均为正常色彩,故排除某一种 + Vulkan/AHardwareBuffer 后端独有缺陷;三条路径共享的 FFmpeg Dolby Vision 状态和 + libplacebo raw-DV 表示是当前基准。 +- 已确认当前样片前几帧为 Profile 5、`use_prev_vdr_rpu=0`、每帧均有 mapping 与未压缩 + DM;完整 `vdr_rpu_id`/DM 继承仍是通用缺口,但不能单独解释本样片持续异常。 +- 已确认 libdovi Profile 5 默认 IPT-PQ 矩阵与 Exo 当前常量一致,不能替换为 FFmpeg 的 + 非 Profile-5 默认颜色矩阵。 +- 当前只剩三个有决定力的字段簇:libdovi 到 `pl_dovi_metadata` 的曲线系数映射、 + AHardwareBuffer 的实际 sample depth/crop、以及 swapchain 返回的真实 target 色彩合同。 +- 保护现有 `app/src/main/cpp/exo_dovi_renderer.cpp` 未提交时间戳匹配改动;本轮不修改 + 播放器路由、MPV 策略或其他模块。 +- 下一动作:逐字段核对上述三个字段簇,选择一个可由源码或设备日志证实的单一根因, + 再做最小 native 修复并进行一次 arm64 构建和一次目标设备播放验证。 +- Checkpoint: MPV三后端均正常,已排除单一Vulkan后端;当前仅核对曲线映射、sample depth/crop与swapchain target +- Next action: 逐字段核对三个字段簇并修复一个可证实根因 + +### E9-3h RPU pivot 差分解码修复 + +- 直接根因:Dolby Vision `pred_pivot_value` 在 RPU 中是差分编码。FFmpeg + `dovi_rpudec.c` 读取时逐项累加后保存绝对 pivot;libdovi C API 则暴露原始编码值。 + Exo 旧实现直接归一化每个原始值,导致多 pivot 帧的分段边界乱序,libplacebo + reshape 选中错误曲线。两 pivot 或特定曲线帧可能偶然接近正确,符合用户观察到的 + “约第 3 秒只有一瞬间色彩正常”。 +- 交叉证据:libdovi 自带 Profile 8.4 曲线为 + `63,69,230,256,256,37,16,8,7`,该序列只有作为差分累加才可能形成有序 pivot; + FFmpeg 锁定源码明确执行 `pivot += get_bits(...)`。 +- 修复:`mapRpuCurve()` 在按 BL bit depth 归一化前先累加并夹取 pivot,保持系数、 + Profile 5 矩阵、时间戳匹配、swapchain 输出和播放器路由不变。 +- 构建:`:app:assembleMobileArm64_v8aDebug` 成功,APK SHA-256 为 + `72fe4c5a391d4543d1473c36c954d52c26b2856d6f5dc2832835b9a88fc41893`;生成的 + `app/.cxx` 已整体保留到 `/tmp/e9-pivot-cxx.36DbvT/app-cxx`,未进入任务范围。 +- 回滚锚点:`recovery/E9-3-color-input/20260828074016-eb5f7af998d3`。 +- 真机:APK 已覆盖安装到 V2453A,并从本地选集播放 `P5_Dolby_Amaze.mkv`。 + 路径保持 Exo `c2.qti.hevc.decoder`,未切换 MPV;一轮统计为 + `matchedFrames=2433`、`unmatchedFrames=0`、`expectedQueueDrops=0`、 + `renderFailures=0`、`malformedRpus=0`。 +- 真机日志证明 RPU/帧配对和 Vulkan 渲染链稳定;最终视觉色彩仍以用户观察为准。 +- 证据:`/tmp/e9-pivot-private.log`、`/tmp/e9-pivot-logcat.log`。 + +### E9-3 DV5 切换 DV7 的 Surface 生命周期修复 + +- 复现:DV5 使用自定义 Vulkan renderer 播放后直接切到 DV7;DV7 已正确选择 + HDR10 fallback,但普通 HEVC decoder 配置时报 + `nativeWindowConnect ... Invalid argument (-22)` 和 + `ERROR_CODE_DECODER_INIT_FAILED`。刷新或重进播放后恢复。 +- 根因:track replacement 只调用 `ExoDv5GpuRenderer.onDisabled()` 并释放 DV5 + codec,没有调用 sink 的永久 `release()`;native Vulkan swapchain 因而继续连接 + 外层输出 Surface,阻止后续普通 `MediaCodecVideoRenderer` 连接。 +- 修复:codec 的 `super.onDisabled()` 完成后调用可复用的 `sink.disable()`,关闭 + native renderer、Vulkan swapchain 和 AImageReader,清空 pending frame 并重置渲染状态; + 保留 Java `outputSurface` 引用且不设置永久 `released`,使之后切回 DV5 时可重新初始化。 +- 验收:arm64 构建并安装后执行 DV5 -> DV7;日志中 `sink disable` 必须先于 DV7 + decoder configure,且不得再出现 Surface `-22` 或 decoder init failure。再切回 DV5 + 应重新初始化并出首帧。 +- 结果:`:app:assembleMobileArm64_v8aDebug` 成功并覆盖安装到 V2453A;设备私有日志 + 记录 `sink disable`,切换后的聚焦 logcat 未再出现 `nativeWindowConnect -22`、 + `Failed to connect to surface` 或 `ERROR_CODE_DECODER_INIT_FAILED`,用户确认复现流程 + 已恢复正常。DV5 停用前统计为 `matchedFrames=855`、`unmatchedFrames=0`、 + `expectedQueueDrops=0`、`renderFailures=0`。构建生成的未跟踪 `app/.cxx` 已完整保留到 + `/tmp/e9-dv7-surface-cxx.FeWxKI/app-cxx`,未纳入任务范围。 +- 回滚锚点:`recovery/E9-3-frame-rpu-match/20260828174850-e6a0ee439028`。 diff --git a/docs/FIX-GITCLOUD-KEYSTORE-tv-token.md b/docs/FIX-GITCLOUD-KEYSTORE-tv-token.md deleted file mode 100644 index b4e9a600b5..0000000000 --- a/docs/FIX-GITCLOUD-KEYSTORE-tv-token.md +++ /dev/null @@ -1,39 +0,0 @@ -# Git 云盘:缺失 AndroidKeyStore 的运行环境 - -## 目标与范围 - -修复电视版在 `AndroidKeyStore not found` 环境中保存 GitHub/CNB token 失败;保留正常设备的系统密钥、AES-GCM 密文格式和现有账户。只修改共用 token 存储及其定向测试。用户于 2026-09-15 要求立即修复、减少重复测试;已确认同机 ARM64 版本正常。 - -基线:`bed561691c239d6a48456c45f3727416c8c40c5c`,分支 `feature/mpv-dv7-fel`。保护原有 `app/.cxx/` 下 70 个未跟踪文件。 - -## 已有证据与方案 - -- 设备 V2453A / Android 15:用户安装的 `leanbackArmeabi_v7a`(202609141845)中,GitHub 校验 HTTP 200,随后 `GitCloudTokenStore.encrypt/key` 报 `KeyStoreException`,内层为 `NoSuchAlgorithmException`。读取既有 token 也在相同位置失败。 -- 只读 JDI 确认该 32 位进程的全局 provider 列表缺少 `AndroidKeyStore` 和 `AndroidKeyStoreBCWorkaround`;没有线程级 provider 覆盖。手机与电视共用同一个存储类,故 CNB 受同一条件影响。不能推广成“所有 32 位电视都不支持 KeyStore”。 -- AOSP SDK 36.1 本地源码 `java/security/KeyStore.java#getInstance` 与 `com/android/internal/os/ZygoteInit.java#warmUpJcaProviders`,访问日期 2026-09-15,一级源码证据:注册缺失解释当前异常,正常系统在 Zygote 注册配套 provider。 -- 调试器临时调用系统注册可以恢复 provider;通过应用 ClassLoader 加载的最小探针调用同一方法则被非 SDK 接口限制拒绝,抛 `NoSuchMethodException`。因此不采用反射恢复或隐藏 API 绕过。 -- Google Tink `AndroidKeysetManager`,提交 `836e570107c170c457500283d39417127a8911b7`,访问日期 2026-09-15,一级成熟实现证据:[源码](https://github.com/tink-crypto/tink-java/blob/836e570107c170c457500283d39417127a8911b7/src/main/java/com/google/crypto/tink/integration/android/AndroidKeysetManager.java)。其 `readOrGenerateNewMasterKey` 在 KeyStore 不可用时支持应用私有存储,并禁止为无法读取的既有密钥盲目重建。本修复只借鉴兼容策略,不引入 Tink 依赖。 - -选择:保留现有系统 KeyStore 主路径,只在获取 KeyStore 失败且该 provider 确认未注册时启用私有软件密钥。继续使用平台 AES-GCM、随机 IV,密钥随机生成并原子写入 `noBackupFilesDir`,兼容密文加 `local-v1:` 标识。读取按标识选择原密钥,既有无标识密文仍走系统 KeyStore。软件密钥依赖应用沙箱与系统磁盘加密保护,不具备硬件 KeyStore 的密钥隔离能力;正常设备不降级。 - -比较:不改动只能要求用户换 ARM64 包,未解决受影响版本的保存;直接反射系统注册已被实际否证;把所有设备改成软件存储会降低正常设备的保护,拒绝;限定在 provider 缺失场景的兼容路径可解决 GitHub/CNB 同类失败并保留正常路径。 - -这是共用存储的局部兼容修复,不涉及播放器、ABI 打包、更新路由或新加密算法。官方源码和成熟项目已足够决定实现,不扩展论文、性能研究和泛化 ROM 问题搜索。 - -## 验收与回退 - -一次定向验证覆盖:provider 缺失时两类 token 可保存/读回,IV 随机;provider 恢复后仍可读取兼容密文;旧格式可读取且正常写入仍无兼容前缀;已注册但损坏的 provider 不降级;密钥丢失或损坏时读操作不生成替代密钥。 - -结果:32 位电视 App 与测试 APK 构建成功(2 分 57 秒),已通过安装助手覆盖安装到 V2453A,保留应用数据。`GitCloudTokenStoreTest` 8/8 通过,包含上述条件及 GCM 篡改拒绝、NIST 旧格式向量;该设备 32 位环境中的系统 provider 正常分支使用测试 provider 验证,64 位正常行为由用户实测确认。未执行真实 CNB 账号授权测试,CNB 共用存储路径已覆盖。 - -现场补充:从 64 位版保留的旧系统密文,在缺少该 provider 的 32 位环境仍无法解密,需要重新输入 token 后保存为兼容密文。07:44 的再次报错属于旧密文读取。用户随后明确确认“可以了,打tag”,按实际验收闭合;未继续增加账号 UI 改动或重复验证。 - -回退代码至基线可恢复原逻辑;回退版本不理解新 `local-v1:` 密文,需在系统 KeyStore 可用的版本重新保存受影响账号。既有系统密文与其 alias 不迁移、不删除。 - -## Recovery anchor - -- 状态:实现、构建、安装及 8 项定向验证已完成,用户实际验收通过并要求提交/tag。 -- 文件:本文件、`GitCloudTokenStore.java`、`GitCloudTokenStoreTest.java`。 -- 证据目录:`/private/tmp/webhtv-gitcloud-keystore-20260915/`,包含异常栈、provider 清单及应用反射探针结果;不提交用户 token 或现场日志。 -- 未完成工作:无;旧系统密文需重新输入 token 的边界见上文。 -- 下一步:按用户确认原子提交并创建本地恢复 tag,不再扩展验证或实现。 diff --git a/docs/FOLLOW-1-following-updates-design.md b/docs/FOLLOW-1-following-updates-design.md deleted file mode 100644 index df586822f9..0000000000 --- a/docs/FOLLOW-1-following-updates-design.md +++ /dev/null @@ -1,1804 +0,0 @@ -# WebHTV 追更功能完整设计与验证方案 - -> 状态:设计稿,待评审;本文只定义方案,不代表已获准实现 -> 任务编号:`FOLLOW-1` -> 设计日期:2026-09-19 -> 评估基线:`dev4@552b68bb8e781d06ca989abfd8caa96a567e494a` -> 参考版本:`power721/atv-player@5867e5f16c8bdb8a320d3ea6bb7f242afd2f02a0`、`power721/alist-tvbox@5bb4b93fbdb2dc6fe61c585e83302e37fe2c7acd` -> 适用范围:Android Mobile、Android TV/Leanback、现有单机备份与跨设备一键同步 -> 目标读者:实现者、评审者、测试者、后续维护者 - -## 0. 结论摘要 - -可以给 WebHTV 增加追更功能。推荐实现为: - -1. **客户端本地追更**:以 TMDB/TV 剧集身份为主键,记录官方播出状态、当前剧集进度、提醒水位和来源绑定。 -2. **独立数据库 `following.db`**:不升级现有 `tv` 数据库的 45 版本,避免把新功能迁移风险和全库回滚风险绑定在一起。 -3. **后台调度使用 WorkManager**:周期检查最小 15 分钟,业务默认 6 小时兜底;下一集预计播出后再安排一次性检查;手动检查走前台任务。不会承诺分钟级准时提醒。 -4. **官方更新和可播状态分离**:TMDB 的“已播出”不等于当前站点“已可播”。列表必须同时显示“官方已更新至”和“当前源可播至”。 -5. **首版不内置 alist-tvbox 的网盘巡检、转存和自动换源**:只预留服务端订阅导入接口;如果用户部署了 alist-tvbox,可读取其 `currentEpisodes` 作为可播信号。 -6. **不直接移植参考项目代码**:两个参考仓库当前均未声明 License 且没有 LICENSE 文件。WebHTV 是 GPLv3 项目,不能把未授权代码直接复制进来;只能基于功能行为、接口语义和独立实现完成 clean-room 适配。 - -### 0.1 决策摘要 - -| 决策 | 结果 | 原因 | -|---|---|---| -| 追更是否复用 `Keep` | 不复用 | `Keep` 同时承载点播收藏、直播频道和频道收藏,语义不同;复用会破坏现有收藏行为 | -| 首版是否支持电影 | 不支持 | 电影没有持续“新集”语义,建议只对 TMDB `tv`、动漫、剧集、综艺等连续内容开放 | -| 官方元数据来源 | TMDB 优先,原站详情兜底 | TMDB 提供稳定季集、播出状态和下一集;原站详情只代表当前可播情况 | -| 数据库位置 | 独立 `FollowingDatabase` v1 | 不升 `AppDatabase` v45,便于禁用/回滚,事件隔离 | -| 后台调度 | WorkManager | 满足 Android 后台约束;不使用精确闹钟或常驻前台服务 | -| 自动换源 | 首版不做 | 首版只提示“当前源缺集”并允许用户手动换源;自动搜索/切换留给后续阶段 | -| 服务端联动 | 默认关闭、人工导入 | 依赖外部部署、网络和账号;不能成为本地追更的必要条件 | -| 首次添加是否提醒 | 不提醒 | 首次添加只建立基线,避免用户刚加入就收到“更新”通知 | - ---- - -## 1. 背景与目标 - -### 1.1 用户问题 - -当前 WebHTV 已支持收藏和观看历史,但没有稳定的“我追的剧更新了没有”状态。用户需要: - -- 加入“我的追更”,而不是只把作品放进收藏。 -- 知道官方已经播到哪一集、当前来源可播到哪一集。 -- 知道从上次观看后新增了几集,能一键继续看。 -- 在没有打开原站详情页时也能看到更新提醒。 -- 在手机端和电视端看到一致的状态。 -- 手机、电视和局域网同步设备之间不互相覆盖进度和提醒状态。 - -### 1.2 首版目标 - -首版必须完成: - -1. 从详情页加入/取消追更。 -2. 追更列表、更新筛选、继续播放、手动检查。 -3. TMDB 官方集数/状态/下一集解析。 -4. 当前来源可播集数探测,但必须与官方状态分开表达。 -5. 后台周期检查和一次性到点检查。 -6. 去重通知、站内角标和已读水位。 -7. 备份、恢复和跨设备同步。 -8. 手机端与电视端的基本功能一致。 -9. 一处可控的关闭开关,异常时可立刻停止后台检查。 - -### 1.3 非目标 - -首版不做: - -- 网盘自动挂载、转存、磁力离线、AList storage 管理。 -- 全自动跨站点换源和自动播放新集。 -- 以通知到达时间作为精确播出时间保证。 -- 电影追更、直播节目单追更、B站动态追更。 -- 账号级云追更、服务端统一身份体系。 -- 把 alist-tvbox 整个服务端嵌入 APK。 -- 对所有收藏记录自动执行追更扫描。 -- 在没有用户明确开启时向外部服务上传本地观看记录。 - ---- - -## 2. 参考项目评估 - -### 2.1 `atv-player` - -`atv-player` 的追更思想和 WebHTV 很接近,主要实现位于: - -- `src/atv_player/following_models.py` -- `src/atv_player/following_repository.py` -- `src/atv_player/following_update_service.py` -- `src/atv_player/controllers/following_controller.py` -- `src/atv_player/following_backend.py` -- `src/atv_player/ui/following_page.py` - -值得借鉴的行为: - -- 保存标题、原始标题、封面、评分、外部 ID、播放来源绑定和当前季集。 -- 保存“官方最新集”“上次检查时间”“下次检查时间”“上次错误”“未看更新数量”等检查状态。 -- 启动后延迟检查,普通时段约 6 小时一次,更新窗口缩短间隔;WebHTV 不能照搬其桌面 QTimer 频率。 -- 首次添加建立基线,不把“首次发现的最新集”当作新更新。 -- 更新提醒有已读/忽略水位,避免每轮重复提示。 -- 播放记录可以反向更新追更进度。 -- 可以从 alist-tvbox 的服务端订阅导入本地追更,并读取 `currentEpisodes` 作为服务端可播集数。 - -相关提交(完整 40 位 ID): - -| 提交 | 说明 | -|---|---| -| `73a0e2ed6458d55f85fe71bae55b56853abf98cb` | 新增追更仓库 | -| `74a8246d50c8be89a7f4670d4c3574a27b9d1889` | 新增追更更新服务 | -| `0176b70f432bdad137174a9e4061fbd2cdaa18a3` | 新增追更控制器 | -| `e77886fbc8694a28f98669a0716738c52fe3a429` | 避免错误追更更新提示 | -| `5d8fff942c3c5340767859fa619b1d40031f56bc` | 接入 alist-tvbox 追剧信号并支持直达网盘播放 | -| `b20f76bcdffdab8cb9615c3696e198a3795ecbed` | 服务端追剧订阅一键导入本地追更 | -| `a9c79fa8d2a3436e1656585eab41eba23601177b` | 修复开启联动后检查更新未走后端 | -| `fea2e3680590da7fb31e27d0e37df700de830684` | 追剧播放会话接入元数据刮削和来源白名单 | - -### 2.2 `alist-tvbox` - -`alist-tvbox` 的追更是服务端资源编排系统,核心文件包括: - -- `src/main/java/cn/har01d/alist_tvbox/entity/MediaSubscription.java` -- `src/main/java/cn/har01d/alist_tvbox/service/MediaSubscriptionService.java` -- `src/main/java/cn/har01d/alist_tvbox/service/MediaSubscriptionCheckService.java` -- `src/main/java/cn/har01d/alist_tvbox/service/MediaSubscriptionNotificationService.java` -- `src/main/java/cn/har01d/alist_tvbox/web/MediaSubscriptionController.java` - -其系统包含: - -- 服务端订阅实体和 `next_check_time` 调度。 -- AList/网盘分享资源搜索、候选池、挂载、转存和换源。 -- 缺集检测、固定挂载路径、播放历史关联和通知事件。 -- TVBox 播放分类和伪播放 id。 -- `/api/media-subscriptions` 等管理接口。 - -起始提交: - -| 提交 | 说明 | -|---|---| -| `83fa30ea4411d5ec79391d393082880bdaf4c909` | 追剧订阅(自动追更)系统 | - -适合借鉴: - -- “官方元数据”和“资源可播状态”分离的思想。 -- `next_check_time`、退避、播后短轮和资源状态模型。 -- 追更记录与播放历史的关系。 -- 可选的服务端订阅导入契约。 - -不适合直接搬入首版: - -- AList、网盘账号、挂载、转存、候选池和自动换源。 -- 服务端 Spring/数据库/调度依赖。 -- 服务端固定挂载路径和伪播放 id 协议。 -- 无 License 的代码。 - -### 2.3 许可证结论 - -截至 2026-09-19,两个 GitHub 仓库 API 均返回 `license: null`,仓库根目录也没有 `LICENSE`/`COPYING` 文件。结论: - -- 不复制、粘贴、翻译或机械改写其源码。 -- 只保留公开行为、系统接口和可验证的架构思想。 -- WebHTV 实现必须由本项目作者独立编写,并在代码评审中记录该边界。 -- alist-tvbox 的 HTTP 接口只作为可选的互操作协议,不把其 Java 实现带入 APK。 - ---- - -## 3. 当前 WebHTV 实现核查 - -### 3.1 现有基础设施 - -| 能力 | 当前位置/符号 | 追更复用方式 | -|---|---|---| -| 收藏 | `bean/Keep.java`、`KeepDao`、mobile/leanback `KeepActivity` | 不复用数据表,只参考 UI 和收藏入口 | -| 历史主键 | `History` 的 `siteKey@@@vodId@@@cid` | 作为无 TMDB 时的来源回退身份 | -| TMDB 历史身份 | `History.tmdbId`、`mediaType`、`tmdbSeasonNumber`、`tmdbEpisodeNumber` | 作为跨来源播放进度主身份 | -| 分季进度 | `bean/TmdbSeasonProgress.java`、`TmdbSeasonProgressDao` | 作为播放进度的权威来源之一 | -| TMDB HTTP/缓存 | `service/TmdbService.java` | 官方集数、状态、下一集和季详情 | -| TMDB 集数策略 | `ui/helper/TmdbEpisodeInfo.java` | 复用 `last_episode_to_air`、`next_episode_to_air` 解析,但补齐强制刷新和持久化 | -| 站点详情 | `api/SiteApi.detailContent(key,id,refresh)` | 探测当前来源可播集数 | -| 来源详情缓存 | `utils/VodDetailCache.java` | 追更探测必须支持绕过过期缓存 | -| 全局搜索 | mobile/leanback `SearchActivity`、`CollectActivity` | 首版用于用户手动换源,不自动批量搜索 | -| 播放进度保存 | `History.save()`、`TmdbSeasonProgressStore.write()` | 变更后投影到追更 DB,或下次检查时重建 | -| 数据库 | `AppDatabase.VERSION = 45`、Room 45 schema | 首版不改 `tv` 数据库版本,新增独立数据库 | -| 备份 | `bean/Backup.java`、`utils/AppBackup.java` | 增加 following 部分的序列化/恢复 | -| 同步选项 | `bean/SyncOptions.java`、mobile/leanback `SyncDialog` | 增加 `follow` 类别 | -| 后台线程 | `utils/Task.java` | 手动检查、探测和投影复用线程池 | -| 通知 | `utils/Notify.java`、Manifest `POST_NOTIFICATIONS` | 新建追更通知 channel,不混用通用通知 | - -### 3.2 现有缺口 - -1. `Keep` 没有追更水位、官方集数、下一集、来源探测和调度字段。 -2. `TmdbService.detail()` 当前 detail 缓存最长可到 7 天;在线剧集不能直接依赖默认缓存判断“刚刚更新”。追更刷新必须显式 force refresh,或提供专用的刷新 API。 -3. 当前没有追更后台 Worker,也没有独立的下一集检查队列。 -4. `History` 有 TMDB 进度,但没有“官方播出”与“当前来源可播”分离状态。 -5. `AppDatabase` 单库升级会扩大迁移影响面;首版不应把追更表直接塞进 `tv` 数据库。 -6. `alist-tvbox` 的导入接口和本地追更模型没有映射层。 - -### 3.3 已有设计文档的边界 - -- `docs/unified-media-identity-cross-site-resume.md` 仍是未完全实现的统一媒体身份设计;追更首版不依赖它完成,而是复用现有 `History.tmdbId/mediaType/tmdbSeasonNumber/tmdbEpisodeNumber`。 -- `docs/tmdb-playable-episode-availability-design.md` 解决的是 TMDB 季度与线路可播季度映射;追更只调用该结论,不重构详情页季度模型。 -- `docs/update-history-design.md` 的“历史版本更新”是 APK 更新,不是剧集追更,不能复用其概念。 - ---- - -## 4. 领域术语与状态语义 - -### 4.1 术语 - -| 术语 | 定义 | -|---|---| -| 追更记录 | 一个“作品/季”的用户追更单元,首版建议一条记录对应一个 `seriesKey + seasonNumber` | -| 官方已播出 | TMDB 或原站元数据认为已经上线、可以观看的剧集 | -| 当前源可播 | 当前绑定的 WebHTV 站点/Vod/Flag 实际返回的剧集范围 | -| 本地观看进度 | `History` 和 `TmdbSeasonProgress` 中最后一次真实观看的季/集/进度 | -| 更新水位 | 用户已经看过、已点开、已提醒或已忽略到的剧集号 | -| 可播缺口 | 官方已播出集数大于当前来源可播集数 | -| 新集提醒 | 官方已播出集数超过提醒水位且用户未读的状态 | - -### 4.2 三种状态不能混为一谈 - -```text -TMDB 官方状态 = 该剧集是否已经播出、总集数、下一集 - ≠ -当前站源状态 = 当前站点/Vod/Flag 实际上能播放到第几集 - ≠ -本地观看状态 = 用户实际看到了第几集、播放位置和时长 -``` - -UI 文案示例: - -- 官方:`已更新至第 24 集` -- 当前源:`当前源可播至第 20 集` -- 用户:`你看到第 18 集,还有 6 集未看` - -### 4.3 更新判定公式 - -针对追更记录所跟踪的季: - -```text -released = 官方已播出集数(季内序号) -watched = 用户已观看到的季内集号 -read = 用户已读/已忽略水位 -notified = 最近一次通知水位 - -unwatched = max(0, released - max(watched, read)) -hasUpdate = released > max(watched, read) -shouldNotify = notified > 0 && released > max(notified, read) && notifyEnabled -``` - -首次添加: - -```text -released > 0 时: - read = released - notified = released - hasUpdate = false - unwatched = max(0, released - watched) -``` - -这样可以避免“用户刚添加就被通知所有历史集数”的错误。 - -### 4.4 完结/取消/未开播 - -- `RETURNING`:继续检查;允许通知。 -- `PLANNED`:不把未来集算成已更新;可以安排下一集检查。 -- `ENDED`:默认不再通知;如果后续官方重新回到在播或新增特别篇,则重新进入检查。 -- `CANCELED`:默认不再通知;UI 显示取消状态。 -- `UNKNOWN`:保留记录,使用原站详情兜底,但降低检查频率。 - ---- - -## 5. 功能需求 - -### 5.1 用户故事 - -#### US-01 添加追更 - -用户在 TMDB 剧集详情页点击“追更”,系统保存作品、季、来源和当前进度基线;首次添加不发送“有新集”通知。 - -#### US-02 看到更新 - -用户打开“我的追更”,看到: - -- 官方已更新到哪一集。 -- 当前来源可播到哪一集。 -- 自己看到哪一集。 -- 新增几集、是否已读。 - -#### US-03 继续观看 - -用户点击“继续播放”,系统按现有 `History`/`TmdbSeasonProgress` 恢复到同一媒体、季、集和进度。 - -#### US-04 检查更新 - -用户点击“检查更新”,系统立即检查当前记录并显示结果;检查失败不能清空旧数据。 - -#### US-05 处理来源缺集 - -官方有更新但当前源缺集时,系统显示缺口,并提供现有全局搜索/换源入口;首版不自动切换。 - -#### US-06 处理提醒 - -用户可以选择“知道了”或“忽略到下一集”;同一集不重复通知。 - -#### US-07 同步 - -用户在一键同步中勾选追更后,作品身份、观看水位和可播状态能跨设备合并;本机的 `nextCheckAt` 和错误信息不应被远端旧值覆盖。 - -#### US-08 关闭功能 - -用户关闭追更开关后,后台 Worker 取消或停止安排新检查;已有记录保留,重新打开后继续。 - -### 5.2 功能验收编号 - -| 编号 | 要求 | 必须留下的证据 | -|---|---|---| -| F-01 | 详情页可添加/取消追更 | UI 源结构测试 + 真机点击 | -| F-02 | 首次添加不产生新集通知 | 策略单测 + 通知 fake | -| F-03 | 官方集数更新可被识别 | TMDB fixture + 策略单测 | -| F-04 | 官方更新和当前源可播分离 | 详情页/列表 fixture + UI 结构测试 | -| F-05 | 同一媒体不同来源不重复建记录 | 身份单测 + DAO 单测 | -| F-06 | 继续播放复用现有 History/TMDB 进度 | 播放恢复单测 + 真机 | -| F-07 | 手动检查失败保留旧快照 | 异常单测 + 数据库 DAO 测试 | -| F-08 | 后台周期检查不依赖精确闹钟 | Scheduler 单测 + WorkManager 测试 | -| F-09 | 通知去重到集号 | 通知 fake 单测 | -| F-10 | 手机端底部导航、电视端首页按钮均提供与点播/直播/设置同级的一级入口,并能打开追更列表 | 入口源结构测试 + 两端真机 | -| F-11 | 备份恢复保留追更 | Backup 单测 + 恢复测试 | -| F-12 | 同步不会用旧数据覆盖新数据 | merge 单测 | -| F-13 | 关闭开关停止后台检查 | Scheduler 单测 | -| F-14 | 现有收藏/历史/播放无回归 | 现有相关测试集 | -| F-15 | 不修改 `AppDatabase` v45 schema | `git diff` 检查 + Room schema 检查 | - -### 5.3 非功能要求 - -- **性能**:列表首屏不触发 N 次网络请求;默认只读本地数据库。 -- **耗电**:后台每轮最多检查 5 条到期记录;默认 Wi-Fi 或任意已连接网络时执行,不保证准时。 -- **网络**:单条失败不能导致整轮 Worker 重试风暴;只在全局网络失败时返回 `Result.retry()`。 -- **安全**:只保存媒体身份、用户进度和来源标识;不保存站点 Cookie、token 或密码到追更表。 -- **兼容**:`minSdk 24`、`targetSdk 28` 保持现状;WorkManager 与 Android 13+ 通知权限按运行时能力判断。 -- **可回滚**:功能开关关闭即可停止新逻辑;独立数据库可保留,不影响旧客户端打开 `tv` 数据库。 -- **可维护**:纯策略类不依赖 Android UI,Worker、DAO、元数据客户端和通知器可以替换实现。 - ---- - -## 6. 总体架构 - -### 6.1 组件图 - -```text -详情页 / 播放页 - └─ FollowingAction -> FollowingStore.addFromDetail() - └─ FollowingDatabase (following.db) - ├─ Following - └─ FollowingSource - -后台 / 手动检查 - └─ FollowingUpdateCoordinator - ├─ FollowingMetadataClient -> TmdbService - ├─ FollowingSourceProbe -> SiteApi.detailContent(refresh=true) - ├─ FollowingUpdatePolicy (pure) - ├─ FollowingStore (DAO) - └─ FollowingNotifier - -播放历史 - └─ History.save() / TmdbSeasonProgressStore.write() - └─ FollowingProgressProjector -> FollowingStore.updateWatched() - -备份 / 同步 - └─ Backup -> FollowingDatabase export / import / merge -``` - -### 6.2 分层职责 - -#### Presentation - -- `FollowingActivity`:手机端和电视端追更列表。 -- `FollowingDetailDialog`/详情面板:显示官方状态、来源状态、进度和操作。 -- `FollowingAdapter`:卡片、更新角标、进度文案、错误状态。 -- `FollowingAction`:详情页/播放页的添加、取消和状态刷新。 - -#### Domain - -- `FollowingIdentity`:生成和解析 `identityKey`/`seriesKey`。 -- `FollowingUpdatePolicy`:纯函数计算 latest、unwatched、hasUpdate、shouldNotify、nextCheckAt。 -- `FollowingSchedulePolicy`:计算周期、一次性检查和退避时间。 -- `FollowingMergePolicy`:备份/同步冲突解决。 - -#### Data - -- `FollowingDatabase`:独立 Room 数据库。 -- `FollowingDao`、`FollowingSourceDao`:查询和事务更新。 -- `FollowingStore`:业务 facade,隐藏 DAO 细节。 -- `FollowingMetadataClient`:读取 TMDB 并解析标准化快照。 -- `FollowingSourceProbe`:读取当前站点详情并提取可播范围。 -- `FollowingProgressProjector`:把 `History` 进度投影到追更记录。 - -#### Infrastructure - -- `FollowingUpdateWorker`:WorkManager Worker。 -- `FollowingScheduler`:注册周期、一次性检查、取消任务。 -- `FollowingNotifier`:通知 channel、权限、去重和点击跳转。 -- `FollowingBackupCodec`:备份和同步序列化适配。 - ---- - -## 7. 数据设计 - -### 7.1 为什么使用独立数据库 - -选择 **独立 `FollowingDatabase` v1**,而不是直接把追更表加入 `AppDatabase`: - -- `AppDatabase` 当前版本为 45,升级会触发所有用户的迁移路径。 -- `AppDatabase` 使用 `fallbackToDestructiveMigration(true)`;回滚旧 APK 时数据库降级风险过大。 -- 追更首版是不需要与 `History` 保持跨表事务的附属功能,适合独立演进。 -- 独立数据库可保留在设备上;关闭功能或回滚 APK 不会影响核心收藏、历史和配置。 -- `History` 仍是播放进度权威源,追更表可以随时从历史重建,不需要分布式事务。 - -代价: - -- 不能做跨数据库 SQL JOIN。 -- 备份和同步需要显式处理 following 部分。 -- 播放进度投影是“最终一致”而不是单事务强一致。 - -这些代价是可接受的,因为追更列表不是支付、登录或关键配置数据。 - -### 7.2 独立数据库定义 - -建议新增: - -```java -@Database( - entities = {Following.class, FollowingSource.class}, - version = 1, - exportSchema = true -) -public abstract class FollowingDatabase extends RoomDatabase { - public static final String NAME = "following"; - public abstract FollowingDao getFollowingDao(); - public abstract FollowingSourceDao getFollowingSourceDao(); -} -``` - -数据库文件名为 `following.db`,schema 导出到: - -```text -app/schemas/com.fongmi.android.tv.following.FollowingDatabase/1.json -``` - -数据库创建使用单独的 `Room.databaseBuilder()`,不注册到 `AppDatabase.create()`。 - -### 7.3 `Following` 实体 - -一条记录对应一个 `seriesKey + seasonNumber`。首版不自动扩展到新季;新季可由用户从详情页加入,后续版本再做自动建季。 - -建议字段: - -```text -identity_key TEXT PK 稳定唯一键 -series_key TEXT 作品级分组键 -cid INTEGER 来源配置 id -site_key TEXT 当前首选站点 -vod_id TEXT 当前首选 vod id -vod_name TEXT 标题快照 -vod_pic TEXT 封面快照 -media_type TEXT tv / anime / variety / documentary -tmdb_id INTEGER 无 TMDB 时为 0 -tracked_season INTEGER 追踪季号 -tracked_episode INTEGER 最近定位集号,可为 0 -watched_season INTEGER 已观看季号,可为 0 -watched_episode INTEGER 已观看集号,可为 0 -position INTEGER 播放位置 ms -duration INTEGER 时长 ms -official_status TEXT UNKNOWN/RETURNING/PLANNED/ENDED/CANCELED -latest_released_season INTEGER 官方最新已播季 -latest_released_episode INTEGER 官方最新已播集 -season_total_episodes INTEGER 当前追踪季总集数 -season_released_episodes INTEGER 当前追踪季已播集数 -series_total_episodes INTEGER 全剧总集数 -next_air_season INTEGER 下一集季号 -next_air_episode INTEGER 下一集集号 -next_air_at INTEGER 预计播出时间 ms,无法确定时为 0 -last_observed_episode INTEGER 上次成功检查看到的最新集 -read_watermark_episode INTEGER 用户已读/已忽略水位 -last_notified_episode INTEGER 最近通知水位 -last_notified_at INTEGER 最近通知时间 -has_update INTEGER 是否存在未读更新 -unwatched_count INTEGER 未看集数,派生快照 -notify_enabled INTEGER 此记录是否允许通知 -enabled INTEGER 是否参与后台检查 -metadata_updated_at INTEGER 官方快照更新时间 -last_checked_at INTEGER 最近检查时间 -next_check_at INTEGER 下次到期时间 -failure_count INTEGER 连续失败次数 -last_error TEXT 最近错误摘要 -created_at INTEGER -updated_at INTEGER -``` - -设计说明: - -- `has_update` 和 `unwatched_count` 是为了列表查询方便而缓存的派生值,权威计算仍由 `FollowingUpdatePolicy` 决定。 -- `watched_*` 是 `History`/`TmdbSeasonProgress` 的投影缓存,不是独立编辑源。 -- `last_notified_episode` 与 `read_watermark_episode` 必须分开,前者控制通知去重,后者控制角标已读。 -- `last_error` 仅保存短文本,不保存完整 URL、Cookie 或 HTML。 - -### 7.4 `FollowingSource` 实体 - -```text -following_key TEXT 关联 following.identity_key -cid INTEGER 来源配置 id -site_key TEXT Site key -vod_id TEXT Vod id -vod_name TEXT 来源标题快照 -vod_pic TEXT 来源封面快照 -vod_flag TEXT 最后成功播放线路 -playable_season INTEGER 来源可播季号 -playable_episode INTEGER 来源可播最新集号 -playable_count INTEGER 来源可播集数 -preferred INTEGER 是否首选来源 -last_probe_at INTEGER 最近探测时间 -last_error TEXT 来源探测错误 -PRIMARY KEY (following_key, cid, site_key, vod_id) -``` - -使用独立来源表的原因: - -- 同一部 TMDB 剧集可能从多个站点添加,来源 URL 不能只保存一个。 -- 用户手动换源后,可以保留旧来源作为回退候选。 -- 首版虽然不自动换源,但探测和手动切换需要区分来源。 - -### 7.5 身份键规则 - -#### TMDB 可用 - -```text -seriesKey = "tmdb:" + mediaType + ":" + tmdbId -identityKey = seriesKey + ":s" + trackedSeason -``` - -示例: - -```text -seriesKey = tmdb:tv:1399 -identityKey = tmdb:tv:1399:s1 -``` - -#### TMDB 不可用 - -```text -seriesKey = "source:" + cid + ":" + siteKey + ":" + vodId -identityKey = seriesKey + ":s" + trackedSeason -``` - -规则: - -- `mediaType`、`siteKey`、`vodId` 先做 trim 和大小写规范化。 -- TMDB 记录永远不能因标题变化而换 key。 -- 无 TMDB 记录只允许在当前 `cid + siteKey + vodId` 内匹配;标题只用于展示和辅助搜索,不作为唯一身份。 -- 如果一个无 TMDB 记录后来获得了 TMDB 匹配,执行显式迁移事务,合并来源和提醒水位,不保留两个可见记录。 - -### 7.6 建议索引 - -```text -Following: - PK(identity_key) - INDEX(series_key) - INDEX(enabled, next_check_at) - INDEX(has_update, updated_at) - INDEX(cid, site_key, vod_id) - INDEX(tmdb_id, media_type, tracked_season) - -FollowingSource: - PK(following_key, cid, site_key, vod_id) - INDEX(following_key, preferred) - INDEX(cid, site_key, vod_id) -``` - -所有“到期检查”查询必须使用 `(enabled, next_check_at)`,不能在内存中全表扫描。 - ---- - -## 8. 独立数据库与现有系统集成 - -### 8.1 为什么不改 `AppDatabase` - -本设计**不要求**: - -- 修改 `AppDatabase.VERSION = 45`。 -- 修改 `AppDatabase` 的 entity 列表。 -- 增加 45→46 `Migrations` 条目。 -- 重导出 46 schema。 - -这使 FOLLOW-1 的核心数据层可以与播放器、历史、收藏和配置迁移解耦。 - -### 8.2 播放进度投影 - -在 `History.save()` 成功写入后,执行一个 best-effort 投影: - -```text -History.save() - -> 原有 tv 数据库事务提交 - -> FollowingProgressProjector.project(history) - -> 若 tmdbId/mediaType/season 命中 Following,更新 watched_* 和 position/duration - -> 否则按 cid/siteKey/vodId 命中无 TMDB 记录 - -> 无匹配记录时直接返回,不创建追更 -``` - -约束: - -- 投影失败不能影响播放、历史保存或应用退出。 -- 投影使用单线程队列,避免并发写 following DB。 -- Worker 每次检查前对 due 记录执行一次进度 reconcile,修复投影丢失。 -- 删除历史记录时,不把追更记录的 `watched_*` 立刻清零;下一次 reconcile 从剩余历史重建。若没有剩余历史,则保留最后一次投影并标记 `progressStale=true`(可在后续版本加入字段,或首版保持缓存值并允许用户重新标记)。 - -### 8.3 详情页集成 - -在 `TmdbDetailActivity` 和 mobile/leanback `VideoActivity` 的详情头部增加“追更”动作: - -- 已追更:显示“已追更”,点击进入追更详情。 -- 未追更:显示“加入追更”,点击保存当前季和当前来源。 -- 失败:不清空页面,显示可恢复错误。 -- 非 TV/非连续内容:隐藏按钮。 - -按钮状态使用 `FollowingStore.find(identityKey)`,不能仅凭运行时内存标记。 - -### 8.4 播放页集成 - -播放页不负责检查网络更新,只负责: - -- 当前播放来源绑定到 `FollowingSource`。 -- 在既有 `History.save()`/`TmdbSeasonProgressStore.write()` 完成后触发投影。 -- 如果当前媒体属于追更记录,返回详情页时刷新追更卡片。 - ---- - -## 9. 元数据与更新检查设计 - -### 9.1 `FollowingMetadataSnapshot` - -所有元数据先转换为统一快照: - -```java -public final class FollowingMetadataSnapshot { - public String source; // tmdb / source / alist - public String status; // UNKNOWN/RETURNING/PLANNED/ENDED/CANCELED - public int latestReleasedSeason; - public int latestReleasedEpisode; - public int seasonTotalEpisodes; - public int seasonReleasedEpisodes; - public int seriesTotalEpisodes; - public int nextAirSeason; - public int nextAirEpisode; - public long nextAirAt; - public long fetchedAt; -} -``` - -快照不保存完整 TMDB JSON、剧照列表或用户不可见的原始响应。 - -### 9.2 TMDB 主路径 - -条件: - -```text -Following.tmdbId > 0 && Setting.isTmdbReady() -``` - -实现要求: - -1. 调用 `TmdbService` 的 TV detail API。 -2. 为追更检查提供强制刷新入口,不能复用 `DETAIL_CACHE_TTL = 7 days` 的旧详情来判断更新。 -3. 优先读取: - - `status` - - `number_of_seasons` - - `number_of_episodes` - - `last_episode_to_air` - - `next_episode_to_air` - - `seasons[]` -4. 必要时只请求当前追踪季的 season detail,不遍历全部季。 -5. 解析 `next_episode_to_air.air_date` 时,如果只有日期没有具体时间,统一按本地 20:00 计算“预计检查时间”,UI 显示日期而非承诺精确到分钟。 - -推荐新增 `TmdbService.detailForFollowing(item, config)` 或等价方法,内部使用 `refresh=true` 和较短的在线剧集 TTL;不要直接改变现有详情页缓存语义。 - -### 9.3 原站兜底 - -条件: - -```text -tmdbId <= 0 || TMDB 不可用 || 用户关闭 TMDB -``` - -调用: - -```java -SiteApi.detailContent(siteKey, vodId, true) -``` - -解析来源的 `Vod.flags` 和 `Episode`: - -- 优先使用已绑定的 TMDB season/episode 元数据。 -- 无 TMDB 元数据时使用现有 `Episode.getNumber()`、`Util.getEpisodeNumber()` 和标题规则。 -- 当前来源可播集数不能直接当成官方总集数。 -- 解析结果必须标记 `source=source-probe`,不能覆盖 TMDB 官方状态。 - -### 9.4 当前来源可播探测 - -默认策略: - -- 新添加时执行一次轻量探测。 -- 官方发现新集后探测首选来源。 -- 用户手动“检查更新”时探测首选来源。 -- 不在周期 Worker 中默认探测所有来源。 - -提取规则: - -```text -playableCount = 当前来源中匹配 trackedSeason 的有效集数 -playableEpisode = 当前来源中匹配 trackedSeason 的最大有效集号 -``` - -要求: - -- 结果去重排序,忽略空 URL、重复集和无效占位线路。 -- 多线路时先使用当前 `FollowingSource.vodFlag`,其次选择覆盖集数最大的线路。 -- 单个来源失败只更新该来源的 `last_error`,不改变其他来源和官方快照。 - -### 9.5 更新检查算法 - -```text -1. 读取到期 Following(enabled=1, next_check_at<=now, limit=5) -2. 按记录计算 provider: - 2.1 TMDB 可用 -> 刷新官方快照 - 2.2 否则 -> 刷新当前来源快照 -3. 如果 provider 失败: - 3.1 failure_count += 1 - 3.2 next_check_at = now + backoff(failure_count) - 3.3 保留旧快照并记录 last_error - 3.4 continue -4. 执行进度 reconcile(从 History/TmdbSeasonProgress 读取) -5. 计算 latestReleased、seasonReleased、unwatched、hasUpdate -6. 如果需要,探测首选来源的可播范围 -7. 事务写回 Following/FollowingSource -8. 计算 shouldNotify -9. 发送通知并更新 last_notified_episode -10. 计算 next_check_at -``` - -### 9.6 多季与季切换 - -首版记录按季创建: - -- 用户在详情页选择的季就是 `tracked_season`。 -- 官方出现新季时,旧记录的 `latest_released_season` 会变化,但不能把旧记录强行改成新季。 -- UI 在作品分组上显示“新一季已开播”,提供“追新季”动作。 -- 追新季创建新 `identityKey`,保留旧季记录和历史。 - -后续版本可增加: - -- 自动创建新季记录。 -- 在旧季完结且用户已看完时自动切换 `trackedSeason`。 -- 多季合并卡片。 - -### 9.7 强制刷新策略 - -不能用简单的“每次 Worker 都 force refresh”: - -- 对 TMDB 网络有成本。 -- 对站点 Spider 有成本和副作用。 -- 对完结剧没有必要。 - -建议: - -| 状态 | 周期 | 是否强制网络 | -|---|---|---:| -| `RETURNING` | 6 小时 | 是 | -| `PLANNED` | 24 小时或下一集日期 | 是,频率降低 | -| `ENDED` | 7 天轻查 | 否,优先缓存;只有用户手动检查时强制 | -| `CANCELED` | 7 天轻查或停止 | 否 | -| 下一集日期已过 30 分钟 | 一次性检查 | 是 | -| 用户手动检查 | 立即 | 是 | - ---- - -## 10. 后台调度设计 - -### 10.1 依赖 - -在 `gradle/libs.versions.toml` 增加稳定版 WorkManager 条目,建议使用 `androidx.work:work-runtime:2.11.2`;不要选择 `2.12.0-rc01` 这类预发布版本。 - -在 `app/build.gradle` 增加: - -```groovy -implementation libs.work.runtime -testImplementation libs.work.testing -androidTestImplementation libs.work.testing -``` - -最终版本以实际 Gradle 解析和 APK 体积为准;如果媒体库已经传递引入兼容版本,仍应显式声明并锁定版本。 - -### 10.2 周期任务 - -```text -唯一任务名:webhtv.following.periodic -周期:6 小时 -flex:1 小时 -约束:NetworkType.CONNECTED -退避:EXPONENTIAL,初始 15 分钟 -``` - -实现要点: - -```java -PeriodicWorkRequest request = - new PeriodicWorkRequest.Builder( - FollowingUpdateWorker.class, - 6, TimeUnit.HOURS, - 1, TimeUnit.HOURS) - .setConstraints(new Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build()) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES) - .build(); - -WorkManager.getInstance(context) - .enqueueUniquePeriodicWork( - FollowingScheduler.PERIODIC_WORK, - ExistingPeriodicWorkPolicy.UPDATE, - request); -``` - -### 10.3 一次性任务 - -用途: - -- 新添加后的首次检查。 -- 下一集预计播出后的检查。 -- 用户手动检查。 - -一次性任务也要使用唯一名称,避免重复排队: - -```text -webhtv.following.one-shot -webhtv.following.one-shot. -``` - -规则: - -- 下一集时间不可用时不设置一次性任务。 -- 一次性任务至少延迟 15 分钟;如果刚完成检查,至少延迟 1 小时。 -- 同一记录已有未完成的 one-shot 时使用 `KEEP`,不重复创建。 -- 关闭追更后取消周期任务和 one-shot 任务。 - -### 10.4 前台/手动检查 - -手动检查不依赖 WorkManager 完成 UI 反馈。实现可以使用 `Task.execute()` 调用同一个 `FollowingUpdateCoordinator.checkNow(id)`,由 EventBus 或 callback 通知 UI。要求: - -- 同一记录同一时间只运行一个检查。 -- UI 显示进行中状态。 -- 捕获所有异常,显示错误但保留旧快照。 -- 不用主线程做网络或 Room 查询。 - -### 10.5 Android 系统约束 - -以下约束必须写入实现和验收: - -- WorkManager 周期最小 15 分钟,且实际运行时间由系统、Doze、网络和厂商策略决定。 -- Doze 或 App Standby 会推迟作业和网络。 -- 本设计不申请 `SCHEDULE_EXACT_ALARM`,不使用 `AlarmManager.setExact()` 保证分钟级提醒。 -- 应用被杀后 WorkManager 依赖系统持久调度,不能假设立刻恢复。 -- 用户重新打开应用时必须执行一次 due check,弥补错过的窗口。 -- Worker 不启用前台服务,避免为追更增加常驻通知。 - -### 10.6 退避策略 - -```text -第 1 次失败:15 分钟 -第 2 次失败:30 分钟 -第 3 次失败:1 小时 -第 4 次失败:2 小时 -第 5 次失败:6 小时 -之后:24 小时上限 -``` - -成功检查将 `failure_count` 归零。HTTP 401/403 和配置缺失等永久错误不进入无限重试,直接记录并等待用户修复。 - ---- - -## 11. 通知与站内角标 - -### 11.1 通知 channel - -建议新增: - -```text -channelId: webhtv.following.updates -名称:追更更新 -重要性:DEFAULT(允许用户自行关闭) -``` - -### 11.2 权限 - -- Android 13/API 33+ 使用 `POST_NOTIFICATIONS` 运行时权限。 -- 只在用户首次开启“追更通知”时请求,不在应用启动时索要。 -- 用户拒绝后,站内角标、列表状态和手动检查继续工作。 -- 通知权限检查使用 `NotificationManagerCompat.areNotificationsEnabled()` 和平台权限 API,不能只看 Manifest 声明。 - -### 11.3 去重与内容 - -通知 id 使用 `identityKey.hashCode()`,更新同一记录时替换旧通知。 - -文案示例: - -```text -《示例剧》更新至第 12 集 -官方已更新至第 12 集,当前源可播至第 10 集 -``` - -点击行为: - -- 点击单条通知:打开 `FollowingActivity` 并定位该记录。 -- 点击摘要:打开追更列表。 -- 当前应用前台时不强制发送系统通知,可只显示站内事件;具体前台抑制应复用现有播放/活动状态,不能在 Worker 中直接持有 Activity。 - -### 11.4 水位更新 - -| 用户动作 | 更新字段 | -|---|---| -| 首次添加 | `read_watermark_episode = released`、`last_notified_episode = released` | -| 打开列表/卡片 | `read_watermark_episode = max(current, released)`,清除角标 | -| 点击忽略到下一集 | `read_watermark_episode = released`,不清除历史 | -| 发送通知成功 | `last_notified_episode = released`、`last_notified_at = now` | -| 看完最新集 | 由进度投影更新 `watched_episode`,角标由策略自动清除 | - ---- - -## 12. UI/UX 设计 - -### 12.1 入口层级与位置 - -入口已确认采用**一级入口**,不做成设置页二级项,也不只放在“更多”菜单中。手机端与电视端的“追更”均和“点播、直播、设置”处于同一主导航层级;详情页另保留上下文操作。 - -| 端 | 入口层级 | 建议位置 | 打开方式 | -|---|---|---|---| -| Android Mobile | 底部导航一级项 | `点播 / 直播 / 追更 / 设置`;实验室显示时为 `点播 / 直播 / 追更 / 实验室 / 设置` | 点击底部“追更”打开 `FollowingActivity` | -| Android TV/Leanback | 首页功能按钮一级项 | `点播 / 直播 / 搜索 / 收藏 / 追更 / 推送 / 投屏 / 历史 / 设置 / 站点注入` | 点击首页“追更”打开 `FollowingActivity` | -| 详情页 | 上下文操作 | 顶部主操作区,显示“加入追更/已追更” | 当前作品没有记录时加入,有记录时打开追更详情 | -| 通知 | 深链入口 | 系统通知点击 | 打开 `FollowingActivity` 并定位对应记录 | - -一级入口的验收标准是:用户不需要先进入设置、收藏、历史或更多菜单,在主页一次点击即可到达“我的追更”。 - -#### Mobile 精确接入点 - -1. 在 `app/src/mobile/res/menu/menu_nav.xml` 增加 `R.id.following`,位置放在 `R.id.live` 与 `R.id.setting` 之间。 -2. 新增 `nav_following` 和追更图标;底部导航当前为无文字图标模式,图标必须有明确 content description。 -3. 在 `HomeActivity.setNavigation()` 中按 `following_enabled` 控制 `R.id.following` 可见性。 -4. 在 `HomeActivity.onNavigationItemSelected()` 中处理 `R.id.following`,调用 `FollowingActivity.start(this)`;首版沿用 `LiveActivity` 这种“底部导航项打开独立 Activity”的既有模式,不强行改成 `Fragment`。 -5. 返回主页时保持当前 `Vod`/`Setting` 选中态稳定,不能因为曾点击“追更”而让底部选中态错位。 -6. 使用 `BottomNavigationView` 的 badge 能力在 `R.id.following` 上显示未读新集数;0 时移除 badge,超过 99 显示 `99+`。 - -#### Leanback 精确接入点 - -1. 在 `HomeButton.all()` 使用尚未占用的稳定 id `8`,添加 `new HomeButton(8, R.string.home_following)`,排在 `home_keep` 与 `home_push` 之间。 -2. 将 `8` 加入 `HomeButton.ALL` 和 `getDefaultButtons()`,保证新安装默认可见、老用户升级后缺失项可以补入排序。 -3. 在 `Func.setDrawable()` 中为 `R.string.home_following` 映射独立图标;需要动态未读数时可像 `home_custom_csp` 一样只改变显示文本,不改变稳定 id。 -4. 在 `HomeActivity.onItemClick(Func)` 中处理 `R.string.home_following`,调用 `FollowingActivity.start(this)`。 -5. 保留“首页按钮”配置,用户可以隐藏或调整追更位置;这只改变显示顺序,不改变 id。 -6. `select_home_menu_key` 是遥控器快捷键动作表,首版不修改其 1–9 映射;“追更”先作为首页一级按钮提供,避免改变既有快捷键语义。 -7. 追更列表沿用 `KeepActivity` 类似的网格卡片与 D-pad 焦点规则。 - -#### 入口角标 - -- Mobile:底部“追更”显示未读新集数 badge;进入追更页并处理已读后清除。 -- Leanback:首页“追更”卡片显示 `追更 · 3` 这类文本后缀或等价角标;焦点移动时数字不得闪烁。 -- 两端角标只读 `following.db`,首页加载不得为追更角标发起 TMDB 或站点网络请求。 -- 详情页顶部“加入追更/已追更”继续保留;历史卡片可提供追更快捷动作,但不自动把历史变成追更。 - -### 12.2 列表筛选 - -```text -全部 -有更新 -未看完 -已完结 -检查失败 -``` - -列表排序: - -1. 有更新且未读。 -2. 有未看集数。 -3. 最近观看时间。 -4. 最近官方更新时间。 -5. 创建时间。 - -### 12.3 卡片字段 - -- 封面、标题、季号。 -- 更新角标:`新 3 集`。 -- 官方状态:`更新至 S2E8`。 -- 当前来源:`当前源 E6`。 -- 用户状态:`看到 E5` 或 `未记录进度`。 -- 错误状态:小图标,不展示长堆栈。 - -### 12.4 追更详情页 - -必须包含: - -- 标题、封面、简介、评分、播出状态。 -- 当前追踪季集数和已播集数。 -- 当前来源可播状态。 -- 继续播放。 -- 检查更新。 -- 标记已读/忽略到下一集。 -- 手动换源入口(复用现有全局搜索)。 -- 取消追更。 - -### 12.5 TV 焦点和可访问性 - -- 所有按钮必须可通过方向键和确认键到达。 -- 卡片焦点状态不得因更新角标闪烁。 -- 长标题使用 marquee,遵守现有列表行为。 -- 图标提供 content description。 -- 更新数量使用文本,不只依赖颜色。 - ---- - -## 13. 备份、恢复与跨设备同步 - -### 13.1 备份字段 - -在 `Backup` 中增加: - -```java -@SerializedName("following") -private List following; - -@SerializedName("followingSource") -private List followingSource; - -@SerializedName("followingSchemaVersion") -private Integer followingSchemaVersion; -``` - -要求: - -- 新备份写入 `followingSchemaVersion = 1`。 -- 旧备份缺少以下字段时不删除本机追更数据。 -- 全量恢复时,在 `FollowingDatabase` 事务内先删后插。 -- 部分恢复时按 `identityKey` 和来源主键 merge。 -- 恢复失败必须回滚 following 事务,不能留下半套记录。 - -### 13.2 同步冲突规则 - -| 字段类别 | 规则 | -|---|---| -| 身份、标题、封面、TMDB ID | 以 `metadataUpdatedAt` 更新者为新;缺失字段可补齐 | -| `watched_*`、position、duration | 取播放进度时间更新者;相同则取较大集号 | -| `read_watermark_episode` | 取最大值,避免设备已读后被旧设备重新标为未读 | -| `last_notified_episode` | 取最大值,避免多设备重复通知 | -| `enabled`、`notify_enabled` | 以显式用户状态为准,合并时保留“关闭”意图 | -| `next_check_at`、`failure_count`、`last_error` | 不参与同步,由各设备重新计算 | -| 来源绑定 | 合并唯一键;优先级/首选字段以更新时间更新者为新 | - -### 13.3 一键同步 UI - -在现有同步选项增加: - -```text -追更 -``` - -默认状态: - -- 首版默认关闭,避免用户未确认就把新数据发送到其他设备。 -- 用户开启后,远端协议和所有 serverless 实现要保留该布尔项,防止同步配置互相覆盖。 -- 如果现有同步以完整备份 ZIP 传递,不要求服务端理解追更表;如果服务端会规范化 options,则 Rust/Go/Cloudflare/Deno/Vercel 五种实现必须加入 `follow` 字段。 - -### 13.4 alist-tvbox 可选导入 - -该功能默认关闭,作为 P5 可选能力: - -```text -GET /api/media-subscriptions -GET /api/media-subscriptions/{id}/detail -POST /api/media-subscriptions -``` - -导入映射: - -| 服务端字段 | WebHTV 字段 | -|---|---| -| `name` | 标题候选 | -| `season` | `tracked_season` | -| `metaProvider/metaId` | TMDB/Bangumi 身份 | -| `doubanId` | 豆瓣辅助匹配 | -| `currentEpisodes` | `FollowingSource.playable_count` | -| `status` | 服务端状态展示 | - -约束: - -- 仅按 TMDB ID、豆瓣 ID、标题+季依次匹配。 -- 导入前先预览,不自动创建大量记录。 -- 服务端失效不能影响本地追更。 -- 不自动调用服务端的挂载、转存、磁力或自动换源动作。 -- token 使用现有安全存储/用户输入,不写入追更表。 - ---- - -## 14. 性能、隐私和安全 - -### 14.1 性能预算 - -- 500 条追更记录首屏本地查询目标小于 500 ms(中端设备,不含图片解码)。 -- Worker 每轮最多检查 5 条到期记录、最多并发 2 条。 -- 列表加载阶段不请求 TMDB,不请求站点详情。 -- 单个来源探测超过 20 秒视为失败,不能无限等待。 -- 通知和进度投影不能阻塞主线程。 - -### 14.2 包体和依赖 - -- 记录 WorkManager 引入前后的 release APK 压缩体积差异。 -- 目标增量不超过 1 MB;超过时必须给出依赖树和原因,不能默默接受。 -- 不因追更新增 native library、FFmpeg、MPV 或 AAR。 - -### 14.3 隐私 - -- 只保存媒体 ID、标题、图片、来源 key 和本地进度。 -- 不保存站点 Cookie、HTTP Authorization、WebHome token、网盘 token。 -- 备份和同步属于用户显式动作;默认不上传后台观察结果。 -- 日志中不输出完整 URL 查询参数和用户账号。 - -### 14.4 安全 - -- alist 导入 token 使用现有安全存储能力或仅保存在用户会话中。 -- 任何外部服务返回的标题、概述、URL 都必须限长、转义,不能直接作为 WebView HTML 注入。 -- 不执行服务端或 TMDB 返回的脚本。 - ---- - -## 15. 测试与验证计划 - -### 15.1 单元测试 - -建议新增: - -```text -FollowingIdentityTest -FollowingUpdatePolicyTest -FollowingSchedulePolicyTest -FollowingMergePolicyTest -FollowingMetadataSnapshotTest -FollowingSourceProbeTest -FollowingProgressProjectorTest -FollowingBackupTest -FollowingNotifierPolicyTest -``` - -必须覆盖: - -#### 身份 - -- TMDB 同剧不同站点合并为同一记录。 -- 同剧不同季不合并。 -- 无 TMDB 来源身份稳定。 -- 标题变化不改变 TMDB identity。 -- 空 key、非法 season、特殊季 0 的处理。 - -#### 更新策略 - -- 首次添加不通知。 -- 官方 8 集、已看 5 集,计算 3 集未看。 -- 官方 8 集、已读 8 集但未看 5 集,角标清除但未看计数仍可显示。 -- 官方状态回退或错误响应不能降低已有最新集。 -- `ENDED` 后不重复通知。 -- 未来播出集不计入已播。 -- 特别篇、电影和季 0 不误判为普通集。 -- 多季切换不会把旧季进度写入新季。 - -#### 调度 - -- 初次启用创建唯一周期任务。 -- 重复启用不会产生多个周期任务。 -- 关闭功能取消周期和 one-shot 任务。 -- 失败退避不超过 24 小时。 -- 下一集时间已过时只安排一次,不创建重复 one-shot。 - -#### 来源探测 - -- 多线路选择覆盖集数最大的线路。 -- 空 URL、重复集和无效占位项被过滤。 -- 当前来源缺集时官方状态仍更新。 -- 来源探测失败不清空官方快照。 - -#### 同步 - -- 旧备份缺少 following 字段不删本地数据。 -- 新备份空 following 按明确的“清空”语义处理。 -- read/notified 水位取最大。 -- nextCheckAt/lastError 不覆盖本机调度状态。 -- 合并后不出现重复 identity。 - -### 15.2 DAO 与数据库测试 - -新增测试: - -```text -FollowingDaoTest -FollowingSourceDaoTest -FollowingDatabaseTest -FollowingBackupCodecTest -``` - -检查: - -- `upsert` 不覆盖更大水位。 -- due 查询只返回 `enabled` 且 `next_check_at <= now`。 -- 来源表主键去重。 -- 删除追更同时删除来源,或由 facade 显式事务删除。 -- 备份导出/导入顺序正确。 -- Room `FollowingDatabase` version 1 schema 可导出。 - -### 15.3 迁移测试 - -首版不迁移 `AppDatabase`,但必须增加独立数据库的未来迁移测试骨架: - -- `FollowingDatabase` v1 空库可创建。 -- 通过完整备份恢复 v1 数据。 -- 如果后续升级 v2,使用 `MigrationTestHelper` 验证 v1→v2。 - -如果团队最终选择把追更表放进 `AppDatabase`,则必须改为: - -- `AppDatabase.VERSION = 46`。 -- 新增 `MIGRATION_45_46`。 -- 导出 `46.json`。 -- instrumentation 测试从 45 迁移到 46,并验证 Keep、History、TmdbSeasonProgress 不丢失。 - -本设计的默认实现不采用该路径。 - -### 15.4 Worker 与通知测试 - -使用 `work-testing` 的 `TestListenableWorkerBuilder` 或等价方式: - -- 注入 fake clock、fake metadata client、fake source probe。 -- 只检查 due 记录。 -- 单条错误不导致整轮失败。 -- 全局网络失败返回 `Result.retry()`。 -- 成功检查写入 `last_checked_at` 和 `next_check_at`。 -- 重复通知水位会抑制第二次通知。 -- 通知权限关闭时仍更新站内状态。 - -### 15.5 UI 源结构测试 - -移动端和电视端分别检查: - -- 详情页按钮只在 TV/连续内容显示。 -- Mobile `menu_nav.xml` 存在 `R.id.following`,且位于 `live` 与 `setting` 之间。 -- Mobile `HomeActivity.setNavigation()` 按 feature flag 控制追更项,`onNavigationItemSelected()` 能启动 `FollowingActivity`。 -- Mobile 追更 badge 能显示、更新和清除,且不会在首页触发网络请求。 -- Leanback `HomeButton.all()`、`ALL`、`getDefaultButtons()` 都包含 id `8`,顺序在收藏与推送之间。 -- Leanback `Func` 能映射追更图标,`HomeActivity.onItemClick()` 能启动 `FollowingActivity`。 -- Leanback 首页按钮配置允许隐藏或移动追更,不改变 id `8` 和其他按钮语义。 -- 两端一级入口都能打开 `FollowingActivity`。 -- 列表卡片读取官方、来源、用户三类状态。 -- D-pad 焦点不丢失。 -- 点击继续播放使用现有历史和 TMDB 进度入口。 -- 错误状态不会覆盖旧卡片内容。 - -### 15.6 真机/设备测试矩阵 - -至少覆盖: - -| 设备 | 场景 | -|---|---| -| Android Mobile API 24/26 | 添加、列表、恢复、通知权限关闭 | -| Android Mobile API 33+ | 首次通知授权、拒绝后角标、后台检查 | -| Android TV/Leanback Android 9+ | D-pad、首页入口、后台恢复 | -| 低内存设备 | 500 条列表、Worker 单条失败 | -| 无网络/弱网 | 手动检查失败、旧快照保留、下一轮恢复 | -| Doze/待机 | 一次性任务延迟不崩溃、回前台补查 | -| 双设备 | 一键同步合并、重复通知抑制 | - -### 15.7 推荐验证命令 - -定向单元测试: - -```bash -bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.following.*' \ - --tests 'com.fongmi.android.tv.bean.BackupFollowingTest' \ - --tests 'com.fongmi.android.tv.db.*Following*' \ - --no-daemon --console=plain - -bash ./gradlew :app:testLeanbackArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.following.*' \ - --no-daemon --console=plain -``` - -UI 和 Java 编译: - -```bash -bash ./gradlew \ - :app:testMobileArm64_v8aDebugUnitTest \ - :app:compileLeanbackArm64_v8aDebugJavaWithJavac \ - --tests 'com.fongmi.android.tv.ui.activity.Following*' \ - --no-daemon --console=plain -``` - -调试 APK: - -```bash -bash ./gradlew :app:assembleMobileArm64_v8aDebug \ - :app:assembleLeanbackArm64_v8aDebug \ - --no-daemon --console=plain -``` - -数据库/Worker instrumentation(按项目现有设备命令替换 flavor): - -```bash -bash ./gradlew :app:connectedMobileArm64_v8aDebugAndroidTest \ - -Pandroid.testInstrumentationRunnerArguments.class=com.fongmi.android.tv.following.FollowingDatabaseTest \ - --no-daemon --console=plain -``` - -最终静态检查: - -```bash -git diff --check -``` - -### 15.8 不接受的“通过”证据 - -- 只编译通过,不证明更新策略、通知去重或同步合并正确。 -- 只有日志看到“已检查”,不证明官方/来源/用户三类状态分离。 -- 只在一台在线设备通过,不证明 Doze、拒绝通知权限和跨设备合并。 -- 只验证旧 APK 能打开,不证明旧版本回滚不会影响 `tv` 数据库;因此采用独立数据库。 - ---- - -## 16. 分阶段实施计划 - -### Phase P0:接口和基线冻结 - -范围: - -- 固定本文档、上游 commit ID、现有本地符号和测试命令。 -- 确认 WorkManager 版本、独立数据库方案和 feature flag。 -- 不写业务实现。 - -退出标准: - -- 评审确认 `identityKey`、三类状态、数据库位置和首版非目标。 -- 明确哪些路径属于本阶段,哪些属于后续。 - -### Phase P1:独立数据层与备份 - -新增/修改: - -- `following/Following.java` -- `following/FollowingSource.java` -- `following/FollowingDatabase.java` -- `db/dao` 或 `following/dao` 下的 DAO -- `FollowingStore.java` -- `FollowingBackupCodec.java` -- `bean/Backup.java` -- 资源:following strings/layout 初始骨架 - -退出标准: - -- following DB 可创建、读写、导出 schema。 -- 备份/恢复单测通过。 -- `AppDatabase.VERSION` 保持 45,核心 schema 无改动。 - -### Phase P2:元数据与更新引擎 - -新增: - -- `FollowingIdentity.java` -- `FollowingMetadataSnapshot.java` -- `FollowingMetadataClient.java` -- `FollowingSourceProbe.java` -- `FollowingUpdatePolicy.java` -- `FollowingUpdateCoordinator.java` -- `FollowingProgressProjector.java` - -退出标准: - -- TMDB fixture、原站 fixture、首次添加、季集回退、失败保留旧值均通过。 -- 手动检查可完成一次端到端内存验证。 - -### Phase P3:调度、通知和生命周期 - -新增: - -- `FollowingUpdateWorker.java` -- `FollowingScheduler.java` -- `FollowingNotifier.java` -- WorkManager 依赖和权限检查 - -退出标准: - -- 周期唯一任务、one-shot 去重、失败退避、关闭取消均通过。 -- 通知权限关闭时仍能更新站内状态。 -- Worker 不阻塞主线程,不启动前台服务。 - -### Phase P4:手机端和电视端 UI - -新增/修改: - -- `app/src/mobile/res/menu/menu_nav.xml`:增加 `R.id.following`,位于“直播”和“设置”之间 -- mobile `HomeActivity.java`:显示一级导航项、处理独立 Activity 跳转和 badge -- mobile `FollowingActivity.java`:追更一级页面 -- leanback `HomeButton.java`:使用 id `8`,加入 `all()`、`ALL` 和默认按钮顺序 -- leanback `Func.java`:追更图标与状态文本 -- leanback `HomeActivity.java`:处理 `home_following` 一级按钮跳转 -- leanback `FollowingActivity.java`:追更一级页面 -- mobile/leanback `FollowingAdapter` -- 详情页“加入追更/已追更”动作 -- 继续播放、检查更新、已读、忽略、换源入口 -- default/zh-rCN/zh-rTW 的 `nav_following`、`home_following` 和追更图标资源 - -退出标准: - -- 手机端底部导航和电视端首页按钮均可一次点击进入追更,与点播/直播/设置同级。 -- 两端可添加、查看、继续播放、取消追更。 -- 两端 D-pad/触摸互不回归。 -- 现有收藏、历史、详情布局测试通过。 - -### Phase P5:同步与 alist 可选导入 - -新增: - -- `SyncOptions.follow` -- mobile/leanback `SyncDialog.follow()` -- `FollowingMergePolicy` -- `AlistSubscriptionImporter` -- serverless options 兼容检查 - -退出标准: - -- 旧备份恢复不丢本地追更。 -- 双设备合并去重、水位 max、调度字段不覆盖。 -- alist 默认关闭,未配置时不发起网络请求。 - -### 预计工作量 - -| 阶段 | 工程量估计 | -|---|---:| -| P0 设计冻结 | 0.5–1 小时 | -| P1 数据/备份 | 2–3 小时 | -| P2 更新引擎 | 3–4 小时 | -| P3 调度/通知 | 2–3 小时 | -| P4 双端 UI | 3–5 小时 | -| P5 同步/alist | 2–4 小时 | -| 完整验证与设备回归 | 2–4 小时 | - -总工程量以单端 MVP 优先;手机端和电视端、后台、同步全部闭环通常需要 12–18 个 Codex 工作小时,实际取决于 TMDB/站点网络和真机可用性。 - ---- - -## 17. 发布、开关与回滚 - -### 17.1 Feature flag - -建议设置: - -```text -following_enabled = false // 初始开发构建 -following_notifications = false -following_source_probe = when_update -following_server_import = false -``` - -进入内部测试后: - -```text -following_enabled = true -following_notifications = user_opt_in -``` - -正式放量前必须完成 P0–P4,P5 可独立延后。 - -### 17.2 开关行为 - -关闭 `following_enabled`: - -- 取消 `FollowingScheduler` 的周期和 one-shot 任务。 -- 不删除 following DB。 -- 详情页隐藏“追更”动作或显示不可用状态。 -- 不调用 `FollowingMetadataClient` 和 `FollowingSourceProbe`。 - -关闭 `following_notifications`: - -- 停止系统通知。 -- 保留列表角标、手动检查和进度更新。 -- 不清除 `last_notified_episode`,重新开启后仍按水位去重。 - -### 17.3 回滚 - -首选回滚是关闭 feature flag,而不是降级 APK。 - -原因: - -- 独立 following DB 不参与 `AppDatabase` 版本,可以保留给后续修复版本读取。 -- 旧 APK 不会访问 following DB,也不会因 `tv` DB 版本不匹配触发 destructive migration。 -- 即使新版本有 Worker 崩溃,取消 WorkManager 任务即可停止后台行为。 - -如果必须回滚 APK: - -- 旧版本可以继续工作。 -- following DB 文件可保留为用户数据,不自动删除。 -- 如果需要彻底清理,必须由新版本提供显式清理入口或用户手动清除数据,不能让回滚过程静默删库。 - ---- - -## 18. 风险与缓解 - -| 风险 | 等级 | 缓解 | -|---|---:|---| -| TMDB 表示已播,当前来源未上架 | 高 | 官方/来源/用户三状态分离;不把来源可播当成官方 | -| 7 天详情缓存导致漏更新 | 高 | 追更使用专用 force refresh/TTL,不改变现有详情缓存语义 | -| 后台任务不准时 | 中 | 使用 WorkManager + 回前台补查 + 站内角标,不承诺精确通知 | -| 每轮扫描过多记录导致耗电 | 中 | 索引 due 查询、每轮最多 5 条、并发 2、退避封顶 24h | -| 站点蜘蛛有副作用 | 中 | 默认只在有官方更新时探测首选来源,探测失败不重试风暴 | -| 参考项目无 License | 高 | 不复制代码;只借鉴行为,独立实现并记录来源 | -| 新季错过或误判 | 中 | MVP 按季记录,UI 提供追新季;自动切换推迟 | -| 多设备重复通知 | 中 | `last_notified_episode` 取最大;系统通知使用稳定 id | -| 备份恢复丢进度 | 中 | 追更 DB 独立事务、旧备份缺失时保留本机数据、恢复前自动备份 | -| 回滚旧 APK 破坏数据库 | 高 | 独立 following DB,核心 `tv` DB 不升级;Feature flag 作为主回滚 | -| 自动导入 alist 造成大批记录 | 中 | 默认关闭、先预览、人工确认、按身份去重 | - ---- - -## 19. 需求追踪矩阵 - -| 需求 | 设计章节 | 主要实现 | 主要验证 | -|---|---|---|---| -| F-01 添加/取消追更 | 8.3、12.1 | `FollowingAction`、`FollowingStore` | UI 源测试、真机 | -| F-02 首次不通知 | 4.3、11.4 | `FollowingUpdatePolicy` | `FollowingUpdatePolicyTest` | -| F-03 官方更新识别 | 9.2、9.5 | `FollowingMetadataClient` | TMDB fixture、单测 | -| F-04 官方/来源分离 | 4.2、9.4 | `FollowingSourceProbe`、UI | `FollowingSourceProbeTest` | -| F-05 身份去重 | 7.5 | `FollowingIdentity` | `FollowingIdentityTest` | -| F-06 继续播放 | 8.2、12.4 | `History`/`TmdbSeasonProgress` 入口 | 播放恢复测试 | -| F-07 失败保留快照 | 9.5、10.6 | `FollowingUpdateCoordinator` | 异常单测 | -| F-08 后台调度 | 10.1–10.4 | `FollowingScheduler`、Worker | WorkManager 测试 | -| F-09 通知去重 | 11.3–11.4 | `FollowingNotifierPolicy` | 通知 fake | -| F-10 双端一级入口 | 12.1、12.5 | mobile `menu_nav`/HomeActivity、leanback HomeButton/Func/HomeActivity | 源测试、真机 | -| F-11 备份恢复 | 13.1 | `FollowingBackupCodec` | `FollowingBackupTest` | -| F-12 同步合并 | 13.2 | `FollowingMergePolicy` | merge 单测 | -| F-13 开关停止 | 17.2 | `FollowingScheduler` | scheduler 单测 | -| F-14 无核心回归 | 8.1 | 独立 DB、feature flag | 现有相关测试 | -| F-15 不升核心 DB | 8.1 | `FollowingDatabase` | schema/diff 检查 | - ---- - -## 20. 最佳实践证据与来源 - -访问日期:2026-09-19。证据等级:A=上游/本地实际源码,B=官方平台文档,C=项目现有设计/测试。 - -### 20.1 上游源码 - -| 来源 | 修订 | 级别 | 支持的结论 | WebHTV 适用性 | -|---|---|---|---|---| -| `power721/atv-player` `src/atv_player/following_repository.py` | `5867e5f16c8bdb8a320d3ea6bb7f242afd2f02a0` | A | 追更身份、快照、进度、检查时间和提醒水位可持久化 | 采用模型思想,独立实现 | -| `power721/atv-player` `src/atv_player/following_update_service.py` | `5867e5f16c8bdb8a320d3ea6bb7f242afd2f02a0` | A | 桌面端使用 QTimer 和更新窗口;不能照搬到 Android | 改为 WorkManager + 延时一次性任务 | -| `power721/atv-player` `src/atv_player/following_backend.py` | `5867e5f16c8bdb8a320d3ea6bb7f242afd2f02a0` | A | alist 订阅可导入本地追更并以 `currentEpisodes` 作为可播信号 | 放 P5 默认关闭 | -| `power721/alist-tvbox` `MediaSubscriptionCheckService.java` | `5bb4b93fbdb2dc6fe61c585e83302e37fe2c7acd` | A | `next_check_time`、退避、播后短轮、资源巡检和挂载属于服务端系统 | 只借鉴调度/状态分离,不搬网盘编排 | -| `power721/alist-tvbox` `MediaSubscriptionController.java` | `5bb4b93fbdb2dc6fe61c585e83302e37fe2c7acd` | A | `/api/media-subscriptions` 是明确的导入互操作边界 | P5 可选适配 | - -### 20.2 官方平台文档 - -| 来源 | 级别 | 支持的结论 | 设计影响 | -|---|---|---|---| -| [Android WorkManager: Define work requests](https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work) | B | 周期任务最小 15 分钟;时间受 constraints 和系统优化影响;存在 flex period | 默认 6 小时+1 小时 flex,不承诺准确时间 | -| [Android WorkManager: Retry and back-off policy](https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#retries) | B | 退避延迟不精确,但不会小于初始值 | 退避时间用于限制频率,不用于精确提醒 | -| [Android Doze and App Standby](https://developer.android.com/training/monitoring-device-state/doze-standby) | B | Doze 和待机会延迟作业、网络和维护操作 | 回前台必须补查;不用 AlarmManager 保证实时 | -| [Android notification runtime permission](https://developer.android.com/develop/ui/views/notifications/notification-permission) | B | Android 13/API 33+ 提供 `POST_NOTIFICATIONS` 运行时权限 | 通知仅作增强;拒绝时保留站内角标 | -| [Android Room migrations](https://developer.android.com/training/data-storage/room/migrating-db-versions) | B | schema 导出、Migration 和测试应作为数据库变更的一部分 | 独立 DB v1,保留未来 MigrationTestHelper 路径 | -| [Android Room testing migrations](https://developer.android.com/training/data-storage/room/testing-db) | B | 数据库迁移需要验证真实旧版本到新版本 | 后续 version upgrade 必须做 instrumentation migration 测试 | -| [TMDB TV Series Details](https://developer.themoviedb.org/reference/tv-series-details) | B | 提供 `last_episode_to_air`、`next_episode_to_air`、`number_of_episodes`、`status` | 官方快照主字段 | -| [TMDB TV Season Details](https://developer.themoviedb.org/reference/tv-season-details) | B | 提供 season episodes、air date 和 episode count | 当前追踪季只按需请求 | -| [TMDB TV Series Changes](https://developer.themoviedb.org/reference/tv-series-changes) | B | 默认 24 小时、最多 14 天;season/episode 变更可能映射到 show-level change | 本首版不依赖 changes 接口,保留后续优化空间 | - -### 20.3 本地项目证据 - -| 本地路径 | 级别 | 支持的结论 | -|---|---|---| -| `app/src/main/java/com/fongmi/android/tv/bean/Keep.java` | A | 收藏不等于追更,缺少季集和检查状态 | -| `app/src/main/java/com/fongmi/android/tv/bean/History.java` | A | 已有 TMDB 身份、季集和播放进度 | -| `app/src/main/java/com/fongmi/android/tv/bean/TmdbSeasonProgress.java` | A | 已有跨来源分季进度 | -| `app/src/main/java/com/fongmi/android/tv/service/TmdbService.java` | A | 有 TMDB 详情缓存;追更需要专用刷新 | -| `app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbEpisodeInfo.java` | A | 已有 TMDB 状态/下一集解析基础 | -| `app/src/main/java/com/fongmi/android/tv/api/SiteApi.java` | A | 站点详情支持 refresh,可用于来源可播探测 | -| `app/src/main/java/com/fongmi/android/tv/db/AppDatabase.java` | A | 核心数据库版本 45,具有 destructive fallback,需隔离 | -| `app/src/main/java/com/fongmi/android/tv/bean/Backup.java` | A | 备份是显式 entity JSON,新增 following 字段可控 | -| `app/src/main/java/com/fongmi/android/tv/utils/Task.java` | A | 有现成后台和 scheduled executor 可复用于手动检查 | - -### 20.4 未采用或未依赖的来源 - -- 不依赖任何未经核实的第三方博客或搬运文章来决定状态语义。 -- 不依赖 `alist-tvbox` 的数据库 schema 或 Spring 调度细节;只把它作为可选 HTTP 互操作目标。 -- 不使用 Android 精确闹钟文档作为实现路径,因为首版不申请精确闹钟权限。 - ---- - -## 21. 未决事项与默认决策 - -如果评审没有提出不同意见,按以下默认值实施: - -| 未决事项 | 默认值 | -|---|---| -| 追更数据库位置 | 独立 `following.db` v1 | -| 首版支持媒体 | 仅 TMDB `tv` 和连续内容 | -| 检查周期 | 6 小时,flex 1 小时 | -| 通知精度 | 不承诺分钟级,不申请精确闹钟 | -| 自动新季 | 不自动创建,UI 提供追新季 | -| 自动换源 | 不自动,跳转现有全局搜索 | -| alist 导入 | 默认关闭、手动预览 | -| 默认通知 | 用户显式开启 | -| 默认同步 | `follow=false` | -| 进度投影 | best-effort,后台 reconcile 修复 | -| 电影/直播 | 不纳入首版 | -| 入口层级 | Mobile 底部导航一级项,与点播/直播/设置同级;Leanback 首页按钮一级项,位于收藏与推送之间 | - -任何改变以下内容的决定都必须重新评审: - -- 把 following 表并入 `AppDatabase`。 -- 承诺分钟级通知。 -- 自动转存、挂载或自动换源。 -- 将 TMDB/站点可播状态合并成一个布尔值。 -- 导入第三方未授权源码。 - ---- - -## 22. 实施前检查清单 - -开始编码前必须确认: - -- [ ] 本文档已评审,`FOLLOW-1` 的范围和路径已冻结。 -- [ ] 已确认采用独立 `following.db`。 -- [ ] 已确认 WorkManager 版本和 APK 体积预算。 -- [ ] 已确认首版不自动换源、不自动转存。 -- [ ] 已确认 alist 导入默认关闭。 -- [ ] 已确认 Mobile 追更为底部导航一级项,Leanback 追更为首页按钮一级项,不做隐藏二级入口。 -- [ ] 已确认 TMDB 不可用时使用原站详情兜底。 -- [ ] 已确认现有 `Keep`、`History`、`TmdbSeasonProgress` 不改变语义。 -- [ ] 已确认旧备份缺少 following 字段时保留本机数据。 -- [ ] 已确认通知拒绝授权不影响站内功能。 -- [ ] 已准备手机和电视真机,或明确将哪些设备验证延后。 - -## 23. 完成定义 - -FOLLOW-1 只有在以下证据都存在时才算完成: - -1. 独立数据库和迁移/备份测试通过。 -2. 更新策略、身份合并、来源探测和通知去重单测通过。 -3. 手动检查和 WorkManager 调度测试通过。 -4. mobile 与 leanback 的添加、列表、继续播放、取消操作通过。 -5. 旧备份恢复、新备份导出和跨设备合并测试通过。 -6. 至少一台 API 33+ 设备和一台 TV 设备完成拒绝通知、后台延迟和回前台补查。 -7. 现有 Keep/History/TmdbSeasonProgress 相关回归测试通过。 -8. 记录 APK 体积差异、完整验证日志、commit 和回滚锚点。 - -## 24. 恢复锚点 - -```text -objective: 为 WebHTV 设计可实施、可验证的追更功能 -authority: 本文为设计稿;未获用户明确“开始实施”前禁止生产代码变更 -baseline: dev4@552b68bb8e781d06ca989abfd8caa96a567e494a -selected_design: 独立 following.db v1 + WorkManager + TMDB/原站分离 + Feature flag -protected_inputs: Keep/History/TmdbSeasonProgress/AppDatabase v45 语义 -upstream_refs: atv-player@5867e5f16c8bdb8a320d3ea6bb7f242afd2f02a0 - alist-tvbox@5bb4b93fbdb2dc6fe61c585e83302e37fe2c7acd -next_action: 等待用户评审本文;如批准,按 P0 冻结接口后从 P1 数据层开始 -rollback_anchor: 关闭 following_enabled 并取消 WorkManager 唯一任务;保留 following.db -``` - -## 25. 实施记录(2026-09-19) - -> 本节记录当前代码状态,不改变上方设计决策。任务 ID 仍为 `FOLLOW-1`。 - -### 25.1 已实施 - -- P1:新增独立 Room v1 数据库 `following`,实体、DAO、identity key、备份 codec 和 schema 已落盘;`AppDatabase.VERSION` 保持 45。 -- P2:新增 TMDB 专用强制刷新入口、官方/来源可播 snapshot、来源线路探测、更新水位、去重策略和播放进度 best-effort 投影。 -- P3:新增 WorkManager 6 小时周期任务、one-shot 任务、失败退避、通知 channel、通知权限检查;首页恢复时排队一次 due check。 -- P4:手机底栏和 Leanback 首页一级入口已接入;两端共用追更列表,支持继续播放、手动检查、只看更新筛选、已读、通知开关、换源入口和取消追更;TMDB 详情页暴露“加入追更/已追更”。 -- 备份/恢复已加入 following 字段和 schema version;旧备份缺少 following 字段时不删除本机追更数据。 - -### 25.2 验证证据 - -- `:app:testMobileArm64_v8aDebugUnitTest` 与 `:app:testLeanbackArm64_v8aDebugUnitTest` 的 FOLLOW-1 定向测试通过。 -- `:app:assembleMobileArm64_v8aDebug` 通过。 -- 模拟器 `HD1910 - 9`(ADB `192.168.50.3:5561`)安装成功;从手机首页点击追更入口打开 `FollowingActivity`,并确认界面显示检查更新、追更数量与空状态。 -- 设备上实际创建独立 `following` 数据库;`following`、`following_source`、`room_master_table` 表存在。 -- 设备端 `FollowingDatabaseTest` 通过;WorkManager 的 `SystemJobService` 作业已出现在 `dumpsys jobscheduler`。 -- 设备端 `FollowingActivityDeviceTest` 通过:预置两条记录后,真实活动显示“2 部/未读 1 部”;点击“只看更新”后卡片数从 2 降为 1。 -- 模拟器实际点击“只看更新”后按钮切换为“显示全部”,空态切换为筛选空态;个性化设置页可见“追更更新”开关,关闭/重新打开均即时更新。 -- 当官方快照出现更高季号时,旧季卡片显示“追新季 Sx”;点击后仅创建/跳转到新季 identity,不改变旧季观看进度和提醒水位。 -- 点击追更卡片打开详情操作面板,集中显示官方/来源/用户三类状态与错误,并提供继续播放、检查更新、标记已读、手动换源和取消追更。 -- 首次添加时若尚无官方缓存,第一次成功拉取只建立提醒基线,不发送“从第 1 集开始”的历史集通知;后续集数才触发提醒。 -- 来源探测优先使用已绑定线路;若该线路没有有效集,则回退到覆盖集数最多的线路,全部无效时不伪装成可播。 -- Leanback 首页按钮全量顺序包含追更 id `8`,并继续保留站点注入 id `9`,两者互不替代。 -- TMDB 主 provider 失败但绑定来源探测成功时,保留上一次官方快照,仅更新来源可播状态,并把本轮视为成功;不会因 TMDB 暂时不可用而丢弃可用来源结果。关闭 `following_enabled` 后 Leanback 首页不显示追更按钮。 -- 追更列表支持“全部/有更新/未看完/已完结/检查失败”五类筛选,并按更新、未看数量、最近观看/修改、官方更新时间和创建时间排序;Leanback 首页追更按钮显示本地未读后缀,不触发网络请求。 -- 来源可播探测会校验 TMDB 映射的季号;映射属于其他季的集不会计入当前追踪季。WorkManager 唯一周期/one-shot 任务的创建和关闭取消已有设备端直接测试。 -- 同配置 `mobileArm64_v8aDebug` 体积比较:功能前 `ceac8d89af` 为 `200,780,386` bytes,当前测试包为 `204,653,512` bytes,增量 `3,873,126` bytes(约 `3.694 MiB`,`1.929%`)。该差值只代表未裁剪的 Debug 测试包,不等价于 Release/市场包体积。 -- 设备端 `FollowingBackupDeviceTest` 使用真实 `following.db` 验证完整导出、合并去重、read/notified/watched 水位取最大、本地 `nextCheckAt`/错误不被远端覆盖,以及恢复后来源表一致性。 -- 修复 TMDB 详情页打开时的 `RoomDatabase.assertNotMainThread` 崩溃:详情页不再在主线程调用 `FollowingStore.findByTmdb/findBySource`,查库、建库记录都通过 `FollowingPlaybackBridge` 在后台执行并回主线程更新按钮。 -- 手机播放页原生动作区、手机 TMDB 详情头部和电视播放页动作行均新增“加入追更/已追更”入口;入口只读取当前 `History`,实际 Room 读写全部异步。 -- 设备端 `FollowingDetailDeviceTest` 已验证:从主线程调用桥接查询不会触发 Room 主线程异常,回调回到主线程。 -- 修复手机横屏/平板布局启动 `VideoActivity` 时 `mBinding.following` 为空导致的 NPE:`layout`、`layout-land`、`layout-sw600dp`、`layout-sw600dp-land` 四个 `activity_video.xml` 变体均加入追更按钮,代码同时保留空值保护。 -- 修复取消追更时主线程调用 `FollowingStore.delete()` 导致的 Room 崩溃:取消、已读、通知开关和来源导入均通过后台桥接执行;设备端 `FollowingDeleteDeviceTest` 验证主线程发起删除可安全完成并同时清理来源表。 -- 手机播放页追更图标改用与搜索、投屏、设置一致的 `_shadow` 双层样式,避免系统主题对 vector tint 产生不同颜色。 -- 修复追更列表点击“检查更新”时的同类主线程崩溃:`checkAll()` 先完整进入后台线程再读取 `FollowingStore.list()`;设备端 `FollowingCheckDeviceTest` 通过真实 Activity 点击验证该入口。 -- 修复手机底栏与 Leanback 首页追更角标的同类主线程崩溃:未读数统一通过 `FollowingPlaybackBridge.refreshUnreadCountAsync()` 在后台刷新并缓存,UI 只读取 `cachedUnreadCount()`;两端首页恢复后异步更新显示。双端定向单元测试通过,`mobileArm64_v8aDebug` 测试包已覆盖安装到 `192.168.50.3:5561`,启动与首页恢复日志未出现主线程 Room 或 `FATAL EXCEPTION`。 -- 播放进度投影改为专用单线程队列:`FollowingStore.project()` 只入队,历史/季进度事务提交后再投影,避免 UI 线程访问 following Room 和事务提交前读取旧进度;设备测试验证主线程调用可安全完成并更新追更水位。 -- 应用处于前台时抑制追更系统通知,避免用户正在使用应用时弹出重复提醒;站内状态、角标和后台后续检查不受影响。 -- 官方元数据回退保护:同一追踪季内 `latestReleasedEpisode` 和已播集数只增不减;TMDB 返回旧季或较小集数时保留已有水位,避免更新角标和未看计数倒退。 -- 设备端 `FollowingNotifierDeviceTest` 验证应用前台时通知器返回抑制状态且不推进 `lastNotifiedEpisode`,确保站内状态更新与系统提醒解耦。 -- 来源线路选择按当前 `trackedSeason` 统计覆盖集数;其他季线路即使集数更多,也不能压过真正覆盖当前季的线路,单测覆盖该回退场景。 -- 季详情解析按“已播最大集号”而不是“已播集数”更新 `latestReleasedEpisode`,并忽略未来集;修复第 1、10 集已播却误记为第 2 集的场景。 -- 继续播放优先复用同源历史,同源没有记录时回退到同一 TMDB 季的其他来源历史,避免用户换源后丢失已有季集和播放进度;设备测试覆盖跨源回退。 -- 调度策略让 RETURNING 状态也使用 `next_episode_to_air`:在 6 小时周期和“播出后 30 分钟”中取更早者,刚完成检查时至少延迟 1 小时,避免在播剧多等一个周期。 -- 追更投影入口受 `following_enabled` 控制;关闭功能后历史保存不再访问 following DB,重新开启后由 Worker reconcile 恢复进度。 -- alist 导入记录显式设置 `enabled=true`,确保导入后能被到期查询和后台 Worker 调度;单测覆盖导入 identity 和调度启用状态。 -- PLANNED 状态在下一集日期较远时仍至少每 24 小时轻查一次,取“24 小时”和“下一集检查时间”中更早者,避免播出前长期不刷新官方状态。 -- 详情页新建追更记录显式设置 `enabled=true`,确保从详情页加入的记录会进入到期查询和后台 Worker;源代码回归测试防止再次漏设。 -- 无 TMDB 的源站追更记录在详情页后续匹配成功后,按 `cid + siteKey + vodId` 自动迁移到 TMDB 季身份;迁移保留来源绑定、观看进度和提醒水位,并立即回填当前 TMDB 官方状态,不保留重复可见记录。 -- 手机和 Leanback 个性化设置切换“追更更新”后额外发送 `ConfigEvent.common()`,立即重建首页入口;修复开关已为开启但底栏/首页追更按钮仍保持旧可见状态的问题。 -- 播放页及 TMDB 头部追更按钮初始为 `gone`,待历史身份与开关状态确认后再显示,避免关闭追更后进入播放页先闪现按钮再消失。 -- 追更列表采用可视区域停留后自动已读:卡片进入可视区域约 800ms 后清除未读;打开卡片也会立即标记已读。未读徽标只表示未读更新,已读后不再冒充“未看集数”,显式操作统一为“标记已读”。 -- 追更 instrumentation 测试新增真实数据库快照/恢复规则;所有会清表或删除数据库文件的测试结束前恢复原有 `following` 和 `following_source`,`FollowingDatabaseTest` 改用内存数据库,避免设备测试再次清空用户追更数据。 -- `following_enabled` 默认关闭;新安装或从未设置过该开关的用户不会显示追更入口,用户显式开启后才注册后台检查并展示追更功能。 -- schema 导出为 `app/schemas/com.fongmi.android.tv.following.FollowingDatabase/1.json`。 - -### 25.3 P5 同步与 alist 导入 - -- `SyncOptions.follow` 默认关闭;mobile/leanback 共用的一键同步对话框已增加“追更”选项。 -- Rust、Deno、Cloudflare、Vercel、Go 五种 serverless relay 的 `normalizeSyncOptions` 均显式保留 `follow=false` 默认值,避免远端规范化时丢选项。 -- 追更页新增“导入订阅”手动入口:地址和 token 只在本次对话框输入,token 不落盘;先预览后导入,也可直接导入。 -- `AlistSubscriptionImporter` 读取 `/api/media-subscriptions`,支持直接数组、`data[]` 和 `data.list[]`;按 TMDB ID 或“标题+季”匹配现有记录,没有本地记录时建立 TMDB/alist 身份记录并把 `currentEpisodes` 写入来源可播快照。 -- 导入能力默认关闭,只有用户在追更页明确提交地址后才设置启用并访问网络;不调用服务端挂载、转存、磁力或自动换源动作。 - -### 25.4 尚未完成的验证 - -- 本轮只具备手机 arm64 模拟器与双端编译证据;TV 真机、Doze/弱网、双设备同步和通知拒绝授权后的完整人工矩阵仍需后续执行。 -- JavaScript serverless relay 已通过 `node --check`;当前工作环境没有 Go/Rust 工具链,因此这两个 relay 只完成了源码级 `follow` 兼容检查和仓库单测,未执行各自编译。 -- 分配的 `5561` 设备是 API 28,因此设计完成定义第 6 条要求的 API 33+ 通知权限实测尚不能成立;不能把 API 28 上的权限代码路径冒充 API 33 验收。 -- 按用户明确要求,资源有限时只构建 Debug 测试包和 androidTest 包,不构建正式 Release 包;因此 Release 体积和签名产物验收未执行,不能以 Debug 结果替代。 - -### 25.4.1 2026-09-20 最新设备回归 - -- 最新 `mobileArm64_v8aDebug` 测试包与 androidTest 包均使用覆盖安装部署到 `192.168.50.3:5561`,未卸载现有应用。 -- 运行 `com.fongmi.android.tv.following` 全包 instrumentation:`FollowingActivityDeviceTest`、`FollowingBackupDeviceTest`、`FollowingCheckDeviceTest`、`FollowingDatabaseTest`、`FollowingDeleteDeviceTest`、`FollowingDetailDeviceTest`、`FollowingNotifierDeviceTest`、`FollowingProjectionDeviceTest`、`FollowingSchedulerDeviceTest`、`FollowingUpdateCoordinatorDeviceTest`,共 10 项全部通过。 -- 该回归覆盖最新提交后的 Activity 渲染、Room 主线程安全、取消/检查更新、备份合并、调度、投影队列和前台通知抑制。 -- 同一批次执行 `:app:testMobileArm64_v8aDebugUnitTest` 与 `:app:testLeanbackArm64_v8aDebugUnitTest`,双端全量单元测试均通过;最新设备包没有发现 `FATAL EXCEPTION` 或主线程 Room 崩溃。 -- 在最后的调度和详情页启用修复后,重新构建 Debug 包并以 `adb install -r -t` 覆盖安装:双端全量单元测试再次通过,追更 instrumentation 仍为 `OK (11 tests)`,日志未出现主线程 Room 或 `FATAL EXCEPTION`。 -- 新增源站身份到 TMDB 身份的迁移设备测试后,追更 instrumentation 为 `OK (12 tests)`;测试确认旧 `tmdb_id=0` 记录迁移到 TMDB 季身份、来源绑定跟随迁移,并立即写入官方状态和已播集数。 - -### 25.5 提交与回滚记录 - -```text -bd4f15581ebc048cd3f10bb6ab4a5e4d11e9809d feat(following): implement core following updates flow -6a7b3a85bb310503a59f582314b1345f0a819e14 feat(following): add sync compatibility and alist import -197b995e3cc2f8902aaf810cb152ea3a0c56d869 feat(following): add filter and feature switch -a0787318670ee74771db137618e15822616e4f8b feat(following): add next season action -dc2e3d4096b85d359c3cf79c5d72b5a3cccba3d6 test(following): verify activity rendering on device -4eb674d89dbd2f2e2e11bffb51b81ba2f1b7d6d4 feat(following): add detail action panel -89a517ce0e79916f82bb57e5cb362befbaf090ee fix(following): avoid first fetch notification -189e622522aa7eabe06a525b9c2c53b7619791c7 fix(following): harden source probe fallback -1496d617d74124fb258646f62b14541f9d6b948b test(following): update leanback home button catalog -fb6b5042f42bba4b8c45418cfbcde7a2ec8c1c29 fix(following): preserve source fallback and gate leanback entry -b85a58209f51b553be1b68c4d1d5f0d772936882 feat(following): complete list filters and leanback badge -8994c01086c917d05ec7959e813cc1afa8d8000a fix(following): enforce season bounds and verify scheduler -e64abf8df4341a5dc5f860ac695577b191673856 docs(following): record debug size and package constraint -78471f77fd4192092c8d1a692ee0f3abeb681f13 test(following): verify backup restore and merge on device -98c198926d02be7e23b0f20c540d08a5e6a5c145 fix(following): prevent main-thread crash and add playback entries -212db245ebef18ce15f963922ed404522a1f06f9 fix(following): add playback entry to mobile layout variants -0b34d1821d82c925a4396cf7fd570ea3f70177c8 fix(following): make cancellation async and unify icon color -f90b311716d30038457a56c583ee5995c93078ff fix(following): move check-all database read off main thread -6c762b863c8ce202ba7c2119150821af54d776f3 fix(following): refresh home badges off the main thread -feb1f5c78731cc7a8985c876b2e6ac7ddafb2958 fix(following): serialize progress projection off the main thread -33398512e3bf1827829af6ee1a5ca309636d94d0 fix(following): suppress foreground update notifications -ad76a8b3ea0f72c4c677efa15efbcd727bc95630 fix(following): keep released metadata monotonic -d5a72def79f0aee2428d7ddd956ed6114f76abdf fix(following): select source flag by tracked season -03b7cd5e78d22609fe61b0c7d8f33cacc5890133 fix(following): use latest aired episode from season detail -981b63bf211b42f607f610916b2b84f7590222bf fix(following): resume from cross-source TMDB history -a6978e6ba4818eab6e1407657deb49c5692e24b5 fix(following): schedule returning shows after next air -7567032bc07a99cb0755f61503e60aa86ae4683e fix(following): gate progress projection by feature flag -2b8d7943938cde453f173261f792ff0165ef0017 fix(following): enable imported alist subscriptions -1cab41ffc9c6d2dc9d77e42b4967e5f32bbb5b53 fix(following): keep planned shows on a daily check -9b17375d2bf60e7083a01586f882d8ec3382d98f fix(following): enable detail-page subscriptions -``` - -每个原子提交均由 `task_guard.sh` 创建独立 `recovery/FOLLOW-1-*` annotated tag;完整标签可在仓库中用 `git tag -l 'recovery/FOLLOW-1*'` 查询。回滚时可以回退到对应提交,也可以仅关闭 `following_enabled`;独立 `following.db` 不需要随 APK 降级删除。 - -### 25.6 恢复锚点(当前) - -```text -objective: FOLLOW-1 P0–P5 代码、备份/同步、双端入口和手动 alist 导入已实施 -authority: 用户已要求按设计实施;当前已提交多个可回滚原子单元 -status: implementation + all simulator-available targeted/device verification complete; badge unread queries are off-main-thread as of FOLLOW-1-BADGE-CRASH; design completion item 6 (API 33+/TV/Doze/multi-device) not available - Release/package-size build intentionally excluded by explicit user constraint -next_action: 如需完成正式发布验收,由具备 API 33+ 手机和 TV 真机的环境执行完成定义第 6 条;当前 5561 上没有剩余可执行的代码级验证 -rollback_anchor: following_enabled=false + FollowingScheduler.cancelAll;不修改 AppDatabase v45 -``` diff --git a/docs/GAME-MODE-20260913-game-mode.md b/docs/GAME-MODE-20260913-game-mode.md deleted file mode 100644 index d047383fa4..0000000000 --- a/docs/GAME-MODE-20260913-game-mode.md +++ /dev/null @@ -1,25 +0,0 @@ -# GAME-MODE-20260913 游戏模式移植 - -## Recovery anchor - -- 目标:参照共享目录 `lab-overlay-8游戏`,让动作卡片支持游戏/网页模式,并保持普通影视条目行为不变。 -- 范围:动作卡解析、输入链式执行、内置 WebView、列表/内容分发入口、Activity 注册。 -- 当前状态:已完成源码移植、入口补齐和加载占位层处理;协议测试 5/5 通过,mobile/leanback Arm64 Debug 主源码编译通过。 -- 当前文件:`ActionCardHelper`、`GameContentHandler`、`GameWebActivity`、`Vod`、`App`、首页与两端 `TypeFragment`、主清单、输入布局、协议测试。 -- 验证记录:`bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.content.GameContentHandlerTest :app:compileMobileArm64_v8aDebugJavaWithJavac :app:compileLeanbackArm64_v8aDebugJavaWithJavac --console=plain` 成功。已加强 GameWebActivity 安全设置:禁用本地文件访问和混合内容,符合 CatWebActivity 与 HomeWebController 水平。 -- 下一步:提交任务守卫并创建本地恢复标签。 - -## 设计与行为 - -1. `vod_tag=action` 且 `vod_id` 是 JSON 时,`Vod.getAction()` 将 JSON 交给动作卡流程。 -2. `TypeFragment` 点击动作卡后由 `ActionCardHelper` 分流: - - `type=browser`:直接在全屏内置 WebView 打开 URL,可携带 Header/User-Agent; - - `type=input`:弹出输入框,确认后将输入回填并调用站点动作接口; - - 其他动作:调用 `spider.action` 或 HTTP action,并展示返回列表/消息。 -3. `GameContentHandler` 注册进 `ContentDispatcher`,支持 `game://` URL 以及内容入口中的 browser 动作卡;普通详情卡不接管。 -4. 游戏 WebView 复用现有 `activity_web_reader` 布局,启用 JavaScript、DOM Storage、媒体自动播放、混合内容,并在渲染进程崩溃时安全退出。所有入口及页面内跳转只接受 `http/https`,拒绝 `javascript/file/intent` 等 scheme。 -5. 首页动作卡和分类动作卡共用同一执行器;`GameContentHandler` 也注册到 `ContentDispatcher`,支持 `game://https://...` 推送协议。 - -## 回滚 - -删除本任务新增的三个 Java 类、输入布局,撤销 `App` 注册、`Vod` 动作兼容、首页/分类动作入口和主清单 Activity 注册即可;不涉及依赖、锁文件或二进制。 diff --git a/docs/HISTORY-COVER-PROGRESS.md b/docs/HISTORY-COVER-PROGRESS.md deleted file mode 100644 index 3d98b01c81..0000000000 --- a/docs/HISTORY-COVER-PROGRESS.md +++ /dev/null @@ -1,53 +0,0 @@ -# 观看历史封面时间进度 - -## Recovery anchor - -- 用户需求:在观看历史的封面显示上次观看时间,方便确认看到第几分钟。手机观看历史页与电视首页历史卡片同时覆盖。 -- 本轮截图定位基线:`feature-menu` / `c4b042bc5441b9e168293f7cadae5798c4608da5`。guard `HISTORY-COVER-SWAP-REMARK`,`quick-fix`;保护既有 `app/.cxx/` 35个文件。初始实现及此前误解记录保留在下文。 -- 范围:仅两端`adapter_vod.xml`及本文。截图17:56:06红圈中的“[54.8GB][潘神…]”对应`remark`,不是底部“潘神的迷宫”对应的`name`。用户要求互换`remark`与`historyProgress`,底部`name`不动。 -- 完成条件:下部顺序为已看时间→圈出的文件名(remark)→底部片名(name)。只互换前两行,保留2dp行间距、现有样式和其他元素/逻辑;原子提交及本地恢复tag,不push。 -- 当前状态:两端已交换这两个子控件,行间距随时间标签从上边距转为下边距。XML解析及严格树比较两端PASS:还原两行顺序与间距边后,整棵布局树与基线完全一致。此次未重新打包、安装或进行真机视觉验收。 -- 唯一下一动作:紧接本记录由guard finish原子提交并创建本地恢复tag;之后等待用户的显示/实测反馈,不重复已通过的检查。 - -## 展示设计与证据(2026-09-10) - -这是已有观看进度的展示补全,复用原卡片标签,不是播放器/上游依赖整合;不占用P9或上游任务编号。 - -| 来源 | 证据与决定 | -| --- | --- | -| 当前基线 `History.java`、Mobile `HistoryAdapter`/`HistoryActivity`、Leanback `HistoryPresenter`/`HomeActivity` | A:position/duration为毫秒,未设置值为负数;手机已有进度条,两端均从同一History读取。已有刷新事件及Diffable链可复用;isSameContent尚未比较position/duration/remarks,补齐会影响封面内容的字段即可,不改保存或读取。 | -| 当前基线两端 `adapter_vod.xml`、`KeepAdapter`、`shape_vod_remark.xml` | A:历史和收藏共用卡片,原标签/删除层/图片尺寸是保留契约。只增加默认GONE的时间标签,历史绑定时显式设置文本和可见性;将原集数与时间垂直排列避免互相覆盖。收藏仍无时间标签,不改变图片或卡片高度。 | -| [AOSP DateUtils.java,android-15.0.0_r1](https://github.com/aosp-mirror/platform_frameworks_base/blob/android-15.0.0_r1/core/java/android/text/format/DateUtils.java#L429),经用户代理读取;快照 `/tmp/history-cover-progress-20260910.CMrH7W/DateUtils.java` | A:formatElapsedTime使用MM:SS/H:MM:SS及累计小时,不将时长当日期/时区处理。采用相同时间形式,在纯Java小格式化器中先过滤未知位置及钳制有效总长,按完整秒展示,不向下一秒四舍五入;英文/简繁中文案走Android资源。 | -| 上游PR/issues/reverts、论文、博客和基准 | 本次无依赖更新、未解决的平台争议、新算法或性能改善主张;不适用新的合并/性能结论。成熟实现与官方契约已覆盖本决策,额外检索不改变数值展示方案。 | - -- 不改:只能看手机比例条,无法得知具体分钟数。 -- 原样平台方案:直接DateUtils.formatElapsedTime可格式化,但不能决定无效历史、删除态、总时长钳制及列表刷新;仍需本地适配。 -- 采用窄适配:每次绑定从现有position/duration生成短时长,原集数标签之下显示一行“已看 …”;无效时设置空文本并GONE,删除态同样GONE。总时长未知不伪装成0,已知时长仅限制显示值,不写回数据、不改变续播位置。 -- 风险与对策:共享布局新增标签默认隐藏;保留原备注样式与位置、卡片外部尺寸和电视焦点;单行窄卡片从前方省略标签前缀以优先保留时间。格式化与边界逻辑纯Java,不依赖Android资源初始化;不新增网络、持久化、权限、依赖、ABI或后台任务。 -- 验证:小于一分钟/一分钟/一小时/跨24小时、毫秒边界、0/负数/未知、未知总时长、超出总长和大于int范围;进度/总长/集数单独变化影响Diffable而相同副本不变。运行Mobile arm64定向单测与Leanback armv7 Java/资源编译,不做原生或全构建矩阵。当前无设备,实机截图不作为已完成结果。 -- 回滚:原子revert本任务提交;上述基线已包含之前MPV修复,本任务不修改其代码/资产。 - -## 验证记录 - -- 已实现:默认GONE的封面时间标签,仅由两端历史绑定显示;原集数仍独立显示,删除态隐藏。共用格式化处理无效/未知/超界/长时长,只计算显示值;History内容比较增加position/duration/remarks,保证单独进度变化能刷新。 -- 2026-09-10:使用独立JDK21运行 `JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home LC_ALL=C bash ./gradlew --offline :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.utils.HistoryProgressFormatterTest --tests com.fongmi.android.tv.bean.HistoryTest :app:compileLeanbackArmeabi_v7aDebugJavaWithJavac --console=plain`,`BUILD SUCCESSFUL in 2m 3s`,96 tasks、24 executed。 -- JUnit XML确认 `HistoryProgressFormatterTest` 7项、`HistoryTest` 7项,均0 failures/0 errors/0 skipped;覆盖时间格式及毫秒/小时/24小时/long边界、无效数据、未知总长、超界钳制,以及相同副本和独立进度/时长/集数变化。 -- 首次命令错误指定了不存在的Android Studio JDK目录,Gradle未开始编译;读取仓库已有命令后改用独立JDK21,一次完成实际测试/编译。不是代码回归,也未放宽验证。 -- 证据目录 `/tmp/history-cover-progress-20260910.CMrH7W/`;日志 `tests-and-compile-jdk21.log`,原始环境失败保留在 `tests-and-compile.log`。设备列表为空,无截图/安装结果;此次不触发原生/CMake或APK打包,不把编译通过等同实机视觉通过。 -- 收口:只提交guard内本任务文件,保留35个预存dirty文件;本地注释恢复tag由guard finish在提交后立即生成,不push。 - -### 2026-09-10 顶部定位(已被下一记录纠正) - -- 将两端`historyProgress`从底部集数容器移到根RelativeLayout,使用`layout_below=site`、`layout_alignStart=image`与`layout_alignWithParentIfMissing=true`。字号、颜色、背景、间距、可见性、文案和Java逻辑均不变。 -- 定向XML检查两端均PASS:解析基线与候选,验证新父节点及定位属性;删除时间标签后,剩余树的标签/属性/内容/顺序完全一致。日志`/tmp/history-cover-progress-top-20260910-layout-check.log`;没有重跑未改动的时间格式化测试或构建。 - -### 2026-09-10 纠正为文件名上方 - -- 用户澄清位置是文件名上方,上一轮将其移到封面顶部不符合要求。把时间标签放回下部容器,顺序为集数→已看时间→文件名,撤销顶部定位属性;其余内容/样式和Java代码不变。 -- `git diff --exit-code 881c8bca2e6831d6a7d32f67c22c64ce37541e0b -- app/src/mobile/res/layout/adapter_vod.xml app/src/leanback/res/layout/adapter_vod.xml`通过,两份文件与此前编译通过的版本完全一致。证据`/tmp/history-cover-progress-above-name-20260910.log`;不重复既有编译/格式化测试,不声称已安装或实机通过。 - -### 2026-09-10 按圈图互换(取代此前对“文件名”的误识) - -- 已查看用户图片,明确红圈是封面内的文件名`remark`,底部`name`是影片标题。此前把两者混为一谈,不符合用户要交换的控件。 -- 仅将同一底部LinearLayout的子项从`remark, historyProgress`换为`historyProgress, remark`;时间标签的2dp上边距换为2dp下边距,保留原行间距。没有移动底部标题或改Java/文字/样式。 -- 定向XML检查Mobile/Leanback均PASS:先断言实际顺序已互换与间距仍2dp,再在内存中还原这两个变化,验证整个树的标签、属性、内容与顺序和基线完全相同。证据`/tmp/history-cover-swap-remark-20260910-check.log`。未打包/安装,不声称实机已更新。 diff --git a/docs/HLS-AD-FALLBACK-MISFIRE.md b/docs/HLS-AD-FALLBACK-MISFIRE.md deleted file mode 100644 index 9c061992fd..0000000000 --- a/docs/HLS-AD-FALLBACK-MISFIRE.md +++ /dev/null @@ -1,45 +0,0 @@ -# HLS-AD-FALLBACK-MISFIRE - -## Objective - -未开启去广告总开关时,Exo 直连 HLS 清单不得被改写;总开关开启但没有任何已启用规则时,不得运行 legacy 启发式误删非广告内容。 - -## Evidence and decision - -- `ExoHlsAdblockDataSource` 原先无条件调用广告管道,并硬编码 `legacyFallback=true`。 -- `Setting.isAdblock()` 只覆盖了核心代理、MPV 代理与 AI 入口,未覆盖 Exo 直连清单。 -- 目标 URL 的顶层清单指向 `3000k/hls/mixed.m3u8`;该 VOD 清单为 1436 行、38,687 字节,只有分段 hash 名称与一个开头 discontinuity。 -- 用 `HlsAdsParser.process()` 的本地复现确认该上游启发式属于普通内容误判路径;关闭 legacy fallback 才能保证无规则时不改写。 - -## Change - -- Exo 广告数据源先检查 `Setting.isAdblock()`;关闭时规则为空且禁用 legacy。 -- 开启总开关时才读取已启用规则;仅当存在规则时允许 legacy 兜底。 -- `HlsAdblockPipeline` 在 legacy 被禁用时继续执行结构化规则,但明确不进入 `HlsAdsParser`。 - -## Verification - -- `bash .codex/scripts/task_guard.sh check` passed. -- First Gradle invocation used nonexistent `:app:testDebugUnitTest`; no code issue. Retry with the repository flavor task passed. -- One compilation miss for `Setting` was fixed with the required import; retry passed. -- `:app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.utils.HlsAdblockPipelineTest` compiled the changed Exo source and ran 4 tests: failures 0, errors 0. -- `git diff --check` passed; changed files are the two runtime guards, one regression test, and this task document. - -## Follow-up 2026-09-16 - -- Device 192.168.50.3:5559 still showed 25:17.066 while the raw child playlist is 47:27.880. -- Installing fingerprint `9452d42...` matched the prior fix APK, so stale installation was ruled out. -- Calling the app's `/m3u8` child playlist returned 379 segments / 25:17.187; its log reported `removed=0 structured=false legacy=true`. -- Root cause of the follow-up: the local `/m3u8` proxy also hardcoded legacy fallback even when the master switch was disabled. -- Added a proxy regression test and applied the same empty-rules/no-master-switch guard. Focused mobile tests passed. -- Installed APK SHA-256 `31a963f7782dfbc54ea82eeca9637a6fb1e06b608d3fe3d118699ce083ec9173`; proxy response now has 712 segments / 47:27.880 and logs `removed=0 structured=false legacy=false`. -- Playback still showed 25:17 because Exo's patched parser has a separate `MediaItem.adblock` legacy path; diagnosis continues under a separate task guard. - -## Exo parser follow-up - -- The Fongmi-patched `HlsPlaylistParser` runs `HlsAdsParser` whenever `MediaItem.adblock` is true. -- `ExoUtil.getMediaItem` set that flag from the master switch alone, so the parser's legacy heuristic ran even with no enabled rules. -- `MediaItem.adblock` now requires both the master switch and at least one configured HLS rule. -- Added `ExoParserAdblockGateTest`; focused mobile test passed. -- Installed APK SHA-256 `5939f11e8cc62e2578e34404e8c80ff5ee9b1a1d765ea18a285ac20a4dba0077`. -- Replayed on 192.168.50.3:5559: child manifest bytes stayed 38,687, `removed=0`, `legacy=false`; telemetry reported `durationMs=2847879` and the UI showed 47:28. diff --git a/docs/MPV-AUDIO-SWITCH-SURFACE-GATE-20260921.md b/docs/MPV-AUDIO-SWITCH-SURFACE-GATE-20260921.md deleted file mode 100644 index 337320a887..0000000000 --- a/docs/MPV-AUDIO-SWITCH-SURFACE-GATE-20260921.md +++ /dev/null @@ -1,25 +0,0 @@ -# MPV 音频切换 Surface 等待修复 - -## Recovery anchor(2026-09-21) - -- Objective / acceptance:音乐 MPV -> EXO / EXO -> MPV 来回切换不得因初始视频 Surface gate 卡住;纯音频且不需要 OSD Surface 时,MPV 必须直接 load,视频、字幕和 ISO 的既有等待合同保持不变。 -- Evidence:用户日志 `webhtv-debug-log (21).txt` 中 trace `p-6kwdkf-19` 于 `13:53:02.797` 记录 `initial surface gate waiting before load`,直到 15 秒首帧超时后于 `13:53:18.278` 才真正 load;旧 MPV 会话因此等待约 15.8 秒。 -- Scope:`MpvPlayer`、纯策略 `MpvSurfaceLoadPolicy`、策略单测和本文;不修改 MPV native、Exo direct fallback、播放器选择或缓存策略。 -- Verification:`MpvSurfaceLoadPolicyTest` 与 `MpvSurfaceLifecycleSourceTest` 通过;mobile/arm64-v8a Debug 覆盖安装成功;本地 MP3 以 MPV 启动时直接 `start-file -> file-loaded`,没有 `initial surface gate waiting`。 -- Rollback:回滚本任务的原子提交即可恢复原 Surface gate;不涉及二进制或依赖。 - -## 根因与修法 - -MPV 初始 load 使用 `MpvPlayer.isSurfaceReadyForLoad()` 防止 Android 尚未创建 `SurfaceView` 时过早打开输出。原逻辑对所有可见 `SurfaceView` 一视同仁,因此纯音频播放器已经不需要视频 Surface 时,仍会等待未就绪 Surface;首帧超时后重建才恢复。 - -新增 `MpvSurfaceLoadPolicy.shouldWaitForVideoSurface()`:仅在“音频媒体且 `requiresOsdSurface()==false`”时跳过视频 Surface 等待。其余条件继续保留:视频、`mediacodec_embed` 和 ISO/OSD 场景不改变。`MpvPlayer` 以 `audio/*` MIME 或 `AudioUtil.isAudioUrl()` 判定本地/直连音频。 - -## 验证记录 - -- `:app:testMobileArm64_v8aDebugUnitTest --tests androidx.media3.mpvplayer.MpvSurfaceLoadPolicyTest --tests androidx.media3.mpvplayer.MpvSurfaceLifecycleSourceTest`:`BUILD SUCCESSFUL`。 -- `scripts/build_arm64_debug_install.sh --serial 192.168.50.3:5557`:mobile/arm64-v8a Debug 构建并覆盖安装成功。 -- 运行证据:本地 60 秒 MP3 以 MPV 启动,trace `p-1vsvjt-1` 的 `mpv: load`、`event=start-file`、`event=file-loaded` 连续完成,当前 trace 无 `initial surface gate waiting` 日志。 - -## 边界 - -设备端测试的 Surface 已处于 attached=true,因此在 dev2 上没有强制复现“Surface 未就绪”分支;该分支由纯策略三态测试覆盖。用户 Sony 设备仍需用原本音乐片源做 MPV/EXO 来回切换确认,重点检查日志不再出现 `initial surface gate waiting before load`。 diff --git a/docs/MPV-REBUFFER-PANEL.md b/docs/MPV-REBUFFER-PANEL.md deleted file mode 100644 index 4ee9c7a94e..0000000000 --- a/docs/MPV-REBUFFER-PANEL.md +++ /dev/null @@ -1,30 +0,0 @@ -# MPV 播放参数面板重缓冲计数修复 - -- 目标:MPV 重缓冲时,手机/电视共用的播放参数面板显示当前播放的重缓冲次数和持续时间;保留原有掉帧来源及 Exo 显示行为。 -- 范围:`quick-fix`,guard `MPV-REBUFFER-PANEL`;仅 `app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerOsdController.java` 和本文。基线 `a18c409b6d5a3c9d1e64edd673cd672daf448ac1`,分支 `feature/mpv-dv7-fel`;保护预存 `app/.cxx/` 的 70 个文件。 -- 时间:2026-09-15 10:48 Asia/Shanghai 开始修改;约 1 分钟修改、1 分钟打包和记录,目标 10:51。用户要求监听正在播放的手机,并沿用用户自行功能测试的要求;不为验证制造卡顿,不中断当前播放。 - -## 根因与现场证据 - -- `PlayerOsdController.getDiagnostics` 对 MPV 使用 `PlaybackAnalyticsListener.Snapshot.empty()`,却从这个 Exo 快照读取重缓冲次数与时长,因此恒为 0。 -- `PlayerManager.getRebufferCount/getRebufferTotalMs` 已提供当前播放器的共用状态计数,并合入 MPV 原盘导航独立计数;正在进行的重缓冲时间由单调时钟累计。`PlaybackBufferingTracker.update` 在非首开缓冲开始时递增,不依赖掉帧。 -- 掉帧数据独立来自 `MpvPlayer.getDroppedFrames`,读取 `decoder-frame-drop-count` 与 `frame-drop-count`,没有与重缓冲计数交换。 -- 用户当前手机 vivo V2453A(`10CF6H1D2L0009S`)原有日志,trace `p-1737sha-1`:手机日志时间 10:44:05.997 重缓冲开始 count=9;10:44:18.145 开始 count=10;10:44:24.164 恢复后 count=10、total=76383ms。另一路帧统计 dec=0、out=46。现场证明内部计数正常、面板数据源错误。 -- 计数关系:第 9 次缓冲中 out=37(10:44:11.016),恢复后 out=43(10:44:16.017);第 10 次缓冲中 out=43(10:44:23.167),恢复后 out=46(10:44:29.197)。两种计数独立,日志中的掉帧增量出现在恢复之后;恢复时追赶音视频时钟可能丢弃迟到帧,但这些聚合日志不证明每帧的具体丢弃原因。 -- 只读证据:`/private/tmp/webhtv-rebuffer-panel-20260915-current.log`;短时实时监听 `/private/tmp/webhtv-rebuffer-panel-20260915-live.log`,取得证据后已停止监听,未操作播放。 - -## 最小修改与交付 - -- MPV 面板改用现有 `PlayerManager` 次数/时长 getter;保留 Exo 原快照、掉帧来源、格式及刷新周期。手机和电视共用同一控制器。 -- 这是既定统计接口的显示接线修复,不改播放、缓存、解码、JNI、依赖或上游代码;没有新增采样或原生查询。不再扩大研究或改动计数器。 -- 最便宜决定性证据:已取得当前播放的真实缓冲开始/结束日志;随后只运行一次手机 arm64 debug 打包。按用户要求不运行自动化或功能测试。 -- 打包已完成:`:app:assembleMobileArm64_v8aDebug` 一次通过(23 秒,`--offline`,沿用 `/private/tmp/webhtv-libass-stage1/isolate-cxx.gradle` 保护预存 CMake 目录);未运行测试。完整日志 `/private/tmp/webhtv-rebuffer-panel-20260915-build.log`,APK `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk`。 -- 用户随后明确要求安装:直接使用上述现成 APK,未重新构建。安装助手已覆盖安装到 vivo V2453A,确认安装包指纹更新并启动 `com.fongmi.android.tv`,退出码 0;日志 `/private/tmp/webhtv-rebuffer-panel-20260915-install.log`。未运行功能测试。 -- 回滚:恢复本次控制器改动即可;原有统计、配置与设备数据保持原状。 - -## Recovery anchor - -- 实现:MPV 面板的数据来源已修正;手机 arm64 debug APK 已构建成功。 -- 当前手机已按用户要求覆盖安装修正版并启动应用,用户已实测确认“可以了,打个tag”。 -- 用户验收:修正版通过用户实测;不追加构建、测试或设备检查,立即闭环。 -- 唯一下一步:执行原 guard finish 原子提交并创建本地恢复 tag,不推送远端。 diff --git a/docs/MPV-SCRIPT-TRIGGERS.md b/docs/MPV-SCRIPT-TRIGGERS.md deleted file mode 100644 index b1fc886b95..0000000000 --- a/docs/MPV-SCRIPT-TRIGGERS.md +++ /dev/null @@ -1,90 +0,0 @@ -# MPV scripts 按钮与执行时机联动 - -## 目标与边界 - -- 用户于 2026-09-11 明确要求实施:关闭“启用按钮”强制启动时执行、禁用点击/长按;从关闭切换为开启默认点击;开启后允许手选启动时,并可在播放中点击同一个按钮开关脚本。 -- `quick-fix`,guard `MPV-SCRIPT-TRIGGERS`,基线 `aa676a941ee101cbb40c9641840e3b638b81c3d4`,分支 `feature-menu`。保护预存 `app/.cxx/` 的 35 个文件。 -- 只改共享配置创建/设置对话框、脚本存储/生成、聚焦测试及本文。手机/TV 共用修复,不改播放器引擎、上游版本、JNI、原生库、脚本内容或配置格式。 -- 2026-09-11 17:32 Asia/Shanghai 估计余下约 20 分钟:修改 7 分钟,验证/构建 7 分钟,真机验证/提交/tag 6 分钟,目标 17:52。 - -## 决策依据(2026-09-11) - -1. 本地代码,基线同上(A级直接证据): - - `MpvConfigCreateDialog.setupScriptSettings/initEvent` 未联动开关;创建/导入回调也没有传递已选设置。 - - `MpvConfigStore.writeCustomButtonScript` 跳过 `enabled=false`;startup 只在顶层执行,无 `.short` 回调。 - - `PlayerManager.sendMpvCustomButton` 及 mobile/leanback `VideoActivity.setupCustomActionButtons` 共用 `webhtv-custom-button(id, short/long)`,无需改两端协议。 - - 脚本源保存在隐藏 `.webhtv/`,不被 mpv 自动扫描,不可依赖“关掉按钮”后自行加载。 -2. mpv 官方规范/实际源码(A级,版本 `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`): - - [Lua 生命周期与消息回调](https://github.com/mpv-player/mpv/blob/cca559b41ceb0bb7731cf6ef2e1f33276cd30c42/DOCS/man/lua.rst):`mp.register_script_message` 将消息映射到函数;同一 Lua 环境的全局状态与定时器可以跨调用保留。读取本地 `build/mpv-native/mpv-android/buildscripts/deps/mpv/DOCS/man/lua.rst` 对应段落。 - - [input.rst](https://github.com/mpv-player/mpv/blob/cca559b41ceb0bb7731cf6ef2e1f33276cd30c42/DOCS/man/input.rst):`load-script` 创建新的脚本客户端,不能作为本需求每次按钮点击的 toggle 实现。 - - 成熟实现 [stats.lua](https://github.com/mpv-player/mpv/blob/cca559b41ceb0bb7731cf6ef2e1f33276cd30c42/player/lua/stats.lua) 的 `process_key_binding` 持有状态并主动停止定时器;与本次复用脚本开关动作一致。 -3. [Material 官方 ToggleButtonGroup 文档](https://github.com/material-components/material-components-android/blob/master/docs/components/ToggleButtonGroup.md)(A级 API 说明,master 页面无固定 SHA,已通过配置代理获取正文):单选/必选及 checked listener 是现有组件契约;仅禁用两个子按钮,保留启动按钮可用,TV 焦点随之调整。未迁移组件或样式。 -4. 真机现场证据(B级,vivo V2453A / Android 15,序列号 `10CF6H1D2L0009S`):现有“显示信息.lua”用全局 `__toggle_info` 和 `__info_timer` 开关及清理定时器。源码只读核对,不复制用户完整脚本到仓库。其 startup 与点击共享动作可实现用户所需开关。 -5. 上游 PR/issues/reverts、论文/性能基准:本轮不移植上游、不改变线程/渲染/性能策略,问题由本地缺失接线直接证明;没有待由这些证据类别决定的设计问题,故不做广泛检索。现场脚本和上游成熟脚本提供相关实践证据。 - -## 方案与验收 - -- 不改:两个明确需求不成立,拒绝。 -- 直接用原生 `load-script`:每次点击产生独立实例,不能复用现有脚本 toggle 状态,拒绝。 -- 采用窄适配:统一归一化启用状态/执行时机;初始化保留保存的有效选择;用户开启时选点击,关闭时选启动。创建、文本保存、导入均传递选择。managed startup 脚本注册与启动共用的 short 函数,启动只调用一次;无按钮则仅启动执行。保留旧多段自定义按钮的局部变量/代码契约。 -- 任意脚本的资源撤销仍由脚本自己的开关逻辑负责;不承诺把任意只执行初始化的 Lua/JS 自动变成可卸载模块,不增加通用卸载、状态劫持或依赖。 -- 接受条件:关闭时 click/long disabled 且 startup checked;开启默认 click;手选 startup/long 保存重开不被覆盖;startup 一次后点击关/再开;点击/长按无额外自启动;新建/导入选项不丢失;旧多段代码可用;两端 Java 编译通过。 -- 最便宜决定性验证:归一化/生成器 JUnit + 用现有 Lua 源码构建的临时宿主解释器执行生成的桥接代码(状态、次数、错误隔离、旧局部变量),随后单个 mobile debug 构建/安装和已连接手机的聚焦场景;TV 只编译共享 UI 调用链,不做 ABI 矩阵/原生库重建。 -- 安全/兼容/性能:不改变数据 schema,不改用户脚本;增加常数级回调接线,无播放帧循环工作或产品依赖/ABI 变化。单元提交 + 本地 annotated recovery tag;回滚本单元提交恢复旧逻辑,用户原有脚本文件不需回滚。 - -## 上一单元验证与交付(MPV-SCRIPT-TRIGGERS) - -- 状态:代码完成,首次合并自动化验证通过;手机安全锁屏,真机 UI/安装未执行。准备原子提交和恢复 tag。 -- 文件/符号:`MpvConfigCreateDialog` 联动与创建参数,`MpvConfigDialog` 保存/导入,`MpvConfigStore` trigger 归一化及 Lua 生成,`MpvConfigStoreTest` 和 Lua fixture。 -- 已完成:开关联动/TV焦点、创建/导入设置透传、存储归一化、startup/short 共用函数、disabled managed script 启动执行、旧多段作用域保留;宿主 Lua 5.2.4 已构建在 `/private/tmp/webhtv-script-trigger.PIW3G6/lua`,仅测试工具,不更新产品 native 产物。 -- 验证:2026-09-11 17:49,单次 Gradle `BUILD SUCCESSFUL in 4m 1s`;`MpvConfigStoreTest` 12 tests / 0 failures / 0 errors / 0 skipped,包括实际 Lua 5.2.4 执行生成代码;mobile arm64 debug 打包、leanback arm64 Java 编译通过。测试还覆盖 hidden startup、click/long 无额外自动执行、旧多段 local 状态、运行时错误隔离及 return。Room 既有 QUERY_MISMATCH/弃用警告不在本轮范围。 -- 命令:`JAVA_HOME=/Users/macbookpro/.gradle/jdks/jetbrains_s_r_o_-21-x86_64-os_x.2/jbrsdk_jcef-21.0.10-osx-x64-b1163.110/Contents/Home WEBHTV_TEST_LUA=/private/tmp/webhtv-script-trigger.PIW3G6/lua bash ./gradlew --offline --init-script /private/tmp/p9-menu-gradle-staging.gradle :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.player.mpv.MpvConfigStoreTest :app:assembleMobileArm64_v8aDebug :app:compileLeanbackArm64_v8aDebugJavaWithJavac --console=plain`。 -- 证据:`/private/tmp/webhtv-script-trigger.PIW3G6/gradle.log`、`app/build/test-results/testMobileArm64_v8aDebugUnitTest/TEST-com.fongmi.android.tv.player.mpv.MpvConfigStoreTest.xml`;APK `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk`。 -- 设备限制:同目录 `before.png/before.xml/device-policy.txt` 证明 `showing=true / secure=true / SCREEN_STATE_OFF`。按 android-device-tester 不绕过锁屏,已请用户解锁;只读备份 `original-scripts.tar`,没有安装、改脚本或改设置,无需恢复。自动化结果不当作真机 GUI 验收。 -- 提交/tag:由 guard 原子创建,记录在提交的 Verification/Task-Guard 及 `recovery/MPV-SCRIPT-TRIGGERS/` annotated tag 中;不为回填新 SHA 另建文档提交,不推送远端。 -- 唯一下一步:手机解锁后安装本次 APK,验证 scripts 开关联动与现有“显示信息”脚本 startup/点击场景。 - -## 后续单元:脚本独立启用状态(2026-09-11) - -- 用户明确要求:仅脚本设置增加默认开启的脚本开关;scripts 列表参考站点注入区分启用/禁用,其他保持原状。guard `MPV-SCRIPT-ENABLE`,基线 `1ce8df96075f703a16cecfa077fd715c66b90279`;保护 `app/.cxx/` 35 个预存文件。不推送、不改上游/原生/依赖。 -- 18:23 Asia/Shanghai 估计约20分钟,目标18:43;实现6分钟、验证/构建6分钟、手机与闭环8分钟。 -- 本地成熟实现(A级,基线同上):`CustomCspDialog.statusColor/rowBackground/titleColor/detailColor` 使用绿色启用、灰色禁用、禁用卡片仍可点击和聚焦;直接复用配色契约,不重排 scripts 列表或改变 mpv.conf/input.conf 样式。 -- 方案:`CustomButton.scriptEnabled` 独立于既有 `enabled`(按钮可见),JSON 缺字段/null 按 true 兼容;设置保存、重命名/导入选项更新保留新状态;禁用时生成器不读/运行该脚本,也不生成回调;两端播放按钮隐藏。配置载入时机保持当前播放器创建时生效,不引入运行中任意脚本卸载。脚本内容、点击/长按/startup 保持上一单元逻辑。 -- 备选:不改不满足需求;复用按钮 enabled 会破坏“关闭按钮仍startup”契约,拒绝;采用单独字段及现有过滤路径,最小可回滚。默认值不改变旧数据状态,新增字段随现有 metadata 文件备份。 -- 规范依据沿用本文 mpv 官方 Lua 生命周期/消息契约;新增仅 App 层已有配置模式,不涉及新的上游实现或运行时API。PR/issues/论文/基准对该字段和既定站点注入样式无待决设计问题,故不扩大检索。 -- 验收:旧数据/新脚本默认启用;停用后startup/click/long全不运行;重新启用不改按钮/trigger/脚本;列表准确读状态、置灰但可编辑,非 scripts 卡片不变;定向单测与实际Lua运行、两端编译、可用手机的一次开关/保存/重开验证。回滚本单元提交即可恢复旧逻辑;新字段在旧版被忽略(旧版不支持停用)。 - -## 后续单元:播放按钮与启动脚本状态同步(2026-09-15) - -- 用户反馈:启动脚本已运行,播放页按钮仍未选中,首次点击后脚本关闭而按钮亮起。用户明确要求立即修复、不运行任何测试,由用户自行实测。 -- `quick-fix`,guard `MPV-SCRIPT-BUTTON-STATE`;基线 `0667ff435ba167cfdff716236d68fea8d7fcd16f`,分支 `feature/mpv-dv7-fel`。保护 `app/.cxx/` 的 70 个预存脏文件;允许修改脚本生成器、App 内 `MpvPlayer`、mobile/leanback `VideoActivity` 和本文。08:18 Asia/Shanghai 开始修改,预计约 3 分钟,目标 08:21;不构建、不测试。 -- 已定位:两端仅在消息入队后翻转 View.selected,启动调用没有回传状态;手机转屏重新创建 View 后也会丢失状态。 -- 窄修复:沿用现有 Lua 回调及 MPV 属性观察接口。Lua 仅在对应动作执行成功后更新按钮状态,通过 `user-data/webhtv-custom-buttons` 发布当前激活 ID;启动和点击使用同一状态。App 缓存属性事件并通知两端 UI,按钮创建/转屏/播放器切换时读取当前播放器缓存,原生上下文销毁时清空,Activity 销毁时解除监听。 -- 沿用本文件已经确定的脚本 toggle 契约;普通任意脚本的内部自发变化不在本次协议中。无 handler 的点击/长按、运行错误不翻转状态,隐藏/禁用脚本及 legacy 多段作用域保持原行为。状态回传异常不打断脚本执行;不修改 JNI、原生库、依赖、用户脚本或数据格式,不引入轮询或播放帧循环操作。 -- 本地设计依据:同文件现有 `discMenuStateListeners` 主线程属性通知/监听解绑模式与 `MpvPropertySnapshot` 缓存;前述已审定 Lua 消息生命周期。此次为既定设计的状态接线修复,不扩大上游研究。 -- 验收由用户实测:startup 成功后按钮激活,首次点击关闭并取消激活,再次点击恢复;手机转屏状态保持;无对应动作或脚本报错不误翻转;手机/电视行为一致。 -- 交付:用户随后要求安装到手机。08:30 Asia/Shanghai 确认原 APK 早于代码修改,执行一次 `:app:assembleMobileArm64_v8aDebug`(`--offline`,沿用 `/private/tmp/webhtv-libass-stage1/isolate-cxx.gradle` 保护预存 CMake 目录);40 秒构建成功,不运行测试。APK:`app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk`;完整构建日志 `/private/tmp/webhtv-script-button-install-20260915-083003.log`。 -- 安装:按 android-device-tester 安装助手覆盖安装到 vivo V2453A(`10CF6H1D2L0009S`,arm64),包名 `com.fongmi.android.tv`;助手确认设备安装包指纹已更新并启动应用,退出码 0。安装日志 `/private/tmp/webhtv-script-button-install-20260915-083003-device.log`。 -- 验证状态:手机端编译/打包与安装完成;按用户要求未运行自动化或功能测试,等待用户实测。不把安装成功标为按钮行为通过。回滚仅需恢复本次五个文件,不影响用户配置。 -- 10:13 用户反馈激活高亮消失:两端 `selector_control_sheet_button.xml` 的 selected 样式完好,设备上的 Lua 桥接也已更新。直接阅读当前 mpv `player/command.c::do_op_udata`、`player/client.c::getproperty_fn`、`options/m_property.c::M_PROPERTY_GET_STRING` 和 `options/m_option.c::print_node`,确认 user-data 为 NODE,STRING 观察值仍为 JSON 字符串;上一版未解码,带引号的 ID 无法匹配 View 标签。10:17 修正 `MpvPlayer.handleProperty`,先解码 JSON 字符串再更新状态;不改变样式、脚本或 JNI。继续原 guard,仅修改 `MpvPlayer` 和本文,预计 10:21 前完成一次手机端打包/覆盖安装,不运行功能测试。 -- 修正版交付完成:同一 mobile arm64 assemble 命令一次通过(25 秒);安装助手处理系统风险确认,确认安装包指纹更新并启动 `com.fongmi.android.tv`,退出码 0。日志 `/private/tmp/webhtv-script-button-highlight-20260915-101753-build.log` 和 `/private/tmp/webhtv-script-button-highlight-20260915-101753-install.log`;未运行功能测试,等待用户重新验收。 - -## 上一单元 Recovery anchor(脚本独立启用状态) - -- 状态:字段/UI/过滤与测试实现完成,首次合并 Gradle 验证通过(1m44s);手机安装成功,用户明确“我测试可以了,打个tag”,本单元验收通过,立即提交/tag,不追加验证。 -- 允许路径:脚本存储、设置对话框/布局、列表适配器/禁用卡片资源、mobile/leanback按钮过滤、三语言string、定向Java/Lua测试及本文。 -- 已完成:新增 `scriptEnabled=true` 默认和 JSON 兼容读写、设置开关/TV焦点、列表状态/灰色卡片、两端 `isButtonVisible()` 过滤、生成器跳过停用脚本;新建/导入界面不变,旧保存接口和导入时机更新保留脚本状态。手机V2453A已连接且解锁(18:22)。 -- 验证执行:使用上一单元已构建 `/private/tmp/webhtv-script-trigger.PIW3G6/lua` 运行扩展脚本测试(预期15项,含真实Lua),同次构建mobile arm64 debug和编译leanback arm64 Java;CMake staging仍在build/mpv-native/app-cxx,不改预存app/.cxx/。日志 `/private/tmp/webhtv-script-enable.oVGHS9/gradle.log`。 -- 设备:原脚本目录已只读备份到 `/private/tmp/webhtv-script-enable.oVGHS9/original-scripts.tar`;安装助手完成风险确认并成功安装、启动本次 mobile debug。列表启用状态与设置界面已进入,最终行为由用户实测确认通过。用户无关屏幕数据不作为任务证据引用。 -- 已验证:`MpvConfigStoreTest` 15 tests / 0 skipped / 0 failures / 0 errors,包含真实Lua执行;mobile arm64 debug打包和leanback arm64 Java编译通过;XML资源通过Android构建。报告在 app/build/test-results/testMobileArm64_v8aDebugUnitTest/TEST-com.fongmi.android.tv.player.mpv.MpvConfigStoreTest.xml。 -- 18:47进度:实际验证启动/工具等待超过原目标,停止额外检查;仅继续手机开关/列表及提交/tag。设备仍已解锁,安装助手日志同证据目录 install.log。 -- 验收:用户确认本次需求测试通过;不扩展相邻场景,不以编译替代用户实测。提交/tag 由 guard 原子生成,本轮不推送远端。 -- 唯一下一步:执行 guard finish 提交本单元并创建 annotated recovery tag。 - -## Recovery anchor - -- 当前目标:修复 MPV 启动脚本与两端播放按钮状态不一致;对应 guard `MPV-SCRIPT-BUTTON-STATE`。 -- 实现状态:五个允许文件已修改;首包手机实测报告高亮丢失,已修正 user-data STRING 观察值的 JSON 解码。修正版已完成手机 arm64 编译、打包和覆盖安装;原样式保留,用户已实测确认“可以了,打个tag”。 -- 保护路径、基线、验收与回滚:见上方 2026-09-15 单元;预存 `app/.cxx/` 不纳入提交。 -- 验收:用户自行实测通过并明确要求打 tag;不追加构建、测试或设备检查,按闭环快路径提交本单元。 -- 唯一下一步:执行原 guard finish 原子提交并创建本地恢复 tag,不推送远端。 diff --git a/docs/P10-mpv-smart-adblock.md b/docs/P10-mpv-smart-adblock.md deleted file mode 100644 index 56b7695606..0000000000 --- a/docs/P10-mpv-smart-adblock.md +++ /dev/null @@ -1,158 +0,0 @@ -# P10:MPV 全局智能去广 - -## Recovery anchor - -- 2026-09-17 广告闪现续修(已验收,待原子收尾):上一单元已提交 `76a78c343974639f12257743a1baa339ca00929b`,tag `recovery/P10-ad-seek-timeline/20260917201113-76a78c343974`。guard `P10-ad-frame-boundary` / standard,范围为MPV适配层、定向测试、本文/索引和既有build证据目录;保护104个原有 `app/.cxx/` 文件。第6节原生边界实现及Surface复用补丁完成:25项广告定向测试、4项Surface生命周期测试通过;最新Mobile64已安装,50个原生条目与基线相同。手机fixture蓝到绿的661帧中广告红帧为0;原链接496.301秒受控截停、512.786秒恢复正文并持续播放。用户随后确认“看起来正常了”,关闭本场景可选验证;不追加回跳/退出操作。唯一下一动作:guard原子提交并生成恢复tag,不推送。 - -- 2026-09-17 续修(当前):guard `P10-ad-seek-timeline` / quick-fix,基线 `98a7f9033d46651d019e2b29a7a75ad4dac317de`,保护原104个 `app/.cxx/` 文件。第5节修复已通过定向验证、打包和安装;用户确认“好像可以了”并要求先打tag,关闭本次可选验证。仍有广告边界闪现的独立优化项,不包含在本次已验收回跳修复中。唯一下一动作:guard原子提交并生成恢复tag。 - -- 目标:开启现有全局“智能去广”时,MPV复用Exo广告判定并自动跳过广告,保留HLS播放、时间轴、音画/字幕同步、seek与切源;关闭开关保持原播放。用户2026-09-17提出实现需求并要求深读成熟实现。 -- 工作区:`feature/mpv-dv7-fel` / `54e7947c272a7b3ebad8b80bfab4c889e1ea86d5`;guard `P10-mpv-smart-adblock` / upstream;保护104个原有 `app/.cxx/` 文件。 -- 范围:MPV的 `MpvHlsProxy.java`、`MpvPlayer.java`、必要的 `HlsAdTimeline.java` 及对应两个定向测试,本文/任务索引,`build/p10-mpv-smart-adblock/` 和既有隔离C++输出。未授权扩展为新广告识别算法或native/依赖升级。 -- 完成证据:第2、3节研究完成;三个MPV源文件与两个定向测试已实现。15项定向单测通过,Mobile ARM64 APK构建通过(2分59秒)。APK CRC/v2签名通过,27个native库和10个MPV assets与基线一致;具体哈希见第4节。 -- 当前状态:实现、15项单测、APK构建/身份检查和手机 `10CF6H1D2L0009S` 的真实MPV场景均已通过。源码、单测及本文作为同一P10单元提交,恢复tag由guard收尾生成;不推送远端。 -- 验收:全局开/关;正文—广告—正文跨边界;手动seek落在广告/正文;中途暂停、切源/释放无旧会话跳转;多码率/独立音轨不能误用另一清单的区间;无法可靠识别时保留正文。一次定向构建/真实MPV场景,native身份保持。 -- 时间:13:18 Asia/Shanghai开始;单次Gradle于13:49完成,14:10真机场景全部通过。收尾超出原估计的原因是OEM安装确认、测试包安装参数与探针换源/起点修正;没有重复成功的正式APK构建、单测或扩大native工作。 -- 唯一下一动作:用guard提交并生成恢复tag,交付已安装的APK;本单元无待修复项。 - -## 1. 已有代码与回归来源 - -- `Setting.isAdblock()` 读取全局 `adblock`(默认true);手机和电视设置共用它。 -- `ExoUtil` 将该开关传给Media3 builder;本地 `media3-exoplayer-hls` sources jar的 `HlsPlaylistParser` 在解析前调用 `HlsAdsParser.process`。该解析器只处理带 `EXT-X-ENDLIST` 的清单,先按路径/前缀主群组识别,后按不连续块识别。不是视频画面分类器。 -- Exo `HlsMediaChunk.createInstance` 以 `discontinuitySequence` 获取 `TimestampAdjuster`,`sharedInitializeOrWait` 将每个不连续段对齐到清单累积时长,因此仅对比过滤后文本不足以证明MPV可以直接复用整条链路。 -- `MpvPlayer.continueOpenCurrent` 经本机 `MpvHlsProxy`;根和嵌套清单已调用同一个检测器。`MpvHlsProxy.applyAdblock` 对MPV的变化结果记录 `mpv-ts-timestamp-integrity` 并返回原文,IJK仍走原有过滤。这解释全局开关打开但MPV广告照播。 -- 保护分支来源:`b5f4129ea34ed3b02c0148512a2aaa76ee736162`(2026-08-07,keep mpv buffering and hls seeks accurate),应保留其时间轴/seek意图,不能当死代码删除。 -- 固定FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18` 已有 `hls_timestamp.c`。`normalize_playlist_timestamp` 只在存在discontinuity时启用,并用 `max(2 * target_duration, 1秒)` 判断跳变;短广告造成的差值仍可能保留,不能把它当作任意删片后的完整重映射。 -- 固定MPV `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` 已有EDL原生时间轴;当前HLS load options却强制 `demuxer=lavf,demuxer-lavf-format=hls`。EDL还涉及源start_time、主清单、多音轨、打开多个区间的时延,不能仅把清单改成EDL文本。 - -## 2. 研究与方案记录(已完成) - -访问日期均为2026-09-17。五类证据以实际源码和规范为主,不把搜索摘要当结论;Google返回挑战页、GitHub部分REST接口403,已改用固定Git SHA和raw源码读取。无关搜索结果不计入证据。 - -| 证据 | 修订/来源 | 等级与用途 | -| --- | --- | --- | -| 当前Exo检测/时间戳链 | 本地 `third_party/maven/androidx/media3/media3-exoplayer-hls/1.11.0-alpha01-fongmi/*-sources.jar`,锁定Media3 `e3e922d5c01bc0b564849940fe589daf37360d15` | A;共用广告判定,不能脱离extractor时间轴照搬删片 | -| HLS规范 | | A;discontinuity、media sequence、AES IV、byterange、独立rendition约束 | -| 当前FFmpeg实现 | `build/mpv-native/mpv-android/buildscripts/deps/ffmpeg/libavformat/hls.c`、`hls_timestamp.c`,`177f090e0503b7e013922ca903bde14b1c375f18` | A;短跳变和显式删片不等价 | -| MPV EDL | ,`demux/demux_edl.c` | A;原生虚拟时间轴可用,但多源启动、源起点和HLS强制demuxer需额外适配 | -| hls.js | 及同修订测试 | A/B;切码率/音轨需按CC/PDT/序号对齐,不能混用另一路媒体时间轴 | -| MPV现场问题 | 、、 | B/C;实际PTS跳变会导致音画/外挂字幕错位,须看当前代码与样例,不能由旧issue认定本机当前仍有同一缺陷 | - -方案比较:保留现状不满足需求;直接采用Exo过滤文本遗漏MPV时间戳/序号合同;原生EDL能提供剪辑时间轴但需处理上述额外约束;借鉴成熟播放器在原时间轴跳过已识别广告区间,可保持清单/密钥/Range和字幕时间戳,但会保留原时长且每个广告区间发生一次正常seek。最终选择见第3节。 - -回滚:本任务只改变App层,原子回退本任务提交即可恢复MPV原有保护分支;Exo/IJK/媒体库、FEL、P8 HDR10及视频手动解码合同保持。 - -## 3. 最终设计与实施边界 - -用户本轮已请求“全局开关打开后,mpv也能支持智能去广”;本单元在该能力范围内实现,沿用检测器,不增加开关、云端请求或native依赖。选择**保持原始清单和播放时间轴,以已识别广告区间驱动既有精确seek**。这是Kodi的商业广告跳过、mpv SponsorBlock的成熟执行方式;本项目无需复制其脚本、联网数据库、投票或音量/播放速度修改。 - -- `HlsAdsParser.process` 仅提供识别结果;将过滤前后媒体分片建立唯一的有序对应,恢复广告在原始清单中的时间区间。相邻广告合并为一次跳转;重复URI导致无法唯一定位、无效时长/溢出、非VOD、删除全部内容、非保序结果均不跳转。读取URI/时长/byterange标识,不输出完整地址。 -- 代理仍返回完整原清单,并沿用现有URL代理改写。AES隐式IV/密钥轮换、MAP、字节范围、MPEG-TS/fMP4原始时间戳均交给现有播放器路径;无需改写或重编码媒体。 -- 每个播放会话保存独立计划。主清单多码率分别保存,依据native已选码率取匹配计划;选择未知时只有所有已声明视频清单的计划一致才采用,不能让最后一次HTTP请求或音频/字幕/I-frame清单覆盖视频计划。 -- 起播/恢复位置或用户拖动落在广告区间时,直接解析到该区间末尾;正常播放复用已有50ms合并的时间位置事件,命中广告只发一次 `absolute+exact` seek。使用现有seek位置稳定器、缓存失效和暂停意图;旧事件不重复跳转,手动seek/新文件/停止清理已执行区间。 -- 保留原总时长和时间轴,进度条直接跨过广告;后台缓存仍按原时间轴记录。切换播放器原有位置合同不改造,不宣称MPV展示压缩后的Exo时长。开关关闭立即停止后续自动跳转;与Exo一样,在开关打开后新播放才保证已有识别计划。 -- 不保证逐帧无缝拼接:中途广告跳过走一次正常seek,仍可能有设备/网络相关的短缓冲或边界瞬间画面。这比删除分片并错置时间轴更可控。计划解析在清单处理线程,播放线程仅查短区间列表,不新增定时轮询、视频/音频分析或额外媒体下载;实际耗时和画面由定向样例验证,不能预先声称性能提升。 - -| 方案 | 决策与理由 | -| --- | --- | -| 维持MPV一律忽略检测结果 | 拒绝;不满足已请求的全局去广 | -| 原样套用Exo删除后的清单 | 拒绝;Exo的TimestampAdjuster未跟随迁移,还存在隐式IV和byterange语义问题 | -| 原生EDL虚拟剪辑 | 本单元不采用;绝对source start_time、主清单/独立音轨、逐区间打开和强制HLS demuxer需要更大改造,不能当作低风险文本包装 | -| 原时间轴区间跳过 | 采用;复用实际MPV seek、会话与缓存生命周期,原清单/媒体字节保持;代价是保留原总时长且切点发生seek | - -补充证据(访问2026-09-17): - -- A/B:[Kodi `CheckAutoSceneSkip`](https://github.com/xbmc/xbmc/blob/1a9c02dceef8f03d84002429b88e591b17330d6c/xbmc/cores/VideoPlayer/VideoPlayer.cpp) 实际读取已同步播放时钟,合并相邻区间,按末端去重,再排队seek;同修订 `Edl.cpp` 区分CUT和COMM_BREAK并维护时间映射。本单元采用COMM_BREAK的原时间轴执行思路,不照抄CUT显示时间转换。 -- A/B:[mpv SponsorBlock](https://github.com/po5/mpv_sponsorblock/blob/7785c1477103f2fafabfd65fdcf28ef26e6d7f0d/sponsorblock.lua) 的 `skip_ads` 观察 `time-pos`,命中起止区间后跳到末端并记录已执行;`file_loaded` 清空旧媒体区间。它的数据来自YouTube众包,而WebHTV使用已存在的本地Exo检测器。 -- C:[SponsorBlock作者博文](https://blog.ajay.app/voting-and-pseudo-randomness-or-sponsorblock-or-youtube-sponsorship-segment-blocker)(2019-07-18)解释重叠边界分组、错误提交和重复跳转;采用“区间合并/避免重复”的工程原则,不引入其随机投票筛选或远端数据。 -- 学术边界:[Ramires等,2018,音频广告检测](https://arxiv.org/abs/1811.02411),已读取2页PDF全文(本地证据 `research/audio-ad-detection.txt`)。其方法需音频解码、±6秒局部上下文、150秒滑窗和训练数据,验证集为26小时葡萄牙电视,作者明确讨论误删正文及跨地区泛化限制。本需求已有清单检测器,不以该论文改成音频/视频分类,不把其0.874相关系数套成WebHTV准确率;借鉴保守处理不确定区间的原则。 - -以上覆盖准确源码/测试、规范、issues与维护者分类、两个成熟相关项目、博文与论文。所列外部修订全部仅为阅读参考,没有合入任何上游提交。原生/ABI/许可证及二进制所有权不变。 - -## 4. 实施与验证记录 - -- `HlsAdTimeline`:不可变原时间轴区间、唯一保序分片映射、相邻区间合并、无效输入保守退出、二分定位;`SkipState` 防旧位置事件重复触发,手动seek可重置。 -- `MpvHlsProxy`:每会话/视频变体记录去广计划;排除独立音频/字幕/I-frame清单;按实际码率选择,不确定时要求各已声明视频变体一致;保留原MPV清单与IJK行为。 -- `MpvPlayer`:复用既有位置事件与精确seek,手动seek/起播位置落入广告时定位至末端,切源/停止重置;无新增轮询定时器或解码策略变化。 -- 定向验证:`HlsAdTimelineTest` 10项、`MpvHlsAdblockTest` 5项全部通过;同一次Gradle调用完成 `assembleMobileArm64_v8aDebug`。完整输出保存在 `build/p10-mpv-smart-adblock/gradle.log`,不重复成功构建。 -- APK:`app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk`,165351568字节,SHA-256 `3f1a3f163493ab95c4e7607279cc3d18c826006a49ee436c4ee2d79755d81b77`。CRC和v2签名通过,27个native库及10个MPV assets与此前P8兼容基线逐项相同。 -- 回滚APK:`build/p10-mpv-smart-adblock/before-mobile64.apk`,SHA-256 `18e641a666268a80369bf426d7761f1e9d3d740cacdd63c5d341b677589befe2`;原提交 `54e7947c272a7b3ebad8b80bfab4c889e1ea86d5`,tag `recovery/P2-4-p81-hdr10-compat/20260917131448-54e7947c272a`。 -- 真机:vivo V2453A / Android 15 / ARM64,ADB `10CF6H1D2L0009S`,现有APK已安装。命令探针调用实际应用和MPV,以native `time-pos` 与PixelCopy取样验收;各次画面取样均为4608/4608像素匹配预期,`hwdec-current=mediacodec`。 - -| 真机场景 | 结果与证据 | -| --- | --- | -| 原时间轴 `[0,6)` 蓝色正文、`[6,8)` 红色广告、`[8,14)` 绿色正文 | 正常起播蓝色,自动跨过广告后绿色;总时长保留14.016秒 | -| 手动seek到6.5秒广告内 | 定位至8.321秒,实际画面绿色 | -| 播放中关闭全局智能去广后seek到6.5秒 | 6.754秒,实际画面红色,允许播放广告 | -| AES-128 CBC、从37开始的media sequence、无显式IV | 0.955秒蓝色起播,广告seek后8.354秒绿色;未删除或重编号清单片段 | -| 两个视频变体的主清单 | 0.854秒正常起播,广告seek后8.321秒绿色 | -| 暂停与恢复 | 暂停后位置保持8.354秒,恢复后继续切源 | -| 从广告计划源切换至无识别线索源 | 6.721秒实际画面红色,旧会话广告区间没有误作用到新源 | -| 停止、退出和设置恢复 | 探针执行stop/finish,原全局开关与播放器选项恢复;临时探针在验证后卸载 | - -完整证据:`build/p10-mpv-smart-adblock/device-probe.log`(普通/手动seek/关闭开关)、`device-probe-remaining.log`(AES)、`device-probe-master.log`(主清单/暂停/切源/退出)。前两份包含当时后续场景的失败记录:测试驱动曾将 `start(PlaySpec,long)` 的超时参数传为0,且同一owner key下新源保留旧位置,和“必须从第1秒开始”的探针断言冲突。改为正常 `browse` 入口,并等待native path切换后明确seek到0;最终记录中新源最初位置8.554秒、归零后0.888秒,证实测试起点需显式设置。只补跑尚未完成场景,正式APK和生产代码保持原构建。 - -验证覆盖检测、跳过执行、加密、变体隔离和生命周期;不把短样例等同于所有站点的广告识别率。识别能力与Exo相同,对有歧义的分片对应或不同视频变体区间保持正文;跳转仍有正常seek的缓冲/边界显示限制,原总时长保持。 - -提交定位:本单元使用 `Task-Guard: P10-mpv-smart-adblock` trailer,唯一恢复tag前缀 `recovery/P10-mpv-smart-adblock/`。最终40位提交ID和具体tag由guard输出及本地task-state记录,避免为记录自引用提交ID再制造文档提交。回滚为撤销该原子提交,或安装上面保留的基线APK。 - -## 5. 实际清单误识别导致回跳被改写(2026-09-17) - -### 证据与决定 - -- 手机日志:19:34:09用户从约521秒请求0秒,紧接着 `seek-latch targetMs=512786 observedMs=512786`。不是显示层单独丢失时间事件:`resolveHlsAdSeekTarget` 确实重写了用户目标。 -- 用户提供的主清单指向 `3000k/hls/mixed.m3u8`。电脑遭CDN地区403;通过手机 `/system/bin/curl` 读取原文成功,证据保存在 `/private/tmp/webhtv-mpv-ad-seek-20260917/`。 -- 原文六个不连续块分片数为124、4、375、5、200、4;时长分别496.320、16.466666、1500.720、17.666666、800.240、16.466666秒。现有 `HlsAdsParser.findAdsByDiscontinuity/getModeSize` 排除最后一块后没有重复块大小,平票选375,进而把124和200片的正文也标成广告。原 `HlsAdTimeline.from` 把连续遗漏块合并,得到0–512.786秒等错误大区间。当前固定Media3源码与第2节来源一致,本轮不升级该依赖。 -- 保持现状和只改进度文字均无法修复真实跳转;直接全盘采信检测器会跳过正文;本轮沿用第3节保守执行合同,在MPV映射层先按原始不连续边界核验每个候选块,再合并接受的相邻区间。 -- 增加明确的自动跳过安全上限:单个候选块超过120秒则完整保留。120秒是本应用保守执行边界,并非广告长度的标准或识别证明;长广告可能保留,不能为自动去广误跳数分钟正文。短块仍须由原检测器先命中,本轮不新增广告分类器、不利用时间长短主动标记其它正文。无不连续标记时,完整连续遗漏区间同样受此上限约束。 -- 实际源应仅接受中插候选 `[496320,512786)`、`[2013507,2031173)` 毫秒;保留原总时长,0秒、广告前及两广告之间的正文位置不得被改写。最后一块沿用原检测器的保留策略。 -- 范围只涉及MPV App时间轴、对应测试及本记录/索引;原媒体字节、FFmpeg/MPV原生库、Exo、AVS3、FEL/P8和视频手动解码合同不变。每清单增加一次边界记录,运行时仍为二分查询,无额外网络请求或播放轮询。 -- 验收:实际清单结构回归、长候选保留、合法短块/相邻块/边界取整;一次Mobile ARM64定向单测和打包;手机使用原URL验证自动跨广告、前后跳转及原生/界面时间持续前进。回滚为撤销本次原子提交,恢复上述基线的三个MPV文件及测试/记录,无二进制回滚依赖。 - -### 验证结果 - -- 定向验证共22项通过:首次21项通过,实际清单用例因Android `TextUtils` JVM桩不可调用失败;改为冻结检测器已观察到的遗漏结果,保留全部区间/跳转断言,仅重跑该用例通过。未重跑已通过用例。日志:`build/p10-mpv-smart-adblock/ad-seek-gradle.log`、`ad-seek-gradle-final.log`。 -- Mobile ARM64打包通过(32秒),APK SHA-256 `7c7eb09495e0f01773a8e5d6f3aaa894b1109842afee540611f7365f3aada04f`,181893921字节;与本次基线APK逐项比较,50个原生库条目全部相同。 -- 已安装至手机 `10CF6H1D2L0009S`,安装器返回Success。通过Android VIEW命令播放原URL,手机日志确认仅接受 `[496320,512786)`、`[2013507,2031173)` 两段,并报告 `exo-hls-detector-long-blocks-preserved`。ADB进度条操作从约24分钟回跳,`/media` 返回实际播放中 `position=488311`、`duration=2847880`,未再被强制改成512786毫秒。 -- 用户随后确认“好像可以了。你先打个tag”,本次回跳修复按该确认收尾。用户同时观察到广告会闪现后再跳过;这是当前位置事件触发seek的边界限制,仍待后续独立优化,不能声称已实现广告首帧完全不可见。 -- 本次受手机取源、Android测试桩和命令控制环境处理影响,超出最初估计;正式原生库未重建,未安装辅助探针APK,未推送远端。 -- 提交/tag由 `Task-Guard: P10-ad-seek-timeline` 和对应 `recovery/P10-ad-seek-timeline/` 记录;回滚范围为本次两个MPV源文件、对应测试及本文。 - -## 6. 广告帧进入显示前裁剪(2026-09-17,已实现并验收) - -### 来源与本地合同 - -访问日期2026-09-17;用户已授权修复广告闪现,并明确要求参考Exo或成熟开源实现。本节在原时间轴和同一native二进制合同内补足第3节的已知边界限制,不引入提前截断正文或图像遮挡。 - -| 证据 | 修订、等级、结论与适用限制 | -| --- | --- | -| Exo `HlsPlaylistParser`、`HlsMediaChunk` | 固定Media3 `e3e922d5c01bc0b564849940fe589daf37360d15` 的本地sources jar,A;解析清单前移除广告,分片按discontinuity调整时间戳,因此不依赖已显示后的进度回调。直接移植文本删除仍违反本节原时间轴、隐式IV与Range合同。 | -| Exo `ClippingMediaPeriod.ClippingSampleStream.readData` | 同修订 `media3-exoplayer` sources jar,A;`buffer.timeUs >= endUs` 时输出EOS而非样本。支持在原生输出之前实施时间边界,而非提高UI轮询频率。 | -| MPV视频/音频边界 | 、同修订 `player/audio.c` / `player/misc.c`,A;`get_play_end_pts` 在 `add_new_frame` 前拒绝边界及之后的帧,音频用 `mp_aframe_clip_timestamps` 裁样本。普通硬解、软解复用此路径,不增加GPU滤镜或下载硬解帧。 | -| MPV原生接口和生命周期 | 同修订 `DOCS/man/options.rst` 的 `end` / `keep-open` / `keep-open-pause`、`player/playloop.c::handle_keep_open`,A;保留最后一帧和已打开文件,后续seek继续;文件局部设置随切源恢复。必须避免把广告边界EOS当成整片结束。 | -| MPV EDL | 同修订 `demux/demux_edl.c`、`DOCS/edl-mpv.rst`,A;实际源码显示同一URL可复用demuxer,修正此前“每段必重新打开”的过强推断。但显式offset使用源起点,压缩后的时间轴还需同步改造缓存/字幕/恢复位置,本次不采用。 | -| 上游现场反馈 | 及其评论,B/C;keep-open EOF停在最后一帧PTS,不能要求进度等于边界毫秒。该报告不是Android性能证据,只支持小范围EOF位置容差和实际输出检查。 | - -上述MPV源码已从锁定本地checkout读取全文相关函数;外部issues通过GitHub接口读取原文和评论。既有Kodi/SponsorBlock代码和博文见第2、3节:播放位置回调适合普通跳段,无法证明首帧不可见。此次不更换检测算法;学术广告分类论文不能决定已知区间的原生输出边界,沿用第2节的明确不适用结论,不重复检索分类论文。未合入或升级任何上游提交。 - -### 实施决策与验收 - -- 不变:完整源清单、源总时长、媒体字节、AES/Range、AVS3/FEL/P8、视频手动解码、已有广告误识别保护。 -- 拒绝:等待已播放位置后才seek(可闪帧);任意提前几十/几百毫秒seek(丢正文);视频滤镜/遮挡(硬解性能或掩盖输出);整条链切为EDL(本次不扩大时间轴/字幕映射)。 -- 采用:在已加载HLS上设置下一已识别广告起点为MPV文件局部 `end`,并临时保留文件/最后正文帧;原生输出触及边界后,由受控EOF执行现有精确seek至广告末尾,并配置下一边界。先设置下一边界再seek。没有候选、关闭开关、切源和停止时归还本任务拥有的设置。 -- 保存/恢复用户原 `end`、`keep-open`、`keep-open-pause`;不覆盖已明确指定的自定义裁剪或AB循环语义。原生单片循环在中间边界临时关闭,到最后正文区间恢复App循环设置,防止广告边界触发整片循环;手动seek重新选择对应边界。原生设置写入失败应归还已写入项,保持现有seek处理并记录原因。 -- 仅把已武装边界附近、无未完成手动seek的EOF作为广告边界;旧EOF、提前断流、真正结尾不得被吞掉。受控跳转保持App的暂停意图、缓存失效和观察会话合同。保持原有真实EOF/错误处理。 -- 验收:边界状态定向单测;一次Mobile ARM64打包/安装;明确颜色广告fixture实际输出、原URL跨广告及回跳、暂停/切源/循环必要邻接路径;原生库身份保持。不能用编译代替广告帧不可见的运行证据。 -- 回滚:撤销本单元App/测试/记录原子提交,上一恢复tag即第5节已验收回跳版本。无native重建和依赖变更。 - -### 执行记录 - -- 已接入文件局部 `end` / keep-open 与受控EOF状态,记录已写入值以避免归还设置时覆盖用户后来修改的值。状态测试覆盖末帧EOF容差、无关EOF、手动seek、重复事件、重置和双向边界查找。 -- 首次定向验证25项通过(边界6、时间轴13、代理6),Mobile ARM64打包通过,耗时1分29秒;日志 `build/p10-mpv-smart-adblock/ad-boundary-gradle.log`。首候选APK SHA-256 `01c62526567f0acbea2619390c83237a912cda748a727152c5c6c6c59d736216`,181893921字节,与AVS3 MediaCodec基线的50个原生条目完全相同;已安装手机。 -- 手机原生日志 `clip armed start=6000 end=8000`,在 `position=5988` 收到受控EOF并跳至8000。录屏 `ad-boundary-device.mp4` 共1438帧;切换到fixture后的313帧蓝色正文紧接348帧绿色正文,红色广告为0。录制开头包含之前播放的真实视频(有红色画面),初次全录屏颜色断言因此失败;依据已保存的逐帧结果,将评估起点限定为fixture的首个纯蓝帧777,不重录、不改变播放器,结果 `ad-boundary-frame-result.json`。 -- 阻断项与定点补丁(21:06):用户报告退出后视频无法播放。手机直接读取原链接两级清单均HTTP 200;日志显示同一MpvPlayer在 `20:50:12 terminal Surface release requested` 后仍被PlaybackService复用,之后 `surface=true attached=false`,没有发出loadfile,最终把正常idle误报为网络失败。`MpvSurfaceTeardownPolicy` 的终止标记原来仅置位,不能在新媒体时恢复。本补丁先销毁已准备终止的旧native上下文,再在新媒体边界恢复绑定许可;不复用已终止的mediacodec_embed上下文,等待Surface时不将idle误判加载失败。原有退出解绑抑制保持到旧上下文销毁之后。 -- Surface生命周期4项测试通过,补丁后Mobile ARM64打包通过(45秒),日志 `ad-boundary-surface-gradle.log`。最终APK SHA-256 `25210897072d3d11d0747a9db9e89a3c5482037720ffd3684ecab0de0740fc5c`,181893921字节;50个原生条目仍全部与基线相同,身份记录 `ad-boundary-final-apk.json`。手机安装返回Success。 -- 原链接最终日志 `ad-boundary-final-device.log`:21:09:28正常file-loaded,21:09:29从487888毫秒续播;21:09:37在496301毫秒收到受控EOF,先配置第二段广告边界2013507–2031173,再跳到512786;21:09:38 playback-restart,随后 `/media` 为播放中、位置526164、原总时长2847880。原链接已恢复并越过广告,没有再次提前结束或报加载失败。 -- 用户确认“看起来正常了”,据此收尾并关闭后续可选回跳、退出重进检查;不能将这两项未追加的操作写成最终包真机验证。Surface复用序列由新增状态测试覆盖,广告逐帧证据来自同一边界实现的前一候选,后续补丁仅修改Surface生命周期与提前失败判断。 -- 范围限制:仍使用已有清单检测结果和120秒候选保护;保留源时长及正常seek可能带来的短暂停顿。用户明确的 `end` / `length` / AB-loop优先,此时原生广告边界不接管,沿用原有跳过逻辑,不承诺自定义裁剪场景也逐帧无广告。未增加解码滤镜、native构建或辅助探针APK。安装确认及Surface复用故障处理使本轮超出原估计,未以重复成功检查或上游扩展研究拖延收尾。 -- 提交与tag以 `Task-Guard: P10-ad-frame-boundary` 及 `recovery/P10-ad-frame-boundary/` 定位;同一原子提交包含实现、定向测试及本文/索引。回滚为撤销该提交,恢复本节开头的已验收回跳tag。 diff --git a/docs/P11-mpv-live-av3a.md b/docs/P11-mpv-live-av3a.md deleted file mode 100644 index abe75c0ac7..0000000000 --- a/docs/P11-mpv-live-av3a.md +++ /dev/null @@ -1,56 +0,0 @@ -# P11:MPV AV3A 直播分片误判修复 - -## Recovery anchor - -- 目标:修复同一 AV3A 直播 Exo 正常而 MPV 加载失败;MPV 应将媒体分片交给已有解码链,保留视频仅手动切换软硬解、音频允许软件解码的合同。 -- 基线:`feature/mpv-dv7-fel` / `18ccd0785f04b279759399623adf3e593e86a22e`;回滚 tag `recovery/AV-DIAG-01-live-params/20260917231508-18ccd0785f04`。 -- guard:`P11-mpv-live-av3a` / `quick-fix`。范围为 `MpvHlsProxy.java`、`MpvHlsSegmentContentPolicy.java`、对应内容策略测试、本文及任务索引;证据和隔离构建输出位于 `build/av3a-live-fix/`、`build/avs3-native/app-cxx/`。保护原有 `app/.cxx/` 104 个文件,不修改 native、依赖或已验收直播 UI。 -- 已证实:手机日志连续出现上游 `code=200`、`invalid nested playlist`,随后 MPV `HTTP error 400`、首分片加载失败。实际清单的 `EXTINF` 后媒体 URI 是 `cctv5.m3u8?ts=…`。旧代码去掉 query 后依 `.m3u8` 后缀判断清单,将数 MB 媒体转为字符串并返回 400;不是解码器初始化失败。 -- 手机:`10CF6H1D2L0009S` / vivo V2453A / Android 15 / ARM64;当前媒体为“CCTV5-真低码AV3A”。已取日志和清单,Web 增量监听已启动。电脑直接尝试分片请求返回 403,不把该请求当成手机媒体 HTTP 200 的替代证据。 -- 状态:修复、19 项定向测试、Debug 增量构建及快速 Release 构建完成;Release 已安装且设备 SHA 相符。真实 CCTV5 AV3A 直播已正常出画面并持续超过 6 分钟,分片 HTTP 200、视频/音频时间戳持续前进、AudioTrack 写入无错误。Debug 的源内迅雷插件 CheckJNI 崩溃独立保留,不以 Release 成功冒称 Debug 源插件兼容。 -- 时间:2026-09-17 23:38 Asia/Shanghai;预计修改 3 分钟、测试/打包 4 分钟、安装实播 6 分钟、收尾 2 分钟,目标 23:53;设备连接不稳时记录实际阻塞。 -- 唯一下一动作:guard 原子提交任务文件并创建 annotated 本地恢复 tag,不推送;本次分片误判修复无剩余实施项。 - -## 证据与最小设计 - -这是已有 HLS 分片处理设计的局部修正,不增加编解码能力或引入上游提交。用户“mpv播放av3a直播还没修复”“继续”及“exo正常,mpv失败”授权继续该故障修复。 - -| 证据(访问 2026-09-17) | 支持的结论和边界 | -| --- | --- | -| 本机 `build/av3a-live-fix/initial-logs.txt`;23:22–23:23 的 `mpv-proxy: invalid nested playlist`,上游 HTTP 200、正文 1.8–3.6 MB,紧接 native HTTP 400 | A,代理自身错误分类导致失败;上游已经返回媒体数据,尚未进入 AV3A 解码 | -| 同目录 `source-playlist.m3u8`;根清单 2065 字符、3 个 `EXTINF`,媒体 URI 仍使用 `.m3u8?ts=…` 端点 | A,后缀不能推翻清单已声明的媒体角色;不在文档保存带鉴权参数的完整地址 | -| [RFC 8216 §3、§4.3.2.1](https://www.rfc-editor.org/rfc/rfc8216.txt),已读缓存 `build/p10-mpv-smart-adblock/research/rfc8216.txt` | A,媒体分片由 URI、可选字节范围及 `EXTINF` 声明;URI 可以是动态端点,不应在取分片时重新按扩展名选择清单解析器 | -| 已锁定 Media3 `e3e922d5c01bc0b564849940fe589daf37360d15` 的本地 HLS sources.jar;`HlsPlaylistParser`、`HlsMediaChunk.createInstance` 114–120 行 | A,Exo 从清单对象创建媒体 `DataSpec`,直接使用 segment URL、byteRangeOffset/Length,不凭分片 URI 的 `.m3u8` 后缀改成加载清单 | -| 当前 `HlsPlaylistRewriter.rewrite` → `UriContext.role` → `MpvHlsProxy.proxyItemUrl` → `Target.role` → `serveItem` | A,语义角色已完整传递,只有消费端忽略它;复用现有结构即可,无需内容扫描、二次请求或重写解析器 | - -选择:不改则持续失败;强制所有 `.m3u8` 都走媒体会破坏主清单及变体;按站点或 `ts` 参数特判不可维护。采用现有语义角色:`MEDIA_SEGMENT` 始终按媒体流处理,`VARIANT_PLAYLIST` 始终按清单处理,`OTHER` 保留 URL/Content-Type 的既有推断。 - -- 请求发出前和响应到达后使用相同判断,避免媒体 Range 被清除、正文被 `body.string()` 消耗或错误记录为播放列表。 -- 已由清单解析出的预取分片同样不再按 URL/Content-Type 拒绝缓存;流式写入、大小限制、HTTP 状态及缓存门控不变。 -- 无额外网络请求、探测缓冲、逐帧工作或新线程;媒体沿用原字节流。密钥/MAP/其他 URI 的既有推断、广告时间轴、IJK 共用链的其余行为保持。 -- 不新增上游/ABI/二进制变更。该问题已有真实日志、规范、成熟 Exo 实现和本地调用链决定方案,无需扩展论文、泛搜 issue 或 native 构建。 - -## 验证与回滚 - -1. 定向测试覆盖 `EXTINF` 后 `.m3u8?ts=…`、错误 MIME、字节范围、低延迟分片、无扩展名变体及未知 URI 的旧推断;保留 PNG 包装分片测试。 -2. 一次 Mobile ARM64 测试/增量打包;核对 native 条目与既有 AVS3 MediaCodec 基线一致。 -3. 安装该包,在实际 AV3A 直播中确认不再出现分片误判、MPV 已识别并初始化 AV3A 音频/视频、持续播放;构建通过不能替代实播。 -4. 定向验证通过后同一 guard 原子提交并创建 annotated 本地恢复 tag,不推送。回滚为撤销本单元提交,保留此前直播播放参数入口。 - -### 2026-09-17 23:48 实施与设备环境 - -- `MpvHlsSegmentContentPolicyTest` 11 项、`HlsPlaylistRewriterTest` 8 项通过;同次 Gradle 完成 Mobile ARM64 Debug,112 tasks 中 11 执行,56 秒。证据:`build/av3a-live-fix/gradle.log` 和 Gradle JUnit XML。 -- Debug APK SHA-256 `1264a29b46945be48710c98955ab5d730bfda191c716817ebdf03861edc8a89c`,181893921 字节;50 个 `.so` 与 `WebHTV-5.6.0-AVS3-MediaCodec-mobile-arm64-debug.apk` 完全一致。OEM 安装确认自动完成,设备 APK SHA 相符。 -- 原设备包 `flags=[ HAS_CODE ALLOW_CLEAR_USER_DATA LARGE_HEAP ]`,无 DEBUGGABLE。新 Debug 在加载源附带的 `cache/thunder/.../libxl_thunder_sdk.so.arm64-v8a` 时,`XLLOader.init` 的 `CallNonvirtualObjectMethodV` 触发 CheckJNI SIGABRT;发生于媒体清单加载之前。栈、进程退出和启动日志已保存于同目录。不是本轮 Java 分片处理栈,不将该失败计作播放通过,也不修改无关 JNI/插件。 -- 为匹配原设备运行环境,增加一次 Release 变体打包/安装,保持标准构建配置;不关闭 Debug 校验、不重编原生库、不重复通过的测试。预计完成时间由 23:53 调整到约 23:57。 -- 23:55:完整 Release 的 Java 编译和 lintVital 已完成,R8 优化超过 6 分钟仍未结束。为完成当前局部修复,取消本任务 Gradle 客户端(退出 130),切换仓库已有 `fastRelease` 路径;仅跳过混淆/资源压缩,保留非 Debug 配置和完整功能/原生库。完整 R8 构建未完成,不计作验证通过;不再重复已通过的单测。 - -### 2026-09-18 交付与真实直播结果 - -- 快速 Release:`assembleMobileArm64_v8aRelease -PfastRelease=true` 通过,2 分 30 秒;119 tasks 中 12 执行、1 来自缓存、106 up-to-date。完整输出 `build/av3a-live-fix/gradle-fast-release.log`。 -- 交付 APK:`app/build/outputs/apk/mobileArm64_v8a/release/mobile-arm64_v8a.apk`,150043820 字节,SHA-256 `37ead8e3a5a986b669b032e386f2ec94134b7285edb89630585c00aec91b7863`。OEM 确认自动完成,设备 APK SHA 相符;安装记录 `install-release.log`、`installed-release-sha256.txt`。 -- 制品核对:50 个 `.so` 中 49 个与 Debug 基线逐字节相同,包括全部 MPV、FFmpeg/AV3A 和 ASS 库;唯一差异是本来由 App CMake 构建的 `libexo_dovi_renderer.so` 的 Release/Debug 变体,源码未变,ELF 依赖和动态导出一致,构建命令及对照证据记录在 `release-apk-identity.json`。没有依赖、补丁或原生源码变更。 -- 手机同一路“CCTV5-真低码AV3A”:00:15:38 发起播放,初始状态由缓冲转为播放,8 次采样中后 6 次持续 `state=3`,位置从 2.353 秒到 17.484 秒。00:22:03 同一 trace `p-1ar58nd-1` 的视频位置 381.280 秒、音频 PTS 381.241 秒,清单持续刷新,分片 `code=200 range=bytes=0- mime=video/MP2T`;当前快照无 `invalid nested playlist` 或 HTTP 400。 -- `release-playing.png` 可见实际直播画面;面板显示 H.264/AVC、1920×1080、MPV MediaCodec 硬解,AV3A 10ch 音频软件解码至 PCM 2.0/48kHz,MPV Vulkan/gpu-next,A-V 显示 0ms。软件日志的实际 `avsync` 约 0.000015 秒;不将此值当作物理屏幕/扬声器测量。 -- AudioTrack 定向证据:2516 次写入,requested/accepted 均为 72444160 字节,short/zero/errors 均为 0;原生播放头持续前进。未自动测量扬声器可听声压,不扩展至其他直播频道或 4K 软件解码性能。 -- 证据位于 `build/av3a-live-fix/`:`release-playback-samples.jsonl`、`release-snapshot.json`、`release-logs.txt`、`verified-events.json`、`release-playing.png`。已停止额外构建/测试;提交定位使用 `Task-Guard: P11-mpv-live-av3a` trailer,恢复 tag 前缀 `recovery/P11-mpv-live-av3a/`,实际完整提交/tag 由 guard 输出和 Git 记录提供。 diff --git a/docs/P2-4-mpv-android-fel.md b/docs/P2-4-mpv-android-fel.md deleted file mode 100644 index 6962704144..0000000000 --- a/docs/P2-4-mpv-android-fel.md +++ /dev/null @@ -1,1585 +0,0 @@ -# P2-4:Android MPV DV7 FEL 双层重建 - -## Recovery anchor(当前:9.26,恢复显式输出模式的 P8 HDR10 兼容) - -- 目标/验收:设备不支持原生 P8 时,沿用已有 HDR10 降级,原样片在用户选择的 GPU/Vulkan、视频硬解模式下实际出帧并可跳转;保留完整 FEL。用户已明确要求恢复该行为,无待批准事项。 -- 工作区/回滚:`feature/mpv-dv7-fel` / `c9a1ac99d05f2d8bcac0558b725d34e196b468e2`,已创建 `v5.6.0-202609171247-fel-crash-fixed`;guard `P2-4-p81-hdr10-compat` / quick-fix,保护原有104个 `app/.cxx/` 文件。 -- 范围:仅 `PlayerManager.java` 的既有兼容判断入口、本文/索引及 `build/p81-hdr10-compat/`、既有隔离C++构建输出;不改 native、依赖、解码模式或原生DV能力判断。 -- 已有证据:手机原 `P8.1_4K60_GlassBlowing.mkv` 在显式GPU/Vulkan硬解下 `Video: no video`,native拒绝不支持的DV路径并正确禁止软件后备;App在非AUTO模式提前返回,已有 `updateDv8Handling` 未执行。证据 `/private/tmp/webhtv-p81-regression-20260917/`。 -- 状态:入口修复、48秒Mobile64构建及安装完成,包内27份原生库及10份MPV资产与上个FEL修复包逐字节一致;手机已确认 `hdr10`、`mediacodec`、`pq` 及GPU/Vulkan硬解。用户随后明确“可以了,打个tag”,以用户实播确认闭合该场景,不追加验证。 -- 验证边界:自动探针在起播541ms取到单色帧,未完成画面/seek/FEL相邻场景,不能记为自动验证通过;用户实播确认后停止可选检查。FEL实际激活后的提前返回和全部原生库保持。 -- 唯一下一动作:按用户确认用当前guard原子提交并创建本地恢复tag,不推送;后台监听保持。 - -## 9.26 显式输出模式下恢复 P8→HDR10(2026-09-17) - -这是既有兼容政策的局部回归修复,沿用本任务已完成的mpv/Android能力研究,不增加上游集成。2026-09-17本地读码证据:`PlayerManager.shouldEvaluateMpvOutput/evaluateMpvAutoOutput` 只为AUTO或FEL请求调度,且显式输出在P8兼容判断前返回;`MpvPlayerEngine.getVideoPlaybackDetails` 已可读取未选中源视频轨,`selectDv8Handling` 已规定“原生DV明确不支持且HEVC HDR10明确支持”时选 `hdr10`;native已有 `demuxer-dovi-profile8=hdr10` 保留HEVC Main10/HDR10基础层并移除DV元数据。使用这些现有入口,保留UNKNOWN三态和严格硬解合同。 - -方案比较:不改会继续无画面;扩展native对样片兼容ID的处理超出恢复既有HDR10功能所需范围;自动转视频软解违反用户合同。采用窄适配:硬解MPV也调度判断,显式模式仅允许源Profile 8执行已有HDR10兼容,随后立即返回,不进入AUTO输出/渲染选择;兼容变化通过 `rebuildAndRestartMpv(null, ...)` 保持用户GPU/直出及Vulkan/OpenGL设置,重用已有位置/速度/播放状态恢复。实际FEL激活仍先返回,无第二次重载。 - -手机样片名称虽为P8.1,容器实际声明Profile 8、compatibility ID 6;保持原始声明,不把6改成1、不放宽native拒绝条件。已知正常版本 `v5.6.0-202609061650` 中也存在同类native拒绝条件,因此不能归咎于AVS3新增该条件。 - -最小验证:一次Mobile ARM64离线构建,包内原生库与已安装FEL修复包逐字节相同;原样片在显式GPU/Vulkan+硬解下实际 `hdr10`/MediaCodec/PQ、真实画面与seek,另核对FEL请求关闭时兼容入口和一个FEL相邻场景。无新依赖、ABI、逐帧处理或native重建。完成后本guard原子提交/tag,不推送;回滚为上述已验证FEL提交。 - -交付结果:`build/p81-hdr10-compat/gradle.log` 显示Mobile64构建48秒通过;APK为 `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk`,165335184字节,SHA256 `18e641a666268a80369bf426d7761f1e9d3d740cacdd63c5d341b677589befe2`。`apk-verification.json` 确认27份原生库、10份MPV资产与基线相同,CRC/v2签名通过,ZIP开销763799字节;`install.log` 确认安装成功。`device-probe.log` 已取得P8 `handling=hdr10 hwdec=mediacodec gamma=pq output=GPU/Vulkan hard=true FEL=false`;首次PixelCopy在541ms仅有一种色值,后续自动场景未执行。随后用户实际播放确认“可以了,打个tag”,接受当前修复并要求立即闭合;未将该自动取帧失败记为通过,未追加构建或相邻测试。 - -## 历史 Recovery anchor(9.25,FEL 起播配置所有权修复) - -- 目标:修复手机开启DV7/FEL后native abort,保留完整FEL及手动视频解码合同;用户2026-09-17明确“修复”,无需再次确认。 -- 工作区/回滚:`feature/mpv-dv7-fel` / `8919cf134218a3d3bb30f91f3180e9cd83eac982`,guard `P2-4-fel-context-ownership` / upstream;保护104个原有 `app/.cxx/` 文件。 -- 范围:FEL patch中 `vo_gpu_next.c` 配置设置及生成源码、定向内存测试、双ARM `libmpv.so`、本文件/索引/构建记录;不改变FFmpeg、libplacebo、JNI、Exo或其他18份MPV库。 -- 已有证据:手机10:24及11:53两次同栈SIGABRT;符号与包内 `.text/.rodata` 逐字节匹配,`preinit→free_option_data→free_obj_settings_list→ta_free`。9.23将静态数组/字符串写入拥有析构权的配置对象,随后释放导致断言。引入提交 `8de0fd70942d513034fb8118de5229d7eb719622`;AVS3前后和手机 `libmpv.so` SHA均 `1d55dbe26cd7192cddf4f7388becdf118c459f7bb4ce15f54e908891af91b454`。 -- 状态/证据:修复与定向验证完成,最终Mobile64 APK已安装手机。真实ta回归先复现旧错误再通过7条路径;补丁往返/静态合同、双ABI增量编译、ELF/完整公开导出、18库身份及APK检查通过;手机原样片完整FEL激活、实际画面、seek和退出通过。证据 `build/fel-context-ownership/`;后台监听PID61269在 `/private/tmp/webhtv-fel-phone-restart-20260917/` 继续采集并自动重连。9.24电视性能结论仍待目标电视证据。 -- 验收:真实ta和对象配置copy/free复现旧代码非法释放;修复后Vulkan、OpenGL、自动切换、双失败及普通路径均安全且无遗失原配置;双ABI/公开导出/ELF及18库身份保持;手机原样片FEL起播、seek、退出不再出现该崩溃。 -- 耗时:12:10开始;原12:25目标因真实allocator回归的host适配超出,12:40目标又因增量APK空洞与OEM辅助包安装拦截超出;仅重新封装APK及复用已有仪器包,12:44完成手机场景。无新依赖、原生全栈重建或泛搜。 -- 唯一下一动作:用当前guard原子提交并创建本地恢复tag,保持后台日志监听,不推送。 - -## 9.25 FEL 起播配置所有权修复(2026-09-17) - -设计证据沿用本任务固定mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` 及现有补丁,不新增上游集成:`options/m_option.h::m_option_copy`、`m_option.c::copy_obj_settings_list/free_obj_settings_list/obj_setting_free` 定义对象配置的深拷贝与释放;`m_config_core.c::free_option_data` 按选项类型析构。2026-09-17已读实际源码(A级)。手机两次符号栈提供独立运行证据;这是已有所有权合同的局部回归,不引入架构或算法,不重复已完成的跨项目研究。 - -方案比较:保持现状会释放静态内存;取消断言或不释放整个配置会掩盖非法释放并泄漏;手工清空字段易遗失原配置和自动切换的分配。选用mpv现有对象设置copy函数,替换时先释放旧拥有的列表,再深拷贝新列表、名称及属性,由原配置析构统一释放。只在FEL创建GPU上下文时发生两组小配置分配,不进入逐帧路径。Vulkan与自动OpenGL共用同一设置函数,普通播放路径不变。 - -保留原FEL完整层重建、10bit、BL/EL同步、渲染选择及质量;这里的GPU后端自动切换不改变用户视频硬解/软解模式。native ABI/公开API/依赖版本、许可证与源边界不变;只重建两份libmpv并核对其他18个库,尤其保留已接入AVS3的libmvcodec。测试使用真实allocator与对象copy/free,GPU由桩提供成功/失败分支,不能将主机通过当作手机真实起播通过。 - -实施与构建证据(`build/fel-context-ownership/`): - -- `baseline-invalid-free-final.log` 由真实 `ta.c/ta_utils.c/ta_talloc.c` 与对象配置copy/free、生产preinit配置块复现旧实现的ASan非法释放;`fixed-memory-contract.log` 通过普通输出、显式Vulkan、显式OpenGL、auto成功、auto回退、双失败、显式Vulkan严格失败7条路径,验证原列表释放和全局名称/label/属性保留。只模拟GPU创建和外层配置容器,不模拟allocator。 -- `set_android_fel_context_options` 对两个列表分别调用 `m_option_copy`;初次选择和自动OpenGL回退复用此函数。权威patch只替换 `video/out/vo_gpu_next.c` 块,其他26块原样保留;`patch-verification.json/static-contract.log` 正向/反向应用、实际源码逐字节往返及既有静态FEL合同通过。 -- 两ABI只执行 `buildall.sh -n --arch arm64 mpv`、`--arch armv7l mpv`,使用同一NDK29/API24与已有静态依赖;`arm64-build.log/armv7l-build.log` 已通过。随后 `scripts/build_mpv_native.sh --abi all --stage-only` 处理依赖命名空间/strip;只复制两份libmpv到assets。第一次ARMv7复制早于stage完成,ELF检查拦截到旧 `libav*` 命名;等待stage结束重新复制后验证通过,错误中间产物未打包。 -- `native-verification.json/native-assets.log`:两库的 `.text/.rodata` 地址与字节对应本次未strip构建;公开动态符号集合和 `SONAME/DT_NEEDED` 与基线相同;其余18库逐字节保持,arm64 AVS3 codec SHA256仍为 `37114bda4673f642229fb71eb3bf4f63d82d3d4939f18cc10556ab51f43359f6`。构建锁四仓及NDK/API沿用9.24,无新依赖或JNI重编。 - -| 产物 | 字节 | SHA256 | -| --- | ---: | --- | -| FEL权威patch | 见仓库文件 | `e96d4e45bba810f4f87c7fda16fd5207c1e054e180699881bffc1bcaf5094818` | -| arm64 libmpv | 17820224 | `9f231d28bb24c976c7ab89daf2d77ca3197eac3d182119bd4ae926674ebe3c65` | -| armv7 libmpv | 14635396 | `ec640eefa4517ca1d92536327de963bcf8b3bafd8e351437fa87d6425c425ed1` | -| Mobile64 debug APK | 165335184 | `73e6c045ca621a70b15c9764ca3ff612481b92e8c8386173555a17d830eaa782` | - -最终打包与手机证据: - -- Gradle使用既有 `build/avs3-native/app-build.init.gradle` 隔离C++输出,执行 `:app:assembleMobileArm64_v8aDebug --offline --max-workers=4`。首次81秒成功,但包体积门槛发现42132002字节ZIP开销;保留该中间包及日志,移走本次输出后仅重封装37秒,最终ZIP开销764153字节。`apk-verification.json` 验证CRC/v2签名、10份MPV assets与候选逐字节一致,以及27份 `lib/` 原生库与修复前APK一致。 -- 最终包由OEM安装辅助脚本完成风险勾选及安装按钮确认,`final-mobile-install.log` 成功;手机实际base.apk SHA256与表中最终包一致。`device-library-sha256.txt` 确认手机实际提取的arm64 libmpv与候选一致,libmvcodec仍为原AVS3版本。 -- `device-fel-probe.log` 使用已有仪器入口调用真实App方法,无UI点选播放:原 `player=2/FEL=2` 保留,vivo V2453A / Android15 / `10CF6H1D2L0009S`,PID11658,原片 `Download/影音测试库/V01_DV_Profile/P7_FEL_4K24_GIJoe.mkv`。native日志明确 `selected-track=1 profile=7 vo=gpu-next api=vulkan single-load=1`;实际 `current-vo=gpu-next`、`current-gpu-context=androidvk`、`hwdec-current=mediacodec`、gamma=`pq`,App实际FEL激活属性为true。PixelCopy起播7789种色值、seek后9101种色值,12秒跳转后播放位置12614ms,同一PID正常stop/退出;不将色值数量当作逐像素FEL质量标定。 -- `device-fel-logcat.log/device-fel-web.log` 保留本轮原始记录,`device-fel-summary.log` 提取起播、硬解和FEL渲染记录;未重现 `ta.c` canary / SIGABRT。新建辅助包被OEM拒绝后改为更新原有任务仪器包并通过shell包管理器安装,未改产品权限或关闭设备安全开关。 -- 这是FEL起播崩溃修复验收;未重新执行无关AVS3/ASS矩阵,未声称完成电视持续掉帧优化、ARMv7实机或完整画质标定。没有新增逐帧工作;配置分配只发生在GPU上下文初始化/失败回退。用户原有播放与FEL偏好保持,后台监听在12:43:35仍有新日志写入并继续运行。 - -回滚:以 `8919cf134218a3d3bb30f91f3180e9cd83eac982` 为源/补丁/两份libmpv恢复锚点,修复前两库及Mobile64包保存在证据目录;用本guard提交并创建 `recovery/P2-4-fel-context-ownership/*` annotated本地tag,不推送。最终提交/tag由task guard闭合记录关联,不为填写提交自身ID再重建或重复手机场景。 - -## 历史 Recovery anchor(9.24,绑定类型对照候选已完成本机验证) - -- Objective / acceptance:实施 9.22-B 的第一层裁决:空控制、sampler-only、storage-only、组合布局,均 fresh record,不提交诊断 draw/dispatch。默认不运行,用户主动、独占、30 秒预算,可取消;保留真实 AImage 所有权与 fence,不保存/输出节目像素。 -- Current unit:`feature/mpv-dv7-fel` / `8de0fd70942d513034fb8118de5229d7eb719622`;guard `P2-4-fel-bind-probe` / upstream;保护 104 个原有 `app/.cxx/` 文件。上个单元已提交,tag `recovery/P2-4-fel-startup-selection/20260916124938-8de0fd70942d`。 -- Scope:MPV 诊断接线、Web 既有诊断抽屉、FEL patch/生成源码、定向测试、双 ARM libmpv、本文及索引;不升级依赖,不改 Exo/JNI/其余 18 库。证据 `/private/tmp/webhtv-fel-bind-probe-pa3hqm7z/`。 -- Status:原生对照真实函数及 core/VO ASan/UBSan、Java 2 类 21 项测试/产品编译、实际 Java 生成的 Web 脚本语法、完整补丁 27 文件往返、双 ABI/ELF/公开导出与 18 库边界均通过。TV64 `202609161336`(SHA256 `9192d5a6df9cb5615f7bacaed8bd55200fd7240bacfd04727dd4324eca2e876e`)的 10 个 MPV 资产、ZIP CRC/v2 签名及 27 个其他原生库与基线一致均通过;104 个既有 `app/.cxx/` 文件无变化。未安装/未取得电视结果。12:55 Asia/Shanghai 起原目标 13:35,因恢复基线修正、一次 mpv 辅助函数名修正与 Gradle 缓存权限等待顺延至 13:45 收尾;不重复已通过且未变更的检查。 -- Risks:首次只裁决绑定类型,普通/YCbCr/稳定/动态图像分组及真实提交依结果再进入;C 仍等待电视证据,不能把诊断包称为卡顿修复。 -- Rollback:上述提交/tag 成套恢复源/补丁/App/双 ABI;保留已交付的 9.23 TV64 包。 -- Exactly one next action:电视安装 13:36 候选,在 Web「诊断 → FEL 卡顿定位」主动运行一次绑定对照并导出日志,用四组结果决定 9.22-B 后续分组;C 不先行。 - - - -## 9.24 用户主动的绑定类型对照(2026-09-16,本机候选已验证) - -用户“继续实施优化/继续”已授权沿 9.22 逐级实施。9.23 已原子保存为 `8de0fd70942d513034fb8118de5229d7eb719622`;本次只进入 B 的第一层,不越过目标电视证据直接实施 C。最佳实践证据、完整来源版本、五类研究、三方案比较和性能门槛沿用 9.19/9.22,不重复泛搜或把既有源码阅读算新发现。 - -具体核对了锁定 mpv 的 `playloop.c::set_pause_state/get_internal_paused/run_playloop`、`vo.c::vo_set_paused/process_fel_prepare/fel_prepare_timed_out`、stable mapper 的 `map/create_input/prepare_conversion`,以及 App `DiagnosticControls`、`MpvDiagnosticCollector` 和 Web 诊断抽屉。当前常驻计时只能观测生产链的组合 bind,未提供最小布局对照;暂停 VO 使用短互斥锁/唤醒,不同步等待 mapper,适合由 core 与 VO 做显式握手。沿用成熟 mpv 内部暂停原因与原子请求,不新建渲染线程、设备或常驻探针。 - -采用的窄实现: - -1. 仅当前正在播放的 Vulkan FEL 实例接受请求;Web 复用同源 POST/限频和日志入口,与深度像素/PCM 统计互斥。请求不因换片/重建自动迁移到新实例。 -2. 下一个合法 AImage 在 mapper 内持有时声明诊断阶段,core 暂停内部播放并确认;保留用户的 pause 意图,结束/取消按最新用户意图恢复。源 acquire fence 必须已 signal;持有期间不归还 AImage、不允许 producer 改写,不额外获取 decoder buffer。 -3. 先 flush 既有 libplacebo 工作,以空队列 fence 排空本应用的 GPU 队列;有界轮询,可取消。该排空提交单独记录,不属于诊断 draw/dispatch。故障/超时未 signal 的 fence 延后回收,不能提前销毁 GPU 在用对象。 -4. 独立诊断 command pool、完整写入的 descriptor set 与最小 pipeline layout;空控制/输入 sampler/输出 storage/组合逐组 fresh reset/begin/bind/end。没有 shader 执行或图像读写,记录布局中的 imageLayout 不冒充真实 GPU transition。每组最多 8 次预热 + 32 次采样 / 5 秒,总请求预算 30 秒,分别报告 bind 与 record 的墙钟/线程 CPU 和样本数,冷创建单列。 -5. 一份原尺寸、原格式目标图像,按实际 VkMemoryRequirements 计入 96 MiB 上限;不降位深/分辨率补数。诊断 descriptor/command 完成销毁后才继续当前帧生产转换。失败仅终止诊断,不替换 FEL shader、配对、缓存或正常同步。 -6. 真实函数 host 合同覆盖默认关闭、独占、取消/截止、暂停恢复、fresh 命令、合法布局、无 draw/dispatch/提交、资源释放和预算。随后同锁双 ARM 构建、ELF/公开导出/18 库边界及 TV64 包身份/签名。电视 B 结果到来前只交付诊断候选,物理呈现/实际流畅度仍未验收。 - -普通图像/YCbCr/动态源分组和真实提交按 9.22 的结果分流执行;这是逐级测量合同,不遗漏后续裁决,也不声称四组 bind 的差值就是播放可获得的收益。厂商 API 自身挂住无法由应用强制打断,保留这一实测限制,不强行销毁仍在使用的资源。 - -### 本机验证与实现边界 - -- `probe-contract-final.log`:直接编译 `hwdec_aimagereader_vk_probe.inc` 与实际 core 暂停/property 函数,ASan/UBSan 通过。四组各 40 次 fresh reset/begin/end,共 120 次真正 bind,3 次完整 descriptor 写入;唯一 2 次 queue submit 是空的排空 fence。默认不开启时无计时/分配;覆盖主动取消、用户暂停意图、原尺寸 10bit、真实 allocation 超额、分配/record 失败、未 signal fence 延后销毁、device lost、acquire 不就绪、握手失败及截止;所有诊断 GPU/descriptor 引用按顺序释放。集成编译修正为锁定 mpv 的 `m_property_strdup_ro` 后仅续跑相关合同与失败构建。 -- `core-contract.log`:实际 core/VO 既有生命周期及新增诊断阶段通过;30 秒不能靠重复轮询续期,诊断结束恢复正常 750ms 交接预算。 -- `gradle-focused-final.log`:仅 `FelBindProbeControlTest`(8 项)与 `MpvDiagnosticsPolicyTest`(13 项)及必要产品编译,52 秒通过。初次 Gradle 因沙箱不能访问已安装缓存锁而未执行测试,使用既有授权重试,不把权限失败算代码失败。`web-verification.log` 核对实际 Java 生成的脚本语法和新增控件唯一 ID,不宣称完成电视 UI 操作。 -- `patch-roundtrip-build-final.json`、`native-static-build-final.log`:27 文件最终基线正向应用、当前树反向检查、逐字节往返及全部既有 FEL 合同通过。第一次恢复目录已带旧补丁,静态合同拦截了不完整差异;修正为先逆应用已验证旧补丁再生成完整 patch,保留未变文件的原 patch 段,没有用错误版本构建/交付。`player/playloop.c` 未在旧 FEL patch 中,基线只撤销本单元的三个明确修改,保留其他播放器补丁。 -- 第一次对照是 `pipeline=none` 的合法最小 layout / record-only 测量,没有 shader 执行;它不能单凭“全部很快”就排除实际 pipeline 或渲染负载的相互作用。输出位深/原始尺寸不变;96MiB 限制是显式图像分配,并非厂商驱动内部/RSS 总量保证。普通播放 map 统计排除该次主动暂停。 -- Web 状态仅读取 App 缓存;请求 epoch 阻止跨媒体投递,native serial 阻止上次结束通知覆盖新请求。已发出的取消保持互斥到 native 完成清理;原生状态区分暂停确认、执行、结束和失败,结果复用既有结构化 native 日志,未改共享事件 schema。 -- `hwdec_aimagereader_vk.c::aimagereader_vk_map/buffer_removed` 共用既有 mapper mutex,源移除回调不能在对照期间销毁正在引用的 input view;core 暂停/取消只使用独立的 VO 状态锁及原子请求。`MpvPropertySnapshot.update` 接受按媒体代际校验的新增属性,状态回调没有被属性白名单丢弃。 - -### 构建、产物与回滚 - -锁定的 mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`、FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`、libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`、builder `99a60ad2141d5ace94453590903c2c6b9a0a2443` 和 NDK29/API24 不变。两 ABI 增量构建通过,日志 `arm64-build.log/armv7l-build.log`;`stage.log`、`native-assets.log`、`native-boundary.log` 核对 ELF/namespace/公开 libmpv 导出和其余 18 库逐字节不变。保留既有 stable shadow 与旧 32 位 time_t 警告,未扩大修复范围。 - -| 产物 | 大小(字节) | SHA256 | -| --- | ---: | --- | -| FEL 权威 patch | 见仓库文件 | `2a6c2288987886bc247eab02ad40c6e516ad06f215ef0322620fa1e45a155032` | -| arm64 `libmpv.so` | 17820296 | `1d55dbe26cd7192cddf4f7388becdf118c459f7bb4ce15f54e908891af91b454` | -| armv7 `libmpv.so` | 14635188 | `7f7e39193526b81f3e90d6a30ad8bfa828d6a5c034d42499e6dc48ac7a337dc4` | -| TV64 debug 5.6.0 / `202609161336` | 164160281 | `9192d5a6df9cb5615f7bacaed8bd55200fd7240bacfd04727dd4324eca2e876e` | - -固定交付副本:`/private/tmp/webhtv-fel-bind-probe-pa3hqm7z/fel-bind-probe-tv64.apk`。包由 `8de0fd70942d513034fb8118de5229d7eb719622` + 本单元未提交工作树构建,不冒称内嵌将来提交号。复用 9.23 已验证且源码未改的 App C++ 产物,打包排除该不变的 externalNativeBuild;最终 APK 的全部 27 个 `lib/` 库逐项与 9.23 包一致,MPV 的 10 个 assets 库与本次资产相符。打包 114 秒,无增量 ZIP 空洞遗留(总 ZIP 开销 801740 字节);完整 CRC、v2 签名和 104 个保护文件通过,见 `apk-verification.log`、`artifact-summary.json`、`protected-cxx-final.json`。 - -本单元以 guard `P2-4-fel-bind-probe` 原子提交并生成本地 annotated recovery tag,不推送。回滚为上述 9.23 提交/tag,成套恢复 patch/App/两份 libmpv;TV64 9.23 固定副本仍保留。这是用户主动的诊断候选,不是持续卡顿/实际显示帧率的验收通过。下一步只从电视四组对照日志裁决后续 B,暂不实施 C。 - -## 历史 Recovery anchor(9.23,起播一次选择候选已完成本机验证) - -- Objective / acceptance:实际选中 DV7 轨道后、VO/decoder 创建前一次选择完整 FEL;不再从任意可用轨道推断后销毁 App 播放器重载;非 FEL 路线、10bit、EL 配对/同步不变。视频首帧须有本轮视频输出提交证据,不能仅凭 READY/轨道尺寸。 -- Current unit:`feature/mpv-dv7-fel` / `5df95f475009ed0d04d864b60d7d22b229e87e95`,guard `P2-4-fel-startup-selection` / upstream;用户多次“实施优化/继续”已授权,无待审批事项。保护原有 104 个 `app/.cxx/` 文件。 -- Scope:FEL 权威补丁及生成的 mpv 树、相关 Java 播放适配/策略、定向合同验证、两份 libmpv 与 lock、本文及评估索引;不升级依赖、不修改其余 18 库/JNI/Exo。 -- Plan:沿用 9.21/9.22 源码与研究结论。`android-dovi-fel` 表达请求,`vo_extra` 保存选中轨道的实际状态;各 core/VO/decoder 消费实际状态。每个 FEL VO 单独指定 gpu-next/context,普通视频配置不被改写;App 观察 native 实际属性,不再启动识别后的第二次 loadfile。 -- Verification / status:实现、6 类 34 项 Java 测试及真实 C 建链/VO/decoder 的 ASan/UBSan 合同通过;源码补丁 24 文件往返一致。最终双 ABI 编译、ELF/公开导出及 18 个其他库逐字节不变通过;TV64 `202609161235` 的 10 个 MPV 库身份、完整 ZIP/v2 签名均通过,未安装/未获电视性能结果。证据 `/private/tmp/webhtv-fel-startup-uwqa5kyt/`。原 11:45/12:10 估计超出:字幕/Surface/自动 Vulkan 回退联动、一次 Java import 修正、缓存权限与产物检查,以及增量 ZIP 空洞导致的一次必要重打包;没有重跑已通过测试。 -- Risks:持续 descriptor bind 等待不由本单元宣称解决;9.22-B 仍为后续独立诊断单元,C 仍需其证据。电视无法 ADB,本机验证不能替代 9.22.5 的设备门槛。 -- Rollback:本单元基线及 `recovery/P2-4-fel-cross-project/20260916082750-5df95f475009`,源/补丁/Java/两份 libmpv 成套恢复。 -- Exactly one next action:按 9.22-B 实施用户主动启动的有界 sampler/storage 绑定对照,用目标电视证据决定后续暂存后端;9.23 包可独立供用户验证起播变化。 - - - -## 9.23 起播一次选择 FEL(2026-09-16,本机候选已验证) - -本单元是用户已批准 9.22 路线的第一项,研究证据和三方案比较直接沿用 9.21/9.22,不重复联网搜索。进一步本地核对锁定 mpv:`android-dovi-fel` 的 UPDATE_VO 选项是全局配置,不能在建链时直接篡改 opts/shadow;`reinit_video_chain_src(track)` 与现有 `vo_extra.prefer_hdr_output` 提供按实际 track 创建/替换 VO 的生命周期。采用同类的实例状态,decoder 通过输出链 `mp_stream_info` 继承;运行中质量保护也按实例激活,不把全局“请求”当作所有媒体的实际渲染状态。首帧证据沿用 `vo_has_frame` 的“本次 seek/reconfig 后已排入 VO”定义,明确不等同于物理屏幕呈现时间。 - -### 实现与边界 - -- native `player/video.c::reinit_video_chain_src` 在实际 track 上选择 FEL,显式输出 `null`/封面/缺失 track(包括 lavfi-complex 输出)不抢猜轨道。FEL 创建 gpu-next 后才创建 BL decoder;VO 复用比较实例 FEL/渲染请求,切轨或换片可恢复原配置。`player/loadfile.c::update_vo_chain_el_state` 使用真实 VO chain 的 track。 -- `vo_extra.android_dovi_fel` 经 output-chain stream info 传播到 decoder;core lookahead、VO、AImageReader 和 stable mapper 均消费实例状态。原全局 bool 只表示请求。两个 decoder 的 fast/skip 在 native 实际激活后约束,显示允许丢迟到帧,BL/EL 不跳过必要解码;普通内容保留原选项。 -- `android-dovi-fel-vulkan=no/yes/auto` 只在 FEL VO preinit 内选择 context;App 复用现有 Vulkan 能力与自动/手动偏好。auto 初始化失败在同一次 load 内试 OpenGL,手动 yes 保持严格;普通视频全局 VO/gpu-api 不改。 -- App 不再从第一条可用轨道推断 FEL,不再执行 `manual-dv7-fel-output/startup` 销毁重载。FILE_LOADED 时取得实际 native 选择,避免属性观察回调晚到触发错误的二次决策;Surface、音频直出策略和字幕能力随实际输出。仅 FEL 恢复 App 原直出 `sid=no` 默认,使用 file-local option;已保存选择、用户明确 mpv.conf sid 继续优先。 -- 新只读 `video-frame-submitted` 要求本轮有效 video chain/VO、READY 视频状态和 `vo_has_frame`;START_FILE 清理 App 首帧状态,seek/reconfig 的 native 状态排除旧保留画面。MPV 提交证据送入 Media3 首帧事件,日志明确 `mpv-vo-submitted`,不把尺寸、音频 restart 或物理显示混为一谈。 - -### 已完成验证 - -- `native-contract.log` 的前半及 `native-contract-remaining.log` 的后半覆盖实际生产函数:选轨/VO 复用/失败/首帧 epoch、core/VO 回压/取消、decoder 发布与EL/BL配对、drop 路径、copy fence/lease、packet/RPU、descriptor 内容缓存及诊断有界性;ASan/UBSan 通过。旧 producer fixture 缺少 9.20 的 latency window stub,补齐后只续跑尚未通过的后半,没有重跑前半。专门的缓存测试继续验证真实 latency 算法,handoff fixture 只观察采样调用。 -- 未提供实样给 host EL 解码,`SKIP EL/RPU sample decode` 保持为跳过;这不等于电视完整画面或性能通过。 -- `gradle-focused-final.log`:TV64 相关 6 个 Java 测试类及产品代码编译通过(59 秒);原有废弃 API/工具链警告不扩大处理。第一次构建缺少 TextUtils import 已修正,成功结果没有重跑。 -- `patch-roundtrip-final.json`:24 文件的权威补丁到实际源码逐字节一致;同锁双 ABI 构建 `arm64-build.log` / `armv7l-build.log` 已通过。`native-assets.log` / `native-boundary.log` 确认仅两份 libmpv 变化,其余 18 库(包括 JNI)及 libmpv 公开导出保持不变。依赖 lock 未改变。 - -### 产物、验收与回滚 - -- TV64 APK:`app/build/outputs/apk/leanbackArm64_v8a/debug/app-leanback-arm64_v8a-debug.apk`;固定副本 `/private/tmp/webhtv-fel-startup-uwqa5kyt/fel-startup-tv64.apk`。版本 `5.6.0` / buildTime `202609161235`,164160281 字节,SHA256 `f5c51f91c9bd1889417b813c86cd790ce5c84a861fe3a045cdea29fd65fb76ba`。包中 `GIT_REVISION` 为本单元基线 `5df95f475009ed0d04d864b60d7d22b229e87e95`+dirty,表示提交前打包,不伪称来自未来提交。 -- libmpv:arm64 17810152 字节,SHA256 `ff9992cf633e4582a8051af34c2d486dd7c9186feb834f3595c4021a24d0ee67`;armv7 14622500 字节,SHA256 `939edf95ac804f22af2fbe329f2f46997b0a2eac82dd166b2fdc0bd395f3a53b`。FEL 补丁 SHA256 `ed2468126e6506c723bde0323c4fdc5677498abaa20c5e0b83f80e51fe57ad95`。最终仅按仓库原补丁格式移除空上下文行的前缀空格,reverse-apply 检查通过,编译源字节未变,无须重建。 -- 首次增量 APK 的 18618205 字节 ZIP 空洞未通过原 5 MiB 预算;只移走本单元生成的 APK,再由 AGP 正常生成/签名。最终无效空间 816267 字节、包内 10 库逐一匹配、ZIP CRC/v2 签名通过;75 秒重打包不重复 Java 单测。原 104 个 `app/.cxx/` 文件内容全部保持,无需恢复/移动任何文件。 -- `startup-contract-final.log` 额外验证最后的三态 Vulkan 请求完整传到 VO;`auto` 能表示同次加载内的 OpenGL 回退,未被 bool 截成手动 `yes`。只有这项受相关代码调整影响的合同重跑。 -- 这是起播修正候选:完整 FEL/10bit 的处理合同保留,未宣称本电视原有 33–37ms descriptor bind 等待消失。电视验收仍需同片独立起播、无 seek 持续区间、切轨/换片/退出和完整画面;无 ADB,不伪报安装或实测通过。源、补丁、Java 与两份 libmpv 由本 guard 原子提交并生成本地恢复 tag,不推送。 -- 回滚保持本节顶部基线和 tag,恢复本单元任务文件为一组;不移动已有 tag,不动 Exo、JNI、其余依赖库或用户脏文件。B/C 后续状态见顶部唯一下一动作。 - -## 历史恢复记录(9.22,跨项目复核后的下一阶段方案) - -- Objective:继续解决日志33的起播慢和持续掉帧;本单元完成跨项目源码、规范、讨论和本机APK静态复核,给出能区分原因的下一步,保留完整FEL、10bit及非FEL行为。 -- Current unit:`feature/mpv-dv7-fel` / `98d247ea193c58a3dbfa4033d679023282632a8e`;guard `P2-4-fel-cross-project` / assessment,范围仅本文与评估索引,104个既有 `app/.cxx/` 文件保护。 -- Evidence:`/private/tmp/webhtv-fel-cross-project-fhko9uzb/`;`evidence-manifest.json`记录来源URL、revision、访问日、等级、结论/限制及SHA256,含失败检索,不能把32个记录称为32篇有效资料。沿用9.21已解析日志,没有重读/重解析7MB导出。 -- Status:确认导入缓存没有淘汰、输出已经是32位打包10bit;开源Mali代码支持进一步区分sampler绑定、storage绑定、格式转换和队列压力,尚不能命名电视闭源驱动内部根因。起播修正、B对照和C替代均未实施;没有新APK或电视验证。 -- Rollback:本单元只改变方案;播放器仍对应上述HEAD及`recovery/P2-4-fel-log33-review/20260916075556-98d247ea193c`。本文的评审tag不表示播放性能合格。 -- Exactly one next action:进入获批的起播修正单元,在实际选中轨道的native建链入口一次决定FEL输出;后续按本节B的绑定类型/外部图像对照决定C,不再盲增缓存。 - - - -## 9.22 跨项目源码与二进制复核:缩小等待来源(2026-09-16) - -用户明确要求难点扩展到其他播放器、图形项目、论文及成熟产品实现。本轮07:58 Asia/Shanghai开始,目标08:28–08:38完成;实际生产代码、补丁、依赖、库和APK均不改。9.19的规范、ANGLE、Filament、GStreamer、FFmpeg/libplacebo及论文记录继续有效,本节只补能改变取舍的新证据。当前并无新的待合并上游提交;下列revision均为只读研究,不升级任何依赖。 - -### 9.22.1 已排除的方向与本地调用核对 - -1. **不是输入导入缓存容量不足。** 从9.21的`native.json`取各trace最大的`fresh-commands`快照:输入hit/miss分别为489/9、461/9、442/9,三组`input-slots=9`、`input-limit=32`、`input-evict=0`。`input-removed=9`是移除/清理计数,不可改称LRU淘汰。结果保存于`cache-counter-evidence.json`。Flutter的扩大缓存修复不适合直接移植。 -2. **不能再把descriptor写入减少当作等待消失。** 9.20候选已减少85.7%的写入,但9.21三组bind均值仍约33–37ms;继续增加set池、更新模板或线程缺少依据。 -3. **计时没有把`/proc`读取包进普通bind计时。** 已逐行核对stable mapper的`fel_wait_probe_begin/end`、`fel_api_begin/end`与`record_conversion`:先完成before采样,再开始`FEL_API_DESCRIPTORS`计时,`vkCmdBindDescriptorSets`后立即截取结束时刻,随后才做after采样。稀疏wait sample可能包含统计/慢日志的额外成本,不能与普通bind窗口混为一个口径。线程调度和时钟读取仍是测量边界的一部分;约1ms线程CPU不等于已证明33ms厂商锁等待。 -4. **现有输出已经保留10bit且只占32bit/像素。** 日志明确`source format 0 / external format 0xf0 / output format 64`;64即`VK_FORMAT_A2B10G10R10_UNORM_PACK32`。`choose_output_format()`已经优先选它,`create_output_image()`用途是`SAMPLED | STORAGE`。没有“把RGBA16改10bit就减半”的剩余收益。`RGB_IDENTITY + ITU_FULL`采样和`configure_dst_params()`的`Cr/Y/Cb`映射属于raw-YUV合同,替代shader必须保持。 -5. **起播确实可以在native选中轨道后决定。** 锁定mpv的`loadfile.c`先选择轨道、执行`on_loaded`,之后才正常建视频/音频链;`video.c::reinit_video_chain_src(track)`在创建VO之前就收到实际track,已有按轨道选direct/HDR输出与替换VO的本地能力。App目前清空`dv7FelOutput`→先建直出→等待轨道信息→销毁整个播放器重载,额外初始化不是mpv要求。 - -本地精确入口:`hwdec_aimagereader_vk_stable.c::{fel_wait_probe_begin,fel_api_end,record_conversion,choose_output_format,create_input,configure_dst_params}`;`player/loadfile.c`的轨道选择和`on_loaded`;`player/video.c::{reinit_video_chain_src,video_output_image}`;`filters/f_decoder_wrapper.c::mp_decoder_wrapper_create`;`MpvPlayerEngine::{resetDv7HandlingForNewItem,updateDv7FelOutputForCurrentItem,buildPlayer}`。文件哈希保存在manifest。编译入口仍是权威`third_party/patches/mpv-android-fel.patch`经现有构建脚本应用;生成树不能替代补丁交付。 - -四仓锁继续为mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`、FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`、libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`、builder `99a60ad2141d5ace94453590903c2c6b9a0a2443`。本轮没有改变这些输入。 - -### 9.22.2 新增外部证据及决定影响 - -访问日均为2026-09-16。A为实际代码/规范/本机二进制事实;B为维护者或厂商解释;C为外部报告/待验证类比;D为线索。等级表示证据类型,不表示可以从别的驱动外推到本电视。 - -| 来源、固定身份与实际阅读 | 支持的事实 | 本项目决定及限制 | -| --- | --- | --- | -| [Flutter PR190710](https://github.com/flutter/flutter/pull/190710),head `4a9dbbccc1e51930967faad5a821a0942a9721d9`、base `b444e7897e249e9538e2afe623b533abbabfa4fb`;已读完整PR说明、实际diff、LRU测试、[TextureView复现及反馈](https://github.com/flutter/flutter/issues/180831)、[Vulkan ProcessFrame](https://github.com/flutter/flutter/blob/4a9dbbccc1e51930967faad5a821a0942a9721d9/engine/src/flutter/shell/platform/android/image_external_texture_vk_impeller.cc)。代码A、性能报告C;访问时**open,未合并** | 真实改动是6→64项有界导入缓存,测试64个key及第65个淘汰;作者用“无纹理/暂停纹理/持续解码纹理”分离新图像成本。报告的设备分别轮转19/31个AHB,旧缓存反复miss;`ProcessFrame`命中复用,miss才建Vulkan图像及layout转换 | 采纳对照方法;本地9/32且零淘汰,拒绝照抄64。PR中约55%改善不能用作本电视预期。原报告者确认TextureView改善不等于PlatformView也修好;PR测试合并SHA不作为已合并commit | -| Mesa25.1.0 `5c142e46f3f6e752ed745fd48912ebb8fad67145`:[PanVK image_can_use_mod](https://github.com/chaotic-cx/mesa-mirror/blob/5c142e46f3f6e752ed745fd48912ebb8fad67145/src/panfrost/vulkan/panvk_image.c),A | AFBC资格明确排除`VK_IMAGE_USAGE_STORAGE_BIT`,另受GPU/格式/tiling/mutable及调试开关约束 | C若实施应使用真正的`COLOR_ATTACHMENT | SAMPLED`输出用途,不能只是给当前STORAGE图像加COLOR_ATTACHMENT。**这不是TCL闭源驱动的AFBC开关证明**,不能保证换usage就启用压缩或获得某个百分比 | -| 同Mesa revision:[panfrost_set_shader_images](https://github.com/chaotic-cx/mesa-mirror/blob/5c142e46f3f6e752ed745fd48912ebb8fad67145/src/gallium/drivers/panfrost/pan_context.c)、[pan_resource_modifier_convert](https://github.com/chaotic-cx/mesa-mirror/blob/5c142e46f3f6e752ed745fd48912ebb8fad67145/src/gallium/drivers/panfrost/pan_resource.c)、[panfrost_mtk_detile_compute](https://github.com/chaotic-cx/mesa-mirror/blob/5c142e46f3f6e752ed745fd48912ebb8fad67145/src/gallium/drivers/panfrost/pan_cmdstream.c),A | 绑定shader image前可能进行AFBC/AFRC转换;固定modifier的资源可用shadow图像,转换会flush writer;MediaTek分块YUV有单独的detile compute和pre-barrier。驱动资源使用路径可包含应用没显式写出的转换 | B必须增加**输入sampler与输出storage分别绑定**的控制,不先认定是AHB输入或“描述符太多”。Panfrost是Gallium/OpenGL路径,不是`vkCmdBindDescriptorSets`的同驱动实现;没有把`externalFormat=0xf0`对应到任何DRM modifier,不能照搬detile算法、拆plane或硬编码格式 | -| Khronos [EXT_YUV_target v18](https://github.com/KhronosGroup/OpenGL-Registry/blob/2e30f7894201bec9bdd3aa5218c5025f9ac5948c/extensions/EXT/EXT_YUV_target.txt),revision `2e30f7894201bec9bdd3aa5218c5025f9ac5948c`,A;已读raw sampler定义、精度及issues 1/6/9/10/12 | `__samplerExternal2DY2YEXT`确能保留raw YUV,分量为Y/U/V;默认sampler为lowp,规范未约束低分辨率chroma的具体重建。普通YUV blit/image-store不能随意代替该能力 | GLES raw-YUV是可研究的后备路线,不等于普通`samplerExternalOES`。若需要跨API,须证明扩展、highp、10bit、chroma/crop、Cr/Y/Cb重排和fence互操作;现阶段优先同Vulkan的窄候选,避免额外GL上下文/同步边界 | -| 锁定mpv的[hook文档](https://github.com/FongMi/mpv/blob/cca559b41ceb0bb7731cf6ef2e1f33276cd30c42/DOCS/man/input.rst),加上述本地`loadfile.c/video.c`完整相关函数,A | `on_preloaded`还没应用默认轨道选择;`on_loaded`已有选中轨道。正常建链入口先决定VO再初始化decoder | 起播应复用native轨道/VO生命周期,不能在`on_preloaded`或App“任意可用轨道”中抢猜FEL。复杂filter的提前建链、`track=NULL`、切轨和VO复用要按实际来源处理;不添加第二次probe/loadfile或Java往返阻塞hook | - -补充讨论实际读了[MDK/fvp #134](https://github.com/wang-bin/fvp/issues/134)的维护者回复,以及[ncnn #5531](https://github.com/Tencent/ncnn/issues/5531)的AHB实验。前者分别涉及渲染器、硬解选择和音频时钟,不能把“掉帧”视为同一根因;后者是Adreno/640×480相机的pipeline创建与queue wait报告,WebHTV已有一次性pipeline,不采用其数字。ncnn回复中“未编译符号导致运行时花屏”的解释也不足以成立,未作为事实采信。另读[Flutter #176695](https://github.com/flutter/flutter/issues/176695)电视纹理停顿讨论:因缺目标设备信息关闭,**不是已修复**,模拟器通过不能代替电视结果。 - -论文与博文类别继续沿用9.19已取得的正文;本轮另查HPCA2013《Reducing GPU offload latency via fine-grained CPU-GPU synchronization》,[Crossref元数据](https://api.crossref.org/works/10.1109/hpca.2013.6522332)和Semantic Scholar仅确认作者/DOI,未得到全文,不能称已读或引用其结论。OpenAlex新查询限流,Arm候选文章404,Collabora响应没有可读正文,均在manifest标为失败,未拿失败页面充当证据。决定所需的API合同、资源用途和具体调用已有上表原始代码;缺少的是**本电视分组测量及同版闭源驱动内部证据**,不能靠增加泛文献弥补,也不能宣布根因研究完成。 - -### 9.22.3 本机Kodi APK静态核对与闭源分析边界 - -实际分析了`/Users/macbookpro/Downloads/Kodi19DV-Final-libbluray-aarch64.apk`,没有安装它。APK为157481012字节,SHA256=`a55173d5240f9b4da418f53b721717b3ecc16e5084c707241e31672d8b934127`;其中`libkodi.so`为88818504字节,SHA256=`ccad3ca24849ca7ea95fbfea92b692ae126def098b0cca36c1143e50b57fefe1`。内嵌版本字符串`19.0.0 / 20210220-73cc2b99ff`不能确定该修改包的完整源码revision,不伪造40位来源。 - -用NDK29 `llvm-readelf`读取动态符号/依赖,提取字符串后,对两个实际函数按ELF地址反汇编:`CDVDVideoCodecAndroidMediaCodec::Open`在`0x16ef638`、长度4812;`ConfigureMediaCodec`在`0x16f0a6c`、长度1500。首次按符号名反汇编未命中,后改用实际地址范围,未将空输出算作成功。保存了`kodi-elf.txt`、`kodi-codec-*-disassembly.txt`和字符串地址表。 - -- `Open`引用真实的`video/dolby-vision`字符串,枚举codec/profile,再调用`createByCodecName`;`ConfigureMediaCodec`调用`createVideoFormat`、取得Surface/SurfaceTexture、`configure`、`start`。这是已核实的MediaCodec路线。 -- `Open`的`0x16f06e4/0x16f06ec`分别记录“Dolby Vision MTK decoder, using NAL header size 4 workaround”并调用`SetDoviWorkaround`。它是码流兼容处理的实证,不是NLQ/FEL软件重建的证据;本项目此前的纯BL/EL分离也不能原样替换成它。 -- 动态依赖有GLES/EGL/Media NDK,没有直接依赖Vulkan;字符串搜索未提供NLQ/完整EL软件重建证据。但缺少字符串/直接依赖不能证明所有动态路径不存在,且厂商硬件内部是否处理FEL也未验证。不能称“该Kodi丢了EL”,也不能称“它与WebHTV完成相同工作且更快”。 - -Kodi本身是开源产品,这次是对用户现有发行二进制的实现核对,**不是已完成Mali闭源驱动逆向**。后续若B指向厂商内部,先取得与电视实际加载库匹配的路径、Build ID/SHA256及API入口归属;再静态定位该入口与锁/等待/转换调用,能取得同设备栈或trace才将其和耗时关联。没有同版二进制时,不用别的手机、模拟器或网上任意libMali代替。成熟闭源播放器也先核实是否确实使用EL残差,可用BL与FEL有可见差异的受控素材,之后才比较实现;“杜比图标亮起”不够。参考产品和驱动二进制不加入WebHTV资产或依赖。 - -### 9.22.4 调整后的最短实施顺序 - -| 路线 | 收益与风险 | 决定 | -| --- | --- | --- | -| 不改/继续扩大descriptor或AHB缓存 | 保留基线;已验证不是主要改善方向 | 仅作对照,停止同假设调参 | -| 原样复制Flutter缓存、Panfrost detile或Kodi MediaCodec路径 | 各自在不同输入/驱动/硬件职责下成立;不满足本项目完整FEL与外部格式合同 | 不移植整体实现,分别采纳对照、用途约束和建链原则 | -| 起播窄修正 | 消除已证实的重复播放器/VO/decoder初始化;需防非DV7、切轨及生命周期回归 | **先实施,独立原子单元**;确定收益是少一次错误尝试/重载,不虚报固定秒数 | -| B:绑定类型+外部图像的有界对照 | 区分源sampler、目标storage、生产者更新及通用队列压力;诊断本身没有流畅度承诺 | 随后实施,复用现有限时诊断入口和Web日志,不做常驻探针 | -| C:同Vulkan fragment暂存 | 可改变storage写入与tile/压缩资格,可能改善copy/渲染依赖;仍采样同一AHB,未必改善其等待 | B支持后实施;保持同位深/尺寸/raw-YUV与源归还,不默认替换 | -| GLES raw-YUV或厂商原生DV/FEL | 可能避开特定Vulkan路径;扩展、chroma、硬件FEL职责及跨API同步均未证明 | 后备路线,需单独证据;不以关EL、普通RGB转换或降分辨率取得“改善” | - -**起播单元:** 将“用户请求FEL”与“当前所选轨道实际启用FEL”分开;在`reinit_video_chain_src`实际来源已知、VO/decoder尚未创建时决定当前路径,按媒体generation同步实际结果给App,避免再次destroy/create/load。不简单把全局`android-dovi-fel=yes`提前:当前`video_output_image`等core路径也消费此bool,会扩大普通视频的暂存范围。恢复用户原始输出选择、filter复杂链、切轨、纯音频、取消/换源/VO复用都是该单元的合同。首帧不能由轨道尺寸或音频`playback-restart`推定;使用可证明的视频输出事件并标清“已提交/已显示”的区别,没有物理呈现反馈就不声称测到了它。内部重新配置与真实缓存重缓冲分别归因,不把所有BUFFERING都隐藏。 - -**B按结果逐级执行,不铺完整测试矩阵:** - -1. 仅用户主动启动的独占诊断窗口运行,记录同一设备/库、实际GPU/queue、图像格式/用途、分辨率和诊断状态;保存/恢复播放状态。先排空既有受控工作,获取并合法持有真实AHB,保留acquire/release fence。不能在节目后台偷偷抢decoder buffer、常驻加线程或输出节目像素。 -2. 先加空计时控制,然后对合法且已完整写入的`sampler-only`、`storage-only`、组合descriptor布局分别做fresh bind/record,首先不提交GPU执行。它们是不同的最小布局,只用来定位绑定类型,不把差值称为相同shader的性能收益。若只有组合慢,保留布局/管线交互方向;若storage单独慢,优先检查输出用途/资源压力。 -3. 若sampler方向突出,再比较普通Vulkan图像、能力允许时的普通10bit多平面YCbCr图像、稳定且不再被生产者改写的真实AHB、正常动态AHB。普通多平面组用来进一步区分YCbCr与外部导入;不支持则记未测,不能把`0xf0`强作P010或降成8bit补数。稳定AHB必须仍有有效所有权,不能归还后继续读一块正在改写的内存。 -4. 对出现差异的组才增加真实提交,再分空闲GPU与既有渲染负载,分别报告bind/record线程CPU和墙钟、submit、GPU query、完成等待。只在带渲染负载时慢,应先查共同device/queue压力;普通组也慢则查通用驱动/调度/实际layer;仅动态AHB慢才重点查生产者同步。任何一步都不能把计时挪到另一个阶段当作优化。 -5. 每项最多8次预热+32次采样/5秒,全任务目标上限30秒;冷初始化单列,提前耗尽预算则报告样本不足。资源逐组复用/释放,最多一份额外source和一份目标图像,按实际allocation累计上限96MiB,超限即不执行,不能缩图制造可比结果。取消检查放在操作之间和有界等待中;厂商API自身不返回时无法保证强制中断,沿现有故障恢复,不强行销毁仍在用的资源。逐组汇总到Web日志,无逐帧刷屏。 - -**C的具体约束补充:** 延用9.19的GStreamer fullscreen pass与同一raw-YUV采样;输出只声明实际需要的`COLOR_ATTACHMENT | SAMPLED`,查询当前10bit格式的color-attachment能力,完整写入时load可DONT_CARE、store必须保留。fresh命令、源foreign ownership、GPU完成和独立source-release fence、输出frame lease、crop/chroma/Cr-Y-Cb映射均保持。先对受控图案验证逐帧像素及NLQ输入,再测电视。现有AHB采样如果本身就慢,fragment仍可能同样慢;不承诺AFBC、固定加速倍数或实时达标。 - -### 9.22.5 验收、交付与恢复 - -后续起播代码单元沿用9.21的真实调用顺序负例、相关Java策略和取消/切轨边界,原始DV5/DV8/SDR/HDR、默认解码、音频/字幕合同不变。B验证所有权、超时/取消、预算、失败清理和默认关闭,不把微基准当播放通过。C只有在像素合同通过后才比较性能。所有实际native变更仍同锁增量构建双ARM ABI、核对ELF/公开导出/其余18库不变,并交付内容/签名可核对的TV64包;无须为了本次文档研究重建这些产物。 - -电视验收保持9.19.8:同片至少三组独立起播与不seek持续区间,接近23.976fps、显示丢帧低于0.5%、A/V差不超过200ms且不持续累积;真实首开、seek/退出与完整FEL画面一起通过。起播减少一次重建不能替代这一门槛。当前观察到的约33–37ms bind和约21ms copy是不同时间域,不能相加减计算预计fps。 - -后续起播单元的agent时间仍以代码/定向验证20–30分钟、热缓存双ABI/TV64构建10–15分钟、记录/提交约5分钟估计;电视等待另计。B涉及独立诊断资源和取消,不借该估计承诺完成,进入该单元时按实际入口重新给总时长。C及闭源内部归因继续以B结果作为准入。 - -本次最小验证为`evidence-manifest.json`来源/哈希、缓存计数、关键本地调用点与索引链接的合并核对,以及checkpoint脚本;执行记录保存到`review-verification.txt`并写入guard收尾证据。仅本文和索引原子提交、本地annotated recovery tag,不推送。下一代码单元成套保存App/权威补丁/测试/两份libmpv及文档,失败回到本节基线,不移动旧tag,也不把已知卡顿基线称为性能合格版本。 - -## 历史恢复记录(9.21,日志33否决整体性能验收) - -- Objective:核对用户07:21 TV64候选的三次复现,区分起播重建、统计的重缓冲、持续Vulkan等待;完整FEL、10bit及既有非FEL行为仍为合同。 -- Current unit:`feature/mpv-dv7-fel` / `44dd3f1386ac47c5d9e2007b9d2b32dfa0f72d8e`;guard `P2-4-fel-log33-review` / assessment,仅本文和评估索引,保护104个既有 `app/.cxx/` 文件。 -- Evidence:`/private/tmp/webhtv-fel-log33-0odn5fr1/`;源日志7337143字节,SHA256=`52fa2af93a6397dbbbfe185d3644a49203f0524779ad7f9ee314a513d8d83c23`。3829条唯一结构化事件、927条重复保留事件已分离;三组平均map为37.514/38.712/41.497ms,写入减少未关闭等待。一次2563ms重缓冲发生于App直出→FEL重建,全部已记录cache pause为false、buffering state为100。 -- Status:A的本机实现已生效,整体FEL性能验收失败;三组均有seek,不能将跨seek均值/末128次分位冒充严格稳态A/B。新起播路径修正和9.19-B尚属下一阶段方案;当前无代码/native改动、无新APK。 -- Rollback:当前代码仍为上述HEAD及`recovery/AV-DIAG-01-WEB-ACTIONS/20260916072655-44dd3f1386ac`;A可由父提交成套撤销,但不能把旧诊断基线称为性能合格版本。 -- Exactly one next action:批准下一阶段后先修复按实际选中DV7轨道在创建VO/decoder之前确定FEL路径的时序,再按9.19-B执行有界外部图像对照;保留电视实际验收门槛,不直接跳到C。 - -## 9.21 日志33:起播重复初始化、重缓冲来源及A的实际结果(2026-09-16) - -07:38 Asia/Shanghai开始只读定位;07:49确定文档范围与约5分钟收尾目标。用户本次提供的是已安装候选的失败反馈,不能继续把A称为卡顿修复。以下事实来自同一份导出和当前源码;不重复已完成的上游研究或native构建。 - -### 身份、去重与三次结果 - -设备仍为TCL Smart TV Pro / MT9655 / Android14 / arm64。App buildTime=`202609160721`;`env.native`记录实际可执行映射的libmpv SHA256=`240935cf90a8ff660bc11cf8f3d20ef2be228ee559317db952af1ee1d1a1a62d`,manifest匹配,即9.20候选。构建时源码标记为`ed3d710ef551210278920ba4cd25e8dda6e19ad6`+dirty,与提交前打包一致,不是装错库。 - -输入`/Users/macbookpro/Downloads/webhtv-debug-log (33).txt`,8615行、7337143字节。按`(processRunId,captureGeneration,seq)`去重得到3829条结构化事件,排除927条快照/保留段重复;传统日志另按`logSeq`去重。统计产物见证据目录`summary.json`、`events.json`、`raw-events.json`、`performance-summary.json`。文件大小本身不能证明日志导致卡顿,未采集关闭日志的同设备对照。 - -| 播放trace | warm map次数 / 均值 | descriptor写入 / fresh命令 | 最后128次bind p50 / p95 / max | GPU copy均值 | -| --- | --- | --- | --- | --- | -| `p-11s7zee-1` | 497 / 37.514ms | 65 / 498 | 26.945 / 72.738 / 151.135ms | 21.193ms | -| `p-11s92mr-2` | 469 / 38.712ms | 63 / 470 | 27.030 / 80.283 / 141.418ms | 21.508ms | -| `p-11sa4pb-3` | 450 / 41.497ms | 75 / 451 | 23.710 / 59.415 / 107.234ms | 21.202ms | - -1419次fresh命令中实际写入203次,内容命中1216次,省去约85.7%的逐帧写入;每帧仍fresh bind/record,未启用replay。三组warm bind平均32.906/34.798/36.554ms,线程CPU每次约0.97/0.98/0.99ms。上一日志32的map平均38.880ms、bind平均34.675ms;这份日志不支持有稳定、实质的整体改善。末窗口与之前稀疏等待样本的p50不是同一统计口径,不能据27ms相对35ms就宣称达到20%门槛。 - -38个等待样本的runqueue长尾仍存在:三组中位4.096/1.207/3.708ms、最大20.646/70.565/50.319ms;不能排除调度争用,也不能把剩余等待直接命名为某个厂商锁。copy约21ms及渲染pass最后已知均值约24–26ms仍是独立成本;这些GPU/CPU重叠阶段不相加成帧墙钟时间。 - -持续掉帧确有证据:三组`frame-drop-count`采样峰值103/124/144,`decoder-frame-drop-count`已知值均为0;A/V偏差峰值4.310/6.073/5.265秒。播放包含seek、计数会重置,因此不是三组累计掉帧或严格稳态分位。`estimated-vf-fps`约23.976反映帧时间戳,不能冒充实际显示帧率。A的主要性能假设在本设备未得到支持,整体验收不通过。 - -### 起播和那一次“本地重缓冲” - -三组均出现`actual=surface/mediacodec_embed fel=false`→识别原始DV7→`rebuild reason=manual-dv7-fel-output`→`actual=vulkan/gpu-next fel=true`。第一组07:34:01.803–02.850依次报MediaCodec启动失败、`Could not open codec`及无可用HEVC解码器;07:34:07.418重新创建后硬解成功。FEL GPU冷初始化分别306/1113/84ms,不足以单独解释全部起播延迟。 - -- 第一组请求07:33:59.683,App记录第一帧13.312秒;FEL的native `playback-restart`在07:34:09.932,主线程处理到07:34:12.869,相隔约2.94秒。该信号本身不证明物理呈现,不能将全部延迟算作shader编译。 -- 第二组07:34:53.479在直出尝试后收到`playback-restart`,输出尺寸属性仍0,尺寸来自轨道元数据;App提前记录3.035秒“首帧”。07:34:54.028才决定启用FEL,随后重建并于07:34:58.647重启播放,相对原请求约8.14秒。 -- 恰好这次重建,在07:34:56.141将重缓冲计数从0加到1,07:34:58.701完成,累计2563ms。第二组采集的`paused-for-cache`全部false、`cache-buffering-state`全部100,后续缓存时长仍有1.544–21.590秒。证据定位的是内部重启等待被通用READY→BUFFERING逻辑计为重缓冲,没有证据说明本地文件读不动或发生网络等待。 -- 第三组App记录首帧8.887秒,仍先直出再重建。三组后续seek都单列,不把seek等待重复算成此2563ms事件。 - -源码对应:`MpvPlayerEngine.resetDv7HandlingForNewItem()`将实际FEL输出清零,`buildPlayer()`先选直出;`updateDv7FelOutputForCurrentItem()`等待track-list原始profile。`PlayerManager.evaluateMpvAutoOutput()`/`prepareMpvFelOutput()`识别后调用`rebuildAndRestartMpv()`,销毁/创建播放器并重新loadfile。`MpvPlayer.handleEvent(MPV_EVENT_PLAYBACK_RESTART)`直接报告READY,`PlayerManager.PlayerListener.onPlaybackStateChanged()`随之完成起播统计。`PlaybackAnalyticsListener`在everReady之后收到BUFFERING便记录重缓冲;这些是不同事件语义,不是掉帧字段与重缓冲字段串用。 - -### 下一阶段窄方案、成熟依据与验收 - -沿用9.19已经完成的Khronos/Arm/ANGLE/Filament/libplacebo/FFmpeg/GStreamer及论文、issue证据;本次不凭新猜测直接重写graphics暂存。对新增起播时序问题实际补读了锁定mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`+本地补丁树的`DOCS/man/input.rst::on_preloaded/on_loaded`、`player/loadfile.c`和`player/video.c::reinit_video_chain_src`,访问2026-09-16,A类证据。[锁定源码中的mpv生命周期文档](https://github.com/FongMi/mpv/blob/cca559b41ceb0bb7731cf6ef2e1f33276cd30c42/DOCS/man/input.rst)明确:on_preloaded已有可用轨道、尚未选择轨道/建立decoder;实际链路在选择轨道和on_loaded之后才建立VO/decoder。可沿现有媒体生命周期一次决定输出,无需先播放错误路径来探测。资料不保证把任意运行时选项移到hook均安全,下一代码单元仍须核对实际选中轨道、VO复用及取消。 - -| 路线 | 决定及约束 | -| --- | --- | -| 保持A并继续增加缓存/线程 | 不作为主要修复:平均等待未改善,新的线程/缓存没有证据支持 | -| 原样套mpv钩子或在App全局强制FEL/GPU | 不采用:App往返hook会增加退出/取消状态,盲目全局启用会影响普通视频;当前native部分core/VO门控只看FEL标志,不能提前无条件置true | -| 起播窄适配 | 推荐先实施:显式FEL意图与实际激活分离,在已选择真实DV7轨道、创建VO/decoder前一次配置FEL链路;保留非DV7原路线。App同步实际结果,避免再次销毁重载;通用缓存重缓冲继续使用真实缓存事件,内部重新配置单独归因 | -| 9.19-B外部图像对照 | A没有解决等待,进入已拟定的普通Vulkan图像/稳定AHB/动态MediaCodec AHB三分法,fresh record及submit分开测;仅用户主动启动、有界、可取消、结果走现有Web日志。它是定位工具,不承诺播放性能收益 | -| 9.19-C graphics暂存 | 继续等待B裁决;不将“copy+render很贵”直接当作格式、精度、队列/同步等价的证明 | - -建议按两个独立恢复单元实施。起播单元预计当前agent代码/定向验证20–30分钟,同锁双ABI/TV64打包10–15分钟、文档/tag约5分钟;B涉及独立资源与取消生命周期,实施前先固化具体入口/资源预算,电视运行必须由用户主动启动。上述是后续实施估计,非本轮已执行工作或性能承诺。 - -起播验收:同一样片只创建所需VO/decoder并load一次,不先出现MediaCodec直出失败,不由元数据尺寸/音频restart提前判视频首帧;内部配置不增加缓存型重缓冲。DV5/DV8/普通视频/纯音频、手选轨道、seek/切片/重复播放/退出、FEL保真和非FEL准入保持。最小验证为真实路径顺序/旧代码负例及必要Java策略用例,再同锁双ABI、ELF/18库边界和TV64内容/签名;真实起播和画面由电视验证。持续性能门槛继续使用9.19.8,不随起播改善而降低。 - -四仓固定版本见9.19.3;无新增上游合并候选。下一代码范围需包含实际改动的App输出/统计适配、FEL patch及对应native函数、定向测试、两份libmpv与本任务/索引;保持其他18库/锁/JNI契约。回滚到本轮基线`44dd3f1386ac47c5d9e2007b9d2b32dfa0f72d8e`,保留已经验收的日志页改动。当前交付为日志验收结论和后续方案,起播新时序、B、C均未实施;按仓库新阶段批准规则进入下一代码单元。 - -## 历史恢复记录(9.20,描述符内容复用实施) - -- 目标:实施用户2026-09-16“优化”批准的9.19-A;仅相同描述符内容省去重复写入,每帧重新绑定/录制/提交,保留完整FEL、10bit、同步与源归还。交付可核对身份的TV64候选;像素与电视性能仍须实播裁决,不将调用次数下降称为卡顿修复。 -- 基线 `feature/mpv-dv7-fel` / `5f6fd1a75c210eac2571e7939a4e5451f23e84e3`,恢复tag `recovery/P2-4-fel-descriptor-review/20260916062200-5f6fd1a75c21`。guard `P2-4-fel-descriptor-content` / upstream,保护104个既有 `app/.cxx/` 文件;不推送。 -- 范围:FEL patch及其生成源码stable mapper、既有Vulkan cache测试与必要校验脚本、两ABI libmpv、本文件和评估索引;四仓锁及其他18份库保持基线。研究/版本/日志32证据全部沿用9.19,不重复泛搜,不实施B/C。 -- 06:33 Asia/Shanghai开始,目标07:05–07:15完成本机候选:修改及定向验证15–20分钟、同锁双ABI和TV64打包10–15分钟、文档收尾约5分钟。电视无ADB,实播时间另计。 -- 已修改stable mapper及FEL patch、既有cache测试/提取脚本、静态契约和native marker校验。真实函数ASan/UBSan通过:1200帧写入1200→72,1128次内容命中,仍1200次bind/record/dispatch;同地址/句柄回收、pending、crop/尺寸/query、reset/begin/end失败、push和有界统计通过。新patch仅重生成stable段,固定pre-FEL基线正向检查及当前源反向/静态契约通过。 -- 证据目录 `/private/tmp/webhtv-fel-descriptor-content.xqkvwpr9/`。真实函数/静态契约、双ABI实际编译、ELF/公开导出、18依赖字节不变、TV64内10库/签名/ZIP均通过,候选 buildTime=`202609160647`,APK SHA256=`0edb43e7a30b5dc92966dd55c6811d2fe720d24cbda7b0b28ce23f3c2e726b74`。本机候选按guard原子提交/tag,精确ID由guard记录;这不是电视像素/性能合格tag。唯一下一步:电视安装含本候选libmpv的包,按9.19.8核对画面与三组性能日志;用户插入日志页需求时保留该待验收状态。 - -## 9.20 描述符内容复用候选(2026-09-16) - -用户已批准实施9.19-A。设计、成熟实现来源、备选、验收及回滚沿用9.19;本轮不改变shader、图像池容量、EL线程、同步或公开设置。只读参考并非依赖升级,mpv/FFmpeg/libplacebo/builder四个固定版本和既有补丁顺序不变。 - -对象生命期是缓存正确性的约束:输入移除/销毁必须失效关联记录,输出/immutable sampler/YCbCr/layout重建必须先销毁记录池;普通set仅在完整hit且copy已完成时跳过相同内容写入,任何miss/冷路径/失败回退继续写入。每次仍reset/begin(ONE_TIME)、绑定、当前UV、dispatch、ownership barrier/query及当前semaphore提交。复用的是资源描述,像素由生产者逐帧改写;不能由host桩证明真实AHB像素正确。 - -代码与产物验证通过后提供本机候选;9.19.8像素与至少三组电视性能对照仍是实际采用门槛。write下降而bind/map不改善,或出现旧帧/回跳时,否决本假设并按9.19进入B,不继续扩pool或加线程。基线TV64 SHA256=`6e9bba7846e2e99620757c7ced5c357b287ce1bc64562a79f2db511e83c86a51`保留作对照。 - -### 实现及定向验证 - -- `recording_matches`在既有input/output、crop、尺寸和query条件上增加两端精确view快照。资源代际由销毁前失效约束:`destroy_input`先清除全部关联有效记录再释放view;`destroy_conversion_resources`先完成copy/销毁recording pool,再销毁输出、immutable sampler、YCbCr和layout。没有只凭AHB地址或可复用Vulkan handle认定同一代资源,也没有扩大缓存。 -- `prepare_conversion`完整命中时省略普通set更新,仍先使record无效,再fresh record,成功才重新有效并选择command。miss/cold/fallback照旧写入;push仍在每个新command内提交两个binding。失败后的下一次重新写入;不重放旧command。 -- 既有`WebHTV FEL reuse:`单行增加`descriptor-content-cache=1`、`content-hit`、`descriptor-writes`、`descriptor-rebinds`和`latency-window`。继续走Java已存在的性能分类,无新增主线程处理或日志行/采样频率;最后至多128次warm bind/record/map的nearest-rank p50/p95/max复用既有计时,日志关闭不新增时钟调用,约5KiB固定元数据增量(view快照与三个窗口),无新像素池。三个阶段重叠,不能相加成整帧时间。 -- `cache-test.log`编译实际函数并启用ASan/UBSan,8组结果通过;`static-contract.log`校验源/patch一致、FEL准入、失效/不可变对象寿命和既有同步。只有受影响的缓存单元与静态契约运行,未重复全量FEL/Java用例。这些结果不是电视像素或吞吐验收。 - -### 本机候选产物与验证边界 - -- `arm64-build.log`、`armv7l-build.log`均只增量编译stable mapper并重新链接libmpv;`stage.log`、`native-assets.log`通过同锁暂存与ELF/命名空间校验;`native-boundary.log`确认仅两份libmpv变化、其他18库(包括JNI)字节不变、两ABI公开mpv导出集合不变。沿用NDK r29/API24及第9.19.3四仓完整版本。既有shadow/locale警告未扩展处理。 -- `apk-build.log`:JDK21、既有隔离CXX缓存、一次TV64构建26秒成功,103任务中9执行/94缓存;没有重建Exo或修改受保护 `app/.cxx/`。`apk-artifacts.log`确认10个包内MPV库逐一匹配,v2签名通过,ZIP结构/签名开销800374字节;未运行ADB/电视操作。 -- 初始05:28包已保存在证据目录 `baseline-tv64.apk`;本次06:47包另存 `fel-content-tv64.apk`,避免随后日志页需求重打包覆盖对照。APK中 `GIT_REVISION=5f6fd1a75c210eac2571e7939a4e5451f23e84e3`、dirty为提交前构建信息,按下列哈希识别实际候选。 - -| 产物/输入 | 字节数 | SHA256 | -| --- | ---: | --- | -| arm64 `libmpv.so` | 17808120 | `240935cf90a8ff660bc11cf8f3d20ef2be228ee559317db952af1ee1d1a1a62d` | -| armv7 `libmpv.so` | 14621300 | `b0a0ed3ae3c4d6c071a31d94d7a6ccc09cd9ca8bb348ba074f1046804f172a74` | -| TV64 debug APK,buildTime `202609160647` | 164143897 | `0edb43e7a30b5dc92966dd55c6811d2fe720d24cbda7b0b28ce23f3c2e726b74` | -| FEL patch | — | `e5e094d234c6ba8ef2c99f09ac051176873c31a4fe7949e3631dc4b0844fa2e2` | -| native lock(未改) | — | `a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159` | - -其余输入哈希见证据目录`build-inputs.json`,20库身份见`native-artifacts.json`。普通视频、push设备与取消/源归还路径保持原设计;native像素、功耗/CPU、起播失败及实际电视掉帧尚未验收。`descriptor-writes`是转换时update调用数,非单个binding数或初始化总写入数;最近128次窗口跨seek时不能作为无seek稳态对照,须按9.19.8取持续区间。不因本机验证通过就宣称A已达到20%采用目标。 - -## 历史恢复记录(9.19,方案评审) - -- 目标:根据新日志32完成Vulkan描述符绑定高耗时的跨项目最佳实践方案;保留完整BL硬解/EL软解/NLQ、10bit、逐帧录制、同步与退出。整体电视实时性能仍未验收,本轮只更新本任务文档和评估索引。 -- 基线 `feature/mpv-dv7-fel` / `57211803d507ab1b6b6a0ee02c626a7f5909eebc`;已有诊断tag `recovery/P2-4-fel-wait-diagnostics/20260916053516-57211803d507`。本轮guard `P2-4-fel-descriptor-review` / assessment,保护104个既有 `app/.cxx/` 脏文件;不修改生产代码、二进制、依赖或用户播放设置,不推送。 -- 05:28 TV64诊断候选已在电视生效。新日志32与在线快照的2429条去重事件完全一致;持续实例604次warm map均值38.880ms,bind34.675ms / 线程CPU0.980ms;16个有效等待样本runqueue中位2.144ms、acquire均none、主动切换均3次。push明确不支持;大量等待仍未归因到某个驱动锁、ioctl或GPU fence。 -- 已读Vulkan规范、Khronos/Arm样例、ANGLE代码与测试、Filament、锁定libplacebo/FFmpeg、GStreamer、Mesa,以及论文、博文、论坛和PR。推荐9.19-A:相同描述符内容不重复更新,每帧仍fresh bind/record;B为失败后的最小归因,C为有条件的graphics暂存候选。A/B/C本轮均未实施。 -- 原始日志、去重事件、统计、阅读源码与下载清单位于 `/private/tmp/webhtv-tv-fel-log32/`。首次播放另有BL连续8次无进展失败,不能由描述符方案自动关闭。唯一下一步:交付本节方案,下一实施单元限定为A的单变量候选及像素/电视性能裁决,不直接启动B/C架构变更。 - -## 9.19 新日志32:描述符绑定等待的跨项目最佳实践评审(2026-09-16) - -### 9.19.1 推荐结论与边界 - -**先实施“相同描述符内容不重复更新,每帧仍重新绑定、重新录制”的窄候选,验证它能否消除驱动在bind阶段延迟处理的工作;不能提前承诺它解决34ms等待。若等待不降,停止沿缓存数量/线程数反复试错,进入外部图像与普通图像的最小对照。GPU暂存改为图形管线是后续候选,不与第一步捆绑。** - -本轮响应用户新增的深度研究要求,覆盖论文、帖子、博文、文档、issues、跨项目代码,并检查当前调用链。它是方案评审,不是依赖升级或生产代码实施。05:55 Asia/Shanghai开始,预计20–30分钟(资料与源码15–20分钟、方案与核验5–10分钟),目标06:15–06:25。最便宜决定性核验为日志统计、源码/引用与文档一致性;本轮不构建、不安装、不运行设备测试。 - -### 9.19.2 日志已经证明什么 - -输入 `/Users/macbookpro/Downloads/webhtv-debug-log (32).txt`,5088054字节、6148行,SHA256=`a7f0f3d0257e77891ea22c6adf8e79e1501fba2455e6a83abe1fbbbd74312ae6`,与9.5的同名旧日志不同。只读在线地址 `http://192.168.1.5:9978/debug/logs` 的TXT快照SHA256=`5c47147da04c462a37a8d8137b94f28c9be364130f444df169c4aec08489606c`。两份导出并非逐字节相同,但按 `(processRunId, captureGeneration, seq)` 去重后2429条事件的集合和内容完全一致;先去重再按时间排序,不能重复统计pinned/history。 - -设备为TCL Smart TV Pro / MT9655 / Android14 / TV64 / 4逻辑核;buildTime=`202609160528`,实际加载libmpv SHA256=`3e3732114fdd2527bbe95cce9b635087f0a67034a50c23a59c28c6c47fc5fa05`。APK内旧基线/dirty来自提交前构建,不代表装错包。用户明确电视不能连接ADB,下一步不依赖ADB。 - -| 观测 | 数值与意义 | -| --- | --- | -| Vulkan能力 | API/device API均1.3.247;Mali-G57,vendor=`0x13b5`,device=`0x90910010`,driver=`0xb001000`;`push-supported=0 push-enabled=0 extension-query-result=0`。强行开push不是修复 | -| 图像格式 | `source format=0`即VK_FORMAT_UNDEFINED,`external format=0xf0`,output format=64;BL硬解、EL软解、raw YUV/NLQ已进入。源是厂商不透明外部格式,不能直接解释成P010/RGBA | -| 持续实例 | `player-20` / `p-11ock86-2`;604次warm map,均值38.880ms,其中bind34.675ms、bind线程CPU0.980ms、descriptor update0.049ms。这是跨seek的每调用累计均值,不是稳态帧率 | -| 有效等待样本 | 持续实例16个,另一起播实例1个。持续实例bind17.190–79.066ms、中位35.222ms;线程CPU中位0.993ms;runqueue中位2.144ms、最大38.477ms。调度影响部分长尾,不能一概排除CPU争用 | -| 样本边界 | 17个样本均`sched-error=0/0 rusage-error=0/0 acquire=0/0`、主动切换均3次。持续实例probe中位0.270ms、最大3.881ms。没有可观察的输入acquire fd,不等于没有隐式同步 | -| GPU/资源 | copy均值21.293ms(605个样本),最后已知render pass均值26.348ms;605次提交/完成/异步归还,最终pending=0,fence timeout/release failure均0。两项GPU统计不是同帧完整关键路径,不能直接相加声称47.6ms整帧时间 | -| 播放后果 | native显示跳过记录已到240;最后一段`frame-drop-count=98`、avsync约4.404s,期间seek,计数不得相加。`decoder-frame-drop-count`最后已知0,不能用它否定显示掉帧 | -| 独立失败 | `player-8`从约25秒恢复,BL解出6帧后连续8次无进展失败;首draw在失败附近。描述符方案不能自动关闭这个起播问题 | - -`fel_api_begin/end`直接包围bind;慢调用日志阈值100ms,上述17个样本均低于阈值,不能用该分支解释它们的3次主动切换。但schedstat/rusage快照有开销与更新粒度,**wall−CPU−runqueue不是精确驱动等待;“3次主动切换”也不能直接命名为3个futex、3个plane或3次Binder事务。**当前证据支持大量非执行/非排队等待,未证明其内部原因。 - -### 9.19.3 本地调用链与实际缺口 - -权威生产输入是 `third_party/patches/mpv-android-fel.patch`。下列源码路径相对于应用补丁后的mpv,当前副本位于 `build/mpv-native/mpv-android/buildscripts/deps/mpv/`。 - -| 文件/符号 | 已有实现与决定 | -| --- | --- | -| `video/out/hwdec/hwdec_aimagereader_vk_stable.c::aimagereader_vk_stable_map` | poll完成任务→缓存AHB输入/独立输出→hold libplacebo输出→prepare/submit→release给libplacebo→独立sync fd归还源;不把GPU调用移到持有VO/core锁的区域 | -| `find_input/create_input/destroy_input/invalidate_input_recordings` | 已有AHB缓存,销毁/移除输入会失效关联记录,防止同地址/同槽冒充同一资源;不是缺少AHB缓存 | -| `recording_matches/select_recording/prepare_conversion` | 最多128条懒分配记录,匹配input/output、crop/尺寸/query,跳过pending;**hit后仍update两项descriptor,再fresh record**。当前复用的是对象槽,还没有复用descriptor内容 | -| `update_conversion_descriptor` | 两个紧凑binding:外部YCbCr combined sampler和storage image,无动态UBO或大批稀疏binding。update快不排除工作被延后到bind,但延后目前只是待证假设 | -| `record_conversion` | 每帧reset/begin(ONE_TIME)、当前ownership barrier、bind、push当前UV、dispatch、release barrier、end;画面回跳后已禁用replay,新方案必须保持 | -| `submit_conversion/finish_output/release_fel_source_async` | acquire/available/ready/source-release/fence职责分离;command/descriptor在copy完成后复用,output像素另外受render semaphore/frame lease保护。cache hit不能替代同步 | -| `hwdec_aimagereader_vk_stable.comp/create_output_image` | 16×8 compute采样raw YUV写独立output;UV除法已移到CPU,output usage=SAMPLED+STORAGE。可研究等价fragment暂存,但不能回到长期保留MediaCodec源 | -| `third_party/mpv-player-jni/tests/fel_vk_cache_test.c` | 1200帧fresh bind/record、pending/失效/失败等真实函数测试已有;Vulkan调用用host桩,并不验证厂商驱动像素或电视性能 | - -构建图不变:mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`;FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`;libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`;mpv-android builder `99a60ad2141d5ace94453590903c2c6b9a0a2443`。均为已覆盖基线,没有新合并提交。 - -### 9.19.4 阅读证据:结论、适用性与限制 - -以下访问日期均为2026-09-16。A=规范/实际源码与测试,B=项目或厂商解释,C=跨项目实验/未独立验证报告,D=线索。代码按完整revision只读参考,不引入依赖;原始正文、源码、检索结果及SHA256清单保存在本轮证据目录。 - -| 来源/固定身份 | 实际阅读与支持结论 | WebHTV适配与限制 | -| --- | --- | --- | -| [Vulkan descriptorsets](https://github.com/KhronosGroup/Vulkan-Docs/blob/f84d432d5b8912362f96f581f29bbc4f3c8c7843/chapters/descriptorsets.adoc),`f84d432d5b8912362f96f581f29bbc4f3c8c7843`;A | `vkUpdateDescriptorSets/vkCmdBindDescriptorSets`:descriptor可在bind的host执行期到shader执行间被消费;相关update可使非update-after-bind的已录制命令失效;pending期间不能覆盖/释放 | bind不保证只是无成本拷贝句柄;不能将update移到bind后。相同资源描述可复用,新command仍需绑定,像素内容与descriptor内容分开管理 | -| [同版memory](https://github.com/KhronosGroup/Vulkan-Docs/blob/f84d432d5b8912362f96f581f29bbc4f3c8c7843/chapters/memory.adoc)及[resources](https://github.com/KhronosGroup/Vulkan-Docs/blob/f84d432d5b8912362f96f581f29bbc4f3c8c7843/chapters/resources.adoc);A | Android外部格式、VUID02396–02398/09457;UNDEFINED源不能任意mutable重解释/添加TRANSFER用途,GL与VK采样结果未必完全相同 | 否决将0xf0强当P010、直接copy/blit或随意拆plane。GL普通samplerExternalOES不证明raw YUV精度等价 | -| [Khronos/Arm descriptor management](https://github.com/KhronosGroup/Vulkan-Samples/blob/ad5dd381b11fe2e28fdb2d14d52b0aac9b0b9ef1/samples/performance/descriptor_management/README.adoc)及[descriptor_set.cpp](https://github.com/KhronosGroup/Vulkan-Samples/blob/ad5dd381b11fe2e28fdb2d14d52b0aac9b0b9ef1/framework/core/descriptor_set.cpp),`ad5dd381b11fe2e28fdb2d14d52b0aac9b0b9ef1`;A/B | 内容作key复用set,避免相同信息重复update、每帧pool reset/free;测性能排除validation干扰 | 支持A;其44→27ms来自大量draw的CPU负载,本机只有一次bind/两个binding且线程CPU约1ms,**不套用38%收益**;动态UBO建议不适用 | -| [ANGLE ProgramExecutableVk.cpp](https://github.com/google/angle/blob/314cb711e226dc1b911f3233992c1d51053cc821/src/libANGLE/renderer/vulkan/ProgramExecutableVk.cpp)、[vk_helpers.cpp](https://github.com/google/angle/blob/314cb711e226dc1b911f3233992c1d51053cc821/src/libANGLE/renderer/vulkan/vk_helpers.cpp),`314cb711e226dc1b911f3233992c1d51053cc821`;A | `updateTexturesDescriptorSet/getOrAllocateDescriptorSet`仅miss写descriptor;image/sampler serial作key;`DynamicDescriptorPool`维护LRU、引用与延后回收 | 最直接的内容缓存参考;保留WebHTV有界槽和移除失效,不复制ANGLE的大型可增长pool/GL状态机 | -| [ANGLE VulkanPerformanceCounterTest.cpp](https://github.com/google/angle/blob/314cb711e226dc1b911f3233992c1d51053cc821/src/tests/gl_tests/VulkanPerformanceCounterTest.cpp)、[AHB实现](https://github.com/google/angle/blob/314cb711e226dc1b911f3233992c1d51053cc821/src/libANGLE/renderer/vulkan/android/HardwareBufferImageSiblingVkAndroid.cpp),同revision;A | `TextureDescriptorsAreShared`检查跨program复用;`initializeImpl`先查普通/external格式能力 | 借鉴测试与能力分流,但普通纹理测试不等于MediaCodec改写AHB的像素测试;本机UNDEFINED源不满足普通格式优先的条件 | -| [Filament VulkanDescriptorSetCache.cpp](https://github.com/google/filament/blob/fa4e346ab2eb0db36f84e8ae2e8ca71040a94b71/filament/backend/src/vulkan/VulkanDescriptorSetCache.cpp),`fa4e346ab2eb0db36f84e8ae2e8ca71040a94b71`;A | `commit`检查layout/上次绑定,`commands->acquire/referencedBy`保留资源;update与bind分离 | 借鉴引用/状态边界;不能跨新command照搬“set相同就不bind”。当前每帧一次bind,没有同command重复bind可删 | -| 锁定libplacebo `src/vulkan/gpu_pass.c::vk_pass_run/set_ds`、`context.c::device_init/finalize_context`;A | 普通路径每次update/bind,完成callback归还set,缺空闲set另有poll慢路径;自动启用可用push扩展 | 成熟实现并非都采用内容缓存;原样复制不会消除热点。其set池等待发生在bind外,不能解释本机单个原生bind计时 | -| 锁定FFmpeg `libavutil/vulkan.c::ff_vk_exec_start/ff_vk_exec_bind_shader/update_set_pool_write`;A | 每执行上下文有set,fence结束后回收依赖,ONE_TIME录制、显式绑定 | 支持对象复用与逐帧命令/同步并存;其无限fence等待不适合照搬到WebHTV可取消链路 | -| [GStreamer descriptor cache](https://github.com/GStreamer/gstreamer/blob/765564f3ecb3ffb066d14ac24c28e81623102788/subprojects/gst-plugins-bad/gst-libs/gst/vulkan/gstvkdescriptorcache.c)、[fullscreen quad](https://github.com/GStreamer/gstreamer/blob/765564f3ecb3ffb066d14ac24c28e81623102788/subprojects/gst-plugins-bad/gst-libs/gst/vulkan/gstvkfullscreenquad.c),`765564f3ecb3ffb066d14ac24c28e81623102788`;A | `prepare_draw_internal/fill_command_buffer_internal/submit_final_unlocked`复用pipeline/view/framebuffer,fence延后释放,graphics处理视频图像 | C的视频实现参考。descriptor cache首先是对象池,不能误称ANGLE同类内容hash;不复制GLib运行时或颜色策略 | -| [Mesa PanVK descriptor state](https://github.com/chaotic-cx/mesa-mirror/blob/5c142e46f3f6e752ed745fd48912ebb8fad67145/src/panfrost/vulkan/panvk_vX_cmd_desc_state.c),Mesa25.1.0 `5c142e46f3f6e752ed745fd48912ebb8fad67145`;A | `CmdBindDescriptorSets2KHR/cmd_desc_state_bind_sets`维护set/offset/dirty state,所读路径没有显式GPU fence等待 | 普通绑定可主要是CPU状态操作,值得做外部图像对照;PanVK不是电视闭源驱动,不能据此断言内部原因或直接移植 | -| [Granite作者博文](https://themaister.net/blog/2019/04/20/a-tour-of-granites-vulkan-backend-part-3/),2019-04-20;B | `hash→VkDescriptorSet→vkCmdBindDescriptorSets`、布局专属pool、闲置回收;讨论persistent set组合数量代价 | 支持内容复用与bind分开;不复制固定“8帧后回收”的经验值,必须以实际fence/lease为准 | -| [NVIDIA Vulkan Dos and Don’ts](https://developer.nvidia.com/blog/vulkan-dos-donts/)、[Advanced API Performance: Descriptors](https://developer.nvidia.com/blog/advanced-api-performance-descriptors/),访问日正文;B | 减少descriptor创建/复制,紧凑layout、push constants、复用command pool;并行录制需任务图 | 本地两个binding和push UV已覆盖部分建议。百万descriptor阈值、bindless和桌面多线程收益不外推到4核电视一次bind | -| [Khronos/Arm async compute](https://github.com/KhronosGroup/Vulkan-Samples/blob/ad5dd381b11fe2e28fdb2d14d52b0aac9b0b9ef1/samples/performance/async_compute/README.adoc)、[layout transitions](https://github.com/KhronosGroup/Vulkan-Samples/blob/ad5dd381b11fe2e28fdb2d14d52b0aac9b0b9ef1/samples/performance/layout_transitions/README.adoc),同Samples revision;A/B | Mali上compute/vertex资源与fragment依赖可能形成空档;color attachment与正确layout有机会保留tile优化 | 支持研究C,反对盲加队列;静态场景transaction elimination、AFBC或压缩收益在本机未测,不套用sample百分比 | -| [Khronos论坛讨论](https://community.khronos.org/t/most-common-vulkan-mistakes-vs-the-world/6861),2016-05-18,含Sascha Willems回复;C | 讨论预录制命令的适用范围,不能把静态样例建议推广到所有动态引擎 | 与现代代码共同支持保留逐帧录制;老论坛不是当前驱动性能证据 | -| [MoltenVK PR2822](https://github.com/KhronosGroup/MoltenVK/pull/2822),head=`fd8ea9dbb1963af3642d912bad651f018434c922`,base=`4aaf714aa1b3e78e26ecfcefa9c75e9a576c500b`;C | 已读实际patch、复现代码与报告,区分resource-use cache/实际argument-buffer binding;提交者报告validation零错误仍可能像素错误 | **访问时open、未合并,非维护者已确认修复**;针对Metal/GTK,不移植到Mali。只借鉴缓存边界和像素复现方法 | -| [WebGPU Dispatch Overhead论文](https://arxiv.org/html/2604.02344v1),arXiv2604.02344v1,§3.3/3.6/4.4/7.2/7.8;C | 区分单次同步与批量dispatch、框架/API/GPU时间及跨后端限制 | 仅借鉴测量设计,不拿24–36μs当Android bind目标;正文“0.56ms低于0.095ms”存在数值矛盾,未采信其外推结论 | -| [Mìmir CUDA/Vulkan互操作论文](https://arxiv.org/html/2504.20937v1),arXiv2504.20937v1,§3.4/4.1/4.3;C | 共享外部内存、交替buffer、显式同步;取消同步可能使计算/渲染争用恶化 | 借鉴对象寿命/内容版本分离并保留同步;CUDA点云不是MediaCodec FEL,9×/12×收益不外推,不引入CUDA | - -检索覆盖GitHub精确AHB+bind、Mali+gralloc+slow、跨项目bind stall、Stack Overflow与论文索引;未找到直接复现并修复本机 `Mali-G57 / externalFormat=0xf0 / bind阻塞` 的成熟补丁。Stack Overflow主要结果为粒子批处理,与一次视频dispatch不符;IEEE2022相关论文仅取得索引摘要,未取得全文,未作为设计依据。Google/DDG遇跳转/验证,已改读官方代码、论坛、OpenAlex/arXiv正文,失败页面不计为已读证据。剩余内部归因需要目标设备对照,继续泛搜不能代替它。 - -### 9.19.5 方案比较与选择 - -| 方案 | 能改变的工作 | 本项目限制/风险 | 决定 | -| --- | --- | --- | --- | -| 不改 | 保留可复现基线 | 继续约35ms bind和掉帧 | 留作对照,非完成方案 | -| 原样套用libplacebo/FFmpeg | 普通路径仍update/bind,push依赖能力 | 不自动改变外部图像驱动路径,可能丢本地取消/归还保护 | 不整体移植,借鉴生命周期原则 | -| A:内容缓存+fresh bind/record | 减少相同descriptor写入及其可能触发的延迟工作 | 直接可省只有0.05ms,大收益待证;须验证AHB循环改写像素 | **首选单变量候选** | -| 旧command/绑定命令跨帧重放 | 减少bind/record | 已有画面回跳,旧命令不能冒充当前内容/ownership | 不重启,也不改名为secondary缓存绕过 | -| update template、pool/reset调整 | 优化更新/管理成本 | 本地update/reset微秒级,已有有界复用 | 暂不做,不能解释34ms | -| 强开push、bindless、descriptor buffer、新bind入口 | 换绑定机制 | push明确不支持,其他feature/YCbCr能力未证实;新入口不保证不同驱动实现 | 不作此电视修复,不因Vulkan1.3假定可用 | -| 增加EL线程/录制线程/队列 | 可能重叠等待 | EL实际5线程/4逻辑核;新pool所有权、取消和CPU争用成本 | 无可重叠关键路径证据前不做 | -| B:外部图像最小对照 | 分清AHB/YCbCr路径与通用绑定/队列压力 | 需要独立有界复现,不能抢当前播放资源 | A无效后只做这一条归因 | -| C:graphics shader暂存 | 改变storage写入和compute/fragment依赖 | 仍要bind,等待可能不变;需格式、像素、queue/barrier适配 | 有条件候选,单独评审 | -| 强制P010/直接copy、普通GL RGB、CPU回读、关FEL | 绕过当前路径 | 格式/精度不成立或降低功能性能 | 不采用;GL raw-YUV路径需另外证明扩展与等价性 | - -### 9.19.6 最小实施单元A - -优化的是**资源引用描述的重复更新**,不缓存视频内容,不缓存逐帧acquire/release,不重放command。当前`recording_matches`比descriptor实际key更严格,第一步保留现有crop/尺寸/query条件,避免重写cache结构。 - -1. 仅显式FEL、普通set、有效完整cache hit且上一copy完成时,复用已写好的两个binding,仍每帧调用`record_conversion`。缓存身份包括image view的对象代际、sampler/YCbCr转换、pipeline layout、descriptor imageLayout;移除/重建必须失效,不能仅凭AHB地址/槽号。 -2. miss/cold/fallback继续写当前descriptor;push路径每个新command继续push完整binding。保留pending排除、输出重建/源移除失效与失败回退;成功record后才标有效,不得命中后返回旧command。不增加像素池或cache上限。 -3. 每帧继续reset/begin、bind pipeline/set、当前UV push、dispatch、barrier/query和当前semaphore提交。descriptor的copy生命周期与output的render semaphore/frame lease分开;更新前确认无pending,不能靠update-after-bind补救错误顺序。 -4. 沿现有FEL日志补充有限的content-hit/write/rebind/fresh-command计数和bind/map分布,区分“少写了descriptor”与“bind真的变快”。日志关闭时不新增采样、线程、磁盘或主线程工作,不导出用户节目像素。 - -预期范围为FEL补丁stable mapper、相关现有host测试/校验、两ABI libmpv及本任务/产物说明;日志沿既有分类,不必新增Java通路。lock、FFmpeg/libplacebo/JNI、Exo、公共API和用户设置保持原合同;普通视频和push设备保持原路径。实际实施另按必要文件声明guard,本轮文档范围不自动授权架构变更。 - -**可证伪条件:**若write下降但bind/map分布未改善,则“重复update引发bind延迟处理”假设在本机不成立,不能把调用次数改善报成卡顿修复,不继续在同一假设下扩pool/加日志/调线程。若出现旧帧/回跳,即便规范允许内容复用也否决候选,恢复逐帧写入并进入B。 - -### 9.19.7 A无效后的分流 - -**B只裁决一个问题:等待是否依赖不透明外部图像。**独立有界复现比较原生Vulkan图像、稳定持有且不再被生产者改写的真实AHB、正常逐帧更新的MediaCodec AHB;三组均fresh command,另区分仅录制和真实提交。普通图像与AHB的格式/YCbCr必然不同,因此只能定位路径类别,不能直接命名内部锁。 - -无ADB时可由候选中的定向复现通过既有Web日志输出;必须由用户主动启动,不能自动暂停节目或抢surface buffer。统一分辨率/输出格式、warm-up/次数,分别统计录制、线程CPU、GPU和完成等待;批量微基准最终同步放在批末,组间设时限/取消出口。它不改变生产路径每帧必要的同步,不借测试取消源归还/依赖。设备不给驱动栈权限就保留未知,不能用读取失败证明无等待。 - -普通图像也慢则先检查实际启用的validation/layer、通用驱动锁和并发队列压力;只有动态AHB慢则检查生产者交接/外部图像实现;稳定AHB也慢则重点检查外部格式/YCbCr绑定路径。17个样本尚不能完成三分法,不应据此直接宣称GPU硬件不够。 - -**C优先评估等价graphics shader暂存。**借鉴GStreamer fullscreen pass,以相同raw-YUV sampler写独立10bit color attachment,复用pipeline/render pass/framebuffer;保持UV中心、crop、分量顺序、full-range、chroma filter和NLQ输入。查询目标格式COLOR_ATTACHMENT+SAMPLED能力,不满足保留compute。完整覆盖输出可用loadOp=DONT_CARE,但输出供后续渲染,storeOp必须保留内容,不能照抄丢弃输出的带宽建议。 - -C需适配graphics queue/stage、source foreign ownership、output layout和双向semaphore,保留独立source release及有界buffer。它可能改善storage写入/流水线空档,也可能完全不改变AHB bind等待;不预称AFBC或固定收益。先做单一等价pass,不同时改EL、分辨率、精度、同步或线程;不能回到直接长期持有MediaCodec表面,否则重新引入buffer耗尽。 - -### 9.19.8 验收、采用标准与回滚 - -- **代码门槛:**现有真实函数host用例1200帧仍有1200次fresh bind/record/dispatch;hit减少write,miss/重建必须write。覆盖同地址新资源、pending、crop/尺寸/query变化、分配失败、record失败;保留既有lease/归还/取消验证。 -- **像素门槛:**用可辨认帧号/交替图案的受控素材,验证同一AHB循环写入、回收、seek/重播/暂停重绘都显示当前内容,再用原FEL片验证raw YUV/NLQ、位深和画面。host桩、PTS一致、fresh计数、validation零错误均不替代像素验证;不自动回读/导出用户节目。 -- **A的性能裁决:**基线为本节05:28候选,保留候选commit/APK/libmpv哈希。同电视/素材/设置/日志类别/散热条件至少3组对照;初始化单列,持续区间不seek。看bind/record/map的中位、p95/最大值以及GPU copy、A/V和显示掉帧。只有write约0.05ms下降不算实质改善;以bind中位至少下降20%、map中位同步下降且p95无回归为A采用目标,未达标不继续把A作为主要修复路线。 -- **整体FEL门槛:**A阶段改善不等于需求完成。原23.976fps片稳定段需接近片源帧率、显示丢帧比例低于0.5%、A/V差不超过200ms且不持续累积,并关闭独立起播失败。保留完整FEL、10bit、色彩、音频/字幕、可取消seek/退出,不降画质换达标。报告重复测量分布和未满足项,不能只挑最快一轮。 -- **构建边界:**未来代码单元只按同锁增量构建两个受影响ARM ABI,检查ELF/公开导出、其他18库不变,打包所需TV64并核对包内库/签名;仅相关修改或不确定结果才重试。本轮没有构建或安装。 -- **生命周期/成本:**A不增加像素池、EL线程、持续采样、运行时依赖;cold/seek/退出不能新增等待周期。不能把bind耗时挪到别的阶段当成变快。C另外记录framebuffer/pipeline内存、包体差额并验证新queue/layout契约。 -- **回滚:**`57211803d507ab1b6b6a0ee02c626a7f5909eebc`及9.18既有tag是诊断基线,不是性能合格基线。未来A/C各成原子source/patch/两libmpv/产物说明提交和annotated tag;失败成套恢复对应父提交,保留证据,不移动旧tag、不推送。文档评审tag只代表方案快照。 - -本轮产出为可评审方案,A/B/C尚未实施,电视卡顿未标记为已修复。下一单元为A及像素/电视性能裁决,再依据结果进入B或验收,只有证据支持时才另行评审C。 - -## 历史恢复记录(9.18) - -- 目标:继续定位电视 FEL 严重掉帧;本单元补齐无 ADB 的等待来源诊断,保留完整 BL 硬解/EL 软解/NLQ、10bit、逐帧录制、同步与退出。它是诊断候选,不是电视性能修复验收。 -- 基线 `feature/mpv-dv7-fel` / `206a57e0e337304a8b78712c487ef245d7cb0fa0`,guard `P2-4-fel-wait-diagnostics` / upstream。启动时保护 104 个既有脏文件,均属 `app/.cxx/`;不修改或移动这些文件,不升级锁定依赖,不推送。 -- 2026-09-15 新日志30与9.15同名旧日志不同:TCL MT9655、Android14、TV64、buildTime=202609152206;FEL已生效,但两轮 warm map 39.28/35.50ms、descriptor bind 34.73/31.65ms(线程CPU约1ms),第二轮360次显示跳过;seek前后计数不能相加。push扩展未启用,尚不能从日志断言物理支持情况。 -- 用户确认电视不能连接ADB,只能用App调试日志;提供 `http://192.168.1.5:9978/debug/logs`,只读访问及TXT下载已成功,status当时 `playingContext=false`。沿用此前持续修复授权,采用本节窄诊断方案,无需再次请求相同授权。 -- 证据目录 `/private/tmp/webhtv-fel-wait-diagnostics-20260915/`;原日志SHA256=`303afdfbd1e782b624b77f2194a15c76f5fd6eeaccc5a82b34cf5aad14fc7791`。采样/能力日志、Java分类、实际函数ASan/UBSan与源/patch一致性通过;旧静态入口断言已适配当前统一诊断链并通过。双ABI实际编译/ELF/导出通过,18依赖(含JNI)不变;13项Java及TV64包内10库/签名/ZIP通过。guard原子提交/tag收尾,精确ID由guard记录,不额外建立回填自身ID的提交。 -- 候选TV64 buildTime=`202609160528`,APK SHA256=`6e9bba7846e2e99620757c7ced5c357b287ce1bc64562a79f2db511e83c86a51`。编译前基线/dirty标记保留在APK中,按本表哈希识别候选;它不证明电视卡顿已修复。唯一下一步:安装此TV64候选,先开调试日志再按原设置播放FEL约30秒,从既有Web地址读取`capability-v=1`及`WebHTV FEL wait sample`裁决等待来源。 - -## 9.18 新日志30:无ADB电视的Vulkan等待诊断(2026-09-15) - -### 问题、研究与决定 - -23.976fps每帧约41.7ms;现有约32–35ms的bind墙钟时间与约1ms线程CPU时间之间有大段等待。GPU copy约21ms、最后已知渲染pass均值约21–26ms,属于异步区间,不能与CPU墙钟相加。AHB缓存已命中、无源归还失败或fence超时;不据此盲加EL线程、重启命令重放或取消同步。当前唯一待裁决问题是:这段等待主要属于runqueue调度延迟,还是非运行/非排队等待,以及输入acquire fence是否在此期间变为就绪。 - -固定依赖沿用9.17:mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`、FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`、libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`、builder `99a60ad2141d5ace94453590903c2c6b9a0a2443`;全部为已覆盖基线,无新合并提交。访问日期均为2026-09-15。 - -| 来源/版本 | 等级、事实、适用性与限制 | -| --- | --- | -| 本地 stable mapper `prepare_conversion/record_conversion/fel_api_begin/fel_api_end`,VO与BL交接调用链 | A;API计时只含调用区间;BL发布前暂存、每帧重新录制与释放栅栏保护仍有效。采样只包围descriptor bind/push,不改这些行为 | -| 锁定libplacebo `src/vulkan/context.c::device_init/finalize_context`、`gpu_pass.c::vk_pass_create`及公开`vulkan.h` | A/B;创建device会自动启用可用push扩展,公开列表为实际启用。`extension-not-enabled`不能当作遗漏App配置,也不能在缺少API版本时等同物理不支持;单独记录支持/启用/版本 | -| [Linux v6.1 sched-stats](https://kernel.googlesource.com/pub/scm/linux/kernel/git/torvalds/linux/+/refs/tags/v6.1/Documentation/scheduler/sched-stats.rst)、同版`fs/proc/base.c::proc_pid_schedstat` | A;三个字段为运行纳秒、runqueue等待纳秒、调度次数;统计未启用时输出全0。读取本线程,拒绝全0、截断、溢出、倒退,不能把不可用记为零等待 | -| NDK29 `sys/resource.h`与`linux/resource.h`;[AOSP simpleperf android-14.0.0_r1 environment.cpp::GetProcessUid](https://android.googlesource.com/platform/system/extras/+/android-14.0.0_r1/simpleperf/environment.cpp) | A/B;`getrusage(RUSAGE_THREAD)`提供本线程主动/被动切换次数,simpleperf对不可读proc返回无值。次数不是时间、不保证指出某个驱动锁;缺失数据明确保留错误状态 | -| [AOSP libsync android-14.0.0_r1 sync.c::sync_wait](https://android.googlesource.com/platform/system/core/+/android-14.0.0_r1/libsync/sync.c) | A/B;poll检查sync fd的POLLIN/错误,timeout=0可只观察、不等待、不消费/关闭fd;前后状态相关不等于因果证明 | -| 上游issues/PR、Arm/Khronos技术基准 | 复用9.15–9.17已阅读的有限检索及基准;没有能证明当前等待根因的补丁。本轮只补测量,不重新扩展驱动修复搜索或套用基准收益 | -| 论文 | 不适用本诊断单元:不提出新重建、调度或性能算法;字段含义和生命周期由Linux/Android实际源码决定 | - -Linux原始GitHub入口返回429,已改读同版本官方googlesource镜像并保存完整源码;联网使用127.0.0.1:7897代理。没有把失败抓取记为已读。 - -比较:不改则下一份日志仍无法区分等待来源;完整Perfetto/系统跟踪需设备能力,当前无ADB;强开push/修改线程或同步均缺少证据。采用窄适配:FEL且视频INFO日志启用时,每3秒最多对一次bind/push读取本线程schedstat、RUSAGE_THREAD及acquire fence前后状态;保留原API墙钟/CPU统计,另报采样自身开销。权限/能力不足只影响诊断,不重试刷屏或改变播放。首次mapper创建记录实际Vulkan版本、GPU/驱动、push广告支持与启用情况,枚举有上限,不启用新扩展。 - -所有样本只进入已有App/Web日志通路并有独立限流,不追加主线程处理、不保存图像/PCM、不引入线程或新的依赖/ABI/权限。只读系统调用存在有限采样开销,日志明确记录;不能从差额直接声称已测得驱动内部等待时间。 - -### 验收、范围与回滚 - -- 授权:用户要求继续通过Web日志诊断此FEL性能问题。代码范围为FEL补丁、相关原生测试/校验脚本、MPV日志分类与测试、两份libmpv及本任务/索引/构建说明;编译仅使用可再生隔离缓存,保护app/.cxx/。 -- 最便宜决定性检查:真实生产函数的host ASan/UBSan验证计数解析/溢出/倒退/全0、权限失败、限频及禁用零采样、主动/被动切换、fence的pending/ready/error与零超时且不关闭;保留原有1200帧push/set录制/生命周期测试。Java检查新增记录不受普通日志洪泛影响且来源/级别严格。 -- 仅同锁增量编译两ABI libmpv,检查ELF/公开导出与其他18库不变。打包本日志对应TV64,确认包内全部MPV资产、签名及ZIP;不以host/编译成功宣称电视流畅。 -- 设备闭环:用户安装候选后,保持原样片/FEL设置,通过已有Web地址读取一次持续播放日志,首先裁决能力、采样有效性、runqueue/切换/fence关系,再决定性能改动。没有可用计数时仍明确未知。 -- 回滚:成套恢复至基线`206a57e0e337304a8b78712c487ef245d7cb0fa0`的补丁/日志分类/两libmpv;原子提交与annotated recovery tag保留本候选,不移动已有tag、不推送。 -- 时间:22:41 Asia/Shanghai声明余下25–35分钟(实现15分钟、验证/增量编译10–15分钟、打包收尾5分钟),目标23:06–23:16;电视操作与采样等待另计。 - -### 03:05跨日恢复与已完成验证 - -源码仅增加诊断:bind/push每3秒采样一次本线程schedstat/RUSAGE_THREAD与acquire fd的poll(0),读失败/全0/倒退保持未知,权限或接口缺失不重复打开;前后采样开销单列。原来API统计、图像输入/输出、push门控、逐帧命令、barrier/semaphore/fence、NLQ和位深逻辑保留。能力枚举最多512项,仅观察,不开启扩展。新日志分类仅使测量避开旧播放状态处理,记录通过项目现有统一诊断存储。 - -实际生产函数的ASan/UBSan覆盖计数解析/溢出/截断/全0/倒退、拒绝访问与失效缓存、关闭时零采样、限频与时钟回退、主动/被动切换、sync fd零超时且不关闭、扩展支持/启用/入口缺失/分配失败/不完整枚举;原有1200帧cache与逐次录制、240帧交接、取消/EOF/lease/NLQ元数据等行为全部通过。EL/RPU样片解码仍因未提供素材明确SKIP。末尾旧静态入口断言已按现有代码修正,未削弱不排队主线程/不重复Logcat约束。 - -2026-09-16 03:05恢复时原交付目标已失效;剩余限定为双ABI增量编译、TV64 APK/Java检查和原子提交tag,估计8–12分钟,不扩研究、不重复成功测试。电视能力/性能仍待该包Web日志,不能标作已修复卡顿。 - -### 本机候选结果(2026-09-16 05:28构建) - -- `arm64-build.log`、`armv7l-build.log`包含实际stable mapper编译及libmpv链接,未重建FFmpeg/libplacebo/JNI。`native-assets.log`、`native-boundary.log`确认两ABI ELF/命名空间/公开导出一致,另18个库逐字节不变。相对本轮HEAD基线libmpv仅增加3680/4536字节。 -- 使用NDK29/API24的固定MPV构建图;Gradle用JDK21、SDK37、离线温缓存及原隔离CMake staging,未触碰受保护app/.cxx/。构建授权等待后05:28实际启动,一次有效Gradle构建1分46秒,112任务(18执行/1缓存/93未变);未重复原生行为测试或整包构建。 -- `MpvDiagnosticsPolicyTest`13项、0失败/0错误/0跳过。`apk-artifacts.json`记录TV64包10项MPV资产全部匹配、签名通过、ZIP额外开销801465字节,包含`lib/arm64-v8a/libexo_ass.so`。旧APK与该variant的精确package增量缓存已保存在证据目录,不删除用户数据。 -- 原生测试的唯一未执行项为缺少实样的EL/RPU解码;此单元不改变FEL数学、解码或像素。目标电视尚未安装该候选,能力与等待分布仍未知。源码/索引检查0错误,二进制修改告警对应本guard显式拥有的两libmpv及补丁,无额外范围变更。 - -| 候选产物 | SHA-256 | 字节 | -| --- | --- | ---: | -| arm64-v8a libmpv | `3e3732114fdd2527bbe95cce9b635087f0a67034a50c23a59c28c6c47fc5fa05` | 17807048 | -| armeabi-v7a libmpv | `be2b7828d6806ae8cf8a390f2f48106608193ffef40d6c5e4369ddaa9dfee6c2` | 14619876 | -| TV64 debug APK,buildTime=202609160528 | `6e9bba7846e2e99620757c7ced5c357b287ce1bc64562a79f2db511e83c86a51` | 164143897 | - -FEL补丁SHA256=`f2b3e011e2fac157750b20c990c447daec958f9d48304c19e4c9ff485002dfb6`;lock保持`a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159`。设备判读先看`sched-error/rusage-error/probe-us`是否有效,再看runqueue及切换次数和acquire前后状态;不把缺失值当0、不把两个异步区间相加、不把pending→ready关联当作驱动内部因果证明。 - -## 历史恢复记录(9.17) - -- 目标:继续修复显式FEL的首次起播失败与持续掉帧,保留BL硬解/EL软解、真实FEL重建、10bit、同步、可取消退出和其他模式。电视画面/实时性能仍未验收,不能以host或构建成功宣称完成整体需求。 -- 当前分支`feature/mpv-dv7-fel`,HEAD/回滚基线`ce10d5c15ef36fa83e27c6195182717a334a1036`,tag `recovery/P2-4-fel-vk-reuse/20260913225443-ce10d5c15ef3`;guard `P2-4-fel-warmup-push` / upstream。原有`app/.cxx/`70个文件全部保护,不移动/修改;同四仓锁、无依赖升级、无推送授权。 -- 新日志31(22:59–23:01)已冻结/分析:首轮首渲染725.890ms与第8帧838ms交接失败重叠;持续轮descriptor bind均值29.496–36.207ms,update仅微秒。新逐帧录制标志已生效,PTS差异计数均0不等于像素正确;用户尚未确认画面回跳消失。证据见9.17。 -- 本轮代码与定向host测试已完成:VO首次实际draw独立10s界限、精确排除等待且不续期;stable仅实际启用扩展/入口/数量合规时push当前双binding,布局创建失败回原路径;保持每帧ONE_TIME录制及同步。新增renderer init/descriptors日志按严格来源独立限流写入App存储。授权为用户当前持续修复要求,未扩展FEL以外行为。 -- 2026-09-14本机候选验证完成:旧VO负例已复现,新实际函数ASan/UBSan、1200帧push/普通路径、240帧交接、并发/取消/失败/默认隔离、源patch契约、双ABI/ELF/导出及两APK各10库/签名/紧凑包通过;18其他库(含JNI)逐字节不变,13项Java测试通过。EL/RPU实样因无本地样片明确SKIP,无目标电视实播。70保护文件未动,guard原子提交/tag收尾,不推送;精确提交/tag由guard记录,不另建仅为回填自身ID的提交。 -- 最终APK生成于00:37,原00:15–00:35目标略超;Gradle第一次被沙箱阻止写已有wrapper缓存,获得权限后实际构建1m17s,仅一次有效构建。旧APK和两份精确debug打包缓存保存在本轮证据目录,CMake仍使用隔离温缓存。证据目录`/private/tmp/webhtv-fel-warmup-push.22C7vl/`。唯一下一步:目标电视安装TV32 SHA256=`4b79ec5aecdb81764cec92fa3b6d3b6f2cf0c9816027849804561ab0eab3a0b8`,同GIJoe设置三次起播、完整57秒及seek/退出,取新App日志裁决能力、帧顺序和实时性能。 - -## 9.17 日志31:首次渲染初始化与descriptor绑定热点 - -### 证据与可证伪问题 - -- 原始`/Users/macbookpro/Downloads/webhtv-debug-log (31).txt`;冻结副本`/private/tmp/webhtv-fel-log31.uK6oSM/device-log31.txt`,5113行、1384543字节,SHA256=`58bcaa55373d2287259836bbb4be3113bb7bc3996101daeb1002f7725d8745fe`。同本地GIJoe 4K23.976、Android14、Mali-G57;`command-mode=fresh-bind-record replay=0`证明9.16候选生效。 -- 首轮`p-yeyf8r-1`第8帧pts39.164、staged7、elapsed838ms后4003;首个`pl_render_image_mix`耗时725.890ms wall/587.604ms thread CPU,两个新AHB分配221/252ms。前7帧acquire/map成功且AImage错误/超时/过期为0。后3轮首次渲染67/8/12ms,没有相同fatal。现有初始化阶段只覆盖stable mapper创建,不覆盖实际首draw;源码证明保护存在缺口,时间重叠支持此假设,但不是厂商驱动内部根因的完整证明。 -- 持续3轮mapper均值32.849/44.494/43.037ms,其中`vkCmdBindDescriptorSets`29.496/36.165/36.207ms、线程CPU约1ms、峰173ms;descriptor update只有微秒。GPU copy约20.6–20.8ms与后续GPU渲染异步重叠,不能直接求和。无中途seek段约13fps,EL常积压8帧,不支持盲加软件解码线程。 -- 所有已记录`pts-different=0`;M/R/A事件包含预取/重选,交错不等于显示回跳,亦不能证明像素正确。电视是否支持push descriptors未知,原扩展列表被旧日志限流丢弃;新路径必须明确记录能力/回退原因。 - -### 有界研究与当前调用链(访问2026-09-13) - -固定依赖不变:mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`、FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`、libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`、mpv-android `99a60ad2141d5ace94453590903c2c6b9a0a2443`;均为已覆盖基线,只读参考,无新合并候选。 - -| 来源/固定身份 | 等级、支持结论、适用性与限制 | -| --- | --- | -| 当前mpv补丁`vo.c::vo_prepare_fel_frame/fel_prepare_timed_out/process_fel_prepare/render_frame`、`vo_gpu_next.c::draw_frame` | A;VO循环在实际draw后才处理下一暂存,首draw编译可阻塞消费者;现有staging init state只含mapper阶段。应分离一次性的渲染初始化,不能统一放宽正常帧期限 | -| 锁定libplacebo `src/vulkan/gpu_pass.c::vk_pass_create/set_ds`、`context.c`、`gpu.c`、公开`vulkan.h` | A/B;实际启用KHR扩展、数量合规时创建push layout,不建descriptor pool,逐次`CmdPushDescriptorSetKHR`;公开extensions表示实际启用,不能只看函数地址。成熟同栈方式可窄用,不升级libplacebo、不复制整个渲染器 | -| [Khronos descriptor规范](https://github.com/KhronosGroup/Vulkan-Docs/blob/f84d432d5b8912362f96f581f29bbc4f3c8c7843/chapters/descriptorsets.adoc),`f84d432d5b8912362f96f581f29bbc4f3c8c7843` | A;每次begin后push状态未定义,所有静态使用binding须逐次写入,包括immutable sampler;`dstSet`被忽略,YCbCr/数量及layout约束必须保持。只改变descriptor提供方法,不能省掉当前帧资源或同步 | -| [Khronos push sample/说明](https://github.com/KhronosGroup/Vulkan-Samples/blob/ad5dd381b11fe2e28fdb2d14d52b0aac9b0b9ef1/samples/extensions/push_descriptors/push_descriptors.cpp),`ad5dd381b11fe2e28fdb2d14d52b0aac9b0b9ef1` | A/B;示例每帧push当前descriptor并避免普通pool/set绑定;README解释CPU管理成本。独立API用法旁证,不代表当前电视支持或一定更快 | -| 定向GitHub issues/PR检索`vkCmdBindDescriptorSets Mali`、`push descriptors Mali`;原响应在本轮证据目录 | C/D;未找到证明当前Mali问题的可直接移植修复。已读Godot PR92611实际针对Adreno5xx draw/uniform问题,不适用、不采纳;不把相似关键词当同根因 | -| 技术文章/benchmark | 沿用9.15已固定的Arm/Khronos descriptor管理基准,加上述官方sample README;本次热点是bind不是update,因此不直接套用其百分比收益 | -| 论文 | 本窄单元不适用:不改FEL/NLQ或并行算法;API合法性与一次性状态机由规范、源码和负例裁决,不能由画质/编码论文解决,也不继续泛搜 | - -相关原始材料位于`/private/tmp/webhtv-fel-warmup-push.22C7vl/`,规范沿用`/private/tmp/webhtv-fel-vk-reuse.e7UiUB/`。联网使用用户127.0.0.1:7897代理。剩余不确定性需设备对照,不再扩大检索。 - -### 比较、决定、验收与回滚 - -- 不改:保留已证实首次初始化保护缺口及bind热点;不满足本轮目标。 -- 原上游:libplacebo已有push实现,但不管理本地stable mapper的layout/外部YCbCr图像;原样升级/复制不能自动修复。增加CPU线程、重启跨帧命令重放或CPU回读不能对准现有证据,不采用。 -- 窄适配:只在FEL+GPU_EL_SW+MediaCodec的首次实际draw进入独立10s初始化阶段,开始/结束主动维护pending期限,即使producer没有采样到阶段变化也正确;结束恢复750ms正常帧期限。初始化不可被轮询、每帧或普通seek无限延续;保持取消/代际/退出保护,不让GPU调用持有VO锁。 -- 同单元对已定位的bind热点:仅设备实际启用push扩展、入口可用且YCbCr/descriptor数量合法时创建push layout,每帧push完整当前input/output,仍ONE_TIME录制、保留barrier/fence/lease与原shader。能力不足或初始化失败保留普通descriptor方式,新增有界能力、模式和API耗时日志进入现有App调试存储。没有默认模式、公开API/ABI、许可证、依赖或二进制所有权变更。 -- 最便宜决定性检查:真实VO函数旧逻辑负例(首draw跨750ms但未达10s),新逻辑覆盖启动/期间新请求/两次轮询间完整warmup、10s真挂死、结束后750ms、取消与非FEL隔离。实际mapper函数host测试覆盖push完整写入/当前帧身份/能力不足fallback/资源销毁,保留已有pending/缓存/帧顺序/源归还契约。 -- 构建验收:权威补丁与固定前FEL源码一致;同锁双ABI libmpv/ELF/公开导出、其他18库不变;必要诊断Java测试;TV32/Mobile64 APK逐库身份/签名/紧凑包。现有`app/.cxx/`70文件不触碰,复用隔离CMake温缓存。 -- 设备验收:同电视同样片重复起播3次,完整57秒并seek/退出;记录是否push、bind/push/map耗时、FPS/掉帧/A-V、无4003/花屏/前后帧回跳。目标接近23.976fps且A-V偏差不持续增长。设备不支持时不承诺加速,不以host计数替代像素/性能。 -- 用户已明确批准继续实现本FEL可靠性/性能任务;本单元先完成可恢复的本机候选,整体需求仍待电视验收。回滚至`ce10d5c15ef36fa83e27c6195182717a334a1036`对应源/补丁/诊断/两libmpv整套,不移动已有tag、不推送、不触碰保护缓存。 - -### 00:12实施与host验证 - -- 实际源码只改`vo.c`、`hwdec_aimagereader_vk_stable.c`,权威补丁由固定前FEL树和当前HEAD机械生成这两个section,其余section原样保留;正向/反向应用及静态契约通过。VO不在GPU调用期间持锁,不在普通seek/reconfig重置一次性初始化;结束只扣本次draw实际占用、保留此前已花的帧等待。 -- push门控按实际启用KHR扩展、两个入口及保守的`sampler_descriptors + 1 <= maxPushDescriptors`决定;immutable YCbCr sampler不变。push模式不建pool/分配set、不对新增output调用普通update;每次新record完整push两个binding。仅push布局创建失败重试普通布局,不掩盖两条路径共同的OOM/设备故障。 -- `warmup-negative.log`:旧VO在900ms首draw的producer检查稳定失败。`native-contract.log`前9组通过(含1200帧push/普通模式及实际draw接线);随后core测试的无操作日志stub触发`phase_name`未使用编译警告,已只修测试stub使参数被消费,未改生产逻辑或弱化-Werror。 -- 辅助remaining脚本首次process substitution没有实际执行测试、无输出,不算通过;改用检查抽取结果再执行的方式。最终`native-contract-remaining.log`覆盖真实VO/wrapper、120+120帧归还、计时/查询、lease/并发、bitstream与继承及最终源/patch全部通过,不重复已成功的前9组。畸形NAL的错误输出为负例预期,EL/RPU样片解码仍SKIP。host不加载目标Vulkan驱动,不验证电视像素/FPS。 - -### 最终本机产物(2026-09-14 00:37,待电视实测) - -- `arm64-build.log`/`armv7l-build.log`实际编译VO及stable mapper并重新链接;`native-assets.log`、`native-boundary.log`确认同锁双ABI/ELF/命名空间/公开导出不变,只2份libmpv变化、其余18库(含JNI)相同。仅既有局部变量shadow/locale警告,不扩展修理。 -- `apk-build-sandbox-denied.log`记录首次wrapper缓存权限限制,未执行Gradle任务;获准访问已有缓存后的`apk-build.log`一次有效构建1m17s、181任务。JDK21/SDK37/NDK29/Gradle9.5.1,离线温缓存。`MpvDiagnosticsPolicyTest`13项0失败0跳过,包含两类新日志来源/级别/限流路由。 -- `apk-artifacts.log`:两包各10项MPV native assets逐字节匹配;v2签名通过;ZIP额外开销Mobile751608/TV792208字节。未安装/操作目标电视,不能用这些结果声称push在该设备启用、性能达标或画面回跳消失。 - -| 产物 | SHA-256 | 字节 | -| --- | --- | ---: | -| arm64-v8a libmpv | `93d948e9518207f3f14e4e7e827f7dcf48f6698e29f5d9ab5ac8d4ba7a0c933d` | 17801680 | -| armeabi-v7a libmpv | `b53a44e19af8c4337f1afe420ce2b1ce25ddb135308b208b3fbeb6a065d527a6` | 14612900 | -| Mobile64 debug APK(00:37:48) | `63521091a1a533bcd8c236c81cf89bbbc5e3886b3201cfa740a09266bf6bdc53` | 151604740 | -| TV32 debug APK(00:37:31) | `4b79ec5aecdb81764cec92fa3b6d3b6f2cf0c9816027849804561ab0eab3a0b8` | 130473287 | - -最终FEL patch SHA256=`ef8e2cf166c7f35e7a02d18d4c561d2629244815d24543ab2f3bb08b72345f88`;host脚本=`8adc75545012c390eef4e20200180776f9d47061b6c3c2106f2929e38bf22fd2`;native验证脚本=`196e9003d3cb1f6354375ef86e340052bbe9ca482e12399239bc49a08347846f`;lock仍为`a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159`。无依赖/许可证/公开API变化,旧模式保留。整体FEL可靠性/实时性能仍是待设备验收状态。 - -## 历史恢复记录(9.16) - -- 目标/验收:手动FEL双层重建正常顺序播放、seek/退出可靠且改善持续性能,保护画质/同步及其他模式。14:52 TV32候选在新日志29(15:46–15:48)被用户否决画面验收;3轮warm map均值15.924/12.733/18.170ms但仍掉帧/A-V滞后,另一次751ms交接失败发生于command-hit=0。尚无像素级根因证明,整体电视需求仍未验收。 -- 分支`feature/mpv-dv7-fel`;原子单元`P2-4-fel-vk-reuse` / upstream;回滚基线`dc1401638532840a8362b869b3220cb952ca7b35`,tag `recovery/P2-4-fel-steady-perf/20260913130843-dc1401638532`。沿用同锁/同scope,无新上游提交、依赖升级或推送。该候选精确提交/tag由guard收尾记录,不额外创建仅为回填自身提交ID的代码提交。 -- 16:22用户明确继续后已完成窄修正:保留有界AHB导入与命令/descriptor对象槽,每个新帧重新绑定、录制ONE_TIME命令,零跨帧命令重放。增加8事件帧关联环(帧编号、输入/输出槽、请求/保存PTS及跨环保留的最近差异)、6个录制API计时和限频慢调用日志,直接进入App调试存储。不改shader/NLQ/10bit、同步、全局期限、线程、默认模式或其他播放器。 -- 本机结果:真实函数ASan/UBSan及最新cache/trace/240帧交接/源补丁一致性通过;1200帧1200次重新绑定/录制、1128次对象槽命中。双ABI实际编译/ELF/公开导出通过,仅2份libmpv变化,其他18库(含JNI)不变;13项Java测试通过。18:39两个新APK包内各10库、签名与紧凑ZIP全部通过,TV32 SHA256=`4971498a956a723cf348592dc4b228f595efdda56a77a884afb51fec05645722`。本机证据不代替电视像素/帧率验收。 -- 文件/证据:stable mapper的可复现源码为`third_party/patches/mpv-android-fel.patch`;诊断policy、tests、定向校验脚本、两份libmpv与本任务/索引/构建文档同一单元。完整日志在`/private/tmp/webhtv-fel-frame-order.sUwUpN/`。native于16:54完成、Gradle实际耗时1m17s;会话多次暂停/恢复使原16:53–17:08及后续18:43交付目标顺延,不增加研究或重复成功构建。 -- 保护/收尾:两处新增Release缓存已确认不含原保护文件后备份至`release-cxx-before.tar`并临时隔离,18:32恢复时guard检查仍通过,原35文件不变;guard提交/tag后须原样恢复`release-cxx-config`与`release-cxx-tools`到原路径。不改ignore/guard清单、不清理其他缓存。唯一下一步:完成guard原子提交/tag及缓存归还后,交付18:39 TV32包在同一电视/样片复测三次启动、完整播放及seek/退出,读取新帧关联和慢API日志裁决回跳与性能。 - -## 9.16 新日志29:缓存优化后的画面回跳回归 - -2026-09-13 15:50 Asia/Shanghai开始只读定位;15:53估计定位/测试25–35分钟、双ABI打包15–20分钟,目标16:33–16:48,设备验证另计。现有guard、分支、锁、路径和回滚基线均沿用9.15,不新增依赖或改变产品设置。范围外缓存的隔离许可是编辑前安全门,未获许可不移动文件或修改生产代码。 - -### 新证据与可证伪假设 - -- 日志副本`device-log29.txt`为4681行/1229107字节,SHA256=`dc510194dc158e39b778846730417ac6c66b31daf68d8b062d370d6021443b89`;对应14:52:31 TV32 APK,SHA256见9.15。 -- 持续播放trace `p-xzid2g-1/p-xzjb48-2/p-xzkc51-4`:warm map均值15.924/12.733/18.170ms,command-hit=325/321/223,导入均为9个缓冲区且没有播放中淘汰。AImage acquired=mapped=389/386/297,timeouts/stale/newer/errors均0;unavailable outputs=0。没有日志证据支持“解码器交出了时间戳错配的AImage”,但这些统计不验证GPU读到的实际像素,也不完整证明显示PTS单调。 -- `p-xzjb48-2`仍记录A/V滞后6570ms,不能用map变快称整体性能达标。其余持续轮含seek,不能混加重置前后掉帧计数。 -- `p-xzk24a-3`在pts=16.225、staged=8、elapsed-ms=751时交接失败;共导入9个新buffer,command-hit/miss均0,尚未生成缓存录制。这次失败不能归因于缓存命令重放;首次输出资源已初始化不代表随后每个AHB导入都便宜。具体超时阶段仍需新日志,不能直接放宽全局超时。 -- 核查`recording_matches/select_recording/prepare_conversion/submit_conversion`:键含input/output身份、crop/geometry/query,逐次提交使用当前semaphore,pending不可复用;`stable_reuse/select_reusable_output`与`hwdec_acquire`仍保留原逻辑身份/lease。尚未找到可由现有源码直接证明的错帧分支。首要假设是新增录制重放在外部动态图像路径不兼容;反假设为扩大导入缓存暴露已有外部图像可见性问题或更快流水线暴露输出生命周期问题。 - -### 有界增量参考、决定与验收 - -- 继续采用9.15已固定revision的Vulkan命令/descriptor生命周期规范;规范允许完成后的非ONE_TIME命令再次提交,因此不能声称“Vulkan禁止复用”或未经实测断定Mali驱动bug。 -- Google ARCore官方`3abfeb18669c2cbb2d07057f135d117ee9d91826`的[`BackgroundRenderer::DrawBackground`](https://github.com/google-ar/arcore-android-sdk/blob/3abfeb18669c2cbb2d07057f135d117ee9d91826/samples/hello_ar_vulkan_c/app/src/main/cpp/background_renderer.cc),2026-09-13读取,A/B:每帧导入外部图像、更新descriptor并记录绘制,可参考其动态资源处理方式;不是FEL、也不能证明本机驱动根因或整段照搬。原文件保存于证据目录。 -- GitHub两次定向检索的原响应已保存;ncnn #5531是AHB导入花屏的单一用户报告,没有证明当前重放问题的修复,等级D,不据此改代码。未找到可直接移植且能解释本次回归的成熟补丁。论文不适用本单变量兼容性回退,不改FEL数学或提出新的并行算法,停止扩大检索。 -- 不改:无法接受新画面回跳;直接整体撤回9.15:回到已知慢路径但无法区分两个变量;建议窄修正:先停用转换命令的跨帧重放,恢复逐帧descriptor绑定与录制,保留导入缓存/原同步,细分record内barrier/bind/push/dispatch耗时并记录帧身份/输出槽关联。若相同设备仍回跳,再以新证据单独处理导入缓存,不盲目扩池/线程/取消fence或降低画质。 -- 定向验收先证明每次新帧确实重录、绑定当前input/output且不会重置pending命令,保留移除/错误/lease保护,再同锁双ABI/包验证;目标设备连续运动场景、seek/退出和重复启动检查无回跳、无错帧、无新增失败。性能仍单独记录,不能用host调用次数或编译成功替代画面验收。 -- 16:22用户明确继续后已实施上述窄修正,缓存隔离与验证进度见Recovery anchor。新增`WebHTV FEL frame order:`是逻辑关联而非像素校验,PTS差异仅记录、不丢帧/失败;`WebHTV FEL api slow:`仅记录≥100ms调用且每3秒最多4条,排除重复的RECORD总计。两类均沿用独立限流、直接写App调试存储而不排主线程/重复Logcat。回滚锚点仍为`dc1401638532840a8362b869b3220cb952ca7b35`及其9.15已记录tag,源/补丁/二进制必须成套;不把14:52候选或host测试标为设备通过。 - -### 最新本机验证与候选产物(2026-09-13) - -- `native-contract.log`行为测试已通过,其末尾源/patch一致性首次失败是因补丁尚未重生成;随后`patch-generation.log`证明固定前置基线正向应用、实际源码反向校验通过。最终输入槽/跨环PTS细化后,`native-contract-followup.log`对受影响真实函数重新执行ASan/UBSan及最终源/patch检查,全部通过;其他已经成功且未变更的契约不重复跑。EL/RPU实样解码因未提供本地样片仍明确SKIP,不计为新设备验证。 -- `arm64-build.log`/`armv7l-build.log`均实际编译stable mapper并重新链接;`native-assets.log`与`native-boundary.log`证明双ABI/命名空间/ELF/公开导出/包集合一致,18个其他库字节不变。只出现既有`VkFormatProperties props`局部变量shadow警告,不扩展清理。 -- `apk-build.log`一次温缓存离线Gradle,JDK21/SDK37/NDK29,1m17s成功;`MpvDiagnosticsPolicyTest`13项、0失败/0跳过。CMake staging仍隔离在之前温缓存目录;只将两份精确debug package缓存和旧APK移至证据目录保留,没有删除用户数据。 -- `apk-artifacts.log`两APK各10个native assets匹配、签名通过;ZIP额外开销Mobile752946/TV793314字节。保留的14:52旧包为`reuse-tv32.apk`/`reuse-mobile64.apk`,不可与本表新候选混用。目标电视不支持ADB,本轮没有安装或实测;回跳、持续掉帧及独立751ms失败仍是待设备裁决项。 - -| 产物(新APK为18:39) | SHA-256 | 字节 | -| --- | --- | ---: | -| arm64-v8a libmpv | `8ca9c014569af24a2b46fc8a60ad77bba80ee71bbb50121684f84f9b64ad6f9b` | 17800368 | -| armeabi-v7a libmpv | `ca5c2b0760a03de8daa76837f408657daed40c268639beaf8ac2aefa2ac183c7` | 14611796 | -| Mobile64 debug APK | `a855f4bcefabb5933a8006e49180a9b6a979102a03cbd4af6425999998d1b83f` | 151604740 | -| TV32 debug APK | `4971498a956a723cf348592dc4b228f595efdda56a77a884afb51fec05645722` | 130473287 | - -最终FEL patch SHA256=`31ebae9c560e47b8edf6850d07e1512996032b9511e2475d150905ef9b2cfc3c`;lock仍为`a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159`。`WebHTV FEL reuse:`中的`command-hit`现在只代表对象槽命中,不代表重放,须同时读取`command-mode=fresh-bind-record replay=0`及`fresh`计数。帧关联环的M/R/A/X分别为map/reselect/alias/miss;只有逻辑身份,不能把PTS一致当作GPU像素正确的证明。 - -## 9.15 日志30:FEL硬件帧导入与Vulkan命令复用 - -2026-09-13 13:31 Asia/Shanghai开始;13:36估计剩余60–75分钟,目标14:36–14:51(定位/研究15分钟,实现/测试30–40分钟,双ABI/打包/收尾15–20分钟);电视无ADB,设备验证另计。仍是P2-4的独立可回滚优化单元,不是依赖升级。 - -### 证据与范围 - -锁定mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`、FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`、libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`、mpv-android `99a60ad2141d5ace94453590903c2c6b9a0a2443`全部保留,不新增上游提交。允许路径以guard为准:FEL补丁、定向tests/scripts、诊断策略/测试、两个libmpv、原任务/索引/构建文档;`build/mpv-native`只是可复现编译副本。其他18库(含JNI)、Exo、音频、光盘和网络行为不变。 - -日志三个trace为`p-xu4mq6-3/p-xu56nw-4/p-xu6561-5`,本地GIJoe 4K23.976fps,Mali-G57、4核。warm map52.190/51.119/49.819ms,线程CPU4.515/4.633/4.572ms;import16.253/14.152/13.810ms、record31.965/34.525/33.410ms。GPU转换区间20.573/20.692/20.741ms、渲染pass约25–28ms,不与墙钟简单相加。第三轮13:17:36.861–13:17:49.019 paired53→210,约12.9fps;13:17:48.552 A/V为7004ms。seek重置计数,不能累加峰值或称三轮完整播放通过。 - -调用链`stage_fel_before_publish → vo_prepare_fel_frame → aimagereader_vk_stable_map → record_conversion/submit_conversion`。stable导入缓存仅8槽,命中仍每帧更新descriptor、reset/begin/录制/end command buffer。尚无API细分/cache miss证据,不能断言特定驱动bug。已有`finish_output`的copy fence/input users及与mapper共用`vk_lock`的buffer-removed回调必须保留。 - -### 最佳实践增量研究(2026-09-13,经127.0.0.1:7897代理) - -| 来源/固定身份 | 等级、支持事实、项目适用性与限制 | -| --- | --- | -| [Vulkan command生命周期](https://github.com/KhronosGroup/Vulkan-Docs/blob/f84d432d5b8912362f96f581f29bbc4f3c8c7843/chapters/cmdbuffers.adoc) | A;非ONE_TIME命令完成后可再执行,pending不可修改/无SIMULTANEOUS再次提交,引用资源销毁导致失效。仅既有fence后复用,不缓存逐帧acquire/release fence | -| 同revision [descriptor规范](https://github.com/KhronosGroup/Vulkan-Docs/blob/f84d432d5b8912362f96f581f29bbc4f3c8c7843/chapters/descriptorsets.adoc) | A;更新descriptor使引用它的命令失效。每条缓存命令独占descriptor,命中不更新,失效重录;不引入update-after-bind新要求 | -| [Arm/Khronos descriptor管理与基准](https://github.com/KhronosGroup/Vulkan-Samples/blob/ad5dd381b11fe2e28fdb2d14d52b0aac9b0b9ef1/samples/performance/descriptor_management/README.adoc) | A/C;移动端更新可昂贵,示例44→27ms,推荐复用且避免不必要FREE_DESCRIPTOR_SET/pool reset。采用有界缓存,但不套用多drawcall的38%收益到本单dispatch | -| 同revision [ResourceCache代码](https://github.com/KhronosGroup/Vulkan-Samples/blob/ad5dd381b11fe2e28fdb2d14d52b0aac9b0b9ef1/framework/resource_cache.cpp) | B;以descriptor内容为key,image view改变需改变key。适配为包含input/output、crop/geometry/query的有界缓存,不照搬全局缓存 | -| [Android 14 NdkImageReader](https://android.googlesource.com/platform/frameworks/av/+/android-14.0.0_r1/media/ndk/include/media/NdkImageReader.h) | A;持有外部图形对象需buffer-removed通知;AHB与AImage/fence生命周期不同。保留callback/users/异步归还,只扩大导入元数据缓存,不扩大AImage/4K像素池 | -| 锁定libplacebo `src/vulkan/gpu_pass.c::vk_pass_run/set_ds` | B;16组descriptor由完成回调归还,不能提前更新in-flight对象。沿用完成证明,不等待所有队列空闲 | -| [Godot #112157](https://github.com/godotengine/godot/issues/112157)及两次GitHub检索,原响应在证据目录 | C/D;多款Mali(含G57)的uniform共享状态崩溃,不能在所有设备复现。无可直接移植修复,也不是当前根因证据;强化缓存失效测试,不因GPU同名推断同一bug | -| 论文/更多泛博客 | 本单元不适用:不改FEL数学、解码器或并行算法;规范、成熟源码与Arm技术基准足以决定资源复用,扩大搜索不能替代实机数据 | - -### 方案比较与决定 - -- 不改:避免新增缓存风险,但每帧重复准备及约13fps不能满足需求。 -- 原上游/原stable:8槽导入、每输出一组descriptor/一次性命令简单;没有命令内容复用。盲加CPU/GPU线程不能解决已测热点,EL已领先。 -- 采用窄适配:仅显式FEL把导入上限8→32;最多128条按需创建的命令/descriptor记录,不增加4K输出槽。仅warm input/output可缓存;key含身份、crop/尺寸、query状态。命中直接再次提交,acquire/release同步仍逐帧更新;LRU不选pending命令,input移除/淘汰和重建主动失效;可选缓存分配失败走原路径。 -- 新增聚合命中/miss/淘汰/失效/退回及API调用数、墙钟、线程CPU;INFO统计继续不排主线程/不重复Logcat。它能证伪“导入抖动/重复录制为主因”,不提前承诺收益。 -- 不改shader/NLQ/10bit、丢EL/降分辨率、CPU回读、fence、解码线程或renderer。默认off及原模式不变,无新API/ABI/依赖/许可证;额外driver元数据有上限,真实占用/收益待设备测量。 - -### 验收、批准、回滚 - -用户本轮明确批准沿上述诊断继续实施,无需重复逐项确认。最便宜决定性验证是抽取真实函数的host ASan/UBSan:warm命中不更新/重录;cold/新input代际/crop/query变化不误命中;pending不可命中/淘汰;容量有界;可选分配失败回原路径;移除在最后GPU用户完成后失效;teardown顺序。保留现有240帧交接/query/取消/EOF/lease契约;12输入轮转验证调用次数减少,不称为电视FPS。 - -然后仅双ABI libmpv增量编译、ELF/公开导出/18库不变、定向Java日志策略测试、TV32/Mobile64包内容/签名。目标电视同设置三轮完整57秒并seek/退出,命中率趋稳/map成本下降,目标接近23.976fps且无递增A/V偏差、4003、花屏或退出失效;无设备证据只交付本机已验证候选,整体需求不标完成。 - -回滚到本节完整WebHTV基线commit/tag,FEL源码/补丁/诊断与两份libmpv成套恢复,不动原`app/.cxx/`、不移动tag、不推送。 - -### 实施与交付证据(2026-09-13 14:55) - -- 实施文件为stable mapper(由固定前置基线更新FEL patch)、诊断policy及测试、定向native测试/验证脚本、两份libmpv。AHB 32槽、最多128条lazy command/descriptor记录;cold、移除、裁剪/尺寸/query变化、pending、分配失败均有原路径或失效保护。不增加像素缓存,不缓存逐帧semaphore,query仍在原fence完成后读取。非FEL仍8槽、一次性命令。 -- `native-contract-final.log`:真实生产函数ASan/UBSan通过,覆盖冷/热layout、pending、deferred removal/同地址新对象、crop/geometry/query、LRU上限、可选分配失败(故意污染失败输出句柄)、录制失败不缓存、API计时及旧240帧交接/取消/EOF/lease。模拟1200帧/12输入/5输出的导入1200→12,descriptor更新/录制1200→72,1128次命中;仅为调用次数。未提供实样给host EL解码,该项明确SKIP,不算新设备验证。 -- `arm64-build.log`、`armv7l-build.log`、`native-assets.log`、`native-boundary.log`:两ABI实际增量编译/ELF/公开导出通过,只有libmpv变化,另18库(含JNI)字节相同。无新依赖/版本/公开API。 -- `apk-build.log`:JDK21、离线温缓存,一次Gradle调用1m50s通过,`MpvDiagnosticsPolicyTest`13项0失败/0跳过,同时构建TV32/Mobile64。CMake staging隔离到本证据目录;旧APK及两份精确package缓存移入同目录保留,避免增量ZIP空洞,没有删除用户数据。 -- `apk-artifacts.log`:两包各10个native assets均与候选一致、签名通过;ZIP开销Mobile755801/TV798535字节。14:55验证完成,比原目标上限14:51晚4分钟,额外成本为失败路径加固/环境定位;未扩大研究或重跑已通过的ABI构建。ADB没有连接设备,未安装到目标电视,不能称已实现实时性能。 - -| 最终产物 | SHA-256 | 字节 | -| --- | --- | ---: | -| arm64-v8a libmpv | `72a9f828b9958f9d8f0b701dd91d84ff9a9fa05d3404c560cc594d19f6bf965a` | 17797552 | -| armeabi-v7a libmpv | `ab8c5a6c1a52763bf7bfeff0ed88e53f6c9d5cc1d4ca1f74b6d445a50de25b03` | 14606652 | -| Mobile64 debug APK | `7dca2f646d2ff8fc90d7b783c5d9afd1b88185ba61c72032c1642621f715ce50` | 151604740 | -| TV32 debug APK | `67b007a129ba488491cc666683ed8b2cc4a35d3e96c7ef6bf26b8cd56df8ad68` | 130473287 | - -FEL patch SHA-256=`d94977b25d729a630504a762fd8c3491d054b14e4ada560b8745c7fd3f6e7eb6`,固定前置基线正向应用/实际源码反向验证通过;lock仍为`a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159`。测试脚本的新Vulkan模拟仅使用已有NDK头文件,设置`VULKAN_HEADERS_INCLUDE=/toolchains/llvm/prebuilt//sysroot/usr/include`或`ANDROID_NDK_HOME`,不加载Android GPU驱动。 - -收尾阻塞(15:01):checkpoint验证通过,但guard finish返回4,报告35个新增范围外文件,位于`app/.cxx/RelWithDebInfo/621cr346/`与`app/.cxx/tools/mobileArm64_v8aRelease/`。其CMakeCache/compile_commands创建于14:55:53,本轮保存的Gradle日志只执行两ABI的`configureCMakeDebug/buildCMakeDebug`且staging在独立临时目录,无法将这些Release文件归属本轮。原保护清单是`app/.cxx/Debug/p104q5y4/`等35文件,未报告原文件变化。没有移动/删除新缓存、修改guard状态或绕过失败门槛,等待用户确认安全隔离方式;源码/包验证无需因此重跑。 - -## 9.14 日志29持续掉帧:先消除诊断干扰并区分CPU/驱动/GPU - -2026-09-13 11:41 Asia/Shanghai开始,目标12:40–12:50提供已本机验证的候选(定位研究15–20分钟、实现测试20–25分钟、温缓存双ABI/打包15–20分钟;设备实测另计)。此节沿用同一P2-4,不新增上游任务或第二份文档。授权为用户本轮“继续优化,解决问题,实现需求,必要时可增加详细调试日志”,不需要重复逐项确认;无推送授权。 - -### 当前证据及决策 - -基线为WebHTV `1620bac1566727f4067eda631647a11652082e74`,锁定mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`、FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`、libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`、mpv-android `99a60ad2141d5ace94453590903c2c6b9a0a2443`均保持,不引入新上游提交。 - -- 三轮`p-xpwush-1/p-xpxv7s-2/p-xpyne5-3`,Mali-G57,4K23.976fps的本地GIJoe样片;每轮有seek,不能用总时长/总帧数混算或把reset后的掉帧峰值相加。持续区间配对约10–12fps,最终A/V滞后数秒。映射CPU墙钟48.4/55.5/61.4ms已超过41.7ms整帧预算;其中可能含驱动阻塞、CPU被抢占和命令回收,并不是GPU shader时间。 -- 当前`aimagereader_vk_stable_map`在原GPU fence后复用槽位,`pl_vulkan_hold_ex`交还输出、录制copy、队列锁/submit、独立SYNC_FD export/deleteAsync。无CPU readback、copy-wait为0,源归还与GPU资源生命周期保护有效。没有证据允许删除这些同步。 -- `vd_lavc::init_avctx → mp_set_avcodec_threads`已支持自动多线程;`f_decoder_wrapper::init_group_decoder/dec_thread`已有独立BL与EL线程;不能无视8帧EL积压盲加worker。FFmpeg调用墙钟不等于所有解码工作线程CPU耗时。 -- `MpvPlayer::logMessage → NativeLogWindow`所有FEL证据共用32条/5秒预算,低频GPU初始化消息仍会被其他警告挤掉;纯统计也排入主线程执行无用故障识别/字幕判断。这一重复工作可确定移除。Logcat阻塞证据支持纯性能日志只写现有App日志存储,不重复漂亮格式输出;真实warning/error仍保持旧处理。 - -### 增量最佳实践证据(2026-09-13访问) - -| 来源/固定身份 | 等级、支持的事实 | 本项目决定与限制 | -| --- | --- | --- | -| 本次日志及上述锁定源:FFmpeg `doc/multithreading.txt`、mpv `common/av_common.c`、`vd_lavc.c` | A;frame/slice多线程不同,额外frame线程增加前视延迟;mpv自动核心数+1 | 输出实际thread_count/active_thread_type,不改用户线程设置,不以更多线程替代瓶颈验证 | -| [Vulkan queries规范](https://github.com/KhronosGroup/Vulkan-Docs/blob/f84d432d5b8912362f96f581f29bbc4f3c8c7843/chapters/queries.adoc) | A;查询依赖timestampValidBits/period;不带WAIT可返回NOT_READY;旧query在reset执行前可能假就绪 | 只在既有copy fence完成后读取同槽timestamp,不等待、不新增fence;不支持或失败则统计不可用,不能使播放失败 | -| [Khronos/Arm async compute](https://github.com/KhronosGroup/Vulkan-Samples/blob/ad5dd381b11fe2e28fdb2d14d52b0aac9b0b9ef1/samples/performance/async_compute/README.adoc) | A/C;Mali compute与vertex共享执行资源,fragment/compute依赖可能造成流水线气泡;多队列收益依设备/任务而异 | 当前已有compute队列,不盲加CPU/GPU队列;必须先区分阶段墙钟与GPU执行 | -| 同revision [command buffer多线程与回收基准](https://github.com/KhronosGroup/Vulkan-Samples/blob/ad5dd381b11fe2e28fdb2d14d52b0aac9b0b9ef1/samples/performance/command_buffer_usage/README.adoc) | A/C;多线程录制面向大量drawcall的CPU瓶颈;过多线程/command buffer可能反而变慢 | FEL copy只有一次dispatch;不照搬1800 drawcall/8线程的收益,不为未证实的reset成本引入复杂缓存 | -| 锁定libplacebo `src/vulkan/gpu.c::vk_timer_query/timer_begin`、`gpu_tex.c::pl_vulkan_hold_ex`及mpv `vo_gpu_next::info_callback` | A/B、成熟项目代码;异步GPU timestamp、已存在渲染pass性能缓存;hold会提交命令而非纯CPU引用计数 | 复用现有render pass统计,独立记录copy时间和CPU阶段;不把API墙钟当GPU耗时,不加同步属性查询 | -| GitHub issues检索:mpv Vulkan Android slow、跨项目Mali vkQueueSubmit performance;原响应保存本证据目录 | B/D;找到的Pi输出分辨率/Flutter/渲染后端讨论不直接证明当前MediaTek路径问题 | 未找到可直接移植且适配当前证据的修复;不采用不相关issue猜测。此前9.12/9.13已有的mpv讨论与所有权规范继续有效 | -| 学术论文/更多泛博客 | 本小单元不适用 | 不修改FEL算法或提出新并行调度;规范、实际源码和有方法的Arm基准足以决定观测方式,电视实时性仍必须实测 | - -对比:不改会继续缺乏可决策耗时;无修改上游已有GPU timer但不覆盖自定义AHB copy,亦不处理App限流;盲增线程/去fence/改变shader或默认renderer风险高且无证据。采用窄适配:保留所有像素/时序与依赖,只给FEL增加聚合CPU分段、可选无等待copy timestamp、已有render pass耗时和实际解码并行信息;纯性能日志独立有界存储,不再重复送主线程/Logcat。它减少确定的诊断开销,但不预先声称主掉帧已解决。 - -允许路径为guard列出的FEL补丁、定向tests/校验脚本、`MpvPlayer/MpvDiagnosticsPolicy`及对应测试、两份libmpv、原任务/构建文档和索引;构建源码缓存只是可复现输入工作副本。保护原`app/.cxx/`;JNI/FFmpeg/libplacebo/Exo/音频/光盘/默认FEL关闭状态不变。新增GPU查询仅几百字节,失败不影响像素、没有新线程/公开API/依赖或许可证变更。 - -验收:GPU时间处理覆盖有效位回绕、未就绪/不支持/失败、fence前不可读和复用,CPU阶段冷/热分离;日志覆盖洪泛后保留低频证据、同类限流、fatal不丢及不转发非纯统计。先真实函数/host ASan+UBSan及纯Java定向测试,后双ABI ELF/公开导出和18依赖字节不变、TV32/Mobile64包内10库与签名。目标电视同设置至少三轮完整57秒(启动10秒后统计稳态)及seek/退出,目标23.976fps附近持续配对、无递增A/V偏差/4003且不改变画质;未取得此证据不得标全部需求完成。 - -回滚:回到上述基线提交对应的任务源、Java/测试及两份libmpv成套恢复,不动其他脏文件、不移动原tag。唯一下一步见顶部Recovery anchor。 - -### 实施与本机验证(2026-09-13) - -- `MpvDiagnosticsPolicy::felPerformanceKind`只接受固定native来源、INFO级别和单行已知统计;11类各自最多8条/5秒,不挤占/受限于旧警告预算。`MpvPlayer::logMessage`对这些纯统计仅脱敏后写一次现有App日志存储,不排主线程、不走漂亮Logcat、不调用MPV;真实warning/error/fatal、未知日志、状态回调、字幕和DTS-HD回退保持原路径。 -- `f_android_fel_perf.h`、stable mapper、`vo_gpu_next`、`vd_lavc`补充暖态map分段墙钟/线程CPU、独立冷初始化、既有copy fence后的无WAIT timestamp、已有libplacebo pass缓存、render/flush/submit/swap耗时及实际解码线程数/类型。未更改解码线程数、调度、图像/位深/NLQ、fence所有权、依赖或默认路由。诊断失败只使统计不可用,不使播放失败。 -- 真实函数host ASan/UBSan、240帧CPU/异步交接、取消/EOF/lease及新增计时契约通过;`MpvDiagnosticsPolicyTest`13项、`MpvDtsHdFallbackPolicyTest`10项全部通过。证据:`native-contract.log`、`java-tests.log`。本轮未提供本地GIJoe实样给EL解码host测试,该项明确SKIP,不重复早先实样验证,也不算设备验收。 -- 同锁两ABI增量编译通过,只有`libmpv.so`两份变化,其余18库(含JNI)逐字节相同;ELF/SONAME/DT_NEEDED和公开mpv导出一致。证据:`arm64-build.log`、`armv7l-build.log`、`native-assets.log`、`native-boundary.log`。仅保留已有shadow/locale/Gradle弃用警告,不处理无关问题。 -- 首次APK构建1m6s,两个包内容/签名正确但增量封装有约15–18MB空洞。原包及精确的package缓存已移到证据目录保留;仅重封装一次(58s),没有重跑native或已通过的单测。最终两个包各10库逐一匹配且签名通过,ZIP开销分别745822/805437字节,低于原5MiB门槛;只交付最终包。证据:`apk-build.log`、`apk-repack.log`、`apk-final-artifacts.log`。 -- 原12:50估计已超出:代码/测试续接与最终封装结构检查后需重封装。13:03本机产物校验完成,剩余仅文档/恢复提交;未扩展研究或重新全编依赖。保护`app/.cxx/`,CMake staging复用隔离临时目录。 - -| 最终产物 | SHA-256 | 字节 | -| --- | --- | ---: | -| arm64-v8a libmpv | `5bf20e53d76076d2a7e297384d19fbda8bd078cd54ecc9d054d6f7844333e780` | 17791200 | -| armeabi-v7a libmpv | `1a99e35a4d2e2c7265108f4cd371a673c06f16897b1e5ba2a211eb862e6c62ff` | 14599740 | -| Mobile arm64 debug APK | `11592a5d8a3eb326930cbd3f64704d58ad9a00f25492b3e4143c0e3ad4a15180` | 151588356 | -| Leanback armv7 debug APK | `871ccbea4ac975055ad54258018c3071c0e51a6624b499746c2e2e1835593f5d` | 130473287 | - -最终FEL patch SHA-256=`291efca6434faacaba46e3e794beee8717c5269f480c69a423748ee8eedd4bc1`;固定前置基线正向应用、实际编译源码反向校验通过(收尾仅去掉六处补丁hunk标题尾空格,编译源码/二进制不变)。未改lock SHA-256=`a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159`。源/测试校验值见同证据目录`source-identity.sha256`。基线仍为`1620bac1566727f4067eda631647a11652082e74`;本机已验证的诊断单元通过guard `P2-4-fel-steady-perf`原子提交并创建同ID下annotated恢复tag,不推送、不将该tag解释为电视实时性能通过。 - -设备门槛仍未关闭:V2453A手机曾短暂连接随后断开,目标Mali-G57电视没有ADB,本轮没有新包真机播放证据。下一轮保持相同FEL设置,重播GIJoe完整57秒至少3次并seek/退出,日志需保留`WebHTV FEL perf`/`perf stages`/`render perf`/`decoder threads`和配对/A-V进度。只用无seek稳态区间的计数差:map墙钟与线程CPU区分运算/阻塞,分段定位具体API,再结合GPU conversion与已有pass数据决定是否值得增加CPU并行;不把GPU区间或各pass均值之和当作整帧耗时。 - -## Recovery anchor(9.14历史) - -- 2026-09-13日志29(本次1,355,757字节、11:18–11:20,不是早前同名文件)确认`1620bac1566727f4067eda631647a11652082e74`候选三轮均进入FEL播放,未见FEL fatal/交接超时,但稳定片段配对约10–12fps,23.976fps片源仍严重掉帧且A/V偏差4–5秒。该提交/tag `recovery/P2-4-fel-vo-handoff/20260913112303-1620bac15667`为本轮回滚点,未推送。 -- 本轮单元 `P2-4-fel-steady-perf` / upstream,分支`feature/mpv-dv7-fel`,保护`app/.cxx/`35文件。用户明确批准继续优化、必要时补详细日志并评估CPU多线程。第9.14节的低开销分段计时和纯FEL性能日志去重已通过本机验证,按同ID guard收尾;不盲改线程数/队列、像素、同步、默认路径或依赖。 -- 已有证据:排除第一次统计后,GPU map的CPU墙钟均值48.4/55.5/61.4ms,非纯GPU执行时间;AImage获取约0.10ms,无超时/错误,异步归还855/855;EL多数仍有8帧待配对。旧32条/5秒通用日志预算会吞掉低频初始化/性能消息。日志还记录一次Logcat漂亮格式同步打印卡主线程1512ms,不能单凭此认定全部持续掉帧根因。 -- 本轮证据目录`/private/tmp/webhtv-fel-perf.C10X7r/`;已编辑构建缓存的stable mapper/新perf helper、`vd_lavc`实际线程报告、`vo_gpu_next`渲染/flush/submit/swap计时,以及App严格来源/级别的纯统计日志快速通道和测试。未改变像素/同步/线程数;CPU并行已存在,不能把线程增加视为已证实加速。新增query区间是GPU conversion区间(可能包含GPU依赖等待),不是纯shader执行时间;libplacebo统计是最近pass均值,不是整帧耗时。 -- 定向native契约(ASan/UBSan)已通过:保留240帧交接/释放所有权用例,新增真实计时函数的冷/热区间、不支持/创建失败/NOT_READY/查询失败、有效位回绕、fence前禁止查询与无WAIT;静态opt-in及实际App快速分支检查通过。固定前置基线正向应用/构建缓存反向校验通过,生产FEL补丁已重生成;EL实样解码未重跑(本地未传样片),不冒充真机验证。 -- Java23项、双ABI/ELF/18库不变及两个最终APK/签名/封装校验已通过;最终TV32 SHA256=`871ccbea4ac975055ad54258018c3071c0e51a6624b499746c2e2e1835593f5d`、Mobile64=`11592a5d8a3eb326930cbd3f64704d58ad9a00f25492b3e4143c0e3ad4a15180`。未安装到目标电视,整体需求仍待性能验收,不重跑已通过的本机门槛。 -- 唯一下一步:目标电视安装上述最终TV32包,用同设置GIJoe样片三轮完整57秒并seek/退出,导出App日志,按第9.14节新增CPU/驱动/GPU证据选择下一项真实瓶颈优化;不能以本机验证声称实时播放已实现。 - -### 日志42续修交付记录(历史) - -- 最新恢复点:2026-09-13用户要求快速保存日志41对应候选,已原子提交`0a82dc13e255524d7c0e4e04c2f51ec9119aec88`并创建`recovery/P2-4-fel-buffer-progress/20260913025508-0a82dc13e255`,含最新源码/native,未推送。用户确认起播成功率提高,但均明显卡顿,且有一次4003;不是验收通过。 -- 当前分支`feature/mpv-dv7-fel`,guard `P2-4-fel-vo-handoff` / `upstream`保持active;仅FEL补丁/定向测试与校验脚本、两份libmpv、原任务/构建文档。保护原`app/.cxx/`35文件,不改FFmpeg/libplacebo/JNI/Exo/音频/光盘或默认设置。本轮未提交/tag、未推送。 -- 日志42否决9.12候选:三次均首帧`staged=0`、751ms交接失败;第一轮GPU map累计CPU耗时2127383us,最终才出现`async-returns=1`。异步归还的最终计数不能证明超时前已归还。首次创建输出/compute pipeline也被算进750ms;失败后反复写EOF,首轮BL队列膨胀到28481个信号帧。没有证据将网络、BL硬解能力或EL/NLQ算法归为此次首帧失败原因。 -- 第9.13节已实施:实际mapper冷初始化单独有界10秒,正常排队/交接仍750ms;wrapper用`fel_stage_frame`持有待处理帧,返回可取消的原dispatch,失败EOF仅一次。保留9.12的generation/lease确认与独立release fence、EL/NLQ/10bit、既有缓存上限及默认路径。 -- 定向证据:旧VO在2.13秒冷初始化并发轮询负例失败;新实际VO/core通过冷初始化/稳态期限、单调阶段、取消/旧generation、lookahead/lease/crop测试。真实wrapper/read/process/dispatch通过pending期间不继续decode/不重复PTS修正、取消、3万次失败后请求只发一次EOF;120 CPU/120异步fence帧及C++契约仍通过。证据根目录`/private/tmp/webhtv-fel-startup.hjGizG/`。 -- 本机交付:固定前置基线重新生成补丁;双ABI增量构建、ELF/公开导出通过,只变更2份libmpv、18依赖含JNI不变。TV32/Mobile64 debug一次Gradle调用4m44s成功,两包各10库与v2签名匹配。电视APK SHA256=`038d77eb0d694a7c96aa0c17bf3aa23eac7996805702187cdc2de835686489f0`,手机=`bb0e60a46c9ae913fc9df181bc8334d59fd75de9a66c05f3e3ab17ef82a521f4`。完整身份/构建更正见9.13末尾。 -- 未验收风险:电视无ADB,新包未安装/实播;不能以host/构建证明真实FEL画质、重复启动、吞吐或系统GPU挂死可恢复。不能沿用日志42旧APK/旧统计判断本候选。 -- 唯一下一步:目标电视安装上述TV32包,保持FEL双层重建,用同一GIJoe样片至少3次起播、完整57秒及seek/退出;取App调试日志核对`GPU init`、`handoff timeout: phase=`、`wait=async`、GPU/配对/帧率/A-V数据,裁决是否越过首帧失败及剩余掉帧。第9.12及以下旧恢复条目均为历史记录。 - -### 日志41之前的恢复记录(历史) - -- 当前基线:用户明确指定的失败候选快照已经提交为 `cbb02fa4c40a2d0b1d04a43d6c5be4265129f98e`,恢复tag为 `recovery/P2-4-fel-reliability/20260912214750-cbb02fa4c40a`。包含第9.10节最新源码、补丁与native资产;未推送。此前 `checkpoint/dv7-fel-pure-bl-20260912` 只指向旧HEAD,不能恢复该测试候选,不移动它。 -- 本轮(2026-09-12 21:52起,Asia/Shanghai):用户授权继续分析修复;新guard为 `P2-4-fel-buffer-progress` / `upstream`,保护原 `app/.cxx/` 35个文件。只允许FEL补丁、定向测试/校验脚本、两份libmpv和原任务/构建文档;不改JNI、FFmpeg、libplacebo、Exo、音频或光盘功能。 -- 新证据:日志39一轮6帧后失败,另一轮持续配对1030帧但约13fps、A/V偏差达数秒;日志40两轮分别13/6帧后4003。均pure-bl生效,已配对RPU全部来自EL且无缺失,所有已提交GPU拷贝在最终统计中完成;这否决了“纯BL输入已经解决停滞”。根因不能仅由最终统计反推第一次端口超时前的持有顺序。 -- 实施状态:第9.11节候选已写入FEL补丁/构建缓存;`f_decoder_wrapper.c::stage_fel_before_publish`在独立BL worker调用现有非阻塞VO暂存,并有界等待共享租约的source-returned原子标记;`finish_output`在GPU fence完成且AImage归还后置位,缓存命中也轮询完成;`hwdec_reconfig`维持raw-YUV存储、显示时恢复实际帧表示。新增生产者/BL/EL耗时日志,旧模式不调用计时器。未改变FFmpeg/JNI/依赖锁或默认路由。 -- 已验证:2026-09-12 22:41定向host测试通过,新测试编译实际生产者交接、GPU完成/缓存命中函数,在单可用输出模型下连续120帧、source-returned顺序、750ms超时和准入隔离通过;受影响core/VO/lookahead/seek/reset/lease/crop/RPU缓存测试及静态接线通过。随后双ABI增量构建、ELF/公开导出通过,仅两份libmpv变化,其余18库(含JNI)字节一致。2026-09-13两个debug包构建通过(Gradle 1m29s),各10个MPV资产逐一匹配且v2签名通过。证据`/private/tmp/webhtv-fel-handoff.hU4yvh/`;不能据此宣称电视问题修复。 -- 当前候选:电视32位APK SHA256=`26a03ee980a26f900dbd79d6fc0b6c0cc03759f2b91829e52d24df014cae9ef8`,手机64位=`6ca4cec6f2c5a5c8b73bd2dc311c1be5fb6266078b49887a6b581f2a3e5dad99`。完整路径、native/补丁身份和验证见9.11节。本轮修改未提交/tag,guard仍active;此前用户明确要求的失败快照tag保持不变。电视无ADB,未安装或验证新候选。 -- 唯一下一步:电视安装本节32位APK,保持「FEL双层重建」并使用同一GIJoe样片,至少重复起播3次、完整播放57秒并seek/退出;用App调试日志的`WebHTV FEL producer handoff`、`WebHTV FEL decoder cost`及GPU/配对统计确认是否仍停滞和是否达到实时。如果仍失败且源已在发布前归还,不再重复调队列/超时,须按新证据更换根因路线。 - -### 此前恢复记录(历史,9.10及以前;不代表当前候选) - -- 目标:在 MPV「播放性能 → DV7处理」增加 **FEL 双层重建**,仅手动选择时允许 BL 硬解 + EL 软解 + libplacebo GPU 重建;默认值与现有模式不变。 -- 授权:2026-09-12 用户批准新分支实施、去掉名称中的“(实验)”,要求保护既有功能和性能、后续无需逐步确认;08:56 用户进一步批准第 8 节最佳实践方案实施。未授权推送。 -- 分支:`feature/mpv-dv7-fel`;基线/回滚锚点:`fc62397591701b2232ae7de4f50a032bd7742064`。 -- Lane/guard:`upstream` / `P2-4-fel-reliability` 已启动,基线 `ec68966c1d72be1c27533e7e9450763a4189febe`;原 `app/.cxx/` 35 个未跟踪文件仍受保护。 -- 状态(2026-09-12,日志38后pure-BL候选):第9.10节硬解输入/RPU分离已实施;真实BSF120包、三处共360帧软件EL/RPU/NLQ、实际继承函数及前6组队列/生命周期ASan/UBSan通过。两ABI编译/ELF/公开导出、最终紧凑两APK各10库及签名通过,产物身份见第9.10节末尾。目标电视未安装/实播本候选,不能宣称已修复;同一guard、未提交/tag,HEAD仍为`ec68966c1d72be1c27533e7e9450763a4189febe`。 -- 当前文件/符号:同一`mpv-android-fel.patch`内`android_fel_packet.h::mp_android_fel_packet_init/prepare`现在只给硬解传纯BL,清理私有context/packet的DV/EL配置;`f_enhancement_pair.c`复用既有EL RPU继承,增加来源/缺失统计。新增`fel_el_rpu_test.c`、`fel_rpu_inherit_test.c`,更新packet测试、host脚本及native marker。之前core异步提前暂存、图像/GPU租约、显示参数、配对、非阻塞诊断和Java/JNI可靠性修改全部保留。本轮相对日志38只有两份libmpv改变,JNI/FFmpeg/libplacebo及其余18个native资产不变。 -- 已完成证据:此前Java/JNI未改的68个不同JUnit、VO丢帧有限缓冲、BSF helper与120个GIJoe包检查保留为历史证据。本轮实际core/VO/mapper函数受限输出及2/6/10帧前视、异步重试/超时/reset、hrseek至EOF、缓存租约、方向/裁剪、实际mp_image引用与并发诊断ASan/UBSan通过;最终两ABI与两APK各10库/签名通过。相对guard仍为4个目标库变化、16个依赖不变。当前产物/哈希以第9.9节末尾为准,第6/9.7/9.8节是历史记录。 -- 已验证/待验边界:68个不同的定向JUnit用例通过(恢复策略9项加此前未改59项);native host ASan/UBSan、两ABI编译/ELF/公开导出、两APK各10个native assets匹配及v2签名检查通过;只更改4个目标库,另外16个库字节不变。日志32确认FEL GPU暂存被调用,但电视连续重建实播未通过;画质、吞吐、seek/退出及独立恢复页生命周期仍待验证。 -- 未解决风险:日志36已否定“只补齐VO丢帧暂存就解决电视停滞”的假设;30个配对帧含恢复点前preroll,不能据此声称持续播放改善。电视不能 ADB,必要诊断进入 `http://192.168.1.5:9978/debug/logs` / `/debug/stream?v=0`。画质/吞吐/seek/退出和独立恢复页生命周期仍待实播,不能用构建通过替代。恢复页不承诺挽救系统/GPU整体挂死。 -- 唯一下一步:目标电视安装第9.10节SHA256以`7666ca86`开头的新32位APK,保持FEL双层重建,用同一GIJoe样片完整播放并seek/退出一次;核对pure-bl、RPU来源/缺失和BL/EL/GPU进度,判断是否越过日志38的固定6帧停滞。无ADB不能以host结果替代,不finish/commit/tag。 - -## 1. 完成范围与约束 - -允许改动:MPV 设置/弹窗、MPV 引擎/输出策略及必要 PlayerManager 接线、相应测试;独立 `third_party/patches/mpv-android-fel.patch`、native 构建/校验脚本与构建说明、两 ARM ABI 的受影响 MPV 资产;本文件与总索引。工作缓存仅用于可复现构建。 - -不改:Exo、IJK、音频策略、原盘菜单逻辑、FFmpeg/libplacebo 版本、既有 Surface/fence/MediaCodec starvation 补丁。首轮未改 JNI;第9节经批准的可靠性续修新增 NODE 事件桥接,必须成套重建两 ABI `libplayer.so`,现有 C/libmpv 与 JNI 导出符号不变。保留旧设置值 `0=P8.1`、`1=HDR10`,新增值,不迁移用户偏好、不自动启用 FEL。 - -当前代理沿用 `http://127.0.0.1:7897` / `socks5://127.0.0.1:7897`。无需重复已完成的上游搜索。 - -2026-09-12 02:40 Asia/Shanghai 的执行估计:代码和测试 40–50 分钟;温缓存双 ABI 构建/校验 15–25 分钟;打包/设备回归 20–30 分钟;收尾约 5 分钟,目标 04:25 前(设备连接等待另计)。估计不是退出或降低验收的依据。 - -## 2. 固定基线与上游台账 - -来源身份以 `third_party/mpv-native-lock.json`、已有源码缓存和前轮保存的上游 API 响应为证。旧 P2-4 分期可从 `9fcab83f9084446566240a8e8f5233d87d0274cc:docs/upstream-player-dependency-merge-assessment-2026-08-20.md` 的 42.5 节恢复。 - -| 仓库/组件 | 完整 revision | 本阶段处置 | -| --- | --- | --- | -| FongMi/mpv(锁定) | `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` | 保持,基于现有 WebHTV 补丁追加窄适配 | -| FongMi/FFmpeg | `177f090e0503b7e013922ca903bde14b1c375f18` | 已有 dovi_split / RPU;不升级、不重编无关 codec | -| FongMi/libplacebo | `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5` | 已有 FEL/NLQ;复用 GPU 合成 | -| FongMi/mpv-android | `99a60ad2141d5ace94453590903c2c6b9a0a2443` | 构建框架保持 | -| mpv-player/mpv PR #17932 merge | `99b4c12cccb4d8d3f72b41944cb6c640e2156650` | 已覆盖,已核实为锁定 mpv 祖先;不重复合并 | -| mpv-player/mpv PR #17932 head | `fb39a3c147bd5da26085a2bed57a5e40670d8c14` | 已被上述 merge 覆盖,仅作为配对/调度参考 | -| libplacebo MR !851 head | `05ac2cca6571c04d06369a26825d207781b73f32` | 当前源码含相关实现;本地浅克隆无该对象,不声称已证明 ancestry | -| FongMi/mpv Android hybrid | `06ec6e1746e5cbdcd271e613fdb1f7f7ecd36042` | **部分适配**:只取 EL force_swdec 与必要接线;其余 Surface/HDR/OSD 重写不采用,保留本地后续修复 | - -NDK `29.0.14206865` / r29,native API 24;`arm64-v8a`、`armeabi-v7a`;构建使用已有 `build/mpv-native/mpv-android/buildscripts/deps/` 与 ABI build/prefix 缓存。缓存含现有补丁,不 reset/覆盖不明修改。 - -## 3. Best-practice 决策记录 - -访问日期均为 2026-09-12。等级:A=实际源码/测试/官方资料;B=维护者讨论或成熟相关项目;C=性能推断,必须实测。 - -| 证据类别与来源 | 等级/支持的结论 | WebHTV 适用性、限制与决定影响 | -| --- | --- | --- | -| [mpv PR #17932](https://github.com/mpv-player/mpv/pull/17932),源码 `demux/dovi_split.c`、`filters/f_enhancement_pair.c`、`vo_gpu_next.c` | A;现有拆分、PTS 配对、EL 软件帧上传可复用 | 使用原配对/seek/reset,不另写不精确的帧序号容错算法;上游桌面成功不证明 Android 成功 | -| [FongMi/mpv 06ec6e](https://github.com/FongMi/mpv/commit/06ec6e1746e5cbdcd271e613fdb1f7f7ecd36042),实际各文件 diff | A;EL 独立强制软解可避免两解码器争用同一 AImageReader Surface | 拒绝 hardware/hybrid decoder wrapper,不能仅关闭普通 hwaccel;只适配必要接线,不能整包 cherry-pick | -| [libplacebo MR !851](https://code.videolan.org/videolan/libplacebo/-/merge_requests/851),锁定源码 `sh_dovi_compose_nlq()` / `sample_el()` | A;已有 BL reshape、NLQ 与采样融合 | 采用 GPU 重建,不把 4K 图像 readback 到 CPU;MR notes API 401,未获取的讨论不作已读证据 | -| [MediaCodec 官方文档](https://developer.android.com/reference/android/media/MediaCodec)、[AHardwareBuffer 官方文档](https://developer.android.com/ndk/reference/group/a-hardware-buffer) | A;Surface / GPU buffer 生命周期及格式能力是硬约束 | 原始 YUV 编码值、10-bit 精度与持有/释放规则必须保持,不能将已映射或 8-bit BL 再叠 EL | -| FelBaker `cae66302433578ec62afa8c5c5809e93d0405b2f`、DoViBaker `ffba39830b694ddca0bf5f73dcf1b462713bb7f4` 的源码/技术说明 | B;真实 FEL 残差与仅用 RPU/BL 不是同一画面 | 可作算法交叉证据和样片预期;离线 VapourSynth 工作负载不是手机实时性能证明 | -| 上述 mpv PR 维护者讨论及原始 API comments;相关项目 benchmark/field 说明 | B/C;双解码队列/残差并非所有设备都能实时承担 | 不承诺所有手机/电视无额外成本;只 opt-in,保留原模式和诊断,性能需比较冻结基线 | -| 论文类别 | 不适用作为实施规范 | 此阶段不是推导新的 DV 算法;没有论文能替代实际 RPU/NLQ 源码、Android 互操作和真机验收,故不新增泛视频论文搜索 | - -原始研究响应保存在 `/private/tmp/webhtv-dv7-fel.7Daduc/`;长期事实已记入此文档,不依赖临时目录作为唯一决策记录。 - -### 当前代码与数据流 - -- `PlaybackPerformanceSetting` 已将 MPV 与 Exo 的 DV7 key 分开;MPV 默认 P8.1,旧值/旧 key 兼容必须保留。 -- `PlaybackPerformanceDialog.optionAction()` 目前在两值间轮换;Mobile 与 Leanback 共用,新增第三值,不扩展 Exo。 -- `MpvPlayerEngine.selectDv7Handling()` 对旧模式始终优先原生 DV7;新 FEL 显式请求必须能覆盖原生直出,其他判断顺序不变。 -- `PlayerManager.evaluateMpvAutoOutput()/prepareMpvOutputForNewItem()` 负责获取源 profile 并按需重建/保留进度;必须防止自动直出覆盖 FEL、也防止普通文件继承上个 FEL 文件的输出状态。 -- `mpv-android-dovi-el-surface.patch` 默认不允许 Android EL;不能简单全局删除保护或让两个 MediaCodec 共享输出 Surface。 -- FFmpeg 两 ABI `CONFIG_DOVI_SPLIT_BSF=1`;libplacebo 两 ABI `PL_HAVE_DOVI=1`、API 375。现有 Vulkan AHB direct 已保留 RGB_IDENTITY/FULL 与真实 sample depth;保留全部 direct/stable/generic 流程。 - -### 方案比较与决定 - -| 方案 | 正确性/功能 | 性能/兼容/维护 | 决定 | -| --- | --- | --- | --- | -| 不改 | 只能用 BL 或保留 RPU 的单层映射,不能真正重建 FEL | 无新风险 | 不满足已批准需求 | -| 原样整包采用 06ec6e | 包含混合解码,但还重写 Surface/HDR/OSD | 会覆盖本地原盘、输出时序与 starvation 修复,回滚面过大 | 拒绝 | -| WebHTV 窄适配 | 独立 opt-in;BL 使用现有 HEVC 硬解,EL 强制软件,gpu-next/libplacebo 合成;非 FEL 路径保持原状 | 不新增大型依赖;EL/GPU 有真实开销,仅选择后承担;容易连同资产整体回滚 | **实施** | -| CPU 4K readback/自行重写合成 | 可能可出图,但易损失精度且带宽/同步开销大 | 不符合当前性能与维护目标 | 不采用 | - -安全/许可:不引入 JVM、远程执行或新下载机制;沿用 mpv/FFmpeg/libplacebo 许可和源码来源。依旧视码流/RPU 为不可信输入,复用既有有界配对和解码错误流程,不新增无界缓存。JNI/API/ABI 不改变。 - -## 4. 验收、观测与回滚 - -1. **设置隔离**:旧值 0/1 与默认值不变;只有 MPV 有第三项;用户手动选择并重新起播/按现有重建流程生效;切回恢复原模式;未知/损坏值回到原默认值。 -2. **路径隔离**:未选 FEL 时不创建 EL decoder,不改变原生 DV、P8.1、HDR10、普通视频的既有路由;选 FEL 后仅符合源 profile 的文件进入重建;不与 `mediacodec_embed` 混用。 -3. **真正 FEL**:日志证明 BL 硬解、EL 软件 HEVC、PTS 配对与 EL/NLQ 合成;使用能分辨 BL-only 和 FEL 的样片,不能把画面正常误称 FEL 成功。MEL/无 EL 不误报重建。 -4. **生命周期**:seek、暂停恢复、连续重开、切回旧模式;不得有重复重建循环、解码 Surface 争用、无限等待、崩溃或已知颜色损失。 -5. **性能**:冻结基线与候选同设备/样片/设置比较;默认/旧模式额外 EL 工作为零。启动、持续丢帧、A/V 同步、CPU/热负载都要报告;FEL 模式新增成本不是默认性能收益,不能用降低精度或丢 EL 冒充优化。 -6. **二进制与产品**:两 ABI mpv 增量构建和 ELF/SONAME/DT_NEEDED/导出与 native 资产检查;Mobile/Leanback 定向 Java 测试/编译,最小 APK 资产匹配;不更新无关 FFmpeg 库,JNI 仅随相关 NODE 接线成套更新。 - -最便宜的决定性检查是设置/输出策略单元测试与 native 源码路径断言,然后才做 native 编译;真实播放/性能不能由这些静态检查替代。 - -回滚:未提交时只撤销本任务自己创建的改动;提交后对本任务原子提交执行 `git revert`,同次恢复 App、补丁、构建脚本和两 ABI `libmpv.so`。不 reset 整个工作区、不改保护路径、不动已发布 tag。验证完成后由 guard 原子提交并立即创建唯一 annotated 本地恢复 tag;不推送。 - -## 5. 实施与验证记录 - -- 02:33–02:40:恢复 Git 和研究;从 `feature-menu` 新建 `feature/mpv-dv7-fel`;guard 保护 35 个初始 dirty 文件;ADB 暂无设备。 -- 02:50:基线 native cache diff 与两 ABI 资产 SHA-256 已冻结到 `/private/tmp/webhtv-p24.z9ixxT/`;不执行全栈 checkout/reset。新补丁以 `VO_CAP_GPU_DOVI_EL_SW` 单独声明软件 EL 能力,Android 仍须显式 option + 源 Profile 7 才启用;默认 `VO_CAP_GPU_DOVI_EL` 保护原样保留。force_swdec 传入配对器、解码线程并拒绝硬件/hybrid wrapper;沿用原 PTS/队列/reset。在新路径内对 MEL 仅省略无用 EL 上传,不改变旧路径。设置、策略接线开始,定向测试未运行。 -- 03:10:首轮两 ABI native 编译通过。补上与 WebHTV `hwdec-software-fallback=no` 的必要兼容:主动 force_swdec 的 EL 不属于 BL 硬解失败回退,不能被该设置拒绝;未 force_swdec 时判断保持不变,修正后需要增量重编。第一轮裸 ninja 的 PATH/跨平台 pkg-config 环境不完整,改用已有 buildall 的单 mpv 目标,不重建无关依赖。Java 定向验证正在运行;Gradle 使用临时 CMake staging 目录,避免改动受保护的 `app/.cxx/`。 -- FEL 的必要 VO/GPU/BL 保真与 demux 选项是耦合契约,只有真实 DV7 激活 FEL 时覆盖冲突的 mpv.conf 同名配置;音频、缓存、Vulkan backend、用户 shader 等不变。显式软解 BL 仍尊重用户选择,但该新模式关闭破坏残差精度的解码跳帧/省滤波,仅允许输出丢帧;EL 始终软解。 -- 06:58(会话续接后重新核对本地时间):57 项定向 JUnit(8 类)全部通过,Mobile arm64 与 Leanback armv7 Java 编译通过(日志 `java-verification.log`,69 秒);两 ABI 更新后的 native 编译/链接通过。原时间估计已过,剩余本机校验目标约 07:25;ADB 仍无设备。 -- 07:05 源码复核:OpenGL 原始 DOVI 映射已有 `GL_EXT_YUV_target` 能力拒绝门控,不会拿 RGB8 冒充原始 YUV;Vulkan 继续复用既有原始 YUV direct/stable/generic。另补齐 `receive_frame()` 的 `force_eof` 重试终止条件,与现有 reinit 的有界失败策略一致,避免新 FEL GPU 路径遇到硬解不兼容时无限重试。此 native 修正须重编两 ABI;Java 未改,不重复已通过的 JUnit。 -- 验证结果、最终资产 SHA-256、构建日志和提交/tag 由本任务后续阶段补充;此时不宣称实现完成。 - -## 6. 最终本机验证与待真机状态(2026-09-12) - -证据根目录:`/private/tmp/webhtv-p24.z9ixxT/`。当前 HEAD 仍为基线,工作树持有本任务完整未提交改动;不自动推送。 - -| 检查 | 结果 | 证据 | -| --- | --- | --- | -| 8 类定向 JUnit,57 项 | 通过 | `java-verification.log`;不重复运行 | -| Mobile arm64 / Leanback armv7 Java 编译 | 通过 | 同上 | -| 最终双 ABI native 编译(含 forced-EOF 修正) | 通过 | `native-bounded-fallback-build.log` | -| `verify_mpv_fel_contract.py --mpv-source build/mpv-native/mpv-android/buildscripts/deps/mpv` | 通过 | 已应用补丁反向检查、opt-in、EL 强制软解、BL fallback 隔离及有界配对 | -| `build_mpv_native.sh --abi all --stage-only --install` | 通过 | `native-final-stage.log` | -| `verify_mpv_native_assets.sh --require-elf` | 两 ABI 通过 | `native-final-assets-verification.log`;NDK r29 llvm-readelf | -| 基线/最终 native 资产 SHA-256 | 仅两份 libmpv 改变,其余 18 份一致 | `assets-before.sha256`、`assets-final.sha256` | -| 基线/最终 libmpv 动态导出符号 | 两 ABI 完全一致 | `native-symbols-verification.log`、`exports-*-before.txt`、`exports-*-final.txt` | -| 手机 64 位、电视 32 位 debug 打包 | 通过,Gradle 报告 2m 8s | `apk-build.log`;两个 assemble 目标,一次调用 | -| APK 内 MPV 资产 | 各 10 份逐一匹配最终资产 | `apk-assets-verification.log` | -| 真机、USB / 无线发现 | 无设备,待连接 | `adb devices -l` / `adb mdns services` 均为空;不以模拟器替代 ARM MediaCodec 验收 | - -Gradle 使用 `JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home`,并带 `--init-script /private/tmp/webhtv-p24.z9ixxT/isolated-cxx.gradle` 将 CMake staging 隔离于原 `app/.cxx/`。构建目标是 `:app:assembleMobileArm64_v8aDebug :app:assembleLeanbackArmeabi_v7aDebug`,没有全产品矩阵或无关 native 全栈重建。 - -最终 SHA-256: - -| 产物 | SHA-256 | -| --- | --- | -| arm64-v8a libmpv.so | `2a936ec8491c6c7826a6bf16503e6eddf8b78c740c29aa606e985f9fceaa9213` | -| armeabi-v7a libmpv.so | `6b7bc5f5bb3dfacb279f233d378cc6426f9696e8a51737da6d5abfe371a586da` | -| Mobile arm64 debug APK(180178693 bytes) | `117960c1c0ee4e23007b3465a7f4dd2bbbc1f5d4106ad616ae8215858d47d599` | -| Leanback armv7 debug APK(144857928 bytes) | `52275d4f023f7874597f362b05609a5049ff5a059953bf3abb80afea28218ae3` | -| mpv-native-lock.json(未改) | `a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159` | -| mpv-android-fel.patch | `06636e67117a491e99efac4a2cde0cdf090ea73b745a1ad8679d502b66f7f611` | -| build_mpv_native.sh | `8722d86d12b4346b472f3ba9045f029894df59dec19f61f458f108c85b0178d5` | -| verify_mpv_native_assets.sh | `daccc5d2f7f7ca9e5fbf312d5ed7d656ff0375b49e6a645a2c2113f24b76e8b5` | -| verify_mpv_fel_contract.py | `ef04328bdaeb4012e0cb3cb70d70d2f14e23b214aa45fc54253cdeeb3b8b9e52` | - -候选包位于: - -- `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk` -- `app/build/outputs/apk/leanbackArmeabi_v7a/debug/app-leanback-armeabi_v7a-debug.apk` - -基线 Mobile APK 及两份 libmpv 已冻结在证据目录的 `baseline-mobile-arm64-debug.apk`、`baseline-arm64-libmpv.so`、`baseline-arm32-libmpv.so`,供同设备对照。 - -样片筛选:下载并核实 `quietvoid/dovi_tool@614c816b6446dcd1dbaf433403d499a6026fbb5a` 的 `assets/hevc_tests/regular.mkv`。它实际是 256×144、Profile 8、`el_present_flag=0`;该项目的 mux 测试也只拼接测试层,不能证明真实 FEL 残差重建。保存 `dovi-regular.probe.json` 和 `dovi-hevc-demux-test.rs`,不将这些输入算作 FEL 验收。 - -尚须完成第 4 节的真实 BL 硬解/EL 软解/有效 NLQ、视觉差异、seek/暂停/切换恢复与默认模式对照。没有设备证据前,不允许用编译成功或 marker 存在声称画质、吞吐、Surface 生命周期或默认性能已验收;不调用 guard finish 创建“已验证”提交/tag。 - -## 7. 电视启动卡死:日志回调主线程饥饿(2026-09-12) - -用户随后明确要求保存当前已知问题快照,故第 6 节产物及源码已于 07:41 提交/tag,**并非通过了设备验收**。本轮回滚锚点为 `792c1f880bc151eb1cb6675034ec144aadc14766`,不修改其 tag。 - -### 证据与最小设计 - -- 用户离线证据:`/Users/macbookpro/Downloads/webhtv-debug-log (30).txt`,trace `p-w2su2i-1`,本地 `P7_FEL_GIJoe_The_Rise_of_Cobra.mkv`。在线原始快照保存于 `/private/tmp/webhtv-p24-freeze.Y6FQWF/tv-live-initial.json` 和 `tv-live-raw.txt`。 -- 07:43:31 原生 `video/dolby-vision` Profile 7 decoder 不存在;07:43:32 App 已正确转入手动 FEL/gpu-next。后续日志显示 `c2.mtk.hevc.decoder` 启动成功,EL 走独立软件 HEVC,不能把首次原生直出失败当作 FEL BL 硬解也失败。 -- 明确卡死链:`MpvPlayer.logMessage()` 将每条日志入主线程队列,再无条件调用 `maybeRetryDtsHdAsCore()`;后者在检查是否配置 DTS-HD 或是否为 AudioTrack 初始化错误之前,就同步读取 `audio-params/format` 与 `current-tracks/audio/codec-profile`。Java 参数提前求值还使 `firstNonEmpty(cached, query())` 在缓存非空时照样查询。 -- 这次实际 `audio-spdif` 为空(PCM),根本不需要 DTS-HD 回退;查询却反复花费约 1545–1550ms,主线程看门狗记录连续堵塞超过 178 秒。Vulkan 自报创建耗时只有 10.207ms,而主线程打印相关日志跨越数分钟,证明当前日志时间混入严重排队延迟,不能直接当作 native 初始化耗时。 -- 最小修复:在任何属性访问前按既有 DTS-HD 配置/一次性门控/AudioTrack 初始化错误筛选;缓存只在确实缺失且需要回退判断时同步补读,保持真实 DTS-HD 回退语义。普通、FEL、非 DTS-HD、非初始化错误日志均不查询这两项 native 属性。 -- 日志补齐:关键 FEL、BL/EL 解码、DV 与失败诊断在 native 回调到达时写入现有 `PlaybackTrace → SpiderDebug → DebugLogStore`,不等待主线程;主线程仍负责状态变化,禁止从回调线程操纵 MPV。补采样限频的主线程日志排队耗时,并显式保留 `WebHTV FEL pair`、`WebHTV FEL GPU input`/NLQ 标记。不开逐帧日志、不增加非调试模式的文件 I/O。 -- 这是有现成策略和日志证据的局部 bug 修复,沿用 P2-4 既有设计;不扩大上游研究、不更改 BL/EL 解码或 GPU 路由、不升级依赖。 - -### 验收与保护 - -作用域:`MpvPlayer.java`、`MpvDtsHdFallbackPolicy.java`、`MpvDiagnosticsPolicy.java`、其两份策略测试、本文件和索引。通过复现条件的门控测试、既有真实 DTS-HD 回退测试、日志分类/脱敏测试及定向编译,生成 Leanback armv7 debug 包后用在线日志验证。APK 必须继续包含第 6 节的同一份 armv7 libmpv,不重建 native。代码/测试约 15 分钟,打包约 5 分钟,目标 08:20;电视安装和交互等待另计。 - -新增“至少可退出”问题的只读评估:Manifest 已有独立 `:error_activity` 的 `CrashActivity`;当前 MPV watchdog 仅在调试开启时运行且只记录堆栈,不会救援。主线程挂死时同进程弹窗不能保证操作;可以补独立恢复页,由用户选择结束播放并返回首页。它还需处理原进程身份、重复触发、服务重启、二进程 App 初始化与后台启动限制,不能用主线程超时或强杀线程冒充。此次未将该新进程行为混入定向日志修复,也不自动杀进程或重播。 - -### 本机结果与候选交付 - -- `MpvDtsHdFallbackPolicyTest`:10 项通过,含日志突发/PCM/重复回退前置门控及原有 DTS-HD 回退场景。 -- `MpvDiagnosticsPolicyTest`:8 项通过,含 FEL/NLQ/解码关键消息即时日志分类、普通逐帧日志排除和原有脱敏策略。 -- 一次 Gradle 调用完成 Mobile arm64 定向单测/编译与 `:app:assembleLeanbackArmeabi_v7aDebug`,结果 `BUILD SUCCESSFUL in 41s`。日志 `/private/tmp/webhtv-p24-freeze.Y6FQWF/java-tests-tv-apk.log`,继续使用上一轮隔离 CMake staging,保护 `app/.cxx/`。 -- 电视候选:`app/build/outputs/apk/leanbackArmeabi_v7a/debug/app-leanback-armeabi_v7a-debug.apk`,144857928 bytes,SHA-256 `26f5b1dbfee477e948ada579323f228f1337bb8ed5eaa0688f3e9a811dcaf390`。 -- APK 内 `assets/mpv-libs/armeabi-v7a/libmpv.so` 实际 SHA-256 仍为 `6b7bc5f5bb3dfacb279f233d378cc6426f9696e8a51737da6d5abfe371a586da`,与已知问题快照一致。此次不混入任何 native 变量。 -- 新日志标签为 `mpv-native`(回调到达即写入 App 调试日志)和 `mpv-anr: native-log-queue delay=...`(主线程排队耗时,5 秒限频)。现有 `/debug/logs` 与 `/debug/stream` 会直接展示,无需 ADB。 -- 随后的 `(29).txt` 和在线快照已证明用户使用新候选复测,结果见第 8 节:音频查询门控有效,但卡死场景未解决。不提交/tag 为已验收的完整修复。独立恢复页仍未实施,不能把本候选当作已经有强制退出兜底。 - -## 8. 真机二次证据与定向外部研究(2026-09-12) - -### 8.1 范围、授权和研究问题 - -用户要求深度查阅论文、帖子、博文、官方文档、issues 和项目源码,为当前电视上的 **MediaCodec BL 硬解 + FFmpeg EL 软解 + libplacebo GPU FEL 重建** 卡死寻找参考。最新授权为调研,不进行新的播放代码修改、构建、安装、提交/tag 或推送。 - -仅更新本文件及 `docs/upstream-player-dependency-merge-assessment-2026-08-20.md`,沿用当前 guard 已包含的文档路径;保护现有五份 Java/测试修改和最初的 `app/.cxx/` 35 个文件。原有源码、锁、两 ABI 二进制及其归属不变。研究从 08:25 Asia/Shanghai 开始,预计 25–35 分钟;核验采用已保存响应、源码调用链、完整提交 ID 和文档校验,不重复构建。 - -三个决策问题: - -1. 软件 EL 较慢时,配对器是否可能占满 BL 的有限硬解输出缓冲?现有上游是否有针对性的修正?反假设是驱动/渲染 fence 或解码数据本身先失败;需要持有数、PTS、端口等待和释放计数区分。 -2. 已经支持 BL 硬解的电视,应怎样保留原始 10-bit 信号并进行真正 FEL 重建?哪些项目可直接提供算法或调度参考,哪些不能证明 Android 实时可用? -3. native 变慢时,怎样避免 UI 的同步等待;若发生永久 native 死锁,怎样让用户仍有退出途径?异步化与进程隔离分别保证什么? - -### 8.2 新日志已经证明什么 - -证据为 `/Users/macbookpro/Downloads/webhtv-debug-log (29).txt` 及在线原始快照 `/private/tmp/webhtv-p24-freeze.Y6FQWF/tv-after-log29.json` / `tv-after-log29.txt`。按时间而非文件编号排序:这是 08:12:58 开始的较新播放,trace `p-w3uti8-1`,本地 GIJoe P7 FEL MKV,从约 14.121 秒恢复。不要与 `(30).txt` 的 07:43 播放混用。 - -| 时刻/指标 | 直接观测 | 可以得出的结论 | -| --- | --- | --- | -| 08:13:05.261 | `MediaCodec started successfully: codec = c2.mtk.hevc.decoder` | BL 硬解启动成功,不是“电视不支持基础层硬解” | -| 08:13:05.925 / 08:13:06.900 | EL `1920x1080 yuv420p10`;BL `Using hardware decoding (mediacodec)` | 双路解码路径已经建立 | -| 08:13:07.035 | `BL=13.889000 EL=13.889000 NLQ=1 software-EL=1` | 已配对到真实 FEL 帧;恢复播放的预解码时间可早于目标时间 | -| 08:13:17.965 / 08:13:18.044 | 4K AHardwareBuffer `10-bit, raw Dolby Vision`;`matched EL uploaded with active NLQ` | 双层输入已经到达 GPU;不等于持续输出、位精度或画质验收通过 | -| 08:13:07.786–08:13:45.350 | 49 次 `Error while decoding frame (hardware decoding)` | native 错误仍存在;相邻间隔最小 0.759s、平均 0.783s、最大 0.865s | -| 08:13:41.561 | `main stalled=34661ms`,栈位于 `readTrackInfo → refreshTracks → handleEvent` | 主线程同步属性读取仍造成可见 ANR;各次调用多为约 1.55 秒 | -| 新日志标签/查询 | 已出现 `mpv-native`;未再出现那两项音频属性的 slow-native 查询 | 上轮日志查询门控确实生效,但只解决了一个入口 | - -两层问题应分开: - -- **已确认的 UI 缺陷**:`FILE_LOADED → refreshTracks()/syncOsdSurfaceRequirementFromMpv()/readTrackInfo() → MPVLib.getProperty*` 仍在主线程串行查询 `sub-visibility`、`sid`、`secondary-sid`、`track-list/*`。 -- **有强依据但未实证的 native 假设**:16 帧 BL 配对队列与有限 MediaCodec 缓冲冲突。49 次错误的节奏吻合本地 FFmpeg 750ms 端口饥饿超时,但最新导出仍没有那条明确的 starvation 日志,也没有实际缓冲持有数量;不能写成“根因已完全证实”。 -- **已确认的重试策略缺口**:`hwdec-software-fallback=no` 在 mpv 映射到 `INT_MAX`;`handle_err()` 只有累计到该阈值才设 `hwdec_failed`。已有 `force_eof` 重初始化保护不能自动终止这类运行时错误循环。禁止 BL 软回退与允许无限错误重试不是同一要求。 -- **诊断盲点**:当前 `shouldLogNativeImmediately()` 按 `error/failed/invalid` 等文本筛选;FFmpeg 的关键句包含 `failing hardware`,不命中这些词。若主线程堵塞,这条错误可能一直留在旧的主线程日志路径。后续应按 native 日志级别保留 warn/error,并限频,而非只扩大关键词列表。 - -### 8.3 最直接的上游参考:mpv PR #18375 - -[PR #18375](https://github.com/mpv-player/mpv/pull/18375) 名为 `f_enhancement_pair: keep retained frames within the hw surface budget`,API 确认为已合并,合并时间 2026-08-22T19:00:12Z。读取了 PR 文件 diff、提交列表、实际合入提交及当前配对器文件历史;截至此次查询,该文件最新修改仍是以下预算修复。没有由此声称整个 mpv 最新树都已完成回归审查。 - -当前锁定代码只包含最初配对器 `5330cae57eba5d34719d2d6a13d98770e8841afc` 的行为及本地 FEL 适配:仍为 `QUEUE_MAX=16`,没有 `el_seen` 和 extra-frame hint 接线,以下三个改动未被本地实现覆盖。 - -| 实际合入的完整提交 | 实际改变 | P2-4 处置 | -| --- | --- | --- | -| [`3b4caf0f8ba3101c3aa0b2f59fbd13863e965cfc`](https://github.com/mpv-player/mpv/commit/3b4caf0f8ba3101c3aa0b2f59fbd13863e965cfc) | 首次 EL 尚未就绪时,不因为 BL 队列满就提前输出 BL-only;seek/reset 清除 `el_seen` | **建议窄适配**,同时保证 EL 调度能推进、有界失败;不能只添加一个无限等待条件 | -| [`228f3109fd1a620094758fea90dd387c64ec22c9`](https://github.com/mpv-player/mpv/commit/228f3109fd1a620094758fea90dd387c64ec22c9) | 增加 decoder wrapper 的 extra hardware frame hint,交给 `VDCTRL_SET_EXTRA_HW_FRAMES` | **有条件参考**;固定 AVHWFramesContext 帧池可用,不能当作 MediaCodec Surface 扩容开关 | -| [`b955aa28f3dc93dc6b21485a0d5b7feb8e6dc10f`](https://github.com/mpv-player/mpv/commit/b955aa28f3dc93dc6b21485a0d5b7feb8e6dc10f) | 配对队列 16→8,并为 BL/EL 申请对应额外硬解帧预算 | **建议适配其资源预算原则**,不机械照搬数字 8 或假定电视容量会随之增加 | - -三者实际父子关系已由 API 验证。PR 中较早的三个修订 ID 分别是 `d3f337bf10603b5f371a0f225fd8abb8f1c9a989`、`4f4e8f6135c6c57109675a9cb48296b40332d524`、`8cadd3b93f03ccced57a2111db7b5b673616ef7b`;处置为被上述实际合入版本覆盖的参考修订,不重复作为待合并提交。 - -为何不能原样照搬到 Android: - -- FFmpeg `libavcodec/mediacodecdec.c:mediacodec_hw_configs` 只声明 `AD_HOC | HW_DEVICE_CTX`,没有 `HW_FRAMES_CTX`。 -- mpv `vd_lavc.c` 据此选择 `use_hw_device`,`init_generic_hwaccel()` 在 `!use_hw_frames` 时直接返回;`hwdec_get_extra_frames()` 增加 `initial_pool_size` 的路径不服务当前 MediaCodec Surface decoder。 -- 当前 BL `AVMediaCodecBuffer` 在配对器中被引用,直到 `hwdec_aimagereader.c:mapper_map()` 才经 `av_mediacodec_release_buffer_status(..., 1)` 提交到 Surface;单纯持有 `mp_image` 并不是廉价、无限的普通内存缓存。 -- 本地 `AImageReader_newWithUsage(..., maxImages=5)` 是另一层“已获取 AImage 数”限制,**不是 MediaCodec 的总输出缓冲数**。不能用 `16 > 5` 直接证明根因,也不能认为把 5 改大就解决了解码端饥饿。 - -### 8.4 各类资料及适用边界 - -访问日期均为 2026-09-12,网络使用用户指定的本机 7897 代理。等级 A=官方契约/实际代码/设备原始证据,B=维护者解释或成熟项目经验,C=未在本机复现的报告/性能结果。论文的可得范围单独标明。 - -| 来源与版本 | 已读证据、等级 | 对我们场景的实际价值与限制 | -| --- | --- | --- | -| [Android MediaCodec 官方文档](https://developer.android.com/reference/android/media/MediaCodec),Buffer Processing | A;官方明确警告持有 input/output buffers 可令 codec 停滞,而且设备相关,部分 codec 要等所有 outstanding buffers 归还才继续 | 支持减少 BL 硬件帧滞留;**不**证明该电视的具体容量或当前错误唯一原因 | -| [NDK AImageReader 官方文档](https://developer.android.com/ndk/reference/group/media#aimagereader_acquirelatestimage),API 24 起的 acquire/release 契约 | A;`maxImages` 耗尽会报 `MAX_IMAGES_ACQUIRED`,acquireLatest 丢旧帧需要至少 2 个余量 | 三层资源分别计数:codec output、AImage、GPU/fence;FEL 必须保持时间戳关联,不能靠随意丢 BL/EL 解套。当前网页涉及新 API 的格式变化不外推到这台 Android 14 电视 | -| [Kodi Android MediaCodec](https://github.com/xbmc/xbmc/blob/45a32be559f5a70a56f089353f71843b9dbbc0ec/xbmc/cores/VideoPlayer/DVDCodecs/Video/DVDVideoCodecAndroidMediaCodec.cpp),`45a32be559f5a70a56f089353f71843b9dbbc0ec` | A/B;`GetAllowedReferences()` 返回 4;归还、丢弃、EOS 均有明确 release;正常解码短等待,丢弃预解码帧时用非阻塞 poll。该提交附有 MediaTek armv7 AVC seek 数据 | 可借鉴有限持有、及时归还、错误状态和预滚动调度;4 不是跨设备容量规范,AVC seek 数据不证明 HEVC FEL 性能,更不等于 Kodi 已提供通用 Android FEL 软件重建 | -| [libmpv client.h](https://github.com/mpv-player/mpv/blob/cca559b41ceb0bb7731cf6ef2e1f33276cd30c42/include/mpv/client.h),`cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` | A;同步调用等待核心可用,等待时间可无界;提供 `mpv_get_property_async`、`mpv_observe_property`、async command/reply,异步调用顺序也需管理 | UI 使用缓存/订阅快照;禁止主线程等 Future、join 或重新同步补读。多个工作线程不会绕过核心串行锁,异步并不等于永久 native 死锁已隔离 | -| [media-kit 原生播放器](https://github.com/media-kit/media-kit/blob/c533e446755f51cf53c7e57aea873f2aa5355f81/media_kit/lib/src/player/native/player/real.dart),`c533e446755f51cf53c7e57aea873f2aa5355f81` | A/B;以 `MPV_FORMAT_NODE` 观察整份 `track-list` 并更新状态;支持带 request id 的异步设置/命令 | 可借鉴事件驱动整份轨道快照。当前 WebHTV JNI `event.cpp` 只处理标量/string,尚无 NODE 分支及 GET_PROPERTY_REPLY 数据接线,不能只改 Java 调用名即视为实现;media-kit 也保留同步配置,不声称其所有路径都不会 ANR | -| [Android ANR 指南](https://developer.android.com/topic/performance/anrs/diagnose-and-fix-anrs)、[进程/线程](https://developer.android.com/guide/components/processes-and-threads)、[后台 Activity 限制](https://developer.android.com/guide/components/activities/background-starts) | A;默认输入超时 5 秒;主线程不做阻塞工作;组件可独立进程;后台拉起有版本限制;GPU 全局挂死通常无法由 App 修复 | 区分异步化、watchdog 检测和进程恢复;不能承诺任何硬件死锁下都能退出,也不能把“线程超时”当取消 native 的保证 | -| mpv [PR #17932 讨论](https://github.com/mpv-player/mpv/pull/17932),本文件第 2 节固定源码;FongMi [Android hybrid 改动](https://github.com/FongMi/mpv/commit/06ec6e1746e5cbdcd271e613fdb1f7f7ecd36042) | A/B;已有双层拆分/配对;Android EL 强制软件解码避免共享同一 MediaCodec Surface;讨论记录了硬解表面不足及后续 #18375,也有另一个动态亮度元数据引发卡顿的案例 | 当前路线不是无参考自研;原有 force_swdec 是正确保留项。但不把桌面、不同 GPU、不同卡顿根因混为同一故障,不整包引入 fork 的 Surface/OSD 重写 | -| [libplacebo MR !851](https://code.videolan.org/videolan/libplacebo/-/merge_requests/851);实际锁定代码 `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5` 的 `sample_el()` / `sh_dovi_compose_nlq()` | A;已读实际 shader 及渲染接线:EL 采样、NLQ 残差叠加;MR 讨论原先 API 401,不当作已读 | 保留现有 GPU 重建核心,优先修调度/生命周期,不重写算法或把 4K 帧常态读回 CPU;原始编码值、位深和上采样仍须同帧参考验收 | -| [FelBaker](https://github.com/bbeny123/felbaker/tree/cae66302433578ec62afa8c5c5809e93d0405b2f),`cae66302433578ec62afa8c5c5809e93d0405b2f`;[DoViBaker](https://github.com/erazortt/DoViBaker/tree/ffba39830b694ddca0bf5f73dcf1b462713bb7f4),`ffba39830b694ddca0bf5f73dcf1b462713bb7f4` | B/C;BL+EL+RPU 烘焙至 PQ12;FelBaker 文档提供 RGB 输出 hash 校验和桌面 benchmark 方法 | 可作离线重建参考与画质验证思路,不移植 VapourSynth/AviSynth 整套运行时;M4/AVX2、best-of-10 的数据不外推到 Mali-G57/armv7。测试样片本身注明仅用于逻辑而非画质评价 | -| [Bigflake MediaCodec FAQ/样例说明](https://bigflake.com/mediacodec/) | B/C;codec 不是输入一帧必立即吐一帧;厂商 buffer 格式不同;Surface/GPU 路径和样例同步风险均有说明 | 交叉支持 backpressure、格式与 GPU 数据通路的注意事项;这是较旧 API 的技术文章,不把其 RGB/8-bit 样例直接用于 10-bit FEL | -| [mpv-android #1088](https://github.com/mpv-android/mpv-android/issues/1088) 及其维护者讨论、[FFmpeg #11676](https://trac.ffmpeg.org/ticket/11676) | B/C;Android TV 的 MediaCodec-copy 与 embed 结果不同,原生硬解能播不等于 copy/合成路径都兼容;FFmpeg ticket 仍 new、未复现/分析 | 反对用强制 `mediacodec-copy` 作为默认解决办法。报告是 Amlogic/Mali-G31,不能拿来断定这台 MediaTek/Mali-G57 的根因 | -| 论文 [Adaptive residual mapping for an efficient extension layer coding in two-layer HDR video coding](https://doi.org/10.1109/ICIP.2016.7532587),ICIP 2016 | 本轮检索了 Crossref 与 OpenAlex 元数据/摘要;后者标为 closed,无公开全文地址,IEEE 页面未取得正文 | 摘要研究 LDR→HDR 双层编码残差映射/编码效率,不是 Android P7 FEL 解码调度实现。**未读全文、不作为算法正确性或安卓实时性能证据**;本轮不更换重建算法,因此该缺口不影响由官方契约和源码得出的调度/异步判断 | - -常规 Google 结果为需脚本页面,Bing RSS 返回了明显无关内容,没有将这些内容或搜索摘要列为依据;转用精确项目 API、官方文档及正式论文索引。原始响应保存在 `/private/tmp/webhtv-p24-research.6Wp7xa/`,前期算法资料仍在 `/private/tmp/webhtv-dv7-fel.7Daduc/`;长期结论、链接、revision 和限制已记在本文件,不依赖临时文件存活。 - -### 8.5 适合 WebHTV 的方案比较 - -| 方案 | 正确性/兼容性 | 性能/维护与决定 | -| --- | --- | --- | -| 不改现状 | 保留当前代码,但已复现硬解错误和 UI ANR,不能满足需求 | **不接受为完成状态**;可作为冻结比较基线 | -| 原样合并 PR #18375 | EL 预热及固定表面预算有价值,但 MediaCodec 不走它的扩容帧池;仅改 16→8 仍可能超过设备可用输出数 | **不直接采用整包方案**;必须 Android 适配和真机验证 | -| Android FEL 窄适配 + 异步状态快照 | 仅新 FEL 软件 EL 路径限制 BL 持有/预取,优先可配对输出,保证 EL 能独立推进;严格 PTS/seek/reset;native 无进展有界退出;UI 不同步查询核心 | **推荐**。不改变未选 FEL 的默认/旧模式,不降低 EL 质量。减少重复 JNI 查询,但实际播放/功耗收益必须实测;包含 native/JNI 的部分须重新声明作用域 | -| BL 硬解后 CPU copy/readback 再上传 | 可作为格式验证后的诊断对照,但厂商布局/10-bit 读回支持不统一,不能默默转成 8-bit 或 BL 全软解 | **不作为默认修复**。仅线性 4K P010 数据约 24.9MB/帧,24fps 单方向约 597MB/s;这只是理论数据量,尚未计拷贝、上传和 stride,专有压缩布局另算 | -| 早期 GPU 复制至自有原始信号纹理 | 若确需更深配对,可能解除 codec buffer 持有;必须正确处理 AImage、fence、时间戳、10-bit 原始 YUV 信号与生命周期 | **后备研究方向**,不是现成修复。增加显存/带宽和渲染复杂度;先验证低持有方案,再决定是否值得引入 | -| 单独 watchdog / 独立进程 | watchdog 只能发现无响应;独立恢复进程或独立播放器进程才可能在故障核心无响应时继续接受操作 | 现有 `:error_activity` 可作为较小恢复页基础;完整 `:player` Service/IPC 是更大架构,**单列评估,不与 FEL 算法修复捆绑** | - -推荐适配的关键约束: - -1. **不是单纯缩小一个数组。** 先明确 BL/EL decoder、pair、VO/GPU 各持有多少帧;配对器有下游需求时先消费已匹配对,再决定是否继续拉 BL。软件 EL 预热与等待不得被 BL 端的同步等待饿死。队列值依实际链路验证,不能把 1、4、8 说成通用硬件容量。 -2. **不把 EL 缺失伪装成成功。** 保留 RPU、PTS 精度、NLQ 和源格式;首个 EL 尚未就绪与“源确无 EL”分开。不能为了不卡而无提示长期只显示 BL,再把它计入 FEL 成功。 -3. **不在主线程补读。** 订阅整份 `track-list`、相关 subtitle/selection 属性,用版本化快照更新 Java 状态;JNI 必须复制事件数据后再跨线程,处理 reply 生命周期、请求失败、过期 generation、解除订阅与 release。批量异步后也不能在 UI `Future.get()`。 -4. **失败退出和软解策略分开。** 建立连续无进展/致命错误的有界终止语义,报告明确播放失败,不因用户禁用 BL 软回退而无限重试;不冒充正常 EOF,避免误触发下一集。不建议只把 750ms 改小。 -5. **释放必须保持所有权。** 未经 GPU 消费/fence 确认,不能强制释放仍被引用的硬解图像;不能在线程超时后销毁仍可能被 native 使用的 handle/mutex/Surface。 - -### 8.6 最小分期、观测和验收门槛 - -这是下一轮的建议,不是本轮已实施清单。建议先解除确定的 UI 问题,同时取得 native 故障的必要计数,再独立改调度;无新增依赖、无全栈升级、无静默降画质。 - -1. **异步轨道快照与完整错误透传。** 复用已有 command/set-property 请求体系,补 NODE 快照与必要异步查询;保留 `source-dolby-vision-*`、HLS bitrate、音轨/字幕选择等本地字段。当前 quick-fix guard 没有 JNI 路径,不在它内直接实施。模拟核心延迟 10 秒时,主线程不得进入同步 MPV getter;遥控器移动/返回仍能被处理,输入响应目标 250ms 内。该目标是待测试门槛,非现有测量结果。 -2. **FEL 资源预算与有界失败。** 在真实新增日志基础上适配 #18375 的预热/预算原则,只影响显式 FEL 路径。每 2–5 秒聚合一次 BL/EL pending/峰值、首帧/最近 PTS、配对/BL-only 数、MediaCodec 未归还 output 数及等待时间、AImage acquire/release/timeout、GPU/fence 在途数、错误及恢复次数;不逐帧刷文件。所有必要错误与汇总从独立 native 事件路径进入 App 调试日志,不能仅在 logcat。 -3. **设备验收。** 同一电视、相同文件、相同设置/温度条件,分别从 0 和约 14.121 秒恢复,至少各 3 次可比较运行;记录首对/首帧耗时、连续 60 秒配对率/输出帧率/错误/晚帧、A/V 同步与 CPU/内存。可正常播放后再做 seek、暂停、退出和重复打开;FEL 缺层必须可解释,不能用长期 BL-only 换速度。因当前基线已卡死,先通过功能门槛,再与稳定旧模式比较性能。 -4. **画质与原功能保护。** 用同一输入、相同 resize/色彩/DM 参数的离线 FelBaker/DoViBaker 参考和已知 FEL 差异样片确认残差生效,不以杜比标志/第一条 NLQ 日志替代画质验证。回归 DV5/P8.1、HDR10、FEL 关闭及一次蓝光菜单/退出的关键邻近场景;只覆盖改动触及的路径,不跑无关全矩阵。 -5. **永久挂死退出兜底另列。** 若要进一步保证用户有操作入口,可在独立恢复进程提供“继续等待/结束故障播放并回首页”,只在用户确认后处置本 App 的目标进程,不自动重播。需要核对 PID/UID/进程代际、单实例、最小 App 初始化、服务重启及 Android 14+ 后台拉起限制。完整播放器独立进程需要 Surface/IPC/生命周期重新设计。系统或 GPU 驱动整体挂死不能承诺由 App 绝对挽救。 - -实现验证随实际改动选择:Java 策略单测和轨道快照/过期回包测试先行;改 JNI 则构建两个受影响 ABI 的 `libplayer.so`,改 mpv 则重建对应两 ABI `libmpv.so` 并核对 ELF/锁/资产/APK 哈希;不重建未改的 FFmpeg/libplacebo。EL 预热、有限缓冲 backpressure、seek 清队列和致命错误应有确定性回归用例;上游这三个提交未带此类测试,不能把“已合并”当作 Android 验证通过。 - -许可证/体积/回滚:目前只研究,包体为零变化。推荐方案不新增库;Kodi 源文件为 GPL-2.0-or-later、media-kit 为 MIT,本轮借鉴设计而非复制代码,后续若移植须保留相应许可与来源。不要引入桌面脚本运行时;二进制大小和性能变化以候选产物实测,不能预先保证数值。未来每个实施单元须将代码、补丁、锁/构建声明、产物和测试原子记录,并通过其 recovery tag 回滚;不得以恢复 `792c1f880bc151eb1cb6675034ec144aadc14766` 的方式抹掉尚未提交的日志修复。当前快照只是已知问题锚点,不是正常播放版本。 - -### 8.7 本轮结论与未决事实 - -- **路线有依据**:mpv/libplacebo 已提供核心重建,FongMi 提供 Android 软件 EL 接线,Kodi 和 media-kit 分别提供缓冲生命周期与异步状态管理的可借鉴实现。 -- **发现了本地未覆盖的相关后续修复**:#18375 的真实三提交必须纳入 P2-4 适配评估,但 MediaCodec 不能照搬固定硬件帧池的扩容假设。 -- **不能下“设备不支持 BL 硬解”的结论**;也不能从第一对 GPU 输入就宣称 FEL 完成。UI 同步等待已坐实,native 缓冲饥饿仍需计数验证,驱动/fence、数据及调度竞争仍是区分项。 -- **未找到已证实可直接替换、并在我们这类 Android TV 路径完整验收的通用即插即用实现**。这不是“网上不存在”的断言;所查成熟部件可组合借鉴,但不是可跳过本机调试的整套成品。 -- **本轮只读研究及文档更新**:生产代码、依赖锁、native 和 APK 均未更改,未复跑旧的成功测试/构建,未提交/tag/推送。下一步仅按顶部 Recovery anchor 继续,不把此调研当作实施授权或设备验收。 - -## 9. 最佳实践适配实施(2026-09-12 08:56 起) - -用户现已明确批准第 8 节推荐方案。实施顺序:先关闭已独立验证的日志门控单元,再实施异步状态快照/完整错误透传、FEL 资源预算与有界失败、最小故障退出保护。各逻辑单元单独声明作用域和验证,不升级 FFmpeg/libplacebo,不修改 Exo、菜单或音频策略。 - -执行估计:当前代理代码/定向测试 60–80 分钟,温缓存双 ABI 构建与电视包 15–25 分钟,收尾约 5 分钟;目标 10:20–10:50 Asia/Shanghai,电视安装确认和真机播放另计。未通过的设备场景不能以编译结果关闭。 - -日志门控单元的验证依据保持第 7 节:10+8 项测试、电视 armv7 APK 构建成功;新 trace `p-w3uti8-1` 已显示即时 native 日志,未再出现 PCM 路径上的那两项音频属性 slow-native 查询。该单元可独立保存,但不代表 native 错误、其他 UI 同步调用或 FEL 画质/性能已验收。 - -### 9.1 可靠性实现单元 - -- 09:15 启动 `P2-4-fel-reliability`,保护原 35 个 dirty 文件。允许 MPV Java/JNI、专用恢复页及 App/Startup/Manifest 最小接线、FEL 补丁、定向测试、两 ABI 的 `libmpv.so`/`libplayer.so`、构建说明与本任务/索引文档;不升级锁或 FFmpeg/libplacebo,不改 Exo、原盘菜单和音频策略。 -- JNI 以有界 NODE 快照传递整份 track-list 与 chapter-list;主线程轨道/章节、OSD选轨和视频元数据读取只消费订阅缓存,缺失值不回退同步查询。保留 ISO 语言回退和已选轨道行为、原有网络缓冲探针及一次性音频故障回退查询;不是全局替换所有 getProperty。按文件代际拒绝迟到事件,避免旧文件污染新状态。 -- Android 软件 EL 路径的配对器采用有限 BL 持有、EL 优先推进;同时约束 decoder wrapper 预取,不把 `hwdec-extra-frames` 当成 MediaCodec 扩池。失败预算与禁止软件回退分离,聚合配对/错误诊断。 -- 恢复保护独立于调试开关;新进程只做最小初始化,界面默认继续等待,只有用户明确点击退出才核验 UID/PID/启动代际并结束故障主进程。后台或系统限制拒绝拉起时记录原因,不自动杀进程。 -- 最便宜验证:快照/缺失与过期状态、配对/错误预算及恢复策略定向测试;随后双 ABI native、两个产品编译/APK 与资产核对。电视不具备 ADB,本机不能替代持续播放/画质/生命周期实测。 - -### 9.2 当前验证证据 - -证据目录 `/private/tmp/webhtv-p24-reliability.ASCawt/`: - -- `native-contract-final.log`:真实 JNI NODE writer 的 ASan/UBSan 编译/运行通过;覆盖 UTF-8/控制字符、int64、非有限数、坏列表、循环/深度/体积限制;FEL 预热不因 BL 满队列丢层、有限预取和连续错误预算的 host 检查通过。生产 patch 反向应用/旧模式隔离检查通过。 -- `java-verification.log`:59 秒 `BUILD SUCCESSFUL`;7 类共 62 项 JUnit 全过(轨道快照9、恢复策略5、诊断10、原盘策略16、OSD9、初始选轨3、DTS-HD门控10),Mobile arm64 与 Leanback armv7 编译通过。后续为防止故障日志误判元数据、保留跨文件音频设备/缓存选项新增/调整了定向测试,仅重跑受影响两类。 -- `mpv-armv7.log`:构建/链接/安装至 prefix 成功。`mpv-arm64.log`:源码编译通过,链接错误来自 `scripts/mpv.sh` 对共享 `meson.build` 写入架构绝对 iconv 路径;不能并行运行两个 mpv 目标。只改执行方式,串行重配 arm64 记录到 `mpv-arm64-serial.log`,不重建 FFmpeg/libplacebo 或篡改依赖。 -- native 新模式使用 BL 待配对1/预取1、EL 待配对8/预取4,并强制两路有界解码队列各自推进;默认仍保留旧队列。连续8次没有成功解出帧的错误终止对应 FEL 解码器,BL/EL 都适用;成功输出与 seek/reset 清零。日志分别记录真实配对/decoder queue 占用,明确不等同于驱动 DPB/Surface 池计数,不能把该假设伪装成已实证根因。 -- 恢复页使用独立 `:playback_recovery` 进程,App/Startup 不启动播放器、网络服务和设备发现。主进程 watchdog 不依赖调试开关,正常路径无恢复文件写入;持续8秒无主线程心跳且应用前台时尝试拉起,后台/锁屏受保护,Android 拒绝拉起不会触发自动杀进程。UID/PID/start-ticks/私有请求令牌和仍未恢复状态均需重验,用户操作在恢复页后台线程执行;请求与用户操作结果分文件、跨进程文件锁保护,页面读锁非阻塞。不能保证挽救系统/GPU/磁盘整体失效。 - -### 9.4 日志31续修决定(2026-09-12 10:37,Asia/Shanghai) - -- 输入:`/Users/macbookpro/Downloads/webhtv-debug-log (31).txt`,trace `p-w8ciwk-1`,10:18:42启动同一GIJoe P7 FEL文件、恢复位置14.121秒。已出现本单元新增队列/有限失败标记,因此不是仅用旧包结果猜测新代码;完整APK哈希仍不能从这份日志反推。 -- 直接证据:10:18:50.468 `c2.mtk.hevc.decoder`成功;10:18:51.503第一对BL/EL均13.889且NLQ=1;10:18:52.153双层输入到GPU;10:18:54.038开始明确750ms MediaCodec端口饥饿;10:18:59.452第8次连续错误停止。最终paired=12、bl-only=0、last-pair=14.348,BL pending峰值1/结束0、wrapper结束0;AImage acquired/mapped=4、timeouts/stale/newer/errors均0。用户报告花屏。 -- 已通过/未通过分开:有限失败确实进入App错误处理,没有上一日志49次错误循环;本次最终有正常页面退出记录。**不代表独立恢复页已测试,也不代表花屏或持续FEL已修复。**这次证据否定“仅BL配对队列过大就是全部原因”。 -- 局部源码核对(Grade A,基于锁定mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` 加本地补丁):`hwdec_aimagereader_vk_direct.c`的`FRAME_COUNT=3`、`map_image()/finish_frame()`保留3张AImage且仅在新图像到达复用槽位时归还;`player/video.c::get_req_frames()`通常还要求2张前视帧。GPU对codec图像的持有未被本轮pair/wrapper预算消除。该循环依赖与实际端口饥饿吻合,但仍缺驱动内部DPB计数,不能宣称唯一厂商根因已实证。 -- 排除一条代码假设:`vo_gpu_next.c::hwdec_reconfig()`已经逐帧更新RPU与HDR元数据,不能无证据把花屏归咎于使用首帧RPU。FFmpeg输出互斥只覆盖release/flush/close,不包住整个750ms轮询,未发现“持同一锁轮询导致不能归还”的代码证据。 -- 最短适配:仅选中FEL且Vulkan后端为auto/direct时,在当前播放实例内部采用已存在的stable GPU转换池;不写回用户设置,退出FEL后仍按原后端配置。保留BL硬解、EL软解和NLQ算法,使用RGB_IDENTITY/FULL的原始分量与至少10-bit暂存,禁止该路径降为8-bit。复制完成后按真实fence归还AImage,FEL额外使用有界等待,不能依赖下一张硬解输出才回收。新日志聚合记录提交/完成、source-held、pending、位深和fence超时;仍不得提前释放GPU正在使用的图像。 -- 方案比较:不改已实播失败;继续只缩小pair队列已有反证;直接减到一个导入槽会影响当前/前视/重绘的合法纹理引用,不采用;既有GPU暂存池能把codec图像所有权与后续合成分离,无CPU回读或BL软解,是本轮窄适配。既有官方MediaCodec/AImage/fence契约与成熟本地池实现已在第8节核对,不重新搜索算法论文或升级依赖。 -- 成本/回归边界:仅新FEL模式可能增加GPU拷贝及显存;4个4K packed-10bit输出约132.7MB是理论纹理上限,不是实测开销。普通DV5/P8.1、HDR10、Exo、音频/菜单与非FEL后端选择均保持原逻辑。性能与花屏是否消除必须由相同电视的后续对照验证,不用“GPU完成一次”冒充成功。 -- 另补正式版恢复身份检查:AOSP Android 36.1本地`android/os/Process.java::getUidForPid()`读取`/proc/PID/status`的`Uid:`;非dumpable进程的`/proc/PID`目录属主不可靠。因此将原`Os.stat`改为严格解析真实UID,保留PID、启动ticks、进程名和私有令牌核验,新增4项正常/缺失/非法/重复字段测试,尚待运行。 -- 范围与授权:沿用用户“实施最佳实践、后续不需要逐步确认”的授权及同一个`P2-4-fel-reliability` guard;所有生产native变更落在现有FEL补丁、受影响mpv资产和定向测试内。JNI、FFmpeg/libplacebo不再更改。旧native/Java验证只保留未触及部分,受影响检查须重跑。回滚仍为本单元基线`ec68966c1d72be1c27533e7e9450763a4189febe`的整套源码/资产。 -- 10:30局域网日志恢复可访问(HTTP200),新快照`/private/tmp/webhtv-log31-current.json`显示用户在播放其他普通视频;不打断该会话、不远程改其设置。电视无ADB,新包安装及持续FEL/视觉/性能验收仍需真实播放证据。 - -### 9.3 日志31之前的本机候选(已被新实播失败证据否决) - -上游后续修复的最终处置: - -| 完整上游 commit | 本单元处置 | -| --- | --- | -| `3b4caf0f8ba3101c3aa0b2f59fbd13863e965cfc` | 窄适配:Android软件EL预热时,即使BL队列已满也等待EL,不再以满队列冒充缺层证据 | -| `228f3109fd1a620094758fea90dd387c64ec22c9` | 不照搬 extra_hw_frames:MediaCodec AD_HOC 不靠它扩池;改为显式约束 wrapper 实际预取,保留原硬件选择 | -| `b955aa28f3dc93dc6b21485a0d5b7feb8e6dc10f` | 窄适配:新路径 BL保留1/EL保留8;不把桌面队列8当成所有Android设备安全容量,旧模式保持不变 | - -最终本机结果: - -- `java-final-tests.log`:最后两类受影响测试共21项通过(快照10、诊断11,16秒);结合前一轮未改动的其余5类43项,当前64个不同用例全部通过。最末的 Android 启动/恢复身份读取收紧由最终两产品编译覆盖,不冒充设备测试。 -- `node-final-sanitizers.log`:最终 C++ NODE writer 的 host ASan/UBSan 边界用例与生产FEL补丁静态契约通过;代码也包含 OOM 拒绝保护,但没有注入实际 OOM。上述检查不是硬件缓冲吞吐实测。 -- `mpv-arm64-serial.log`、`mpv-armv7.log`、`jni-build-final.log`:两 ABI 实际编译/链接成功。JNI 首次因项目只有 ALOGE/ALOGV 宏、没有 ALOGW 而编译失败;改用已有 ALOGE 后两 ABI 通过,不是跳过错误。 -- `native-stage.log`、`native-assets-final.log`、`binary-provenance.log`:ELF/SONAME/DT_NEEDED、锁定版本、原有 Surface/音频/菜单等标记与新FEL/NODE标记通过;C/libmpv与Java JNI公开导出未变。恰好4个目标二进制更改,其他16个逐字节相同。两 ABI native 总增量分别5,496B和5,520B;不能将它当成CPU、画质或APK压缩体积收益。 -- `apk-final-build.log`:手机arm64和电视armv7 debug APK均 `BUILD SUCCESSFUL`(2分36秒)。`apk-verification.log`:每包10个MPV资产与候选源完全匹配;编译后 Manifest 的恢复页明确为 `exported=false`、独立进程/独立taskAffinity、singleTask并排除最近任务。APK包名 `com.fongmi.android.tv`、versionCode 560、versionName 5.6.0、targetSdk 28,不能只靠版本名辨别本候选,应核对哈希或新日志标记。 -- ADB无设备;`http://192.168.1.5:9978/debug/stream?v=0` 默认/沙箱外两次均8秒0字节超时。没有安装候选,没有观测本候选的电视播放、退出或恢复页;保留这些验收门槛,**不能宣称已根治驱动问题、真实FEL画质已通过或性能无回归**。 - -| 最终产物 | 字节数 | SHA-256 | -| --- | ---: | --- | -| arm64 `libmpv.so` | 17763288 | `f3b938d62886673d5aaa2da98aa79af6621301673a4e746b1d5c875c7ddaf483` | -| arm64 `libplayer.so` | 96160 | `89cea66bf8b77b1081d602cdea3cedcec9ee9c34b4c03827d88ab000fd739689` | -| armv7 `libmpv.so` | 14569092 | `f97da0cfab135e0fc2af8794354b17df8e51b9c404e5afe9dd28df95caed8e30` | -| armv7 `libplayer.so` | 62172 | `72c3e113f9bff7e92a68793a3ea3e0982d8caa31dab40065a1ecb7480ebc3549` | -| `app/build/outputs/apk/leanbackArmeabi_v7a/debug/app-leanback-armeabi_v7a-debug.apk` | 144857928 | `483a967236c48fee4edcb4d69f5bfd5ed8e10ed360eeaf82717ad3ef863c6b0e` | -| `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk` | 180180453 | `a50ebc5206c3433cadd99b30918e3ca72b976a1014ba60d6cdd9b3ec73f6e7de` | - -电视检查顺序:原文件0秒起播 → 恢复点起播 → 两次均播至EOF(该样片总长57.140秒,不要求单次60秒),核对 `WebHTV FEL stats` 的 paired/bl-only/队列峰值、`WebHTV FEL decoder queue`、GPU NLQ 与错误记录;可播后做重复seek、暂停、退出和重开,再回归FEL关闭、DV5/P8.1、HDR10和一次原盘菜单。正常运行日志不等同于FEL残差画质验证;画质仍需第8节的参考帧/差异样片比较。若发生 native 永久等待,核对恢复页能操作、等待后回到播放器、用户确认退出只处理原目标进程且不自动重播。 - -回滚单位为本节对应的原子实现提交:反向恢复 Java/JNI/Manifest、FEL补丁、验证说明与4个二进制;不改变依赖锁,不回退前一独立日志门控修复,不包含原 `app/.cxx/`。候选只本地提交和恢复tag,不推送、不作为已验收版本发布。 - -### 9.5 日志32:GPU暂存已生效,连续播放仍失败 - -- 输入:`/Users/macbookpro/Downloads/webhtv-debug-log (32).txt`,trace `p-wak4ac-1`;截图`IMAGE 2026-09-12 11:22:43.jpg`显示正常色彩的一帧与“音视频解码失败”提示。设备TCL/MT9655、Android14、Mali-G57、32位,文件仍是57.140秒的GIJoe FEL样片,恢复目标16.082秒。用户观察为“短暂播放后报错,没有花屏”。 -- 11:20:43.907确认`WebHTV FEL GPU staging`,43.929 BL的`c2.mtk.hevc.decoder`启动成功;45.246首对BL/EL均15.891且NLQ=1;46.056 matched EL上传。不能把初始直接输出尝试寻找原生DV7 codec失败,混同于随后成功的HEVC BL硬解。 -- 53.139第8次连续错误触发FEL fatal、App错误4003;53.148配对统计paired=11、bl-only=0、stale-EL=0、last-pair=16.308、BL-held=0/peak=1、EL-held=8/peak=8。渲染最终仅4次请求/4次AImage成功导入。 -- 53.212 GPU池submitted=4、completed=4、source-held=0、pending=0、output-depth=10、fence-timeouts=0;累计拷贝等待98,947µs、最大37,696µs。AImageReader timeouts/stale/newer/errors均0。**该证据反驳“GPU仍持有AImage就是本次停滞完整原因”;继续查未送达渲染端的帧/MediaCodec输出及调度,不能靠继续缩队列或延长750ms来假装修复。** -- 第9.4节代码验证保留:`/private/tmp/webhtv-p24-reliability.ASCawt/recovery-uid-tests.log`(9项)、`fel-staging-host.log`、`fel-staging-arm64.log`、`fel-staging-armv7.log`、`fel-staging-native-verification.log`、`fel-staging-apk-provenance.log`、`fel-staging-compact-apk-verification.log`。增量APK曾有ZIP空隙,移走本任务产物后仅重新package,逐库及v2签名核验通过,不重复编译未改源。 -- 用户反馈对应的本机候选:电视APK SHA256 `21e774a4def6aac2443f6456f29cb57e86bab0b7f80669c2edca67cdc221e9f4`,130,440,519B;手机APK SHA256 `f0a8cf90385e1523c638d1abb300d67ab213b6a1c59409d5e8dc613530d2ae18`,151,571,972B。日志只证明新路径生效,不能单凭标记反推整包哈希。**不得再次将这些已失败包当成修复完成包交付。** -- 11:28续修估计:定位10–15分钟、受影响修正/构建15–25分钟,目标12:10前给出结果;原guard/范围不变,保护`app/.cxx/`。设备实播是剩余门槛,不能以本机编译替代。 - -#### 日志32的剩余循环等待:只读状态查询重新锁住解码线程 - -- Grade A、本地源码复核(2026-09-12):`MpvPlayer.observeProperties()`订阅`hwdec-current`;`player/command.c::mp_property_hwdec_current()`调用`mp_decoder_wrapper_control(VDCTRL_GET_HWDEC)`;后者无条件`thread_lock()`,等到解码线程当前工作完成。该函数自己的注释明确说正常播放应避免这类等待。BL线程可能正等MediaCodec端口可用,端口又需要播放核心继续向VO送帧/释放缓冲。异步Java订阅不能切断这个native内部环路。 -- Grade A/B、已读实际上游提交`f2ef360444b733f45044048bf9cb930e0ef31a81`(`f_decoder_wrapper: allow VDCTRL_GET_HWDEC to actually fail`):为保留硬解探测阶段的`CONTROL_FALSE`,把原本缓存特判移入了同步decoder锁。**不直接回退该提交**,否则会恢复“未确定硬解方式就报告no”的旧错误。提交提及的`a3823ce0e0353fa4ae4b75b0ff2cc17e61969005`不在当前浅缓存中,未读取,不将其当已审核来源。 -- 本次处置:对`f2ef360444b733f45044048bf9cb930e0ef31a81`作FEL专用窄适配,保留探测可用状态,缓存拥有自己的名称副本,getter复制到调用方线程专用存储再返回,不能跨线程借用decoder内部字符串;在FEL帧发布前及时更新缓存。只读`container_fps`为线程启动前已固定的值,同模式读取也不等待decoder。seek/reinit/fallback/销毁仍保留原同步规则。 -- 比较:不改/沿用上游同步查询都保留核心等decoder的环路;仅关掉App状态订阅会损失诊断且挡不住其他调用;选取FEL限定的有界状态快照,普通模式不改变,状态未知时仍返回不可用。新增内存为两个小快照,无逐帧堆分配、不改变BL/EL/NLQ、画质或队列容量。发布序号和查询日志进入App调试日志。 -- 验证/回滚:用host harness直接编译生产`update_cached_values`、`mp_decoder_wrapper_control`和fps getter,先使旧实现因“只读查询调用decoder锁”失败,再验修正路径无需该锁、保留探测unknown/software/hardware状态和字符串所有权、普通模式与修改型控制仍走同步分支;随后仅重建两ABI mpv和APK。最终仍须同一电视从0及恢复点播至EOF,不能把这一源码环路证据夸大为所有厂商问题均已排除。回滚仍以本可靠性单元整套源码/资产为单位。 -- 11:48–11:50本机回归:`fel-status-before-fix.log`在旧生产函数上确定性失败`read-only FEL query waits for the decoder`;修正后`fel-status-host.log`的NODE/FEL helper和真实wrapper函数ASan/UBSan均通过,无等待、unknown/硬解/软解区分、名称副本/超长拒绝、普通路径与修改型同步控制通过。随后production patch反向检查因reset hunk遗漏既有`first_packet_pdts`上下文失败,已只修patch上下文;不把它当运行时回归,也不重跑未改且已通过的host二进制测试,单独重验补丁契约。 - -#### 日志32续修候选的本机验证与交付 - -证据仍保存在`/private/tmp/webhtv-p24-reliability.ASCawt/`: - -- `fel-status-patch-contract.log`:上下文修正后,生产补丁反向应用与旧模式隔离/构建可达性检查通过。没有再次执行已通过且未改变的host测试。 -- `fel-status-arm64.log`、`fel-status-armv7.log`、`fel-status-native-stage.log`:两ABI mpv串行实际编译、链接、安装成功。JNI、FFmpeg、libplacebo未重编,之前的Vulkan局部变量shadow警告未改动。 -- `fel-status-native-verification.log`:两ABI ELF/SONAME/DT_NEEDED、锁定版本、原有功能标记与新snapshot标记通过;该校验本轮约6分26秒,是超过原预计12:10的主要额外耗时,不因此减少验收。 -- `fel-status-apk-build.log`:电视armv7和手机arm64 Debug均构建成功(35秒)。为避免增量ZIP空隙,仅将本任务上一轮已失败APK移动保留为`log32-failed-tv.apk`和`log32-failed-mobile.apk`后打包;保护路径`app/.cxx/`继续使用原隔离CMake目录避开。 -- `fel-status-provenance.log`:相对guard基线只有4个既定目标库改变,其他16个逐字节相同;本轮两JNI与此前已验证版本哈希一致;两ABI libmpv公开接口不变;每个APK的10个MPV资产均与校验源一致,包体没有异常增长。 -- `fel-status-tv-signature.log`、`fel-status-mobile-signature.log`:APK签名验证通过(v2=true)。Java未再改,保留之前68项不同JUnit通过记录,不重复执行。 -- `fel-status-tv-current.json`:LAN HTTP读取成功(约5.9MB日志),尚无`WebHTV FEL status snapshot`,故目前没有本候选的电视实播证据;未操作用户会话、未安装新包、不声称播放验收完成。新电视包已向用户交付本地路径。 - -| 最新候选产物 | 字节数 | SHA-256 | -| --- | ---: | --- | -| armv7 `libmpv.so` | 14570788 | `bc402066dba9c413d5d53825cbfd296a0d890be2834c4e3de507efe4e5fbff63` | -| arm64 `libmpv.so` | 17765112 | `6c542aa6500a93ed521c442757da4bb49be148ea4b24690677bdf871830da997` | -| 电视 `app-leanback-armeabi_v7a-debug.apk` | 130440519 | `80cb16e9dd4f214efb119bc0798ba1141ba549e4f09ef8679ae477dff4b8a361` | -| 手机 `app-mobile-arm64_v8a-debug.apk` | 151571972 | `c302e91d4e2accdde11b6623dee51bfc7747efc374d7c9e163d7da63f3d811bc` | - -本轮输入SHA256:`mpv-android-fel.patch`=`1d21ff3fc0c10350aa6e5beab0c1a1fcc8b0aeb1ae467de9f6eac535c3ef890f`;`test_mpv_fel_contract.sh`=`553d3fc64620f491fb4112d91ab8eb358f86eb8c5673d62c63cda0fb66b6a40b`;`verify_mpv_native_assets.sh`=`a4ebb72faf4a621f04c3094ce003b1cb18067bd813dc4c1a3d0e5ed79f8abdc9`。两JNI哈希仍分别为`89cea66bf8b77b1081d602cdea3cedcec9ee9c34b4c03827d88ab000fd739689`(arm64)和`72c3e113f9bff7e92a68793a3ea3e0982d8caa31dab40065a1ecb7480ebc3549`(armv7)。 - -本轮没有commit/tag/push:实际电视连续FEL、画质/性能和恢复生命周期仍是未通过门槛;不能将有针对性的源码修正与本机通过记录扩张为整项需求已经完成。 - -### 9.6 日志33:新路径生效,但尚缺停帧位置证据 - -- 输入:`/Users/macbookpro/Downloads/webhtv-debug-log (33).txt`,trace `p-wd4cpb-1`,GIJoe 57.140秒样片,恢复点17.947秒。 -- 12:32:27.219命中FEL GPU staging;27.265普通HEVC硬解`c2.mtk.hevc.decoder`启动;27.331/27.335两次命中`WebHTV FEL status snapshot ... (no decoder wait)`。因此不是装错旧包,也不能再将GET_HWDEC同步等待当作完整根因。 -- 28.447首对BL/EL=17.893、NLQ=1;最后配对18.143,共7对,BL-only/stale-EL均0。BL pending峰值1、EL pending峰值8。 -- 36.194连续8次解码错误触发有界停止/4003。结束时GPU submitted=completed=4、source-held=pending=0、10-bit、fence-timeouts=0、总copy-wait=85377us/最大22275us。AImage acquired=mapped=4、其余错误/错配/超时计数为0。不能把GPU暂存完成等同于最终渲染/显示提交完成。 -- 已核查锁定mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`及本地补丁的调用链:`player/video.c::write_video()/video_output_image()`、`filters/f_async_queue.c::process_in()/process_out()`、`filters/filter.c::filter_recursive()`、`vo_gpu_next.c::draw_frame()/flip_page()`、`vd_lavc.c::send_packet()/decode_frame()`。`VDCTRL_CHECK_FORCED_EOF`仅在EOF后调用,不能解释首次端口超时;内部filter调度不递归运行另一decoder,未发现可据此直接宣称根因的锁。现有FFmpeg输出释放锁没有包住端口等待循环,不改其保护补丁。 -- 决策:不改/沿用上游日志都只能知道端口超时;再调队列、GPU池或750ms没有新的决定性证据。选取FEL实例内的有界原子进度快照,分别记录core/BL/EL/VO阶段、阶段起点和收发帧计数;在原fatal消息内附加快照,复用现有fatal限流豁免与即时App日志。无需Java主线程/native属性查询、无等待/分配/逐帧日志,无新增线程或依赖,不改变配对/解码/重建/超时行为。 -- 依据:上述生产调用链与日志为Grade A;第8节Android ANR/API异步契约支持旁路非阻塞诊断。该步不改变算法或采用新上游方案,既有资料评审继续有效;不能凭本地测试把诊断包称为已修复包。 -- 验收:真实诊断helper的禁用隔离、计数、时间回绕、缓冲截断与并发读写测试;两ABI编译/ELF/既有标记及新pipeline标记;APK包内资产一致。电视失败时应在同一条fatal日志读到四条lane及VO请求帧预算,用它在“核心等未来帧/decoder被阻塞/渲染或提交被阻塞”之间作出可证伪判断,之后才实施对应修正。回滚仍为本可靠性单元整套源码/资产。 -- 执行目标:2026-09-12约12:41 Asia/Shanghai起,诊断/定向修改20–30分钟、双ABI构建/校验15–20分钟,目标13:30前交付可识别阻塞点的新候选;设备复现不计作已通过。 -- 13:09已实现:每个VO独立持有4条lane;阶段/时间戳使用同一个64位原子值,编译期要求ARMv7/arm64均为always-lock-free;其他计数仅为各自原子的近似观察,不伪装成全管线一致事务。记录无日志I/O、无内存分配、无锁/重试;只有现有fatal发生时格式化一次,沿用不可限流的fatal通路。VO预算记录request/refs;stage覆盖取过滤帧、等VO、GPU预加载/合成/flush/submit/swap-buffers,既有行为参数不变。 -- 本机`fel-trace-host.log`:真实诊断header的禁用隔离、有效/无效PTS、时间回绕、极小缓冲、4线程并发及一致stage/time测试通过ASan/UBSan;原NODE/FEL和真实decoder-control用例通过;补丁反向契约通过。同步构建缓存前修正了新增diff的空行/漏失上下文,失败时均恢复原补丁;没有丢弃其他native补丁或受保护文件。 - -#### 日志33定位包结果(13:19) - -证据目录沿用`/private/tmp/webhtv-p24-reliability.ASCawt/`: - -- `fel-trace-tsan.log`:同一个生产header测试额外通过ThreadSanitizer,并发读写无报告。 -- `fel-trace-arm64.log` / `fel-trace-armv7.log` / `fel-trace-native-stage.log`:两ABI实际编译/链接和安装通过;32位同样通过always-lock-free静态断言。没有升级、重编FFmpeg/libplacebo/JNI。 -- `fel-trace-native-verification.log`:两ABI ELF/SONAME/DT_NEEDED、锁定版本、旧功能标记与新pipeline标记通过(本轮约46秒)。 -- `fel-trace-apk-build.log`:两Debug变体构建成功,57秒;继续使用隔离CMake目录,未用`app/.cxx/`。 -- `fel-trace-provenance.log`:相对guard基线只改变4个既定库,其余16个字节一致;本轮JNI与上一候选完全相同。两libmpv公开接口一致,两APK各10个MPV资产逐项匹配。 -- `fel-trace-signature-arm64-v8a.log` / `fel-trace-signature-armeabi-v7a.log`:两APK签名通过。失败的上一候选保存在`log33-failed-tv.apk` / `log33-failed-mobile.apk`,未删除。 - -| 当前定位包/产物 | 字节数 | SHA-256 | -| --- | ---: | --- | -| armv7 `libmpv.so` | 14577356 | `b05166fd22273768fd2e1c4107590f6d1d9518c5a72b27c8cdd4990470b246da` | -| arm64 `libmpv.so` | 17770856 | `337bc3acc92afe62d85e99df7928d08781081fbb3d53321257613f7a65fdf540` | -| 电视 `app-leanback-armeabi_v7a-debug.apk` | 130440519 | `603e40c221c00537bd80c801009795ba2d1383510b1659f76bd27cc05034c6b3` | -| 手机 `app-mobile-arm64_v8a-debug.apk` | 151571972 | `f307cd4fe53e009ee14ceecb99f79e9645cff7acd17f5bd7f28b4d6f511891e7` | - -本轮输入SHA256:`mpv-android-fel.patch`=`17958856c0aa1506499f1439e701d8fd3bd64231de436985f51fb6890f238a75`;`test_mpv_fel_contract.sh`=`d1a3a34e1a91ae7f18a1ff9a2c74539f0d00905615bf7d8638491bb5b8bbb89c`;`verify_mpv_native_assets.sh`=`839651e8d817115285cdef4a00eac6eac465061bae110a1ac8432c5173f77966`。语义不变的patch空白格式化在打包前完成,最终安全检查另行核对可逆应用契约,不重复host执行。 - -状态:定位包待电视复播;真实FEL播放失败的门槛仍未关闭,未提交/tag/push。这里的`VO out`是完成显示提交调用的次数,不等同于屏幕实际呈现帧;其他lane计数也仅作诊断,不参与调度。不得将构建/并发测试通过当作电视播放已修复。 - -### 9.7 日志35:VO丢帧绕过GPU暂存形成的等待链 - -- 新证据:`/Users/macbookpro/Downloads/webhtv-debug-log (35).txt`,trace=`p-wexl39-1`。13:23:09.464命中新pipeline;09.517普通HEVC `c2.mtk.hevc.decoder`启动。19.570在8次连续错误后停止(4003)。快照为`requested=2 refs=3 core={core-wait-frame age=22ms pts=19144 in=8 out=4 held=1} BL={codec-receive age=776ms pts=19478 in=14 out=8 held=0} EL={codec-decoded age=5589ms pts=19686 in=27 out=20 held=1} VO={vo-idle age=5989ms pts=19102 in=5 out=5 held=2}`。 -- 配对8帧、BL-only=0,最后配对19.186;GPU submitted/completed=4/4、source-held/pending=0、输出10bit、fence-timeouts=0;AImage acquired/mapped=4/4,无超时/错配。排除当时EL生产不足或VO正在GPU提交中阻塞。core输入含恢复点前的preroll,不能把`in-out`都解释为丢失帧。 -- 源码核实(锁定mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`及既有本地补丁,2026-09-12,Grade A):`player/video.c::get_req_frames()`需要2帧;`vo_gpu_next.c::preload_deferred_hwdec_frame()`已为正常提交的未来帧完成暂存,故单纯“VO惰性上传未来帧”不是准确解释。真正缺口在`vo.c::render_frame()`:迟到帧分支跳过整个`draw_frame()`,却保留`current_frame`及其中未来帧;这也跳过GPU暂存/AImage归还。核心已送出19.144而VO最后处理19.102,与此路径一致。待用实际函数测试确认等待链,并以新日志确认电视触发频率;尚不宣称唯一厂商根因已实证。 -- 依据与范围:沿用第8节Android MediaCodec缓冲所有权、mpv #18375预算、成熟GPU路径与技术资料评审,不重复广泛搜索;这里修复的是本地既有暂存接线遗漏,不替换算法、FFmpeg、Surface/fence或上游版本。论文不适用于决定这一具体控制流缺口。`DOCS/man/options.rst::video-latency-hacks`明确警告损害插帧/帧率判断,故不采用全局或隐式低延迟切换。 -- 方案比较:不改/原样上游继续保留有限Surface输出;降低核心需求到1帧会影响帧时长和用户插帧;禁止所有FEL丢帧会额外合成和显示迟到画面。选择窄适配:仅显式FEL、MediaCodec、支持的gpu-next路径,在VO丢弃显示前通过内部control完成所持帧GPU暂存,归还硬解图像,仍保留原丢帧计数/显示策略/双帧时序和用户设置。正常画面不增加GPU拷贝,迟到画面只执行原本被绕过的必要暂存,不执行完整合成/显示。 -- 预定保护:映射必须在VO锁外完成;临时未就绪保留原帧并请求重绘,永久错误仍走既有backend-error。不新增线程、公开API、依赖或JNI,不增大队列/超时,不牺牲BL/EL配对、10bit或NLQ。补充fatal同一物理行及丢帧暂存计数,避免pipeline第二行被限流。 -- 验收/回滚:以实际`render_frame()`和新增暂存函数编译有限输出池回归(旧路径失败、新路径前进),覆盖FEL关闭、软件帧、正常显示、丢帧、暂时/永久错误及锁外执行;再做两ABI、受影响资产/APK验证。电视仍须连续播放、seek/退出及画质门槛;无设备通过证据不提交/tag。回滚锚点保持本guard基线整套源码/资产。13:33起预计分析/修正20–30分钟、构建/验证10–20分钟,执行目标14:20 Asia/Shanghai。 -- 13:48本机证据:`fel-vo-drop-before.log`在旧真实scheduler上失败`codec_outputs == 0`,保留未来帧占用输出导致下一BL无法解码;`fel-vo-drop-host.log`通过实际scheduler和新暂存函数的ASan/UBSan回归,以及既有NODE/FEL、wrapper状态快照、并发进度header及可逆patch契约。测试模型只模拟DPB之外剩一个可用输出,不声称测得电视精确池容量。新实现仍计一次VO丢帧,不合成/显示,只暂存缺少的未来帧;FEL关闭/软件/其他VO/正常显示/暂停/GL均隔离,临时等待重绘与永久错误、锁外映射均通过。源码缓存已从保存的旧FEL patch可逆更新,没有替换其他native补丁。 - -#### 日志35修正候选:构建与最终交付 - -证据目录仍为`/private/tmp/webhtv-p24-reliability.ASCawt/`: - -- `fel-vo-drop-arm64.log`、`fel-vo-drop-armv7.log`、`fel-vo-drop-stage.log`:两ABI真实编译/链接与stage成功;没有重编JNI/FFmpeg/libplacebo。原有stable局部变量shadow及armv7时间范围warning不属本次新增,不扩大修复范围。 -- `fel-vo-drop-native-verification.log`:两ABI ELF/SONAME/DT_NEEDED、锁定版本、既有功能及新丢帧暂存标记通过。耗时约3分25秒。 -- `fel-vo-drop-apk-build.log`:两Debug构建成功(39秒)。增量APK含旧libmpv占位;移至`fel-vo-drop-gap-tv.apk`/`fel-vo-drop-gap-mobile.apk`保留可恢复,再仅重新package,见`fel-vo-drop-compact-apk-build.log`(28秒)。继续隔离CMake目录,未使用受保护`app/.cxx/`。 -- `fel-vo-drop-provenance.log`:两libmpv公开接口不变、两JNI与前包字节一致;相对guard仅4个目标库变化,其余16个依赖一致。 -- `fel-vo-drop-compact-verification.log`:重新封装后的最终两APK各10个MPV资产逐项匹配,v2签名通过。只重验改变了封装的APK,没有重复native/Java测试。 -- 16:04只读LAN取证:`/debug/logs`为HTML页面,实际JSON在`/debug/stream?v=0`。保存`fel-vo-drop-tv-stream.json`,最后记录13:39:56,FEL部分仍为日志35的`p-wexl39-1`;无新`dropped-frame staging`证据。未远程安装或操作用户播放。 -- 最终作用域/检查点输出保存于`fel-vo-drop-final-safety.log`。原14:20交付目标未达到;重新打包本身已成功,执行会话随后失效,16:00恢复时收尾仍未完成。本轮停止额外研究,只核验最终产物和交付,不以耗时作为降低实播验收的理由。 - -| 最终产物 | 字节数 | SHA-256 | -| --- | ---: | --- | -| armv7 `libmpv.so` | 14578788 | `8acedebbbe149f0ce3fffaf45bb7dca80126753f270d343a0e937d6452483a21` | -| arm64 `libmpv.so` | 17772120 | `9c8637ae82dfabd8779ba67c51fe2220b2408b12421caf0607b2afd6209a9986` | -| 电视 `app-leanback-armeabi_v7a-debug.apk` | 130440519 | `3baae67b07ba7c93f71ae4f5a70bb865dcfd53508a8798b26455632524efac12` | -| 手机 `app-mobile-arm64_v8a-debug.apk` | 151571972 | `a58eb183a57d8a0daf28d82fa514d01f1b5cba3ef87f3f01c70e3cb6ce9af437` | - -输入SHA256:FEL patch=`1fb5dff9741fa96621b49fd957e73f360ce69a2e5ab98d0c4ebdc3a4722f4815`;host测试脚本=`167b666d64163be77d96e2c87314f2fd5377bd1c8cda354ef73f6cd6c7e58225`;native资产校验脚本=`eee37fe9bd10f0ffaa54036edf89b9074bd319a7fbd77f3003e51bda4be3a0ed`;未改的native lock=`a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159`。完整可靠性单元的电视播放门槛未关闭,未commit/tag/push。 - -### 9.8 日志36:丢帧暂存已成功,MediaCodec仍停滞 - -- 新证据:`/Users/macbookpro/Downloads/webhtv-debug-log (36).txt`,trace=`p-wlewnw-1`,恢复点约19.810秒、首对BL/EL为18.894秒(含preroll)。16:24:46.140在8次连续错误后4003停止,新单物理行fatal/pipeline证明修正版正在运行。 -- 关键快照:`requested=2 refs=3 surface-drops=3 drop-prepared=3 drop-retries=0 core={core-wait-frame age=10ms pts=20061 in=30 out=7 held=1} BL={codec-receive age=767ms pts=20270 in=36 out=30 held=0} EL={codec-decoded age=5994ms pts=20604 in=49 out=42 held=1} VO={vo-drop-done age=5982ms pts=20061 in=6 out=6 held=2}`。 -- 配对30帧、BL-only=0、stale-EL=0,最后配对20.103;BL-held=0 peak=1,EL-held=8 peak=8。GPU submitted/completed=8/8、source-held/pending=0、output-depth=10、fence-timeouts=0;copy-wait-us=163423、max-copy-wait-us=21617。AImage acquired/mapped=8/8、timeout/stale/newer/error全0,render requests=8、unavailable/pending replacements=0。 -- 结论边界:已执行的GPU/AImage操作未残留资源等待,VO也非提交中阻塞;上版修的是实际控制流缺口,但不是完整电视根因。保留它的已通过定向测试,不重复未改构建,不延长750ms保护、不盲目降低核心需求或禁用插帧。不能用含preroll的配对数声称持续播放改善。 -- 待证问题:`dovi_split`的`el_rpu`只生成EL副本时,硬解BL是否仍收到原始交织BL+EL?需查实际dispatcher/decoder与FFmpeg metadata处理,不以函数名或摘要替代证据;任何净化都必须保留真实EL/RPU/NLQ并隔离到显式FEL路径。 -- 16:37恢复:同一guard、同一作用域与回滚锚点,既有Java/JNI及其他未变更验证保留。定位/修正约20分钟、受影响构建核验15–20分钟,目标17:15–17:20 Asia/Shanghai;真实电视验收未过前不提交/tag。 -- 源码核实(2026-09-12,Grade A):锁定mpv的`demux/dovi_split.c::mp_dovi_split_dispatch()`仅返回`el_rpu`副本;`demux_lavf.c`与`demux_mkv.c`仍将原包作为BL输出。`vd_lavc.c::send_packet()`直接送出该包;锁定FFmpeg的`mediacodec_extract_hevc_metadata(const AVPacket *)`只读取RPU,未改包。故“BL硬解仍含EL NAL”是已证实输入隔离缺口,是否独立导致该厂商停滞仍待实播。 -- 决策证据:FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`的`libavcodec/bsf/dovi_split.c::nal_is_kept()`提供`bl_rpu`模式,去除type63外包EL、逐字节保留type62 RPU与BL,并支持Annex-B和1–4字节NAL长度。`bsf.h`明确send接收packet所有权;mpv `common/av_common.c::mp_set_av_packet()`却借用demux引用,必须另建ref,禁止原地交给BSF。沿用第3/8节PR、平台和成熟实现的研究;此修正不改变重建算法,论文和重复field搜索不能替代具体码流/所有权测试。 -- 方案比较:不改/原样桌面路径依赖硬解忽略EL;在demux全局过滤会改变原生DV及其他decoder消费者;自行写NAL解析增加格式/畸形输入风险。选择在显式Android FEL的MediaCodec BL入口复用已有`dovi_split=bl_rpu`,不升级或重编FFmpeg、不改变共享demux数据、codec/RPU配置、EL、NLQ和GPU逻辑。普通播放、旧DV7模式、软件EL不创建BSF、无逐包扫描成本。 -- 最小实施/验收:用独立owned packet承接过滤结果,EAGAIN重试不消费原包,纯EL包作为已消费空输出而不是无限重试,seek/reset清BSF,销毁归还引用;启动/首包/退出或fatal统计进入现有App调试日志。定向ASan/UBSan测试真实helper和公共BSF,覆盖BL/RPU字节相等、EL独立副本不变、时间戳/side-data/原包所有权、Annex-B/长度前缀、空/坏包与重试/复位、非FEL隔离;通过后才重建两ABI/mpv及APK。双ABI来源/资产/公开接口仍需一致;回滚继续使用本guard整套源码/资产锚点。用户持续实施授权适用,未扩至Exo/音频/菜单或新依赖。 -- 实施:新helper只在`android_fel && !force_swdec && MediaCodec && source_profile==7 && HEVC`分配BSF;不将`par_out`写回共享codec/RPU配置。坏输入失败关闭,不把原始混合包送硬解;空输出消费一次;硬解EAGAIN下原始demux包仍可重试。`BL-input={isolated=... packets=... bytes=...->... empty=...}`与fatal同一物理行,避免限流丢掉关键新诊断;启动、首包、退出另有低频信息。 -- 本机验证:`fel-bl-isolation-host.log`的原有NODE、decoder-control、VO-drop和并发trace测试通过;新增packet测试首轮发现Annex-B四字节起始码的首零可由FFmpeg保留为前一NAL的合法`trailing_zero_8bits`,测试错误要求长度完全相等。按`h2645_parse.c::ff_h2645_extract_rbsp()`实际行为修正测试,只允许新增尾零、仍严格检查每个有效载荷字节,不改生产数据或降低精度。 -- `fel-bl-isolation-packet.log`:新增真实helper + 系统FFmpeg 9.0.1(libavcodec 63.1.101)ASan/UBSan通过,覆盖Annex-B和长度1–4、32组准入组合、H264排除、借用/无ref包、重试/复位/空/畸形输入。锁定Android FFmpeg为63.3.100,源代码已对照,host结果不冒充Android二进制实播。GIJoe本地样片前120个视频包移除296个EL NAL,41149779→37620884字节;BL/RPU每个载荷与PTS/DTS/duration/side-data保持一致。`fel-bl-isolation-patch.log`的可逆补丁/旧模式契约通过。ADB无设备;未重复未变更的Java测试,尚未构建/实播新产物。 - -#### 日志36续修候选:构建与交付证据 - -证据仍在`/private/tmp/webhtv-p24-reliability.ASCawt/`: - -- `fel-bl-isolation-arm64.log`、`fel-bl-isolation-armv7.log`:两ABI真实增量编译`vd_lavc.c`/新header并链接安装成功,未重编JNI或其他依赖。`fel-bl-isolation-stage.log`与`fel-bl-isolation-native-verification.log`通过ELF/SONAME/DT_NEEDED/锁版本/旧功能及新BL隔离标记。 -- `fel-bl-isolation-apk-build.log`记录首轮被沙箱拒绝用户Gradle缓存锁,属于环境权限,不是代码失败;提升权限后`fel-bl-isolation-apk-build-approved.log`成功,耗时1分10秒。继续使用既有isolated-cxx脚本,原`app/.cxx/`受保护。打包前将两旧APK移至`fel-log36-tv.apk`/`fel-log36-mobile.apk`保留可恢复,避免增量封装残留旧库占位,本轮只执行一次成功构建。 -- `fel-bl-isolation-apk-verification.log`:两APK各10个MPV资产与源码assets逐项SHA256一致、v2签名通过;相对旧APK各自仅`libmpv.so`变化,JNI及其他18份库完全相同;两ABI公开导出集合一致。native增加约1.1/1.2KB,最终APK字节数因对齐未变,但APK/MPV SHA256均已变化,不能用大小判断是否更新。 -- `git diff --check`首轮发现机械生成patch的空context行含空格;仅规范化patch空行后再次通过,`fel-bl-isolation-final-patch.log`的可逆源码一致性/旧模式契约也通过,无生产源码变化,不重复native构建。两个变更shell脚本的`bash -n`通过。 -- 本地GIJoe样片`/Users/macbookpro/Downloads/影音测试库/V01_DV_Profile/P7_FEL_4K24_GIJoe.mkv` SHA256=`06e42fc4e06ee90c8eea0b7a31450f844ad4228ea639a5ba12d51c05d2930e63`;不能据此宣称已比较电视文件哈希或测得目标设备吞吐。 - -| 最终产物 | 字节数 | SHA-256 | -| --- | ---: | --- | -| armv7 `libmpv.so` | 14579892 | `38694f30e4e397c37aa77d03d5d6462baa391cf2379748510056ecbaee5eecab` | -| arm64 `libmpv.so` | 17773320 | `6e130c534f6efaf5d6f6b75a27f10362a4f637443f8b9fa52f50365a601517fc` | -| 电视 `app-leanback-armeabi_v7a-debug.apk` | 130440519 | `f5dfd020371dc5e6114e2fd5eb86fde057afd2723b2a74b844dc17c65261238b` | -| 手机 `app-mobile-arm64_v8a-debug.apk` | 151571972 | `1521ff7dab7d1dcf706167e9c5b497baba12c5b66e88d43828f77c41bf7242fc` | - -输入SHA256:FEL patch=`3a0e06cbde365b56954d76a4ad7267c284844c8271921b1fb1957c71547dbd7f`;host脚本=`f803903a1f83e1ed9085a1daf94038b340a43df8750a1e158f1798f21eb1578d`;native验证脚本=`6467bbce2bef48e5a93238051a208975569d8629223e323829ddaac1afd8b8fc`;锁未改,仍为`a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159`。 - -17:09:本轮预计17:15–17:20的构建交付目标已达到;目标电视无法ADB,尚无新候选实播。保持同一个active guard,不finish/commit/tag/push;连续重建、画质、性能、seek/退出及恢复页门槛仍需实际证据。 - -### 9.9 日志37:核心前视帧未进入VO的缓冲等待链 - -- 证据:`/Users/macbookpro/Downloads/webhtv-debug-log (37).txt`,trace=`p-wngh5c-1`。17:21:47.614启动`bl_rpu`隔离;首包423759→313263字节,PTS20.896。17:21:55.809仍在8次连续错误后4003退出。`BL-input={isolated=1 packets=12 bytes=3250565->2855109 empty=0}`否定未安装新包/过滤未生效假设。 -- fatal:`requested=2 refs=3 surface-drops=1 drop-prepared=1 drop-retries=0 core={core-wait-frame age=42ms pts=21063 in=6 out=4 held=1} BL={codec-receive age=781ms pts=21271 in=12 out=6 held=0} EL={codec-decoded age=5601ms pts=21605 in=25 out=18 held=1} VO={vo-idle age=750ms pts=21063 in=6 out=6 held=2}`。paired=6、last-pair21.104、BL-only/staleEL=0;GPU/AImage=5/5,source-held/pending/fence-timeout=0,10-bit,copy-wait123311us/max40220us。 -- 本地代码(mpv锁定`cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`与现有补丁,2026-09-12,Grade A):`player/video.c::video_output_image()`将第1张未来帧放入`next_frames`,`get_req_frames()`要求2帧,`write_video()`在`VD_WAIT`时直接返回;只有攒够帧才`vo_queue_frame()`,之后才运行GPU暂存。因此至少这1张核心持有帧没有进入GPU统计,不能再将“GPU已归还5帧”解释为所有MediaCodec outputs已释放。日志37的6/5差值与此路径一致,精确厂商池容量仍未测得。 -- 研究复用:第8.4节Android官方“某些codec需归还所有outstanding buffers”、mpv #18375持帧预算、Kodi及时release及Bigflake生产者背压资料仍适用;第9.7节只覆盖已进入VO后的丢帧,不覆盖本次core等待。此处不改FEL算法,新增论文/泛帖子搜索不能决定控制流问题。需要以实际core函数的有限输出池测试区分新路径与旧路径。 -- 方案比较:不改/原样桌面路径保留上述等待环;强行降为1帧会改变帧时长推断与插帧;同步`vo_control()`让core等GPU会引入新的阻塞面。选择同一FEL暂存能力的异步core接线:core保留帧内容和两帧预算,在读取下一帧前请求VO线程暂存;只有有界单槽请求,完成通知core,seek/reset代数作废旧任务,锁外GPU工作,核心不等待dispatch/GPU。 -- 准入/生命周期:仅显式FEL、MediaCodec帧及支持的软件EL VO;普通模式不建请求。逐帧完成标记防止两张前视帧重复轮询互相覆盖;含seek保存的帧也须在继续解码前处理。保留原PTS/RPU/EL与raw-YUV >=10bit、现有fence/750ms保护,不用CPU回读、不新增线程或公开JNI/API。检查提前暂存与后续裁剪/VO重配的缓存一致性,不能释放源后又丢掉暂存纹理。 -- 验收:直接编译实际`video_output_image`/前视判断和新VO请求处理函数,旧路径在受限输出模型下不能凑够2帧,新路径可推进;覆盖前视/EOF/预滚动保存、非FEL/软件/其他VO、异步重试、超时、锁外执行、seek中途取消及释放。再重建两ABI和APK;电视仍须持续播放/seek/退出/画质与性能门槛。回滚保持同guard基线整套源码/资产。 -- 17:25执行估计:定位与代码/测试30–40分钟,构建核验10–15分钟,目标18:15–18:20 Asia/Shanghai;不以估计降低验收,不复测日志37的失败包。 -- 实施细化:异步请求仅占一个槽,core按20ms兜底轮询、VO完成主动唤醒,重试间隔5ms、750ms超时;不在core调用同步dispatch。`mp_image`内部的ref-counted暂存租约随图像引用复制,防止核心/插帧仍使用的纹理被环形池覆写;仅FEL池允许从原4槽按需增长,上限`2*VO_MAX_REQ_FRAMES+4`,不预分配额外4K纹理,普通模式保持4槽。FEL mapper比较仅剔除由gpu-next处理的显示裁剪/方向/比例,真实像素尺寸/格式/色彩仍严格比较;不得全局放宽参数判断。上述均在已有FEL补丁所有权内,不改锁/公开API/JNI。 -- 18:06–18:20验证:`fel_core_preload_test.c`直接编译生产core前视/读取、VO请求/worker/cancel、GPU租约选择和mapper重配函数,受限输出模型下旧模式保持卡住(证明反例),候选40帧可推进;2/6/10帧请求不被缩减,软件/非FEL隔离、单槽、5ms重试、750ms超时、锁外GPU、reset先行/处理中取消、hrseek保存帧至EOF、重复租约和裁剪均通过ASan/UBSan。`fel_image_lease_test.c`直接编译生产ref/destructor/dummy/unref-data并链接真实AVBuffer,引用/EL引用/清理/租约唯一析构通过;修改后的原子进度测试通过。不是测得电视池容量或实时性能。 -- 补丁溯源:在`/private/tmp/webhtv-fel-preload-patch.R5eptw/`按构建脚本的原始参数重放旧补丁链,旧FEL patch可应用,新patch与缓存可逆;前后源码只差第9.9节8个mpv文件,未带入其他模块。最初给所有旧补丁加`--recount`的生成命令在disc poll旧式patch失败,纠正为构建脚本逐条原始参数后通过;没有改原盘补丁。差异记录`/private/tmp/webhtv-p24-reliability.ASCawt/fel-core-preload-source-delta.log`。 -- 首轮native日志`fel-core-preload-*-build.log`编译失败,原因是新增调用前未声明`wakeup_locked`;已补声明。外层裸`bash`没有继承buildall的shebang `-e`,不能信任其退出0;v2显式`bash -e`并检查完整日志的失败行。失败轮产物未暂存/打包。18:20预计延期10–15分钟至18:30–18:35,仅继续必要构建和产物核验。 -- v2两ABI与两APK通过、各10资产/签名一致,和日志37相比仅两份`libmpv.so`变化。但19:11最终源码消费检查发现`map_frame`直接使用mapper的`par.rotate`,不能把规范化后的缓存参数当作呈现参数。v2未交付,保存为`fel-core-preload-v2-tv.apk`/`fel-core-preload-v2-mobile.apk`。新增`restore_fel_display_params`仅恢复每帧方向/翻转/比例,保留mapper真实格式/尺寸/纹理边界;实际helper纳入ASan/UBSan,验证90度旋转/翻转/非方形比例恢复且普通模式不受影响。core提前暂存失败改为统一FEL fatal日志,包含错误值、PTS及完整pipeline,满足无ADB诊断要求。此次必要边界修正导致超过前次估计;不省略重建最终产物验证。 - -#### 第9.9节最终候选(v3,未通过电视实播) - -- 证据根目录:`/private/tmp/webhtv-p24-reliability.ASCawt/`。`fel-core-preload-test-v3.log`是最终实际函数ASan/UBSan;`fel-core-preload-*-build-v3.log`是两ABI成功编译;`fel-core-preload-native-assets-v3.log`是ELF/依赖;`fel-core-preload-apk-build-v3.log`是两APK构建(48秒、172 tasks);`fel-core-preload-artifacts-v3.log`含公开导出、JNI身份、全部APK库字节、签名和哈希。修改后的host脚本语法检查通过。 -- 两APK各10个native assets与源码资产逐库一致,签名通过;对日志37旧APK逐库检查,只有对应`libmpv.so`不同,其余18个库字节不变。相对guard仅4个既定目标库变化、16个依赖不变。两ABI公开`mpv_*`导出一致;JNI保持arm64 `89cea66bf8b77b1081d602cdea3cedcec9ee9c34b4c03827d88ab000fd739689`、armv7 `72c3e113f9bff7e92a68793a3ea3e0982d8caa31dab40065a1ecb7480ebc3549`。 -- 新包包含`WebHTV FEL core preloading: async single-slot, leased GPU cache, lookahead unchanged.`;fatal的`core-preload={requests=… done=… retries=… errors=…}`能区分未进入VO、暂存重试和解码器本身停止。仅FEL额外缓存允许按需增长,实际GPU内存/耗时仍需测量,不能把host模型当作厂商输出池容量证明。 - -| 最终产物 | 字节数 | SHA-256 | -| --- | ---: | --- | -| armv7 `libmpv.so` | 14584844 | `cd38c28e6a2621085ec762babf331036d839428116756a1184e55167df130f7b` | -| arm64 `libmpv.so` | 17778864 | `e076515c29fbbcd9b0e1d77e7ac9002159367d7a974807439cdc8207568ffcdc` | -| 电视 `app-leanback-armeabi_v7a-debug.apk` | 130456903 | `f8d0a9e860c595eba033e4ef9f3f029cc78e808ff36fdfae5e5c560893c8d333` | -| 手机 `app-mobile-arm64_v8a-debug.apk` | 151588356 | `aa4ff05b66ad578c383b0ca181c7fe26dc6f3417d6ebf914c1e2bb4ef32df4d8` | - -最终输入SHA256:FEL patch=`fe844402414a3e8c1c7d060632145c5725b91bcd4abd9075310b4a20bfe7838e`;host脚本=`9251e525c0ab945dbaed38467233f03d28ff109f87e6f663b17882ce5fbca49d`;native验证脚本=`6467bbce2bef48e5a93238051a208975569d8629223e323829ddaac1afd8b8fc`;锁仍为`a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159`。生产源码对应同一锁定图,无依赖升级。 - -日志37旧APK已保留为`fel-log37-tv.apk`/`fel-log37-mobile.apk`;v2中间包也保留,均非本次交付。下一步只做目标电视同源FEL连续播放/seek/退出验收及日志核对;尚未提交/tag/push,不宣称已完成真机需求。 - -### 9.10 日志38:6/6提前暂存完成后仍停滞 - -- 原始证据:`/Users/macbookpro/Downloads/webhtv-debug-log (38).txt`(2010行)。用户确认表现与之前相同;同一本地GIJoe样片两次播放,trace分别为`p-wse9rz-3`和`p-wsept7-4`。`WebHTV FEL core preloading: async single-slot, leased GPU cache, lookahead unchanged.`证明v3接线正在执行。 -- 两次共同状态:`core-preload={requests=6 done=6 retries=0 errors=0}`;BL `in=12 out=6 held=0`;EL `in=25 out=18 held=1`;core `in=6 out=0 held=0`;配对6帧;AImage acquired/mapped均6,所有错误、过期和超时计数0;GPU submitted/completed均6、source-held=0、pending=0、fence-timeouts=0,输出位深10。 -- 第一轮19:40:09.503 fatal:最后配对33.784秒,BL输入隔离`4129616->3881051`字节,GPU累计copy wait 138807微秒、单次最大25003微秒。第二轮19:40:30.674 fatal:最后配对3.420秒,BL输入`4069095->3582799`字节,GPU累计132977微秒、最大23696微秒。最终均为MediaCodec端口750ms不可用,累计8次错误后4003退出。 -- 已排除的旧解释:不是未安装v3,不是本轮第6帧尚未送入GPU,不是GPU fence等待超时。恢复点前preroll没有正式呈现(core out=0),不能把6帧解码当作播放改善;VO held=1是worker完成前的最后阶段快照,不能用它否定GPU实际source-held=0。 -- 尚未证明:厂商DPB容量、缓冲归还后的驱动行为、输入/配置兼容性。`av_mediacodec_release_buffer_status()`在实际render-release路径会先减少`hw_buffer_count`,因此仅看到源`mp_image`引用存活不足以断言硬件输出未归还。必须读完整控制流与真实返回值再决策。 -- 用户新增研究要求:遇到难点扩大到非Android/非mpv的开源播放器及相关视频项目,不局限已有实现。研究问题限定为“输出已暂存归还后仍固定停止,是否存在输入调度、Surface消费者或配置契约遗漏”,保留第8节算法/生命周期研究,不重复泛搜。 -- 2026-09-12 19:57 Asia/Shanghai续修估计:源码与跨项目核对10–15分钟,范围内修正/定向测试10–15分钟,双ABI和APK约10分钟,目标20:35–20:40。guard/保护文件/依赖锁不变;没有基于本日志的新生产修改,v3产物是已失败证据而非可再次交付的修复。 - -#### 跨项目研究与下一候选决定(访问2026-09-12) - -问题:BL/RPU仍送普通HEVC硬件,而EL已由软件解码时,是否应将RPU也留在软件侧,使硬件输入成为纯BL?反假设是硬件输入无关、问题完全在Surface消费者;电视对照必须能区分两者,不能把新方案当成已证实厂商根因。 - -| 来源/固定修订 | 等级与实际代码证据 | 对本任务的影响与限制 | -| --- | --- | --- | -| FFmpeg锁定`177f090e0503b7e013922ca903bde14b1c375f18`,`libavcodec/hevc/hevcdec.c::decode_nal_unit/decode_nal_units`、`mediacodecdec.c::mediacodec_extract_hevc_metadata` | A;常规HEVC解析将62/63类NAL排除在slice解码之外,单独解析RPU;当前MediaCodec wrapper虽解析RPU,仍把原始NAL送硬件 | 证明普通hwaccel与整包MediaCodec wrapper契约不同。仅移除EL不等于硬件输入只含BL;不升级或修改FFmpeg | -| mpv锁定`cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`及本地既有补丁,`demux/dovi_split.c`、`filters/f_enhancement_pair.c::inherit_dovi_from_el` | A;现有EL分支用`el_rpu`;按PTS配对后若BL无RPU,既有函数从EL复制DV映射与亮度字段 | 有可复用的RPU来源,不需另写解析器或取消FEL。必须实测软件EL的逐帧RPU完整性,且保留共享track/codec配置 | -| [Kodi BitstreamConverter](https://github.com/xbmc/xbmc/blob/4aab36cf9a40ce4c4889df6c1eeed3f57307bd1b/xbmc/utils/BitstreamConverter.cpp),`4aab36cf9a40ce4c4889df6c1eeed3f57307bd1b` | A/B;`m_removeDovi`同时剔除UNSPEC62和63,转换P8.1则是另一条分支 | 参考纯HEVC输入边界,不声称Kodi此函数完成FEL重建;本轮不复制GPL实现或修改用户旧模式 | -| [Nova aos-avos PR #7](https://github.com/nova-video-player/aos-avos/pull/7),未合并head `cbd1feb7d4ef2eb178896c7dc20606d961b27c6c`,实际`Source/codec_mediacodec_dovi.c` | A(代码)/C(作者设备报告);BL先提取RPU,再`dovi_strip_dv_nals`,纯码流进MediaCodec,RPU按时间戳单独保留 | 是独立相关实现,支持元数据/硬解分离方向;其双硬解、CPU copy、队列和性能声明不直接移植,也不把未合并PR称成熟通用解 | -| [VLC MediaCodec](https://github.com/videolan/vlc/blob/d543b35ad1d3483cc365630ad5fc7876342f0fab/modules/codec/omxil/mediacodec.c),`d543b35ad1d3483cc365630ad5fc7876342f0fab` | A/B;独立`OutThread`在锁外取输出、flush后区分旧输出归还,输入和输出各有进度 | 调度是可借鉴替代路线,但目前mpv已有独立VO且6帧归还完成;不足以证明必须重写MediaCodec线程,不实施此较大替代 | -| [GStreamer V4L2 bufferpool](https://github.com/GStreamer/gstreamer/blob/46db8717ba577a8f83f31ae984da5c7e689f9728/subprojects/gst-plugins-good/sys/v4l2/gstv4l2bufferpool.c),`46db8717ba577a8f83f31ae984da5c7e689f9728` | A/B;有限硬件池低水位时深拷贝并立即归还源buffer;最新qbuf错误路径也要unref | Linux设备旁证早归还的重要性,支持保留已有GPU暂存,但不能解释日志38全部归还仍停滞;不复制CPU readback路线 | -| [Android MediaCodec官方文档](https://developer.android.com/reference/android/media/MediaCodec#DataProcessing)、[CSD约定](https://developer.android.com/reference/android/media/MediaCodec#CSD) | A;明确设备可能等所有输出/输入归还才前进;CSD与媒体数据各有提交契约 | 保留有限等待/退出保护,不增加超时或硬件队列作为试错。官方并未承诺所有普通HEVC驱动会忽略DV7扩展,厂商具体行为仍待验证 | -| [Media3 #2711](https://github.com/androidx/media/issues/2711)及维护者评论、[#3347](https://github.com/androidx/media/issues/3347);[Nova #1650](https://github.com/nova-video-player/aos-AVP/issues/1650) | B/C/D;MTK上存在HEVC初始化成功但无输出或持续延迟的报告,部分仅DRM/不同系统版本;#3347报告VLC同设备正常,并否定简单调阈值 | 不把芯片名相同当同根因,也不据此降级为“设备不支持硬解”;只保留作为额外诊断线索 | - -以上新增固定修订均为只读参考,不列为升级/cherry-pick候选。原始响应/源文件保存在`/private/tmp/webhtv-p24-reliability.ASCawt/research38-*`。论文类沿用第8节:本轮不改NLQ/色彩/编码算法,算法论文不能决定MediaCodec输入契约,未新增论文作为根因证据。技术文章沿用已读Bigflake回压说明;没有用搜索摘要替代以上实际源码。 - -方案比较:①不改已被日志38否决;②继续扩大池/超时无法解释6/6归还,拒绝;③整体迁移VLC线程或Nova CPU-copy会扩大架构/性能/ABI范围,暂不采用;④窄适配:仅显式FEL+Profile7+MediaCodec BL使用已有`dovi_split=bl`,剔除该私有decoder context/packet中的DV及EL配置,保留原始共享codec/demux和EL/RPU,按现有PTS匹配把软件EL的RPU交给GPU,推荐实施。用户已授权本FEL可靠性任务持续实施,无新模块/依赖/公开API/设置行为扩张。 - -验收:纯BL每个NAL字节/PTS不变;原始共享配置及EL/RPU不变;无FEL/软件BL/P5/P8路径不分配、不扫描、不改配置;实际软件EL解码的RPU按PTS与原包逐字节一致并含FEL NLQ;实际继承函数引用/色彩字段正确;双ABI/ELF/导出/两APK身份通过后交电视验证至少越过固定6帧、完整播放57秒并seek/退出。只有这次实播能判定输入隔离是否解决日志38。新增日志须标明`pure-bl`、`RPU-source=EL`及缺失计数,不每帧刷日志。 - -回滚:保留第9.9节v3补丁/产物证据;本次只改变已有FEL补丁、配套测试/校验marker/构建说明及对应两份libmpv,不重建FFmpeg/libplacebo/JNI。若元数据验证不通过,不打包此候选;电视未过不finish/commit/tag。 - -#### pure-BL实施及主机验证 - -- `video/decode/android_fel_packet.h`仅FEL/硬解MediaCodec/P7准入,改为`dovi_split=bl`;成功初始化后只移除私有AVCodecContext的DOVI及HEVC EL配置;每包用独立ref过滤并清除同类packet配置,输入借用对象、共享轨道及EL分支不变。`vd_lavc`启动/首包/销毁日志明确pure-BL;配对按既有函数从EL继承RPU,统计BL来源/EL来源/缺失,seek reset清零。 -- 实际源码三处变更:上述header、`vd_lavc.c`、`f_enhancement_pair.c`;权威补丁已由固定pre-FEL基线机械再生成,反向`git apply --check --recount`通过。生成脚本/临时结果为`refresh-pure-bl-patch.sh`和`/private/tmp/webhtv-fel-pure-bl-patch.IqB8Gd/`,此前源码/前置补丁不改。 -- `fel-pure-bl-host.log`前6组NODE/解码器控制/VO丢帧/core暂存/图像租约/并发快照ASan/UBSan通过;随后packet测试两处旧helper名称造成编译失败,已修正。选择脚本段的辅助命令无输出、未执行测试,不计为通过;改用显式测试脚本,没有重跑已通过的前6组。 -- 最终定向结果`fel-pure-bl-metadata-tests-final.log`:Annex-B和长度1–4、准入/借用/ref/重试/复位/畸形输入通过;样片前120包移除416个DV NAL,41149779→37572364字节,所有BL载荷、PTS/DTS/duration及非DV side-data一致。新`fel_el_rpu_test.c`在0、3.212、33.575秒三个seek入口各解码120帧,360帧的原始RPU与输入按PTS逐字节一致,均为1080p10软件EL、10bit BL/EL参数、residual开启及linear-DZ NLQ。新`fel_rpu_inherit_test.c`直接编译实际继承函数,验证引用、亮度/色彩、像素range/depth保留及原有BL RPU/禁用元数据优先级;源码准入/构建接线静态检查通过。 -- 主机FFmpeg为63.1.101,Android锁定为63.3.100;测试不是Android硬解/画质/性能实播替代。task guard检查通过,无ADB设备;JNI/FFmpeg/libplacebo尚未变化。为完成真实元数据验证,目标从20:35–20:40顺延至约20:50 Asia/Shanghai,仅继续必要双ABI/打包验证。 - -#### pure-BL最终产物(待电视实播) - -- `fel-pure-bl-arm64-build.log`、`fel-pure-bl-armv7-build.log`:显式`bash -e`串行增量编译成功;`fel-pure-bl-stage.log`、`fel-pure-bl-native-assets.log`:暂存/ELF/锁版本/依赖命名空间通过。 -- `fel-pure-bl-artifacts.log`:两ABI公开mpv导出与基线一致,JNI身份未变;相对guard仍仅4个目标库变化、16个依赖字节不变;相对日志38只两份libmpv不同,其余18库相同。首次APK构建45秒、172任务;但增量ZIP有旧内容空洞:电视145052016字节,实际压缩条目只129652858字节(与旧包内容仅差734字节)。未交付膨胀产物,移动保存在`fel-pure-bl-incremental-tv.apk`/`fel-pure-bl-incremental-mobile.apk`;只再次运行两个package任务生成紧凑APK,无native重编。 -- `fel-pure-bl-compact-apk-build.log`:重打包46秒成功;`fel-pure-bl-compact-artifacts.log`:最终两APK各10库与源资产一致、签名通过。临时统计脚本最初取错zipinfo字段,导致overhead显示0;该辅助数字无效,已修正为第6列并单独重验实际ZIP开销,不重复已通过的签名/逐库验证。 -- 21:04告知超过20:50估计,剩余路线缩为干净重打包/验签/记录,未继续研究或扩展功能。局域网日志可读,但ADB无设备,本轮没有安装/操作电视。locale/CXX32位/deprecation提示为环境或现有构建警告,没有据此改动产品。 - -| 最终产物 | 字节数 | SHA-256 | -| --- | ---: | --- | -| armv7 `libmpv.so` | 14585596 | `049b7872070810cbb54c639292ac7116746ecb419853e66c61a86d98b3f5dced` | -| arm64 `libmpv.so` | 17779360 | `b5cd05c4c68cddc8db4697742f4a78381c4524ea5233ed6d12e43dae2f5ed5c0` | -| 电视 `app-leanback-armeabi_v7a-debug.apk` | 130456903 | `7666ca86315bd107dea68ece38bbaeaed211724142f453ddac7703f2c2fe3955` | -| 手机 `app-mobile-arm64_v8a-debug.apk` | 151588356 | `463543f9a1e044c250d23236f49d12ae98756bb71adc13bd2b2d52846256bf06` | - -最终输入:FEL patch=`563608db9d4e1c08aedab1ebe967bff0ecc5a564ddb2521e4692d53fa4dacff8`;host脚本=`776b1aad53c7e926ccc0a899f02cb4b8845d84749412d386caa4f1daa831d9f0`;native验证脚本=`a3bb7cb1f50c28633acf80694b27ed5281eb48e26bb14f37c50bbd6fd77e65e3`;native lock仍为`a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159`。未提交/tag/push;只有电视新候选的持续播放、画质/吞吐/seek/退出通过,才能关闭可靠性单元。 - -### 9.11 日志39/40:生产者与消费者交接窗口 - -- 日志39:`p-wwfrl3-1`从56.139秒开始,BL输入12包/输出6帧后4003;`p-wwgd7y-2`从8.800秒开始,21:33:45.379配对55帧,到21:34:58.380配对1019帧,73.001秒推进964帧(约13.2fps,片源23.976fps),21:34:59.196 GPU完成1030/1030,累计CPU等待GPU拷贝23741517微秒,最大84306微秒。`mpv-frame-timing out`为VO丢帧计数而非已呈现帧数,末尾615;A/V偏差曾9047毫秒,不能称正常掉帧。 -- 日志40:`p-wwlxdc-7`从34.576秒开始,输入19包/输出13帧、core暂存13/13,core out=0后4003;`p-wwmg05-8`从7.299秒开始,输入12包/输出6帧、暂存6/6,core out=1后4003。两轮GPU均完成且无fence timeout;解码器都是`c2.mtk.hevc.decoder`,GPU是Mali-G57。没有证据证明电视不支持基础层硬解。 - -#### 决策依据(2026-09-12,只读来源修订不变) - -| 来源 | 等级、已读事实 | 本轮影响与限制 | -| --- | --- | --- | -| Android MediaCodec DataProcessing官方文档,见9.10节URL及保存的`research38-android-mediacodec.html`第33813–33817行 | A;设备可能在任何一个输入/输出未归还时停止产生新输出 | 要约束的是调用下一次解码时的真实所有权,不能只看销毁时统计;不证明这台MTK的唯一内部根因 | -| mpv固定`cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`加当前补丁:`f_decoder_wrapper.c::read_frame/decf_process`、`player/video.c::video_output_image`、`vo.c::vo_prepare_fel_frame` | A;worker输出BL后可继续decode,而暂存发生在下游配对/core;异步队列limit=1并不等于上一帧已归还 | 确认有未覆盖的生产者/消费者时序窗口;现有host测试只覆盖core/VO侧,尚未测试这个交接契约 | -| FFmpeg固定`177f090e0503b7e013922ca903bde14b1c375f18`加既有端口保护:`mediacodec_receive_frame`、`av_mediacodec_release_buffer_status` | A;双端口不可用在一次调用内等待并750ms返回错误;render时降低hw_buffer_count,但GPU copy后的AImage归还是另一阶段 | 保留保护,不用增大超时或无限重试掩盖;本轮不修改FFmpeg | -| GStreamer `46db8717ba577a8f83f31ae984da5c7e689f9728`,`gst_v4l2_buffer_pool_process` capture分支(保存源码2035–2117行) | A/B;有限捕获池不足时,在交出下游前深拷贝并归还原buffer | 借鉴“在生产者输出边界脱离硬件池”的原则,不复制Linux/CPU-copy实现;继续复用既有GPU raw-YUV copy | -| VLC `d543b35ad1d3483cc365630ad5fc7876342f0fab`输出线程、Nova未合并PR#7 `cbd1feb7d4ef2eb178896c7dc20606d961b27c6c`输入/拷贝队列 | B/C;独立硬件输出归还与后续显示解耦 | 作为既有跨项目旁证,不照搬整体播放器/CPU readback,不把未合并项目称成熟FEL实现 | -| Media3 #3347、Android/Bigflake回压技术资料,沿用9.10与8节记录 | B/C;同设备硬解迟到可受应用调度影响;改drop阈值不等于修复吞吐 | 本轮不改drop阈值。论文不适合作为这个API所有权缺口的判定依据;不引入新的算法或泛搜论文 | - -比较:不改已重复失败;原样upstream异步队列仍不能约束设备持有;整体移植VLC/Nova或重写FFmpeg超出最小修复;采用WebHTV窄适配,在FEL硬件BL的worker输出边界完成GPU暂存/源归还后才发布帧。EL继续独立软件解码,GPU仍在现有VO线程运行,配对/RPU/显示时序不变。复用原有GPU缓存,不增加第二次像素拷贝。同步仅发生在独立BL worker,不将新的同步等待放到App主线程或mpv core;最坏GPU/驱动挂死仍依赖已有独立恢复机制,不承诺消除系统级挂死。 - -实现要点:使用现有共享AVBuffer租约的一字节原子完成标记,初始为0,仅在GPU fence完成且AImage归还后发布1;VO缓存命中也推进未完成fence,重试有界。暂存发生在EL RPU继承前,因此mapper的FEL raw-YUV存储契约须与逐帧RPU无关,不能先做HDR10转RGB,也不能因后续继承RPU而销毁暂存像素;渲染时恢复实际帧的表示/显示参数。旧模式、EL软件路径和不支持该暂存的mapper维持原路由。 - -验收:生产者不会在未归还上一已输出硬件帧时进入下一decode;真实GPU像素未完成不能提前标记;共享引用/超时/取消/缓存命中/无FEL/软件EL/非MC隔离由定向测试覆盖;实际mapper函数确认RPU继承前后复用缓存且保留10bit、range、裁剪/旋转、HDR/RPU。仅新测试与受影响已有测试重跑,然后双ABI/libmpv导出/ELF及目标APK逐库验证。电视至少重复起播3次、完整播放57秒及seek/退出通过,才可宣称可靠性修复;性能先记录生产者暂存耗时,不能用host模拟宣称电视达到实时。 - -回滚到本轮基线`cbb02fa4c40a2d0b1d04a43d6c5be4265129f98e`对应FEL补丁和两份libmpv;不回滚此前已保存的保护功能。授权沿用用户持续实施FEL可靠性的明确要求,未增加新产品模式、依赖、JNI/API或其他播放器行为。21:52的总目标22:45–22:55;定位阶段超出原20分钟,原因是最终日志不足以证明首次超时的时序,停止可选研究,只走上述可验证的所有权交接路线。 - -#### 本机候选交付记录(2026-09-13,尚未电视验收) - -- `f_decoder_wrapper.c::stage_fel_before_publish()`在独立BL worker的`output_frame:`发布边界调用已有非阻塞`vo_prepare_fel_frame()`,2ms重试、总界限750ms;不在App/core主线程增加同步dispatch等待,超时沿用失败结束流程。`VO_NOTIMPL`保留原路径。`fel_stage_failed`随reset清除。 -- `filters/f_android_fel.h`以共享AVBuffer的一字节原子ready标记表达源归还。`finish_output()`仅在fence成功且AImage归还后置位,`stable_reuse()`缓存命中以零超时推进fence,不能提前释放硬件buffer。`hwdec_reconfig()`将FEL暂存的存储表示固定为raw YUV,显示前恢复实际表示,避免EL RPU继承前后重新建图或误做HDR10到RGB转换;不伪造RPU、不降位深、不增加第二次像素拷贝。 -- 新`WebHTV FEL producer handoff:`日志记录staged/retries/平均与最大耗时/PTS和`source-returned=1 before-publish=1`;`WebHTV FEL decoder cost:`按BL/EL区分send/receive调用数、解出帧数、累计与最大耗时。首帧/每3秒采样;未选FEL不调用新增计时器。复用App现有调试日志通道,电视不依赖ADB。 -- 2026-09-12 22:55已确认串行双ABI构建、stage和native ELF检查成功。恢复后的旧工具cell不可读取,但持久化校验日志完整,未重编。2026-09-13 02:17恢复时Git/guard一致;Gradle首次受缓存锁文件权限阻止,按授权访问现有缓存后构建成功,未清理缓存或改变受保护CXX目录。旧两个生成APK移至证据目录的`before-handoff-{tv,mobile}.apk`保留,避免增量ZIP空洞;没有删除用户文件。 - -| 最小验证 | 结果与证据 | -| --- | --- | -| 实际生产者、GPU完成及缓存命中函数host ASan/UBSan | `producer-test.log`;单可用输出120帧、fence完成→源归还→发布、750ms超时及准入隔离通过 | -| 受影响core/VO/lookahead/lease测试 | `core-test.log`;2/6/10帧前视、有限输出进展、异步retry/timeout/reset、preroll EOF、裁剪和RPU前后缓存复用通过 | -| FEL静态契约/补丁可逆应用及脚本语法 | 通过;`patch-generation.log`及本轮工具记录。Java/JNI未改,保留原JUnit/NODE证据,不重复全量测试 | -| 双ABI构建、stage、ELF | `arm64-build.log`、`armv7-build.log`、`stage.log`、`native-assets.log`通过;沿用第2节固定lock、NDK r29/API24 | -| native边界/公开接口 | `native-boundary.log`;相对`cbb02fa4c40a2d0b1d04a43d6c5be4265129f98e`仅两份libmpv变更,18个依赖及两份JNI不变,公开mpv导出集合一致 | -| Leanback armv7 + Mobile arm64 debug | `apk-build-authorized.log`;一次调用,1m29s通过。首次沙箱缓存权限失败记录在`apk-build.log`,不属于代码失败 | -| 两APK各10份MPV资产/v2签名 | `apk-artifacts.log`及`apk-*-signature.log`通过;ZIP结构/签名开销电视802065字节、手机756710字节,无显著增量空洞 | -| 目标电视重复启动、FEL画质、吞吐、seek/退出 | **未验证**。host模型不模拟真实MTK/GPU调度或像素;此候选不得称可靠性/性能已修复 | - -产物与输入身份: - -| 文件 | 大小(字节) | SHA256 | -| --- | ---: | --- | -| `app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmpv.so` | 17781048 | `3c32fc5e436c61f782232c77259fb7662a5f84b7edd4d5cc3a62327829798fa0` | -| `app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmpv.so` | 14587580 | `812438797193b6c596b989645d01b166410bc75c7a94412a00ea8bc210a2bb60` | -| `app/build/outputs/apk/leanbackArmeabi_v7a/debug/app-leanback-armeabi_v7a-debug.apk` | 130456903 | `26a03ee980a26f900dbd79d6fc0b6c0cc03759f2b91829e52d24df014cae9ef8` | -| `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk` | 151588356 | `6ca4cec6f2c5a5c8b73bd2dc311c1be5fb6266078b49887a6b581f2a3e5dad99` | -| `third_party/patches/mpv-android-fel.patch` | — | `27bba3db80c58e7b9ddc221171d61a9ed76bad070203ff042aa1f9622e527dec` | -| `third_party/mpv-native-lock.json`(未改) | — | `a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159` | - -当前交付仅是用户已批准可靠性修复的测试候选;不finish或创建“验证通过”的commit/tag,不推送。唯一下一步与顶部恢复锚点一致:在目标电视用相同样片验证源归还与下一次decode的顺序、重复启动和持续吞吐。如发布前归还已经生效仍复现,现假设被否决,必须换路线而非继续扩大队列、错误预算或超时。 - -### 9.12 日志41:完成确认与异步释放栅栏 - -本轮基线为`0a82dc13e255524d7c0e4e04c2f51ec9119aec88`;固定四仓依赖仍见第2节,没有新上游合并。用户2026-09-13明确授权继续优化/实现并要求难点跨项目研究,不需逐步确认。当前问题是设备帧的同步与调度,不是重写FEL/NLQ算法。 - -#### 证据、现有实现与设计依据 - -访问日期2026-09-13;原始资料保存在`/private/tmp/webhtv-fel-vo-handoff.81ObkE/`。 - -| 来源、身份 | 等级/实际结论 | 本轮影响与限制 | -| --- | --- | --- | -| 日志41;本地`vo.c::vo_prepare_fel_frame/process_fel_prepare`与`stage_fel_before_publish` | A;mapper可以返回可用纹理而GPU尚未完成,消费者确认后重入,ready快速路径可能遗留同一请求槽;正常交接约90–103ms且GPU CPU-wait约25–26ms/帧 | 修补状态确认,不能仅抬高750ms;总交接时间不等于GPU执行时间 | -| AOSP `frameworks/av@e2f098935447ca4945946de5cb69db843fe3f003` [`NdkImage.h`](https://android.googlesource.com/platform/frameworks/av/+/e2f098935447ca4945946de5cb69db843fe3f003/media/ndk/include/media/NdkImage.h) 的`AImage_deleteAsync` | A;API26支持随image归还传递releaseFenceFd,硬件buffer由栅栏表示可复用时刻,调用后不可再用AImage指针 | 在GPU提交后异步归还,不等于允许未完成GPU时无保护复用。现有接口已动态加载,不新增Android最低版本 | -| AOSP `frameworks/native@4f463a6b1de9198963dc6aff74154a504ba3f8f6` [`GLConsumer.cpp::syncForReleaseLocked`](https://android.googlesource.com/platform/frameworks/native/+/4f463a6b1de9198963dc6aff74154a504ba3f8f6/libs/gui/GLConsumer.cpp) | A/B;系统成熟消费路径使用native fence交回BufferQueue,缺少能力才CPU等待 | 借鉴所有权规则而非复制OpenGL代码,保留fallback | -| Khronos Vulkan-Docs `f84d432d5b8912362f96f581f29bbc4f3c8c7843` [`synchronization.adoc`](https://github.com/KhronosGroup/Vulkan-Docs/blob/f84d432d5b8912362f96f581f29bbc4f3c8c7843/chapters/synchronization.adoc) 的`vkGetSemaphoreFdKHR`/copy transference | A;导出SYNC_FD将fd所有权交应用,copy-transference消耗源semaphore的payload;必须避免未消费signal再次signal、泄漏fd/使用已销毁资源 | 独立source-release semaphore,与libplacebo ready semaphore分离;导出失败停止该mapper的后续异步导出并安全等待,不复用未消费的信号 | -| Khronos/Arm Vulkan-Samples `200b3b21bd1970fc92d887dbac5abf52e0f94dde` [`wait_idle/README.adoc`](https://github.com/KhronosGroup/Vulkan-Samples/blob/200b3b21bd1970fc92d887dbac5abf52e0f94dde/samples/performance/wait_idle/README.adoc) | A/C;Mali实测说明CPU逐帧等GPU会使流水线闲置;应按资源生命周期使用异步fence | 支持减少CPU阻塞的方向,不把示例22%改善或G76结果外推到此G57电视 | -| 当前mpv补丁树`hwdec_aimagereader_vk_direct.c::finish_frame/export_release_fence` | A;既有direct路径已有SYNC_FD能力探测、导出、AImage_deleteAsync和fallback;stable目前仅导入acquire fence | 复用已存在的内部API与设计,新增行为只在显式FEL stable暂存中;不修改default/direct实现 | -| [mpv PR17303](https://github.com/mpv-player/mpv/pull/17303),head `2eb4c8c38cb3e5213bd94e29fc50c2354f2f22db`、merge `731450f883f24bfa0e3441c205477f1c8d85f1ee`,已读描述及维护者/Android用户讨论 | B/C;GPU fence必须与实际清理消费路径配套,否则可出现泄漏;该PR属于OpenGL | 仅作为生命周期风险旁证,不移植、不将其当本Vulkan故障的修复 | -| 论文类别 | 本轮不作为规范依据 | 没有新重建或调度算法要推导;决定由Android/Vulkan所有权规范、实际状态机回归和同设备测量裁决。泛CPU/GPU吞吐论文不能证明特定厂商的fence实现,不额外扩展搜索 | - -现有调用链:独立BL wrapper → 单槽`vo_prepare_fel_frame` → VO线程`preload_dropped_fel_frame` → AImageReader → stable GPU copy → libplacebo独立纹理与EL配对/呈现。`finish_output()`仍拥有提交后的VkFence/input缓存生命周期;提前交回的只能是带释放栅栏的AImage,不能销毁VkImage/VkMemory、复用未完成command buffer或覆盖仍被帧租约引用的输出。FFmpeg纯BL输入与EL/RPU继承、10bit输出、裁剪/HDR、seek/reset和错误预算保持。 - -#### 方案比较、决定及验证 - -- 不改:日志已否决稳定性/实时性,不满足目标。 -- 原样使用上游/当前CPU逐帧同步:保留简单所有权,但阻断流水线;只修槽可消除一个错误来源,不能解决额外GPU等待。 -- WebHTV窄适配(采用):请求只有“安全归还源”才可确认,快速路径不遗留自己的槽;支持SYNC_FD时提交独立release semaphore并立即交还AImage+fd,GPU输入/输出及render ready semaphore继续受原队列约束;无能力或导出失败使用原有界等待。增加源已交还与GPU真正完成的区分、异步归还计数及VO分段耗时,日志不把异步交还冒充GPU完成。 -- 整体更换renderer/另建GPU线程、CPU readback、降低分辨率/位深或丢EL:扩大回归面或违反需求,本轮不选。 - -验收:先让定向测试在旧VO状态机稳定失败,再验证延迟fence、快速命中清槽、无能力、fd导出失败、fd=-1已完成、重复使用/取消/销毁和非FEL隔离;GPU未完成时只能在有效release fence保护下交还源,不能提前复用GPU资源。保留两ABI/ELF/导出、18个未改依赖、APK逐库/签名。真实性/性能仍须同一GIJoe样片至少3次可重复起播、57秒完整播放及seek/退出;吞吐目标接近23.976fps且无持续A/V累积,默认模式无新增GPU/EL工作。不能以host模拟代替真实10bit像素、功耗或电视性能验收。 - -回滚:撤销本guard的FEL补丁、测试/诊断和两份libmpv,恢复上述已保存的用户候选;不改已发布tag,不推送。新增导出沿用既有Vulkan扩展,无新动态依赖、JNI/公开API、许可或包下载能力;不支持的设备继续安全fallback。 - -#### 实施与定向验证 - -- `vo_prepare_fel_frame()`先消费同一请求再判断prepared快速路径,其他帧的快速命中不消费当前请求。`process_fel_prepare()`仅在源已安全归还时确认成功,texture已映射但源仍占用则保留同槽重试;提交结果同时核对generation和lease身份。 -- stable为显式FEL且EXPORTABLE时创建独立source-release semaphore,与libplacebo ready semaphore一起提交;SYNC_FD导出成功后由`AImage_deleteAsync`接管图像/fd,调用后不再使用AImage。原VkFence继续保护input/cache/command buffer生命周期,直到GPU真正完成才标记complete和重用GPU资源。导出失败会禁止该mapper后续source semaphore signal,避免重复signal未消费payload;继续原有界等待。fd=-1成功表示已signal,不误判为导出失败。 -- 原子lease状态区分safe-to-publish与GPU-completed;前者只在实际GPU完成或Android已接管release fence后可见。`producer handoff`增加gpu-complete字段;GPU pool增加async-returns/release-failures;`map cost`分离MediaCodec release、AImage acquire、GPU map的累计CPU墙钟时间,均只对FEL计时。 -- 新host回归先对旧代码运行,按预期在“映射成功但源未归还,应该继续等待”断言失败。修正后定向ASan/UBSan检查实际core/VO和生产者/submit/export/finish/reuse函数:120个CPU fence帧、120个async fence帧、源归还/fd转移先于发布、GPU未完成时保留input/command资源、fd=-1、分配/导出失败、失败后禁止再次signal、重复调用不二次转移fd、两信号独立与非FEL隔离全部通过。证据`targeted.log`;模拟时钟不作为电视性能改善的量化证据。 - -#### 双ABI与APK交付证据(2026-09-13) - -证据根目录:`/private/tmp/webhtv-fel-vo-handoff.81ObkE/`。固定mpv/FFmpeg/libplacebo/mpv-android revision与NDK/API见第2节;没有JNI/依赖升级。源API还核对了同一AOSP revision的`NdkImage.cpp::AImage_deleteAsync/AImage::close`:releaseFenceFd随关闭进入reader释放路径,图像对象此后不可用;调用顺序与既有direct路径一致。 - -| 验证 | 结果 | -| --- | --- | -| 旧代码负例 | `core-before.log`按预期断言失败;原因是mapped但源未归还时过早确认 | -| 改后实际函数ASan/UBSan与C++策略 | `targeted.log`三组通过;含请求/延迟/复用/回退/超时/隔离,不重复无关BSF/Java/JNI测试 | -| 补丁生成/反向应用/静态接线 | `patch-generation.log`、`static-contract.log`通过;修改的两个shell脚本语法通过 | -| arm64、armv7串行增量构建/安装 | `arm64-build.log`、`armv7-build.log`、`stage.log`通过;没有重复完整依赖构建 | -| ELF/SONAME/依赖/新诊断标记 | `native-assets.log`两ABI通过;已有局部变量shadow警告不在改动处,未扩展修复 | -| baseline/candidate native边界与mpv公开接口 | `native-boundary.log`;2份libmpv变化,其他18库及公开mpv导出完全不变 | -| TV32/Mobile64 debug构建 | `apk-build.log`一次调用1m12s通过;复用隔离CXX目录,原35文件受保护 | -| APK内容/签名/结构 | `apk-artifacts.log`两包各10库逐一匹配、v2签名通过;电视ZIP开销799964字节,手机754872字节,无显著增量空洞 | -| 目标电视真实FEL画质、性能、重复启动/seek/退出 | **未执行**;ADB列表为空,不能用host模型代替 | - -| 产物/输入 | 大小(字节) | SHA256 | -| --- | ---: | --- | -| `app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmpv.so` | 17782888 | `c0dbdade8e905d9ee4dadf1abe686d9ac40660471bb6d34a6c5a3104b986dc0c` | -| `app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmpv.so` | 14589660 | `941dc0af9b3cdc902964cea9c1a9fd5bb903ec444cf7c3965cbefe4dccae06bf` | -| `app/build/outputs/apk/leanbackArmeabi_v7a/debug/app-leanback-armeabi_v7a-debug.apk` | 130456903 | `7db281f0024e60f3ce8e451a53b84c41d99eb475fba8b778d1abd21019a11a3d` | -| `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk` | 151588356 | `6854b1c300d928de2f658b020787f6f1868c0b9d4f83ebbf58d00852dbce4bbb` | -| `third_party/patches/mpv-android-fel.patch` | — | `4a5ffb6acdd9764de642c7e17a37883bf382539552a6469c39db8bda58eb0366` | -| `third_party/mpv-native-lock.json`(未改) | — | `a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159` | - -旧两APK保存在本证据目录的`before-async-tv.apk`与`before-async-mobile.apk`,未删除用户文件。同步/资源生命周期核对较估计久,04:29已确认APK构建成功,停止可选检查,仅完成包内身份/签名和记录。当前仅交付待电视验证候选,不将guard结束或需求标为完成;后续唯一动作是顶部所列同设备重复实播,优先看真实异步能力、交接耗时、4003和持续帧率,而非继续盲调队列/超时。 - -### 9.13 日志42:冷初始化误用帧期限与失败EOF空转 - -本节继续同一`P2-4-fel-vo-handoff`原子单元;基线、固定四仓revision及回滚点不变,无依赖/JNI/App接口升级。授权沿用用户持续修复FEL并保护默认功能/性能的要求。 - -#### 已确认事实与可证伪原因 - -日志`/Users/macbookpro/Downloads/webhtv-debug-log (42).txt`的三轮`p-xl7f32-1`、`p-xl7t3k-2`、`p-xl86d0-3`均在首帧`result=0 elapsed-ms=751 staged=0`失败,最终各GPU只提交1次、异步归还1次,未配对出首帧。第一轮`map cost`记录`gpu-map-us=2127383`(CPU墙钟,不是GPU执行时间),超过当前750ms;普通native日志经主线程转发有延迟,不能混用打印顺序反推GPU事件先后。第一轮另有主线程缓存扫描1506ms,只作为调度干扰保留,不扩展修缓存功能。 - -代码`stage_fel_before_publish()`在BL线程内sleep/poll,`vo_prepare_fel_frame()`也从请求入槽开始计750ms;`preload_hwdec_image → hwdec_reconfig → stable_map → create_input → create_outputs/create_pipeline`首次创建资源没有独立阶段,故正常冷初始化也会超时。原host模拟从已建好的资源开始,没有覆盖这个真实缺口。另一处确定错误:`read_frame()`在`fel_stage_failed`后每次被请求都写EOF;`f_async_queue::frame_get_samples()`对信号帧计0,因而max_samples=1挡不住重复EOF,日志队列28481并非真实硬件帧/DPB数。 - -#### 增量最佳实践证据(2026-09-13访问) - -| 来源/固定身份 | 等级/支持的事实 | 本轮适用与限制 | -| --- | --- | --- | -| 日志42与本地上述实际函数;固定mpv `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`加当前补丁 | A;首次资源创建与逐帧交接混计、EOF信号无样本权重 | 用冷初始化/并发消费和EOF一次性回归裁决,不能再只测试已初始化mapper | -| Khronos/Arm [Pipeline Management](https://github.com/KhronosGroup/Vulkan-Samples/blob/200b3b21bd1970fc92d887dbac5abf52e0f94dde/samples/performance/pipeline_cache/README.adoc) | A/C;`vkCreateComputePipelines`会内部编译shader,首次资源准备与稳态渲染是不同工作负载;已有SPIR-V不等于免驱动编译 | 支持独立冷初始化阶段;暂不加入持久化pipeline cache,因为它不能保证第一次启动正确,且不能凭示例数字推断本电视耗时 | -| 同revision的[CPU/GPU同步样例](https://github.com/KhronosGroup/Vulkan-Samples/blob/200b3b21bd1970fc92d887dbac5abf52e0f94dde/samples/performance/wait_idle/README.adoc) | A/C;避免CPU同步等待阻断工作管线 | 保留上节独立release fence,不恢复逐帧CPU等GPU;性能仍须实测 | -| mpv [filters/filter.h](https://github.com/FongMi/mpv/blob/cca559b41ceb0bb7731cf6ef2e1f33276cd30c42/filters/filter.h) async规则、`misc/dispatch.c::mp_dispatch_queue_process`、[`f_decoder_wrapper.c::lavc_process`](https://github.com/FongMi/mpv/blob/cca559b41ceb0bb7731cf6ef2e1f33276cd30c42/filters/f_decoder_wrapper.c) | A;pending数据属于filter状态,等待应回到可处理reset的dispatch;上游用`eof_returned`只传播一次EOF | 复用已有BL线程/dispatch,不新增线程或让复位重入一个仍持有栈帧的process函数;只修FEL失败分支 | -| 9.12的Android/Vulkan所有权规范、AOSP成熟消费者、mpv PR17303讨论 | A/B;异步image归还不解除GPU资源生命周期保护 | 沿用,不重复搜索;generation+lease身份仍防止取消后旧任务写回新任务 | -| 论文/额外泛博客 | 本轮不适用 | 不推导新的调度或FEL重建算法;精确状态机、规范与冷初始化实测足以决定,不以泛基准支持电视实时性结论 | - -原始增量证据目录`/private/tmp/webhtv-fel-startup.hjGizG/`。 - -#### 比较、决定、验收与回滚 - -- 不改:三次实播已否决。 -- 只把所有帧750ms改大:掩盖稳态卡死、延长stop/reset等待,不采用。 -- 无修改上游:正常async filter/EOF设计可借鉴,但上游没有本项目Android硬解源归还前发布的约束,不能删除FEL交接。 -- 采用窄适配:显式标记实际GPU资源冷初始化,只有该阶段使用单独的有界初始化预算(10秒),普通排队/拷贝交接仍750ms;单调阶段标记不能被重复轮询续期。BL pending帧交给wrapper持有,每次process只检查一次,返回已有dispatch处理reset/退出;取消只撤销同一lease的VO请求,不等GPU、不释放GPU仍持有的资源。失败只发一次EOF且停止继续feed。分段输出初始化/资源/pipeline耗时,让下一次电视日志能验证具体慢点。 -- 未选方案:CPU readback/降精度/丢EL/增队列、更换renderer、增加GPU线程或依赖,均不需要且违反当前范围。 - -最小验证:先在旧VO代码稳定重现2.13秒冷初始化被750ms误杀;改后覆盖冷初始化、10秒初始化挂起、750ms稳态停滞、阶段切换不续期、reset/取消/旧generation、pending时不继续decode、失败后大量请求只产生一次EOF和默认模式隔离。继续使用真实函数体及ASan/UBSan;再串行构建两ABI、ELF/导出/18未改库、两APK逐库与签名。原GPU fence/lease/NLQ/10bit、BL/EL配对、core lookahead契约保持。目标电视仍须至少三次成功起播、完整57秒及seek/退出;本机测试不是该验收替代。 - -回滚:同9.12,恢复`0a82dc13e255524d7c0e4e04c2f51ec9119aec88`对应任务源/测试/两份libmpv;已知它也有严重掉帧,不称稳定版本。当前工作未经实播验证,不自动提交/tag成通过状态、不推送。 - -#### 实施与host验证 - -- `f_android_fel.h`新增单调冷初始化阶段;`vo.c::fel_prepare_timed_out`按实际mapper阶段计时,只对冷资源阶段使用10秒,其余仍750ms。`vo_cancel_fel_frame`只取消同一lease;保留generation/身份双核对及原source/GPU就绪语义。 -- `f_decoder_wrapper.c`用`fel_stage_frame`持有待交接帧,`stage_fel_before_publish`单次非阻塞检查、期限由VO统一管理;`dec_thread`回到原dispatch并仅有pending时2ms可中断等待;reset/stop先取消待处理引用,再复位/终止。失败发出一次EOF后不再feed/read,正常路径没有新增定时唤醒。 -- stable mapper只在真实`resources_ready=false`时标记冷初始化,初始化日志分开输出图像资源与pipeline创建时间;不新增GPU工作、不改shader像素算法、依赖或默认策略。 -- `core-before.log`对旧真实VO函数稳定失败在“初始化中并发轮询仍应等待”,不是编译失败。`core-after.log`通过旧core/lookahead/lease/裁剪及新增阶段期限/取消;`producer-after.log`通过120 CPU/120异步fence帧及fd生命周期;第一次该测试失败是fake VO仍提前把未归还源标为成功,按真实VO现有契约修正fake后通过,没有放宽生产契约。`async-after.log`编译真实read/process/dispatch/reset/handoff,覆盖2.13秒冷初始化、待处理期间无额外decode或PTS修正、取消与3万次失败后请求仅一次EOF,ASan/UBSan通过。 -- ADB列表为空;目标电视仍不能自动安装/实播,后续必须通过App调试日志验证。上一APK和本轮待打包产物不能混用。未修改原`app/.cxx/`。 - -#### 2026-09-13本机候选产物与边界 - -09:12(Asia/Shanghai)原估计定位15–20分钟、修复测试20–30分钟、双ABI/APK15–20分钟,目标10:10–10:25;补齐真实filter/dispatch退出边界的回归超出估计,10:29已停止可选研究,仅完成构建核验。首次native编译发现初始化标记误插到同名`find_input`调用的buffer-removed函数,已移动至真正map路径;相关失败保存在`{arm64,armv7}-build-before-fix.log`。上游buildall在此错误下仍能以旧prefix继续stage,新标记检查拦截了旧产物;本轮临时构建编排增加编译错误/新标记检查,最终两ABI确实重新链接后才stage,不修改仓库构建框架。静态接线检查也约束begin/create_input/end必须在同一map函数按顺序出现。原host测试的函数体未受这次插入位置修正影响,不重复跑已通过用例。 - -最终`arm64-build.log`、`armv7-build.log`、`stage.log`、`native-assets.log`及`native-boundary.log`均通过;仅2份libmpv改变,18依赖字节不变,公开libmpv导出一致。保留原有局部变量shadow警告。`patch-generation-final.log`/`static-contract-final.log`通过,两个修改shell脚本语法通过。Java/JNI未修改,不重跑无关单测矩阵。 - -Gradle使用JDK21、原隔离CXX init script与授权缓存,`apk-build.log`记录一次调用4m44s成功(172项,18执行/154缓存),未重试或切换offline。期间工具授权/配置输出等待不能当作native或网络故障。`apk-artifacts.log`核验两包各10个MPV库逐一匹配,两个signature日志v2通过;电视/手机ZIP结构开销分别797680/752635字节,没有增量APK空洞。原日志42两包已移入证据目录`log42-before-tv.apk`/`log42-before-mobile.apk`保留,未删除用户数据。 - -| 产物/输入 | 大小(字节) | SHA256 | -| --- | ---: | --- | -| `app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmpv.so` | 17785112 | `1531f24447944768932207d1809b1cd82e8b5ab9155826e957cb2502ce270291` | -| `app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmpv.so` | 14591980 | `8edf92a75f30dea35b1218cde0005d45f8d44924b4d5e95f7dae62b80b23dd0d` | -| `app/build/outputs/apk/leanbackArmeabi_v7a/debug/app-leanback-armeabi_v7a-debug.apk` | 130456903 | `038d77eb0d694a7c96aa0c17bf3aa23eac7996805702187cdc2de835686489f0` | -| `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk` | 151588356 | `bb0e60a46c9ae913fc9df181bc8334d59fd75de9a66c05f3e3ab17ef82a521f4` | -| `third_party/patches/mpv-android-fel.patch` | — | `20d26942ff703d308da40fd5820813ae482050a3ec3944eabb3e6529135ebc7f` | -| `third_party/mpv-native-lock.json`(未改) | — | `a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159` | - -诊断接线已读`MpvDiagnosticsPolicy::shouldLogNativeImmediately`及`MpvPlayer::logMessage`:新`WebHTV FEL`日志走既有App调试通道,不依赖ADB;普通信息仍可能限流/经主线程延迟,fatal维持豁免。新增`GPU init`耗时是CPU墙钟,不能冒充GPU执行时间。本候选尚未电视验收,不finish/commit/tag、不推送;唯一下一步与顶部一致。 diff --git a/docs/P9-MPV-BLURAY-MENU.md b/docs/P9-MPV-BLURAY-MENU.md deleted file mode 100644 index 760d06a0c7..0000000000 --- a/docs/P9-MPV-BLURAY-MENU.md +++ /dev/null @@ -1,752 +0,0 @@ -# P9 MPV HDMV Blu-ray 菜单 - -## Recovery anchor - -- 当前修复单元(2026-09-11 15:22 Asia/Shanghai):用户对上一轮《夜王》诊断和通用父子菜单触摸方案明确“修复”。guard `P9-MPV-PARENT-MENU-HIT`,基线 `48dfa4d67a433390e9959b934b87331a78f11436`,保护预存 `app/.cxx/` 35个文件。只改 libbluray 菜单输入补丁、routing C/脚本测试、双ABI `libmpv.so`、本文与索引;`build/mpv-native` 为派生源码,不改 FFmpeg/JNI/Java/网络/解码策略。 -- 已完成证据:9月10日手机实际加载最新库;`/tmp/p9-nightking-menu-20260910.f86HLd/navigation-evidence.log` 记录设置页3的主栏触摸 `hit=0`,字幕按钮仍正常;19:22:52由按钮8向下至自动按钮20,作者指令从页3返回页1/按钮2。已有适配必须先命中当前子页按钮,遗漏无当前可选对象的主栏。 -- 本轮接受标准:实际访问父页后,子页没有可命中主栏按钮也能沿可验证作者返回图关闭/切换;真实当前按钮优先,不扫描未访问父页、不执行未验证VM写寄存器/播放操作;新IG/初始化/恢复清理历史,禁用/歧义/动画/超时/新输入安全退出。先定向源码测试,再双ABI构建/ELF/包内一致性,手机《夜王》及已验收两片回归。 -- 当前状态(2026-09-11用户确认):有界已访问父菜单记录、当前页未命中路由及原入口关闭/其它入口切换已实现。用户明确“我测试可以了,打个tag”,接受其已测试场景并关闭可选验证;不把此确认扩大为全部原盘或完整电视回归已通过。源码抽取测试(ASan/UBSan、原有副本/背景菜单、8层历史边界、生命周期接线)通过,双ABI libbluray/MPV构建与ELF校验通过,其余18个原生库哈希不变;两个Debug包构建1m45s成功且包内libmpv逐字节一致。证据 `/tmp/p9-parent-menu-20260911.q9clO3/`。 -- 产物:arm64 libmpv `df70eb842ff43bbe70bce56a9ec9dd05fd508511fffa8e1366ad5009135886c7`;armv7 libmpv `07cf1995f2733118b61333bc53b27d47cd352d152abe774a02fd1aa9cb6e75b5`;输入补丁 `260eac3a32d89b828d19b2aee82941b2a3282d9810773394fd9395670a55695a`。手机APK `d878e29dcd9a4914bc411c01ccbbe2da19ada9e4bab0037fa46a09ad81f4141d`;电视APK `c70c0633d48c81bc6959f7ab29b2db76b7313cf8211223c01b3712641ab5b8a2`。 -- 构建偏差已处理:并行MPV构建共享meson依赖路径导致arm64首次链接错用armv7 iconv,改为串行重链arm64成功;电视Gradle新生成34个不在scope的CMake缓存,已移至证据目录 `generated-cxx-armv7` / `generated-cxx-armv7-tools`,原有35个文件未改。未升级依赖或修改构建策略源码。 -- 唯一下一动作:按用户确认用当前guard原子提交并立即创建本地注释恢复tag,不push、不追加构建或设备取证。 - -### 2026-09-11 《夜王》父菜单未命中修复决策(已批准) - -- 问题:不是新版库遗漏安装、网络等待或旧 `button_effect_running` 活性缺陷。鼠标输入已到GC,但子页只能命中实际可选对象;旧 `_mouse_move` 返回0后 MPV 正确拒绝激活,`_request_authored_return` 无法进入。已有零指令副本/合并背景适配保留。 -- A级源/访问2026-09-11:固定 libbluray1.4.1(源包 SHA-256 `76b5dc40097f28dca4ebb009c98ed51321b2927453f75cc72cf74acd09b9f449`)`graphics_controller.c::_mouse_move/_user_input/_select_page/_gc_reset/gc_decode_ts/gc_run`,以及公开 `keys.h`/`bluray.h`;代码是HDMV按钮状态和VM调用的直接证据。新状态仅在同一GC互斥所有者内操作,不改变公开API/结构体ABI。 -- 独立A级实验:上述手机日志和前后截图;菜单方向返回成功,不能以一次输入接受值代替切页成功。原版父页打开设置的 `SET_BUTTON_PAGE` 与子页返回页1共同证明真实父子关系。 -- B级成熟项目:复核本地缓存 Kodi `DVDInputStreamBluray.cpp::UserInput/MouseMove/MouseClick/OnMenu`,revision `b2637ca499afe69f9d15c928809ffd6c42144250`,来源 `https://github.com/xbmc/xbmc/blob/b2637ca499afe69f9d15c928809ffd6c42144250/xbmc/cores/VideoPlayer/DVDInputStreams/DVDInputStreamBluray.cpp`;仍由libbluray执行导航,不能把Kodi直接调用等同本项目触摸适配已齐全。原P9 PR/官方文档/论坛研究继续适用,不新增上游提交;此轮是已有结构识别缺口,没有需要论文或新性能博客裁决的算法/性能主张,不重复网上检索。 -- 方案比较:不改无法满足关闭/切换;原样Kodi鼠标API仍只命中当前页;全盘搜索同坐标或无条件ROOT会误跳/重播且丢状态。采用已批准窄适配:只记录真实用户激活引起的页切换和当时启用的父按钮,历史有界;当前页未命中时匹配最近实际父页,再沿既有可验证无副作用返回图执行作者命令,回父页后重新检查目标启用状态,原入口只关闭、其它入口再激活。 -- 生命周期/风险:IG替换、菜单初始化/恢复、非关联跳转清记录;不保留已释放对象指针。方向和当前真实按钮优先;不改变BD-J静默回退、UO策略、媒体读数、菜单动画或直出。不同光盘作者脚本不能提前承诺全兼容;失败保留原有返回/鼠标结果,不伪造成功。无帧循环新增工作,只在菜单激活/换页/点击时做有界状态操作;包体只有两份MPV静态链接代码变化。 -- 验证/回滚:新增无子页可选对象的父栏、同入口关闭/其它入口切换、键盘返回、实际访问证据缺失/禁用/歧义/多级/过期/新IG/动画与新输入测试,保留《豪斯医生》背景及《倩女幽魂》副本夹具。双ABI只重编libbluray/重链mpv,保留FFmpeg和JNI哈希。设备验收尚未完成不宣称修好;通过后原子提交/tag,不push。回滚本单元源码/两库至基线提交,已验收P9能力不撤回。 - -- 当前修复单元(2026-09-10 13:18 Asia/Shanghai):用户在手机菜单卡住与电视黑底/入口遗漏/片头循环诊断后明确“修复”。guard `P9-MPV-MENU-LIVENESS-TV`;基线 `1ec569658157d1a9323b5c2ef00cb3468b876fca`,保护既有 `app/.cxx/` 35个文件。以下此前已验收状态是历史,不代表本次缺陷已修复。 -- 本轮目标:暂停时HDMV菜单仍完成动画并可切换/关闭;TV底栏有原盘菜单入口;同次播放直出失败不循环重入;修正已捕获的MediaCodec flush/旧帧释放竞态,保留硬解、直出及既有作者菜单返回语义。仅MPV链及对应App接线,不扩展Exo/BD-J/网络缓存。 -- 当前状态(2026-09-10 构建完成):三处App/菜单缺陷及MediaCodec竞态修复代码已完成;菜单活性/原输入测试、480组并发释放/flush/close测试、19项Java输出策略测试通过。完整补丁链prepare、双ABI FFmpeg/MPV实际编译链接、ELF/资产校验通过;两个Debug包2m15s构建成功且包内三库逐项SHA一致。手机在构建期间断开,ADB设备列表持续为空,**未安装本候选、未完成真机验收,未提交/tag**。不能宣称两片实机已修好。原定时长超出后已停止研究/可选检查,当前剩余门槛是设备连接和实机。具体产物/命令/风险见下方本轮验证记录。 -- 当前文件:新`mpv-discnav-poll.patch`、`mpv-mediacodec-embed-reset.patch`、`ffmpeg-mediacodec-output-serialization.patch`;TV Activity/layout、PlayerManager/MpvAutoOutputPolicy及Java测试;native构建/验证脚本、liveness/serialization源码抽取测试。临时派生源码只在`build/mpv-native`,版本锁/JNI/Exo不变。 -- 唯一下一动作:手机重新连接后,用OEM安装助手安装下列Mobile arm64 Debug包,直接验证《倩女幽魂》暂停菜单切换;随后继续TV armv7两片跳转/背景回归,验收通过才guard finish提交/tag。不要重新研究或重跑已通过的构建。 - -- 当前优化单元(2026-09-09 23:11 Asia/Shanghai):用户在两片日志诊断后明确“优化”,批准原始 ISO 字节层渐进读取/需求优先;guard `P9-MPV-ISO-PROGRESSIVE`,基线 `310f8feef5c0a05e5dae6c0a063453113214ea2c`,已验收菜单恢复 tag `recovery/P9-MPV-HOUSE-MENU-RETURN/20260909193413-310f8feef5c0`。下列早期状态属于历史阶段,以本文末尾“原盘渐进读取优化”记录为准。 -- 本单元范围:`RemoteIsoSource.java`、`HttpRangeIsoSource.java`、`IsoPlaybackSession.java`、新增 `ProgressiveIsoPageCache.java`、对应 HTTP/渐进缓存测试及本文档;保护既有 `app/.cxx/` 35个文件。保持原生库、菜单交互、解码/渲染、BD-J静默回退和默认关闭不变。 -- 当前状态(2026-09-10用户确认):渐进缓存/HTTP续读/取消已实现,34项定向测试通过(新缓存13、原缓存13、HTTP8);Mobile debug和Leanback Java编译通过,构建2m16s。APK `fc0b397af908989bf7e4ed6cefac6bdea491770d900af48d402e618a45543885` 已由OEM安装助手成功安装并启动。用户明确“速度好像改善了,打个tag;稍后有新需求”,按此接受当前观察场景并立即关闭可选验证;没有完成严格三轮同源A/B,不宣称达到30%或其他量化收益。原生库和已验收菜单交互未修改。 -- 当前唯一下一动作:使用本guard原子提交并创建本地注释恢复tag,不push、不追加取证或构建;之后等待用户新需求。 - -- 目标:在 WebHTV 的 MPV ISO 播放路径实现 HDMV Blu-ray 菜单画面、按钮高亮、方向键、确认、返回、Popup、章节/设置/特别收录跳转、discontinuity 和 still frame。 -- 接受标准:HDMV 菜单从远程/本地 Range ISO 入口可达并可操作;菜单跳转后音视频轨和时间线重建;普通 Blu-ray 最长标题、DVD、非 ISO、双 Surface OSD、硬解/软解和现有 Range 行为不回退。 -- BD-J 产品边界:不启动 BD-J runtime,不处理 BD-J ARGB 菜单,不新增提示;遇到 BD-J 菜单时继续按现状选择最长标题播放。 -- 用户决定:2026-09-06 明确“实现;遇到 BD-J 菜单时不用提示,就不处理菜单,跟现在一样即可”。 -- Lane / task guard:`upstream` / `P9-MPV-HOUSE-MENU-RETURN`。 -- 分支/HEAD:`feature-menu` / `4ded105fa7ea0e7aacd19a256919bd44d643ce96`;上一阶段菜单修复 `5b774430f155039d843f2e6a79aefc7df9f918cc`,恢复tag `recovery/P9-MPV-BLURAY-MENU-INPUT/20260909164438-5b774430f155`;其后仅独立移除docs忽略规则。 -- 当前修复保护路径:未跟踪 `app/.cxx/`;延续现有任务源码修改,不接管其它工作。 -- MPV 固定基线:`cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`;构建框架 `99a60ad2141d5ace94453590903c2c6b9a0a2443`;libbluray 1.4.1 tarball SHA-256 `76b5dc40097f28dca4ebb009c98ed51321b2927453f75cc72cf74acd09b9f449`。 -- 当前状态:Checkpoint 21 的历史访问记录、有界双路原始页预读与导航停顿计数已实现,37项定向Java测试、Mobile debug打包与Leanback Java编译通过;安装脚本确认包指纹变化并启动App。2026-09-08 17:54用户再次明确要求“先打个tag”,本次保存已通过上述检查的阶段性恢复点,**不等于P9全部验收通过**。新包真机开场/计数/历史效果和预览选集页退出仍待验证;这轮未改原生库,也未宣称菜单退出已修复。 -- 本轮补充修复:ISO 探测返回 null 的异常、Leanback 抢先消费方向键、手机触屏/菜单控制入口、HDMV Top Menu 判别及菜单启动失败回退。设置默认关闭,DVD 不增加菜单能力。 -- 回滚锚点:`831b70433e3dbdfd6f119c6036a3c8cf22d85ae4` / 本地 tag `recovery/P9-MPV-BLURAY-MENU-FIX/20260908-071735`;tag 仅包含已提交基线,不包含当前修复。回滚需成套恢复 MPV patch、JNI、App 接线和双 ABI assets。 -- 本轮实证:手机已连接,trace包记录《倩女幽魂》章节页点击设置时按钮 #29 被激活但没有VM切页指令;从章节有效选择经向下,光盘的不可见自动按钮 #33 执行 `SET_BUTTON_PAGE` 返回主页面。**尚未读取按钮29的实际num_nav_cmds,不得仅据缺少执行日志断言它是装饰按钮或一定零指令**。当前候选未验收、未commit/tag,`app/.cxx/`仍保护。 -- 最新用户结论(2026-09-09 10:17):用户再次确认“几个需求全都没解决”,要求增加详细debug日志;上一轮仅《倩女幽魂》一次 `prev` 成功截图不代表功能通过。本任务仍未验收,未commit/tag。 -- 已部署非trace候选:双ABI构建/资源验证/手机debug打包安装通过;arm64 libmpv SHA-256 `81b2816bb1212680a980b8b4fa4b7a877c786e63b39daa54b311ad17249ca4ea`,armv7 `2633e8decd8b8b2d7f3238fd11155a9af1911b83974af2d4afb12bfd51bad98a`,APK `dd83551c41f82a8bd8c055aa039b78879a43d7fc8396b337e74e5e9de39f7b50`。临时写文件trace已移除。设备曾断开,短片段验证未完成。 -- 诊断候选已完成:新增仅debug的Java命令/耗时/1秒状态快照;MPV按线程上下文转发现有HDMV/GC日志(每API96行、每线程每秒600行上限),输入/事件/pump记录代数及状态。过滤/限额/线程隔离/关闭上下文的host测试、输入测试、still/clip边界测试均通过;双ABI构建/ELF检查、Mobile debug和Leanback Java编译通过。2026-09-09新候选APK SHA-256 `2b3d681103d9ddc65aec7e366ac2908d7f1267caa9707ce17cbcd1ace2f24ae0`。 -- 设备诊断已经完成:安装成功且手机加载arm64指纹与本次构建一致。`debug-chain-setup-app.log`含完整章节成功/设置失败链路;`debug-chain-trailer-menu.log`含短片TITLE2→菜单TITLE0→光盘主动恢复第12页的指令,`debug-chain-menu-return.log`含原章节子页调用ROOT后进入第2页。底栏触发时横竖屏变化导致控件未命中,本项不算通过;菜单键结果不能代替底栏验收。 -- 最新授权(2026-09-09 14:33恢复):用户明确“继续,解决bug,直到实现我之前提的所有需求”,批准继续在libbluray内部定位和修复。当前guard精确新增 `third_party/mpv-player-jni/patches/libbluray-hdmv-input.patch`;不变更受保护的 `app/.cxx/`,不改解码/渲染/网络。14:35 ADB未发现设备,本地工作继续,真机验收待重新连接。 -- 最新决定性证据:同一ISO远程Range仍有效;用固定libbluray 1.4.1在host只读取约12MiB菜单所需数据,复现章节→设置失败。`gc-boundary-host-session.log`证明第4页按钮29实际 `cmds=0, auto=0, up=down=left=right=29`,`_user_input`前后命令数均0,GC不向VM提交;主页面2章节按钮1为cmds=1,GC→VM接收返回0且正常切页。不是VM拒绝或渲染覆盖,是真正没有动作的主导航副本被触摸直接选中,使方向也困在自身。 -- 已实现未真机验收(2026-09-09 16:23):`libbluray-hdmv-input.patch`加入可达作者返回图、无动作副本不抢焦点、限时/限步的跨VM菜单切换、再次点击收起、HDMV Back及显式ROOT后沿作者路径退出恢复子页。设置页还含按PSR选择按钮的纯菜单auto节点,因此逐段执行原VM并重算路径,不直接跳过。MPV优先尝试作者Back,缓存包期间以最多约30Hz推进可见菜单动画,普通播放不新增轮询;新增键只用于HDMV,不改BD-J。 -- 本地验证:`author-route-final-host.log`记录同ISO章节→设置(4→2→9→10)、设置再次点击经36→37→42的作者auto回主页面2、章节Back、特别收录预告TITLE2/PLAYLIST2无菜单播放后一次ROOT回TITLE0、作者恢复12后自动返回主页面2。`test_bluray_menu_routing.sh`通过真实源码抽取测试(副本不困焦点、切换/收起、多段VM、禁用/可见auto/未知寄存器/歧义拒绝、超时、新输入取消、动画等待);`test_disc_navigation_input.sh`通过更新后的菜单命令/缓存30Hz节流测试。未等同于解码渲染或手机底栏验收。 -- 设备/产物:16:17仍无ADB设备;手机仍是之前诊断包,新作者路径候选尚未双ABI打包安装。保护 `app/.cxx/`,现有guard不重启、不commit/tag。已从保存日志定位豪斯医生同资源,但尚未取其菜单定义。 -- 阶段验收/保存授权(2026-09-09):双ABI增量构建、ELF检查、Mobile debug APK构建通过,安装助手已在手机10CF6H1D2L0009S完成安装和启动。用户实测明确“我播放倩女幽魂是正常的”,并要求“先打个tag”;按此确认关闭该场景的可选验证,保存当前已验证的阶段性源码/补丁/双ABI产物。**用户同时确认豪斯医生菜单按钮仍不能关闭弹窗/切换,因此本恢复点不是P9全部完成,不代表豪斯医生通过。** -- 豪斯医生真机证据(17:01):语言选择英语后主页面1,点击选集按钮2(cmds=4)进入预览页4。点击底部声音图标窗口(936,965)→盘(696,965),实际命中一整条区域的按钮7(pos=0,792,cmds=1),唯一指令为NOP(group=0/sub=0/op=0/dst=src=0)。该按钮不是零指令自环副本,因此前阶段算法不处理;四向均引用按钮0。证据 `house-chapter-popup.png/.log`、`house-audio-switch-failed.log`。手机仍加载前阶段已tag版本。 -- 17:35恢复后设备状态:ADB再次为空;豪斯医生旧远端URL的单次Range检查返回412,不能继续用过期URL反复尝试。新guard已补 `_button_has_no_action`,严格识别零命令/纯NOP(不把跳转、修改寄存器、缺失数组当无动作),并对这类大背景按钮输出当前页各按钮与有界指令摘要;现有自环副本修复保持通过,未猜测豪斯医生返回节点。真实源码抽取路由测试通过。改动在libbluray独立patch、对应测试及文档,`app/.cxx/`保护;未commit/tag。 -- 18:22真机决定性证据:恢复ADB服务后连接10CF6H1D2L0009S,保存16:44豪斯医生有效输入及当前会话原盘缓存,安装NOP诊断包成功。`house-nop-audio-click.log`读取预览页4按钮6:不可见auto,四条指令为MOVE立即数到GPR、MOVE立即数到GPR、寄存器形式SET_BUTTON_PAGE、NOP,返回页1且选择按钮2;底部按钮7是覆盖五个图标的整条NOP背景,方向均指向预览按钮0。不是直接切页或逐个图标副本;旧识别条件漏掉这两层结构。 -- 本轮验收与保存授权(2026-09-09):用户真机明确确认“可以了,打个tag”,据此关闭豪斯医生本轮菜单修复的可选验证,立即原子提交并创建本地恢复tag,不push;不把此确认扩展成所有原盘/全部P9场景均通过。已完成真实源码抽取路由测试(含NOP合并背景、裁切边界、常量GPR返回、收起/切换及拒绝型案例);同盘host证据 `house-background-final-host.log`确认选集→声音、再次声音收起、选集→字幕、Back;双ABI构建、ELF校验、Mobile debug打包、补丁反向应用检查及安装助手通过。手机10CF6H1D2L0009S已覆盖安装修复包并重新打开豪斯医生;保护`app/.cxx/`,未改解码、渲染、网络或BD-J边界。 -- 最终产物SHA-256:独立libbluray补丁 `8a6087d5404b1eb4535d91d03a68739efccf8fdea83f69c33f48d935afc16076`;arm64 libmpv `eddfed3df1e1cea3b263a42778088ba3cf70f94dde6f08839357957084723948`;armv7 libmpv `371303839f5a5bd0a3724bd4fb84449d4ebae62a38cda111682c694598b86370`;已安装APK `13a7dfb6faf4567cbd95a90096c78ab8157d0e98be9903f79b8c14640cf55ce7`。证据目录 `/tmp/p9-menu-device-20260907.Vq7vyl/`,本guard以源码/测试/两ABI/本文档成套提交;回滚锚点为本guard基线 `4ded105fa7ea0e7aacd19a256919bd44d643ce96`。 -- 唯一下一动作:交付本次原子提交与本地恢复tag;仅在后续新反馈下开启下一修复单元,不追加测试。 - -## 1. 授权、范围与排除项 - -这是已批准的实施阶段,不再等待设计授权。 - -### 2026-09-10 菜单活性与电视直出修复设计 - -- 用户授权:在上一轮明确说明三处缺陷、保留硬解/直出及初始flush异常验证边界后,用户明确“修复”。本轮沿用P9唯一文档;不重复此前HDMV总体调研,不引入新上游版本或合并候选。 -- 现场证据:`/tmp/webhtv-tv32-disc-menu-20260910.OTLNXh/diagnosis.md`及原始日志;Mobile补充位于`/tmp/webhtv-mobile-disc-freeze-20260910.R0dn4D/`。新验证目录`/tmp/p9-menu-liveness-20260910.sMRWiR/`。日志有私人媒体URL,不进入仓库或对外摘录。 -- Mobile `p-102igow-2`:OpenGL/GPU,12:48:51 noisy通知后暂停于7629ms;52.772最后一次成功切至page9,56.747起持续`button_effect_running`/`-12`,至13:05位置不变。首次49.940短暂动画拒绝早于暂停,不能倒置因果;无ANR、输入送达。TV `p-101lf66-3`/`p-101ok54-4`:MediaCodec在flush期间releaseOutputBuffer异常,回退GPU后8秒左右重新自动进入直出,再次重开ISO,repeat=false。 -- 源码/提交身份(A级):MPV `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` + 当前P9/渲染补丁;FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18` + 本地MediaCodec补丁;mpv-android框架 `99a60ad2141d5ace94453590903c2c6b9a0a2443`;libbluray1.4.1源包SHA-256 `76b5dc40097f28dca4ebb009c98ed51321b2927453f75cc72cf74acd09b9f449`。全部保留基线,仅窄适配,不升级/新增公开JNI API。 -- 确切调用链:`discnav.c::disc_nav_update`暂停时虽每50ms唤醒core,但仅在EOF/still请求一次媒体读包;`demux.c::read_packet`在导航中禁止预读,`nav_pump`只一次;`demux_disc.c::d_read_packet`才调用`STREAM_CTRL_NAV_POLL`;libbluray `_read_ext`的零字节读推进`GC_CTRL_NOP`/单调时钟动画。输入拒绝不调用demux_drive_nav,缓存状态查询不执行VM。33ms条件检查不是调度器。 -- FFmpeg证据:`av_mediacodec_release_buffer_status`、`av_mediacodec_render_buffer_at_time`和`mediacodec_buffer_release`对serial检查和平台释放不在同一临界区;`mediacodec_dec_flush_codec`可在检查后并发flush;close在count先递减时也可能提前stop。引用计数已保证ctx活到最后buffer,适合把锁生命周期与ctx统一。MPV `VOCTRL_RESET`需清待提交帧和旧PTS,不能仅靠忽略错误。 -- 官方契约(A级,2026-09-10经用户代理读取):[Android 15 MediaCodec.java](https://android.googlesource.com/platform/frameworks/base/+/android-15.0.0_r1/media/java/android/media/MediaCodec.java),`flush()`文档明确已发出的buffer index失效、ownership归还codec,不能继续释放旧index;快照`MediaCodec.java`。libbluray固定`bluray.h`及`bluray.c::_read_ext`零字节poll为事件/VM已有契约。 -- 成熟项目/维护讨论(B级):沿用前述mpv PR #18080及已审阅菜单链,不重新抓取不变PR;复核缓存的[Kodi Bluray input](https://github.com/xbmc/xbmc/blob/b2637ca499afe69f9d15c928809ffd6c42144250/xbmc/cores/VideoPlayer/DVDInputStreams/DVDInputStreamBluray.cpp) `Read`/`BD_EVENT_IDLE`,由libbluray执行作者VM且保持导航循环。只作为分层对照,不能据此声称Kodi在所有暂停场景都有独立动画线程。此前VLC/Kodi输入及论坛类别研究仍有效。此次为已有状态机调度/互斥缺口,无新算法/性能提升主张;论文或重复博客不会决定下一动作,采用真实源码夹具与同设备复现作为独立验证。 -- 方案比较:不改继续卡住/循环;原样上游或强制预读仍把动画耦合到媒体并可能提前推进盘VM;全局关闭直出牺牲原有能力且不能修Mobile菜单;窄适配采用已有core导航节拍请求**仅零字节事件处理**,由同一demux线程在媒体需求之外执行,不增加每tick媒体包。成功输入保留必要的一次读包;拒绝输入仍可请求事件泵,不伪造成功、不清作者animation guard、不取消系统暂停。TV入口复用现有共享openDiscMenu和TV焦点样式。直出失败标记只在新item/明确runtime重置时清除,自动策略不可重入失败模式。MediaCodec用每context互斥串行化serial检查/释放/flush/stop,最后引用释放时销毁,MPV reset清待交付帧。 -- 兼容/性能:普通非导航文件不请求事件泵;20–30Hz仅可见HDMV菜单/既有idle场景,绝不busy-loop、主动恢复音频或打开预读。MediaCodec每帧增加短临界区,flush期间阻止旧索引调用;必须实测输出/CPU/前后台,无量化改善承诺。BD-J静默退回、开关默认关、原盘作者返回程序、双Surface和字幕/硬解选择不改变。 -- 封闭路径:guard所列TV Activity/layout、PlayerManager/MpvAutoOutputPolicy及对应Java测试;`third_party/patches`、`third_party/mpv-player-jni/patches/tests`中的本次相关文件;native构建/验证脚本与构建说明;双ABI MPV assets;本文与索引。`build/mpv-native`和临时目录仅派生源码/测试证据,不提交,保护`app/.cxx/`。不改库锁版本、Exo、IJK、网络、BD-J。 -- 验收:先源码抽取测试证明无媒体需求仍poll、拒绝输入可恢复、取消/非导航不poll;MediaCodec并发释放/flush/close夹具与旧serial/重复release;Java同项失败不可重入、新项恢复资格。双ABI重新编译受影响组件,完整ELF/命名空间检查,Mobile arm64与Leanback armv7 Debug。真机《倩女幽魂》《豪斯医生》菜单打开/关闭/切换、暂停切页与恢复、短片/正片返回;TV背景/底栏/遥控焦点、选正片持续播放和前后台,无自动片头循环。无候选通过记录前不宣称修复/提交完成。 -- 回滚:本轮源码、补丁、构建引用、资产、App接线原子回滚至 `1ec569658157d1a9323b5c2ef00cb3468b876fca`,保留既有P9功能;只创建本地恢复tag,不push。当前风险为设备flush实现及节目切换时的独立packet overflow,若仍复现必须沿具体日志继续,不以回退循环修复替代黑底验收。 - -### 2026-09-10 本轮实现与验证记录(待设备验收) - -- 状态:本轮源码、测试、双ABI原生库和两个Debug包已完成;2026-09-10 15:46 Asia/Shanghai恢复检查时,分支/HEAD与guard仍一致,`adb devices -l`为空。没有安装本候选,没有候选真机通过记录;guard保持active,未commit/tag。已停止额外研究和重复构建,下一步依赖手机重新连接并解锁。 -- 实现:`mpv-discnav-poll.patch`在同一demux owner增加独立于媒体读包的事件处理请求,暂停/输入被动画暂拒时仍推进HDMV事件,不解除暂停、不打开预读;`ffmpeg-mediacodec-output-serialization.patch`串行化serial检查、输出释放、flush与stop;`mpv-mediacodec-embed-reset.patch`在VO reset清旧待交付帧/时序。TV补底栏入口、菜单状态监听及控制栏焦点保护;同item直出失败记忆跨rebuild保留,新item或显式设置变更才恢复资格。既有硬解、直出、BD-J静默回退和默认关闭保持。 -- 测试:`test_disc_navigation_liveness.sh`与原`test_disc_navigation_input.sh`通过,覆盖暂停/队列已满而无媒体读取的事件推进、请求合并、取消/blocked、非导航/DVD、EOF/still及失败输入唤醒;`test_mediacodec_output_serialization.sh`通过480组并发释放/flush/close、timed/immediate/final、旧serial/重复释放及错误解锁,日志`mediacodec-test.log`。这些是源码抽取夹具,不替代Android平台codec/菜单实测。 -- Java:`:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.player.mpv.MpvAutoOutputPolicyTest :app:compileLeanbackArmeabi_v7aDebugJavaWithJavac`通过,19项策略测试,`java-test-compile.log`记录`BUILD SUCCESSFUL in 3m 9s`;相关shell语法检查通过。首次Gradle缓存权限失败已按sandbox权限问题处理;未降低测试门槛。 -- 原生:保留上节固定源/版本锁,NDK `29.0.14206865`、API24;完整补丁链prepare成功。首次prepare遇到旧派生`player/discnav.c`残留,已移至证据目录`candidate-discnav-before-prepare.c`保留,再执行完整prepare成功(`native-prepare-resumed.log`)。在buildscripts以`WEBHTV_ANDROID_API_LEVEL=24 WEBHTV_MPV_LIBCURL=enabled cores=8 LC_ALL=C LC_CTYPE=C`分别运行`bash buildall.sh -n --arch arm64 ffmpeg`、`arm64 mpv`、`armv7l ffmpeg`、`armv7l mpv`,四份`native--.log`均记录实际编译/链接成功。三新补丁在最终源码反向apply检查通过;JNI/API不变,`libplayer.so`未重编。 -- 打包:`scripts/build_mpv_native.sh --stage-only --abi all --install`成功,`bash scripts/verify_mpv_native_assets.sh --require-elf`通过(`native-stage.log`、`native-verify.log`);Mobile arm64与Leanback armv7 Debug同次构建成功,`apk-build.log`记录2m15s。两个APK内的`libmpv.so`、`libmvcodec.so`、`libplayer.so`分别解包SHA比对均与下表资产一致,不能把包内一致性表述成实机通过。 -- 32位伴随产物:`libmvformat.so`也因本次FFmpeg链接改变,前后同为4,231,080 bytes;日志明确重编`http.o`,当前`http.c`相对上游仍只有原有代理Range offset的8行适配,本轮未修改HTTP补丁。字符串对比可见编译文件路径由`src/libavformat/http.c`变成`./src/libavformat/http.c`,伴随布局/地址变化;没有证明每个二进制差异都只是元数据,不作此宣称。保留同一锁定源码/补丁完整重编的库组,不手工换回旧库;原库`baseline-libmvformat-armv7.so`与对比`libmvformat-armv7-string-diff.txt`保存在证据目录,设备回归需覆盖实际原盘读取。 -- 交接安全检查:checkpoint脚本通过(0 error;唯一warning为本任务已声明的原生资产/补丁变更)。首次guard检查发现32位APK构建新增34个未跟踪CMake文件不在源码范围内,未发现35个受保护初始文件内容漂移。核对`initial-dirty`及构建日志后,只把新增`app/.cxx/Debug/p104q5y4/armeabi-v7a`目录(含生成日志)和`tools/mobileArmeabi_v7aDebug/armeabi-v7a/compile_commands.json`移至证据目录的`cxx-generated-armv7`和`cxx-generated-armv7-compile_commands.json`,未删除数据、未扩展scope或放宽保护规则。因工作树已实际修正,接着只重试guard范围检查;不重复已通过的构建、测试或checkpoint检查。 - -证据根目录:`/tmp/p9-menu-liveness-20260910.sMRWiR/`。原始设备日志含私人URL,不提交;`before-install.png`因设备断开未成功生成,不能当作截图证据。 - -| 产物 | SHA-256 | -| --- | --- | -| `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk` | `04935314d4497024ee0a3a25fa4f2a65e2a7869cdf1352b1649069e897e8d574` | -| `app/build/outputs/apk/leanbackArmeabi_v7a/debug/app-leanback-armeabi_v7a-debug.apk` | `26e9cc8996a3987baf8c722e4b1ff96dbc23412c1081f6b162f5bff669117e02` | -| arm64 `libmpv.so` | `4e27798c8846a0ed57e2e4ce67b9581511b9621cf4681d6ae6c8386ec0d0b74c` | -| arm64 `libmvcodec.so` | `405e7d5530c3e61e68f36fd1e50a01db804d4464ac826e7cad22391f793b72ec` | -| arm64 `libplayer.so`(不变) | `a63e7b34f5ccfdf00a5d8204401e57042f7f9366e4c1fd00538aafc84210c3a1` | -| armv7 `libmpv.so` | `f228e330c0eb2cb270908756a765817ba9f3ad90e1c29e209c158bdc30c2ac93` | -| armv7 `libmvcodec.so` | `42e8fd5cae02b1153390658a21fad1011552402005c6912e9567c08191ec98a2` | -| armv7 `libplayer.so`(不变) | `60f058ff16d70eec42d4bb3acf31253d7f7e507853adebf9f70ca011ce958977` | -| armv7 `libmvformat.so` | `5dc848be56424e03e87b4f968c8f3f458807c9ad76e3f5a3139741da4b3d9597` | -| armv7 `libmvformat.so`(基线) | `c08e555baa180398b84a73eb1e30307aa646893e264bb7ed7a0d418c42ccf56f` | -| `mpv-discnav-poll.patch` | `bff0e63e53d31cdf3a3995fd2540ae2c738b8baf6da76e90d7b7ee8e5a67dee8` | -| `mpv-mediacodec-embed-reset.patch` | `910153408594804031222589cc3dcb4c93e5b2bfe52d2a0034527933c494d3fa` | -| `ffmpeg-mediacodec-output-serialization.patch` | `b90680788e35741f12e261ff999eeb105f1c101b5c9655e3de78927d2b7cf3c2` | - -剩余验收:设备`10CF6H1D2L0009S`(vivo V2453A/Android15 API35)接回后,用OEM安装助手覆盖安装Mobile候选,先验证《倩女幽魂》暂停菜单切页/关闭/恢复和播放往返;再覆盖TV候选验证两片背景、底栏入口/DPAD焦点、短片打断、正片持续播放与返回菜单。结合新时间/trace的`cache/webhtv-debug-log.txt`排除flush释放异常、自动回退重入、packet overflow,最后恢复手机候选。安装保留应用数据;无实机证据前不提交/tag,也不把不同VO/ABI互相替代。回滚仍为上节基线,保护`app/.cxx/`35个预存文件。 - -### 2026-09-09 子菜单输入适配决定 - -- 证据:固定libbluray源码(`graphics_controller.c::_mouse_move/_user_input`、`hdmv_vm.c::_set_button_page`,A级)及同ISO host重现(`gc-boundary-host-graph.log`,A级);成熟Kodi输入对照沿用本文件已有源码研究,不重复网络检索。已读取光盘真实定义:章节页有不可见自动返回按钮,单条立即数 `SET_BUTTON_PAGE` 返回包含真实主导航按钮的页面;普通下一页自动按钮有可见图形,与返回节点可区分。无动作副本的位置和selected图形与主菜单真实按钮一致。 -- 不改:继续选中self-loop零指令副本,三个交互要求失败;原样上游鼠标:同样直接选择副本,不能满足触屏;采用窄适配:仅对零指令、非auto、无外向方向引用的副本寻找**当前启用按钮图上可达**的不可见自动返回节点,只接受单条立即数切页命令和可验证的主导航图形对应。执行作者原有指令,不直接改页/寄存器。返回稳定页面后重新验证目标按钮;若作者已选中该按钮,则仅收起,否则激活另一个主导航按钮。 -- Back复用作者返回路径,找不到可证明路径则保留原ROOT回退。显式ROOT后光盘若主动恢复子页,再沿作者返回路径退出;不重复ROOT,不影响FIRST PLAY、BD-J或全局UO策略。指针意图限时且在新输入/重置时取消,不能覆盖尚在运行的VM命令。 -- 验证/回滚:host真实ISO章节→设置/收录/关闭、输入图夹具(自环、禁用、可见auto、歧义/损坏、延迟和取消)、短片ROOT、双ABI/ELF/App编译,随后真机底栏/豪斯医生验收。保持本任务原子可回滚;未通过真实交互不commit/tag。没有算法性能论文适用的新算法主张,不新增依赖/ABI命名空间或BD-J能力。 - -### 2026-09-09 豪斯医生合并背景/寄存器返回补充 - -- A级证据:手机同盘`house-nop-audio-click.log`完整按钮/指令;固定libbluray 1.4.1 `hdmv_vm.c::_read_setbuttonpage_reg/_set_button_page`确认高两位保留为标志、低12位取GPR,SET_BUTTON_PAGE在IG中终止当前指令程序。沿用前述Kodi/官方/issue调查;本次缺口由真实盘数据决定,不重复无关研究。 -- 保持现状或原样上游:点击合并背景只能执行NOP,且先抢占预览按钮焦点;现有直接立即数返回识别不能证明寄存器返回目标。窄适配:仅接受最多16条、无条件/跳转的立即数MOVE准备、单个SET_BUTTON_PAGE与NOP;只解析本段程序内已确定的两个操作数,未知寄存器/其它副作用拒绝。实际执行仍由原HDMV VM完成,不自行写寄存器或切页。 -- 合并背景只在作者方向图可达的隐藏返回节点给出的父页中匹配;至少两个真实可操作父页按钮的中心位于当前NOP背景内,且触点同时位于背景和唯一目标按钮的原始命中框,才适配点击。19:04真实盘边界日志证实父页按钮图像底部996–1000,而背景底部991(声音页背景还裁掉右侧边缘),因此不能要求完整图像框包含,改用中心避免图像边缘裁切导致误拒绝。保留已验证的逐图标副本匹配、方向键行为、超时/取消/动画/启用状态复核;空白区域、歧义、可见auto、未知指令不猜测。Back可使用同一已验证的父导航布局,无需鼠标位置。 -- 此为用户已批准“收起/切换”同一需求的补充,不扩展到JVM/其它播放器或库升级。验收为真实选集收起、声音/字幕切换、Back及底栏菜单返回;风险是错误识别不透明菜单程序/重叠按钮,因此用拒绝型夹具、同盘host和真机验证。源码/测试/两ABI成套提交及回滚到本任务base HEAD,未验收不打完成tag。 - -范围: - -- MPV disc navigation state/action、动态 duration/chapter/edition、菜单 OSD、slave demuxer 重开、interactive cache、still/discontinuity。 -- WebHTV ISO callback 继续复用 Java Range 数据源;由 MPV/libbluray 菜单栈读取原始 ISO。 -- App 仅在实际 HDMV 菜单激活时截获方向、确认、返回和菜单键。 -- 两个 ARM ABI 的 coherent native/JNI 构建、ELF/资产校验和最小 App 编译。 - -明确排除: - -- BD-J JVM、JAR、AWT/ARGB runtime、字体/XML 依赖和任何 BD-J 提示。 -- AACS/BD+ 解密能力扩展。 -- 整体升级 MPV、FFmpeg、libplacebo、mpv-android 或其它播放器。 -- 无关渲染、音频、字幕、网络、配置和 UI 重构。 - -## 2. 决策问题与结论 - -问题:怎样在不整体升级 FongMi MPV、不中断 WebHTV Java Range ISO、且绝不启动 BD-J 的前提下,实现完整 HDMV 菜单? - -假设:把上游完整 discnav 状态机移植为独立 MPV 补丁,并让 `webhtv-dvdiso` 回调提供原始可 seek ISO 字节,由 MPV 的 `stream_iso -> stream_bluray -> libbluray` 路径拥有菜单 VM,是最小完整方案。 - -反假设:继续让 `iso_dvd.cpp` 自己解复用最长 playlist,再只补 `bd_user_input()` 和一张菜单 bitmap 即可。该方案无法可靠处理 playlist/title hop、slave demuxer 重开、runtime track/list 更新、still、discontinuity 和播放器缓存,已被上游提交依赖关系否定。 - -结论:采用 WebHTV 适配方案;不整体升级,不做 Java 层自建 Blu-ray VM。 - -## 3. 修复前 WebHTV 调用链 - -- `IsoSessionManager.create()` 产生 `webhtv-dvdiso:///longest`。 -- `MpvPlayer` 用 `loadfile` 打开该 URL。 -- `iso_dvd.cpp` 当前在 callback 内创建 libbluray、选择最长 playlist,并把已导航的 M2TS 字节交给 `+disc` demuxer。 -- `mpv-stream-cb-disc-controls.patch` 只桥接 duration/current time/seek/chapter/language,没有 NAV command/state/overlay。 -- `MpvPlayer` 已有透明 OSD Surface 生命周期,但没有 `disc-menu-active` 观察或 `discnav` 命令接口。 -- Leanback `VideoActivity.dispatchKeyEvent()` 会在普通播放状态消费方向键,因此必须在它之前给活动菜单优先权。 - -## 4. 方案比较 - -| 方案 | 正确性 | 兼容/风险 | 决定 | -| --- | --- | --- | --- | -| 保持现状 | 普通最长标题稳定;没有菜单 | 不满足需求 | 拒绝 | -| 整体升级到 `FongMi/mpv@13eafa069366edb54606637b323b0d10efd05fa3` | 包含最终菜单链 | 同时引入 130 个分叉提交和渲染/音频/格式变化,覆盖本地补丁风险高 | 拒绝 | -| 只拣 `3a8b6995e925f2b1a6836c8e48d1fd210f4ed7ea` | 能生成部分 HDMV overlay | 缺少 demux reopen、OSD state、输入、cache、still 和后续跳转修复 | 拒绝 | -| 独立移植完整 discnav 链并适配 WebHTV raw Range callback | 上游状态机完整,保留当前二进制基线和 Range 所有权 | 补丁较大,必须做双 ABI、生命周期和真实 ISO 验证 | 采用 | - -## 5. 上游提交台账 - -访问日期:2026-09-06;网络使用用户指定代理。仓库:`https://github.com/FongMi/mpv.git`。以下是本阶段实际判断的菜单链;DVD-only 代码只有在提供共用 discnav/still/cache 契约时才作为依赖吸收,产品验收仍以 HDMV 为准。 - -| 完整 commit ID | 作用 | P9 disposition | -| --- | --- | --- | -| `5db1db2852db3b43bfcc0fe1b611b4c30caa45cb` | runtime duration/chapter/edition 更新 | 实施依赖 | -| `e2b7a983fa22795b903fbcb266c275ad612c372b` | disc-nav action/state contract | 实施 | -| `fbd0e047b1c025cdfa8d517dcd98e3d7f029f33d` | `disc-menu` option | 实施,App 仅对 ISO 启用 | -| `b17feb1f7910fffb4e4dcb9dec603c30c8b7414b` | DVD menu implementation | HDMV 不直接采用;仅提取共用 lavf/disc contract(若最终补丁需要) | -| `3a8b6995e925f2b1a6836c8e48d1fd210f4ed7ea` | HDMV overlay/navigation | 实施核心 | -| `af9a11a8e5b0d06cd48a591ad22563b5ca3ed0f6` | disc hop 后重开 slave demuxer | 实施核心 | -| `e204c1cfd96835d0e24564e0d55c9b0a2206bb46` | synthetic Disc Menu edition | 实施 | -| `8d36904e053ba78c7242dae70ee11e5727250c87` | `OSDTYPE_DISC_MENU` | 实施 | -| `652ca81af9dad106d60e9cc5855cfd4ed7813c5b` | player disc-menu state/overlay | 实施 | -| `816eca1fef78077f406b5d30d12a88eb4a35b642` | runtime 新轨自动选择 | 实施依赖 | -| `5713c301603fec36352a49505e113bec1e568985` | player 响应动态 chapter/edition | 实施依赖 | -| `a5682eb2adf359b982c7b8227d34c9fe5cd28bd5` | `disc-menu-active` property | 实施 | -| `216e26c87130683724702ad4a75a6b7a48529b2d` | `discnav` command/default keys | 实施命令;App 自行映射 Android 键 | -| `4725c2e492e4fb405a32f6bec5fbcf79b2be3dd5` | partially seekable | 实施 | -| `70174945a4b7302613030d5877e572183dccdfad` | VM 与音轨/字幕选择同步 | 实施 | -| `c625405ddcdf9d40cdda2ffe3708865c105ed965` | BD-J ARGB overlay/menu | 明确排除 | -| `f0bc30aaf12c5e04e2a1f8caf19f8924b8d4d3cf` | interactive disc cache/read-ahead | 实施 | -| `5f192099531e9eaec11210d26766a434d9ded552` | Blu-ray angle 统一为 1-based | 实施兼容修正 | -| `fbcced5bf068afcb4bc0a2bf1dea80ba9eac2169` | cached disc state controls 加锁 | 实施线程安全修正 | -| `3897b3a579014b5bb293180f6e2a24fe3e6fb9a5` | player/discnav 共用 still contract | 提取共用部分 | -| `4084e7609fa06a9e40b36d689ba5cd80a6ca9c17` | Blu-ray still frame | 实施 | -| `af53a9dc4b378568f0760afdf7c5e7ec9de7ed30` | 以 `bd_read_ext()` 驱动 VM/event | 实施 | -| `dbe496e6bec0332fb169818fe0826e982f9a30e2` | drop buffers 时清 sticky AVIO EOF | 实施跳转修正 | -| `2c8d954d2111b045842110151e414edc49fecb71` | 仅有菜单支持时添加 menu edition | 实施 | -| `c7859fe5b62c35c1e9cdeda70fdec72e8c6cd1a6` | OSD image overlay 命名/所有权整理 | 采用最终语义 | -| `c9422ff88fafef2d4b7c28f6c614099ef5dbfde5` | highlight contract 整理 | 采用最终语义 | -| `bb19b0fc3696c31438adc8aba8ef1e3b6997e2fb` | 移除不可靠 popup/menu event 判定 | 实施最终语义 | -| `4a110f39cfaf062d88d84887294dbb4eca71fe28` | 每流 timeline generation | 实施跳转正确性 | -| `abbbffe0dd24f3e3221e41ebaa55bac2ef7965bb` | 保持 BD jump boundary 供 player resync | 实施 | -| `15750e12123b9cb39f44f5cb4432f4c264097deb` | still 前排空 queued events | 实施 | -| `0c87e46c83f31a8b20db0f1301134a6e28af0d3d` | buffered data 存在时延后 slave reopen | 实施 | -| `a47452aac02fca2226edf4cd306b09d4aacc58bf` | 未预告 reposition 视为 jump | 实施 | -| `6f6bedec72287d070e71a0b7d5567e2e889ea382` | BD time seek 原位 reset slave | 实施 | -| `79186bd167b295227988e14d42401dc49a024a47` | idle 时轮询 disc VM | 实施 | -| `795cd15438639e7aa1dc1b2519d5584a098f10b6` | demux 标记当前节目缺失轨 | 实施依赖 | -| `93451ef64f6267e176f5da05724282462e1ba21d` | disc program track 可达性 | 实施 | -| `c318236b8882af860f16f936225430ad053a2179` | 无 EL 时移除 enhancement pairing | 实施邻接回归修正 | - -## 6. WebHTV 适配设计 - -1. `webhtv-dvdiso` callback 增加 raw ISO 模式,只负责 Java Range read/seek/size,不在 JNI 内抢先选择最长 playlist。 -2. MPV `stream_iso` 识别该 callback scheme,并让 `stream_bluray` 明确通过 `stream_info_cb` 打开嵌套原始流,避免递归和 FFmpeg URL handler 绕行。 -3. 只有当 libbluray 报告 `top_menu_supported` 且 `top_menu->bdj == 0` 时进入菜单;调用 `bd_play()` 从 FIRST PLAY 启动并让 `bd_read_ext()` 执行光盘自己的初始化,不在初始化完成前强跳 TOP MENU(Checkpoint 15 修正)。不注册 BD-J ARGB callback。BD-J top menu 直接走最长标题,不能仅凭整盘 `bdj_detected` 排除其中的 HDMV top menu。 -4. 其它情况(包括 BD-J top menu、无 HDMV top menu、menu boot 失败)无提示回退 `bd_get_main_title()`/最长标题语义。 -5. App 按默认关闭的 `playback_bluray_menu` 设置,在每次加载时同步 `disc-menu=yes/no` 与 ISO `/raw`/`/longest` 路由;非 ISO 探测返回 null 时保持普通播放。HDMV 菜单激活时优先转发 DPAD/ENTER/BACK/MENU;菜单曾激活的当前 ISO 正片中,MENU 仍可唤出 Popup。音量/媒体键和普通播放输入保留原行为。 -6. 菜单 bitmap 继续走 MPV OSD renderer,复用现有 Android OSD Surface;不在 Java 额外复制整帧 bitmap。 -7. 手机点击由 MPV `mouse` + `discnav mouse-click` 处理,坐标经原生 VO 的源/目标矩形映射;长按菜单画面打开导航面板。进入正片后可从播放控制栏的“蓝光原盘菜单”再次打开导航面板。 - -## 7. 证据 - -| 证据 | 等级 | 支持的结论 | WebHTV 影响 | -| --- | --- | --- | --- | -| FongMi MPV 上述完整 source commits 和最终 tree | A | 菜单是 stream/demux/player/OSD/input 联动链 | 不能只移植单 commit | -| libbluray 1.4.1 `bluray.h`、`overlay.h`、`keys.h` | A | HDMV 使用 RLE overlay、`bd_user_input()`、`bd_read_ext()`;BD-J 状态可区分 | 可明确禁用 BD-J 并选 HDMV top menu | -| mpv PR #18080 及合并链 | B | 上游维护者采用相同分层和 demux reopen/still/cache 设计 | 支持 adapted upstream 方案 | -| VLC/Kodi libbluray 集成源码 | B | 成熟播放器同样让 libbluray VM 拥有导航并把 overlay 接入播放器 | 反对 Java 层伪造菜单状态机 | -| 论文 | 不适用 | 本需求是既有 Blu-ray VM/API 工程集成,不含新的算法、调度或性能主张 | 不以论文替代源码/实测 | -| 博文/论坛 | C/D,仅作现场线索 | 不同盘的 HDMV/BD-J 外观无法仅凭截图判断 | 运行时必须读取 disc info | - -## 8. 风险、验收与回滚 - -风险: - -- 菜单补丁跨 stream/demux/player/OSD,编译通过不能证明真实盘跳转正确。 -- 远程 ISO 菜单随机读取会放大 Range 延迟,interactive cache 必须与现有页面缓存共同验证。 -- Surface direct 的菜单依赖 OSD Surface;快速 Surface 重建和字幕关闭场景必须保留菜单 OSD。 -- 某些盘混合 HDMV first-play 与 BD-J top menu;P9 以 top menu 类型为准,BD-J top menu 直接回退最长标题。 - -最小自动门槛: - -- 独立补丁按构建脚本顺序干净应用。 -- MPV native 双 ABI 编译;`libplayer.so` 双 ABI 重建。 -- `verify_mpv_native_assets.sh --require-elf` 通过,无 `libav*`/`libsw*` namespace 回退。 -- Mobile arm64 与 Leanback arm64/armv7 Java/资源编译通过。 - -设备验收: - -- 至少一份确认的 HDMV ISO:菜单背景/视频、按钮高亮、四方向、确认、返回、Popup、章节/设置/特别收录跳转。 -- 菜单与正片间反复切换,含 still、seek、音轨/字幕选择、Surface 前后台和退出重开。 -- 一份 BD-J 菜单盘:无新增提示,仍播放最长标题,不进入菜单。 -- 普通 Blu-ray、DVD 和非 ISO MPV 回归。 - -回滚:revert P9 原子提交并恢复同一提交前的 patch、JNI、App 接线和双 ABI assets;不单独保留新 `libmpv.so` 或 `libplayer.so`。 - -## Checkpoint 1:2026-09-06 方案冻结 - -- 完成:任务 ID、用户授权、BD-J 无提示边界、基线、替代方案、提交台账、适配架构和验收矩阵已落盘。 -- Source identities:WebHTV `966b3b6747ece447c2f34f7787df7c6af572baa0`;MPV baseline `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`;FongMi review head `13eafa069366edb54606637b323b0d10efd05fa3`。 -- Files changed:仅本任务文档和恢复索引。 -- Validation:待补丁生成后执行;当前没有把研究结论误记为构建/设备结果。 -- Unresolved:尚无任务内真实 HDMV/BD-J 样片设备结果。 -- Rollback anchor:`966b3b6747ece447c2f34f7787df7c6af572baa0`。 -- Next action:在隔离 MPV 源码副本中生成 adapted HDMV patch,并验证它可与现有 WebHTV patch 序列共同应用。 - -## Checkpoint 2:2026-09-07 适配补丁与 App 接线完成 - -- 完成:将隔离仓库生成的 `/tmp/p9-final-source.patch`(相对 MPV `p9-local`)替换为生产 `third_party/patches/mpv-discnav.patch`;补丁覆盖 demux/cache、discnav 命令、HDMV YUV/RLE overlay、OSD、slave demux reopen、still/discontinuity、动态 chapter/edition 和 Blu-ray stream callback。 -- 完成:`MpvPlayer` 设置 `disc-menu=yes`、观察 `disc-menu-active`、为 ISO 请求 OSD Surface,并暴露 `isDiscMenuActive()`/`sendDiscNav()`;`PlaybackActivity` 仅在 HDMV 菜单激活时转发 DPAD/ENTER/BACK/MENU。 -- 完成:`scripts/build_mpv_native.sh` 与 `scripts/verify_mpv_native_assets.sh` 增加补丁应用和 `disc-menu-active`/`discnav` marker 校验。 -- BD-J 边界:`stream_bluray.c` 根据 `BLURAY_DISC_INFO.bdj_detected` 静默回到 `BLURAY_DEFAULT_TITLE`;未注册 `bd_register_argb_overlay_proc`,未启动 JVM/JAR/BD-J runtime。PG 字幕不触发菜单激活。 -- 取舍:未吸收 BD-J 提交 `c625405ddcdf9d40cdda2ffe3708865c105ed965`、DVD-Audio 代码、会删除 WebHTV 本地 DOVI/Vulkan/OSD/proxy/live/albumart 行为的整棵父树,以及 `4a110f39cfaf062d88d84887294dbb4eca71fe28`/`70174945a4b7302613030d5877e572183dccdfad` 的不兼容完整父树;仅保留其 HDMV 所需契约并作 WebHTV 适配。 -- 验证:完整现有 WebHTV MPV patch chain 加 P9 补丁在固定基线应用成功;临时树 `git diff --check` 通过;`bash ./gradlew :app:compileLeanbackArm64_v8aDebugJavaWithJavac --offline` 通过;生产脚本 `bash -n` 和 Task Guard `check` 通过。 -- 未完成:MPV Meson 探针因环境缺少 `libplacebo` 失败;当前环境无可用 Android NDK/clang,因此双 ABI native、ELF/asset、`libplayer.so` 重建和真实 HDMV/BD-J 设备验收未执行,不能视为通过。 -- Files changed:`third_party/patches/mpv-discnav.patch`、`app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java`、`app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java`、`scripts/build_mpv_native.sh`、`scripts/verify_mpv_native_assets.sh`、本任务文档。 -- Rollback anchor:`966b3b6747ece447c2f34f7787df7c6af572baa0`。 -- 唯一下一动作:具备依赖时执行一次双 ABI native 构建和资产校验;否则以当前未完成 native 验证的状态进入用户可见交接,不虚报设备支持。 - -## Checkpoint 3:2026-09-07 原始 ISO 接管修复与开关 - -- 完成:播放设置在 Mobile/Leanback 加入默认关闭的“蓝光原盘菜单”开关,首选项为 `playback_bluray_menu`,并纳入备份设置;仅 MPV 播放器显示。 -- 完成:`MpvPlayer` 按开关传递 `disc-menu=yes/no`。开启时把 `webhtv-dvdiso:///longest` 改为 `/raw`,关闭时保留 `/longest` 兼容路径。 -- 完成:JNI `iso_dvd.cpp` 增加 RAW callback 模式,只提供 Java Range 原始字节,不创建 libbluray、不扫描标题、不选择 playlist;原有关闭开关的 Blu-ray/DVD 最长标题路径未改动。 -- 完成:MPV disc patch 让 `stream_iso` 识别 WebHTV raw scheme,并让 Blu-ray/DVD nested stream 通过 `stream_info_cb` 打开,避免 FFmpeg URL handler 绕过 Java Range callback;固定基线上的 patch chain 已重新应用通过。 -- DVD 结论:DVD-Video 确有 VMG/VTS 菜单,但当前 WebHTV 仍由 JNI 直接选择最长标题,未接入 DVD overlay/按钮/导航;本开关不宣称支持 DVD 菜单。 -- 验证:`bash ./gradlew --offline :app:compileMobileArm64_v8aDebugJavaWithJavac :app:compileLeanbackArm64_v8aDebugJavaWithJavac` 成功;固定 MPV 基线先应用 `mpv-stream-cb-disc-controls.patch` 再应用当前 `mpv-discnav.patch` 成功;`bash .codex/scripts/task_guard.sh check` 成功。第一次 `./gradlew` 无执行权限,已按仓库规则改用 `bash ./gradlew`,不属于代码失败。 -- 未完成:当前环境仍无 Android NDK/libplacebo/native 构建依赖,未重建 `libplayer.so`/`libmpv.so`,也未安装新 APK 做真实 HDMV/BD-J 设备验证;不能把源码接线等同于实机菜单已验收。 -- 当前文件:App 设置/资源、`MpvPlayer.java`、`iso_dvd.cpp`、`mpv-discnav.patch`、本任务文档。 -- 回滚锚点:`831b70433e3dbdfd6f119c6036a3c8cf22d85ae4`(本修复前);保护未跟踪 `app/.cxx/`。 -- 唯一下一动作:在具备 Android NDK、libplacebo 和确认的 HDMV/BD-J 样片时,执行一次双 ABI native 构建并安装到设备,验证开关开/关两条日志与菜单画面;若仍缺依赖,保留“源码完成、native/实机未验证”状态。 - -## Checkpoint 4:2026-09-07 双 ABI、自动验收与 debug 包完成 - -- 环境恢复:NDK `29.0.14206865`、API 24、本工作区 `build/mpv-native` 缓存可用;Checkpoint 2/3 的“缺 NDK/native 依赖”已不再成立。`feature-menu` HEAD 仍为 `831b70433e3dbdfd6f119c6036a3c8cf22d85ae4`,本轮尚未提交。 -- 补齐修复:`MpvDiscMenuPolicy` 使 ISO 路由对 null 安全、仅改写本协议末尾 `/longest`;`MpvPlayer` 每次加载同步开关;Leanback 在普通播放键处理之前路由菜单;只消费实际菜单按键,不吞音量/媒体键的抬起事件。 -- 手机入口:新增 `DiscMenuDialog` 导航面板和控制栏入口;菜单画面支持触屏定位/点击,长按打开方向、确认、主菜单、Popup、菜单返回面板。布局/字符串同时经过 Mobile/Leanback 编译。 -- 原生修正:检查实际 top menu 类型,菜单启动失败静默退回正片;RAW callback 在镜像 EOF 返回 0 并约束最后一段读取长度;删除会把通用 `stream_cb` 限制为仅一个 scheme 的 protocol marker,保留其它 callback 的原有协议能力。 -- 构建:armeabi-v7a 全依赖首次构建通过;最后使用相同补丁源码分别增量重编两个 ABI 的 MPV,并 `--stage-only --install` 安装产物;`build_mpv_player_jni.sh --abi all --install` 在 RAW EOF 修复后重编两个 ABI 的 JNI。没有更新上游锁定版本。 -- 自动验收:`MpvDiscMenuPolicyTest` **6 tests / 0 failures / 0 errors**;Mobile arm64、Leanback arm64 与 armeabi-v7a Java/资源编译通过;`verify_mpv_native_assets.sh --require-elf` 双 ABI 通过;固定 MPV 基线加 callback-controls/discnav 补丁干净应用,生成的 `stream_bluray.c` / `stream_cb.c` 与实际编译源码一致。 -- 打包:`bash ./gradlew --offline --init-script /tmp/p9-menu-gradle-staging.gradle :app:assembleMobileArm64_v8aDebug` 成功(1m33s)。临时 init script 仅将 App CMake 中间文件重定位到 `build/mpv-native/app-cxx`,不改生产 Gradle 文件,也不接管原有 `app/.cxx/`。 -- APK:`app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk`;包名 `com.fongmi.android.tv`;versionCode 560 / versionName 5.6.0;大小 168019744 bytes;SHA-256 `0625bac238d0a9bd806330b64ef9a48d267aecec3f4a8e04b8e0afe8eaa74d1e`。已解包逐字节比较全部 arm64 MPV 库,确认装入的是本次候选资产。 -- 输入哈希:lock `a009a6dd9066eacd8547383f93cc7dce2956fff7d6d338bd886be75b9e4ae159`;discnav patch `17efb68ea38a08746d462a146fdc824b1df33a0407d8af4d4cbd2dd52dd4e567`;JNI ISO source `7335df9abf1cf0988f48415440ed8afd3d8b53b4aaa7051f45a83aa45734a17d`。收尾检查发现补丁中 5 行空白 context 的空格,已规范化为空行;只调整 patch 文本形式,源码一致性用重新应用后比较确认,不重复 native/APK 构建。 - -| 资产 | SHA-256 | -| --- | --- | -| arm64 `libmpv.so` | `497cb7e9549323f9e9bb8278b277edebb09651a6258f32b6a6a58b66815286a1` | -| arm64 `libplayer.so` | `a63e7b34f5ccfdf00a5d8204401e57042f7f9366e4c1fd00538aafc84210c3a1` | -| armv7 `libmpv.so` | `7acfbb728c3530fc5555ab5f51a760032d762edc66548ec4f6bee418c542afec` | -| armv7 `libplayer.so` | `60f058ff16d70eec42d4bb3acf31253d7f7e507853adebf9f70ca011ce958977` | - -- 完整临时证据:`/tmp/p9-menu-native-armv7.log`、`/tmp/p9-menu-mpv-arm64-final.log`、`/tmp/p9-menu-mpv-armv7-final.log`、`/tmp/p9-menu-jni-final.log`、`/tmp/p9-menu-assets-final.log`、`/tmp/p9-menu-native-sha256.txt`、`/tmp/p9-menu-app-tests.log`、`/tmp/p9-menu-apk-build.log`。Junit XML 位于 `app/build/test-results/testMobileArm64_v8aDebugUnitTest/TEST-androidx.media3.mpvplayer.MpvDiscMenuPolicyTest.xml`。 -- 未完成/风险:截至 21:24,`adb devices -l` 无设备,mDNS 也未发现无线调试服务;未安装本 APK、未运行真实 HDMV/BD-J/DVD、菜单跳转和 Surface 生命周期场景。编译/资产通过不是菜单画面与操作通过,不做原子提交或 recovery tag。 -- DVD 边界:DVD-Video 确有菜单,但本实现不提供 DVD 菜单;开关关闭保留 JNI 最长标题路径,开启后的 DVD 分支也选最长标题。BD-J 不提示、不启用菜单,继续普通正片播放。 -- 回滚:现有 HEAD `831b70433e3dbdfd6f119c6036a3c8cf22d85ae4` 是成套源码/二进制恢复基线;不要单独混回某一个 ABI 或 `libplayer.so`。 -- 唯一下一动作:手机连接后,用 Android 安装辅助脚本安装上述 APK,测试默认关闭、开启后的 HDMV 菜单操作和 BD-J 静默回退;不重新做已完成的依赖研究、构建或自动检查。 - -## Checkpoint 5:2026-09-07 真机发现协议注册冲突 - -- 设备:`10CF6H1D2L0009S` / vivo V2453A / Android 15 API 35;安装更新时间 21:39:27;设备 base.apk SHA-256 为 `0625bac238d0a9bd806330b64ef9a48d267aecec3f4a8e04b8e0afe8eaa74d1e`,与本次测试包完全相同,不是旧包混装。 -- 证据:`/tmp/p9-menu-device-20260907.Vq7vyl/history.log` 保存了 21:41–21:43 的七次失败;`live.log` 继续捕获 21:51 的多次重播,均先报 `iso protocol registration failed: invalid parameter`,随后 `/raw` 报 `No protocol handler found`。未进入 libbluray 菜单判断,不是 BD-J 不支持造成的。 -- 根因:`stream_iso.c::stream_info_iso.protocols` 声明 `webhtv-dvdiso`,而 JNI 又通过 `mpv_stream_cb_add_ro()` 注册同名 callback;`player/client.c` 明确用 `stream_has_proto()` 拒绝覆盖内置协议。ISO 在此只是探测器,不应抢占 WebHTV 底层 callback 的注册名。 -- 最小修复:仅在 `stream_has_proto()` 检查中排除 ISO 探测器对 `webhtv-dvdiso` 的占名,不放宽 file/https 等真正内置传输协议保护、不放宽重复注册、不改其它 callback API;保留现有 raw/nested callback 路由及开关关闭的最长标题行为。 -- 验证计划:新增手机可运行的 `third_party/mpv-player-jni/tests/disc_protocol_test.c`,覆盖 ISO 注册、重复注册、其它 custom callback、file/https 保留名和空 callback。先证明当前库失败,再验证候选库通过;只增量重编 MPV 两 ABI,不重建未改动的 FFmpeg/libplacebo/JNI。 -- 当前边界:日志持续抓取,未清日志、未改用户 MPV 配置。`http-allow-redirect` 和 `custombuttons.json` 的配置报错不是当前 ISO 入口失败的根因,不在本修复中清理。 -- 已验证:旧 arm64 库的 ISO callback 首次注册返回 `-4`,其余 5 项通过;修复后同一手机、同一测试 6 项全通过,重复注册及 file/https 内置协议覆盖仍被拒绝。日志:`protocol-before.log` / `protocol-after.log`,位于上述证据目录。手机独立测试目录 `/data/local/tmp/webhtv-p9-protocol-20260907/` 不改 App 数据或配置。 -- 构建:仅增量重编 MPV arm64、armv7,未重建未改动的 FFmpeg/libplacebo/JNI;双 ABI staging 和 `verify_mpv_native_assets.sh --require-elf` 通过。固定基线重新应用 callback-controls/discnav 补丁后,`stream.c`、`stream_bluray.c`、`stream_cb.c`、`stream_dvdnav.c`、`stream_iso.c` 与实际编译源码全部一致。 -- 候选 SHA-256:arm64 `libmpv.so` 为 `cda2282c89ea2b2c07c0ac7fec676a9f3d34986cfbef2d14a55ee6e197a924ed`;armv7 `libmpv.so` 为 `80786d019f6533279a84447401c29e1b553ff8709d4329f980d11224daf82aca`;discnav patch 为 `1fb616f4b60bc3a1f073f340a32ae9d4a808a637ba6cb6152780076373443856`。 -- 22:30 正在运行一次增量 `:app:assembleMobileArm64_v8aDebug`,继续用 `/tmp/p9-menu-gradle-staging.gradle` 保护 `app/.cxx/`。尚未安装本轮修复,不得沿用 Checkpoint 4 的 APK 哈希作为新候选。 -- 下一动作:核对新 APK 内候选库并安装,复播此前失败的原盘,确认已进入实际 ISO 读取及菜单/静默回退路径。 - -## Checkpoint 6:2026-09-07 修复包安装与实际原盘复播 - -- 新 APK:`app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk`;SHA-256 `686b514cb59b10d15c19318c0daab4d7e793bff0c5bb7853068e7396296588ec`。增量构建用时 1m21s;解包 `libmpv.so` 与 Checkpoint 5 arm64 候选逐字节相同。 -- 安装:OEM 辅助脚本成功处理风险复选框和继续安装按钮,覆盖安装保留用户数据,随后启动。设备 base.apk 哈希与新包一致,App PID `8672`。证据 `install-protocol-fix.log`、`after-install.xml` / `.png`。 -- 日志:旧采集会话在设备连接中断后结束;已保留原文件并从最后时间续采至 `live-protocol-fix.log`,未清 logcat。用户 `playback_bluray_menu=true`,未修改 MPV 配置文件。 -- 复播《背水一战》:23:32:57 手机日志确认 `iso-native opened raw Blu-ray ISO callback session=1001 size=51435929600`;后续持续 Range 206,正片画面及约 188 秒进度可见。原有协议注册错误及 `/raw` 无 handler 未再出现,入口修复有效。 -- 未通过项:同次播放有频繁缓冲/音频 underrun,后续出现 `mpegts: Packet corrupt`、`dca: Failed to decode block code(s)`、PTS discontinuity 和音画不同步告警。尚不能归因给片源或新实现,不能报告“播放完全正常”。App 播放性能的正常日志级别为 warn,该次日志不能证明菜单类型。 -- 下一动作:同片菜单开关开/关对照并恢复原状态;继续以实际 HDMV 菜单画面和操作作为 P9 验收条件。 - -## Checkpoint 7:2026-09-08 首次真实 HDMV 菜单及开关对照 - -- 《背水一战》关菜单对照:session `1003`,`iso-native opened Blu-ray image ... playlist=1 titles=46 durationMs=6433260 chapters=17`,确认走旧 JNI 最长标题路径。00:21:07 同样出现 `dca: Residual encoded channels are present without core`;持续有缓冲,故这两项不是开启菜单后独有。开启路径后期的 Packet corrupt / PTS 跳变仍不可由此单次对照完全归因。 -- 《哈利·波特3》带 BDJ 菜单修改标記的原盘:session `1002` `/raw` 打开成功且有 MediaCodec 持续运行,未复现入口错误;未将文件名当作已验证的 BD-J 类型证据。 -- 《超脱》HDMV:session `1004`,原始 ISO 大小 `21159477248` bytes;00:58:01 明确 `bdnav: cfg_title=-1 hdmv_mode=1`、`HDMV entered; current title=0`、`Blu-ray successfully opened`。`detachment-menu-now.png` 显示 PLAY / SET-UP / SCENE INDEX 原盘菜单及高亮。 -- 交互取证:触摸 SET-UP 打开音轨/字幕设置页(`detachment-setup-confirm.png`),点圆形 X 关闭(`detachment-audio-closed.png`);长按画面出现方向、确认、主菜单、弹出菜单、菜单返回控制面板(`detachment-controls.png`),方向输入日志返回成功;关闭面板后触摸 SCENE INDEX,显示章节 1–4 缩略图页(`detachment-chapter-page.png`)。仅按键返回 0 不作为页面跳转成功证据,以截图为准。 -- 日志续采:原采集在 01:14:21 结束,新增 `live-hdmv-test.log` 从该时间续采且只监听 App PID `8672`;不清历史日志。 -- 临时状态:菜单开关已由关闭对照恢复 `true`;为了得到菜单模式证据,播放性能里的“详细日志”由正常改为详细。恢复时须设回正常并移除本次增加的 `mpv_verbose_log` override,保留用户原有 `mpv_render` override;不要重置整个播放性能或修改 `mpv.conf`。 -- 唯一下一动作:确认章节 2 启动正片与 Popup 返回,随后恢复临时日志状态,按实际剩余风险决定是否达到提交验收条件。 - -## Checkpoint 8:2026-09-08 菜单到正片、Popup 与静默回退实测 - -- 章节选择:01:20:40 点击章节页第 2 个缩略图,libbluray title 切至 13、playlist 切至 0;01:20:42 `discontinuity 6->8, reopening slave` / `reopening slave demuxer`,菜单状态关闭,截图 `detachment-chapter-two.png` 已是正片。记录“章节页选项启动正片”已验证,不把启动时 chapter=0 误写成已精确验证任意章节时间点。 -- Popup:01:28:25 `disc navigation action=popup result=0`、菜单 event 30=1;`detachment-popup-return.png` 显示正片上 MAIN MENU / SET-UP / SCENE INDEX 弹出菜单。该盘 Popup 会自行超时关闭;不把超时后发送的确认键当作已验证的返回主菜单动作。 -- 无菜单回退:01:34:48《哈利·波特3》session `1005` 打开同一 4K ISO;01:34:50 `cfg_title=-2 hdmv_mode=0`;01:34:53 AudioTrack 7.1,01:34:58 `VO: [gpu-next] 3840x2160 mediacodec` 且首帧,未弹 BD-J 提示。`bdj-verbose-playing.png` / `live-hdmv-test.log` 为证据。该模式输出证明未启用 HDMV 菜单;实际 BD-J 类型仍以盘元数据而非文件名为准。 -- 恢复用户状态:退出测试播放后备份最新 SharedPreferences,唯一文本差异是 `perf_mpv_verbose_log: true -> false` 和移除本轮新增的 `mpv_verbose_log` override;保留 `mpv_render`、`playback_bluray_menu=true` 及其余内容。首次 `adb exec-in` 临时文件传输得到零字节,校验失败后在 App 未启动的状态立即从私有备份恢复并逐字节确认;改用 adb push、复制到私有临时文件并先逐字节校验,再替换成功。重新启动后只读确认这两项恢复正确。 -- 隐私/清理:未修改 `mpv.conf`、未清日志或 App 数据。仅移除本任务产生的设备公共临时传输文件;App 私有备份 `shared_prefs/com.fongmi.android.tv_preferences.xml.p9-log-backup` 和本机权限 0600 的恢复证据仍在,均不进 Git。 -- 日志继续:`live-user-followup.log` 以 UID `10464` 过滤本 App,避免 App 重启后旧 PID 过滤丢失日志;回到 App 首页交还操作。完整证据目录仍为 `/tmp/p9-menu-device-20260907.Vq7vyl/`。 -- 本轮结论:统一 ISO 入口致命错误已修复、已换包,真实 HDMV 核心交互链路已证实;尚未验证完整 DVD/所有 HDMV still/任意章节定位,且《背水一战》后期时间戳/损坏包需同一片段比较。保持 P9 未收尾,不以编译或一次菜单成功宣称全量无回归。 -- 唯一下一动作:仅对《背水一战》的同一异常正片时间段做菜单开/关对照,区分现有音频/网络问题和新 RAW 路径风险。 - -## Checkpoint 9:2026-09-08 最近观看复播失败与原生崩溃修复 - -- 用户反例取代 Checkpoint 8 的下一动作:最近观看中的《超脱》《幽灵公主》均不正常;不再把曾显示菜单或首帧当作完成。 -- 证据(同一 vivo/API35,UID 10464):`recent-user-failures-0358.log` / `recent-user-0358.png` / `live-user-0400.log` / `recent-native-crash.log`,均在 `/tmp/p9-menu-device-20260907.Vq7vyl/`,不清设备日志,不输出分享直链/凭据。 -- 03:57《超脱》session 1001,ISO 21159477248 bytes;file-loaded 后立即 seek,接着 SPS/PPS 缺失、no frame,最终连接超时。只读历史副本 position=821112、duration=794960;Mobile `onPrepare -> setPosition` 无菜单判断,`onTimeChanged/updatePlaybackHistoryPosition` 把不同 playlist 时间线写成单一电影进度。此为直接调用链证据,不据此解释全部原生故障。 -- 03:55 和 04:13 SIGABRT:`FORTIFY: pthread_mutex_lock called on a destroyed mutex`。用已安装库对应的 arm64 未剥离产物 `llvm-addr2line` 解析 0x695e60 / 0x692678 / 0x692508 / 0x69e914 / 0x69dc44,依次为 `demux_flush / demux_shutdown / demux_free / reopen_slave / d_read_packet`。 -- 确认根因:`reopen_slave` 释放 `p->slave` 后,遇到短暂 event-only/EOF 从 peek 分支提前返回,但未清空指针,下一次 reopen/close 重复释放。另一个初始化缺口是初次 probe 后 discontinuity 基线仍为 0,首轮读取误将初始化 playlist 事件当成跳转,丢弃首个短 clip。 -- 04:14 崩溃后重新点击《幽灵公主》,这次是分享页 HTTPS(urlLen=35)直接交给 MPV,不是 ISO callback;需独立检查配置/推送解析恢复,不能把该次 unknown_format 当成 HDMV 解码证据。原先 02:08 的 raw ISO unknown_format 仍仅作旧反例。 -- 局部设计沿用第 7 节 libbluray/上游导航契约,不新增 VM 或扩大 DVD/BD-J 能力:保留现状不满足复播;对所有 ISO 禁续播会破坏 BD-J/普通标题;采用原生只读 `disc-nav-active`(区别于瞬时 `disc-menu-active`),RAW ISO 延迟到 file-loaded 判别后决定是否恢复进度。实际 HDMV 会话不恢复/覆盖单一历史时间线,非导航回退保持续播。历史不删除、不迁移,菜单开关仍默认关。 -- 本轮源码改动:失败立即置空 slave;初次读取冻结导航 generation;App 不给待判别 RAW ISO 嵌入 loadfile start/提前 seek;实际导航会话冻结历史时间/片尾跳转,Mobile/Leanback 一致。新单测覆盖待判别、HDMV、BD-J/无菜单回退和普通媒体;C fixture 提取真实 `reopen_slave` 函数覆盖 event-only 重试/关闭/探测失败/恢复。 -- 打包完整性发现:缓存中 `player/discnav.c` 为未跟踪新文件,旧持久补丁只引用它,未收录其内容;本轮将该文件逐字纳入 `mpv-discnav.patch`,防止干净构建依赖残留缓存。 -- 验证:真实 `reopen_slave` fixture 对旧 `/tmp/p9-protocol-apply.oJenrV` 在“失败后必须置空”处失败;对当前源码 event-only 三次重试、关闭、探测失败及恢复全部通过。干净基线先应用 stream-cb patch 再应用菜单 patch 成功,`discnav.c`、`demux_disc.c` 与编译源码逐字一致,目录 `/tmp/p9-resume-apply.VmeyRn`。 -- Java:`:app:testMobileArm64_v8aDebugUnitTest --tests androidx.media3.mpvplayer.MpvDiscMenuPolicyTest` 10/10,通过;同次 `:app:compileLeanbackArm64_v8aDebugJavaWithJavac` 通过(44s)。日志 `resume-app-tests.log`。native 第一次被沙箱 `sysctl ... Operation not permitted` 阻止,实际未开始编译;获准后重跑两个 ABI,仅编译 MPV,均成功,日志 `resume-mpv-arm64-built.log` / `resume-mpv-armv7-built.log`。 -- 产物:双 ABI stage/安装 assets 与 ELF 校验通过,日志 `resume-native-stage.log` / `resume-native-assets.log`。patch SHA-256 `b91aee65650c4e6cb5e3671b3cad8e5e90121a379fee86a52d44e143102cc5cb`;arm64 libmpv `b15d07c4bf201fa7560289d05c2bf9fe44cff7d367ed8903668d7fec9adefda3`;armv7 libmpv `37cc016ef993800cc7903dd69a22a2c12198e303530deed549af4e8669fe67e2`。JNI 未变,不重复重编或重跑已通过的注册测试。 -- 分享页错误的代码解释:进程死亡后直接恢复播放/历史 Activity,不经过 HomeActivity.initConfig;VodConfig sites 为空,SiteApi 的空 push_agent 回退将分享页作为直接媒体。原生崩溃是本轮必须修复的触发条件;全局配置初始化未改,先验证正常启动后最近观看可恢复,不能将此解释误写为已修复全局进程恢复。 -- 授权/范围/回滚仍为本 P9 guard 与 HEAD 831b70433e3dbdfd6f119c6036a3c8cf22d85ae4,保护 app/.cxx/。尚未装本轮新包,不提交/tag。 -- 唯一下一动作:安装正在构建的 debug 包,从正常启动的首页进入最近观看并重复播放两个条目、菜单到正片跳转,检查原生崩溃和历史写入。 - -## Checkpoint 10:2026-09-08 新包复播与导航缓存暂停冲突 - -- 新包构建 22s,通过;APK SHA-256 `1999ce4848acd53d0d4d9c281735c00773983c58f998f1ada67372d76b7641cc`。OEM 辅助安装成功,从首页正常启动;第一次播放后设备私有 libmpv SHA-256 已确认是 Checkpoint 9 的 b15d07c4... 候选(播放前磁盘保留旧库,首次使用时才重新提取,不能在提取前误判)。 -- 新采集 `/tmp/p9-menu-device-20260907.Vq7vyl/resume-device.log`,UID 10464;前一采集 04:35 断开,已从该时间接续,未清日志。 -- 《超脱》04:56:39、04:58:07 两次最近观看复播均进入 HDMV 菜单,日志明确忽略旧历史 821112ms,未再执行错误续播;截图 `resume-detachment-menu.png`。04:57:58 为主动 stop,不是 EOF/崩溃。04:59:14 PLAY、随后主菜单/Popup 操作有状态切换;尚不能据此宣称播放正常。 -- 反例:菜单画面仍反复缓冲,一分钟内 rebuffer 计数增长到 41;菜单暂停/恢复期间网络流量和进度间歇变化。这轮未再看到旧 destroyed-mutex SIGABRT,但持续性验证未完成。 -- 确认配置冲突:native `demux.c::read_packet` 在 nav_active 下禁止普通 read-ahead;App 初始化强制 cache-pause=yes,而 `playloop.c::handle_update_cache` 仍按缓存低阈值暂停。因此修复必须改变真实 cache-pause 行为,不能只隐藏 App 进度提示。 -- 最窄适配:仅在 file-loaded 已确认 `disc-nav-active` 时设置 `file-local-options/cache-pause=no`,不关闭网络/ISO页面缓存,不改持久设置;BD-J/无菜单回退不适用。mpv 固定源码 `DOCS/man/input.rst` 的 file-local-options 文档、`command.c::mp_property_local_options/access_option_list` 明确保证停止当前文件时自动恢复旧值;`loadfile.c` 在通知 file-loaded 前已设置 playback_initialized,设置时机有效(A级源码/项目文档证据,2026-09-08)。 -- 相比全局关闭 cache-pause,此方案不污染普通影片或复用上下文;相比再改 native cache 调度,此方案只补 App 已有导航集成的选项冲突。Native ABI/产物不变,无需重建两个 ABI或重复通过的原生门槛。 -- 本轮新 Java 变更待构建实测,不提交/tag。唯一下一动作:只增量打包/安装这个 file-local 修正,验证两个最近观看条目、连续菜单/正片进度和退出重入。 - -## Checkpoint 11:2026-09-08 用户补充菜单触控/全屏与导航面板反例 - -- 用户 05:05 两张截图:内嵌菜单无法唤出全屏操作;全屏底栏的菜单按钮打开了透明、低对比、横排方向键的辅助面板。用户授权修正这些菜单 UI 问题,仍在 P9 App/资源范围内。 -- 直接代码证据:Mobile 视频 FrameLayout 的 touch listener 先调用 `dispatchDiscMenuTouch`,后者对整个视频区域无条件消费事件,导致原有 `onSingleTap/showControl` 与 `onDoubleTap/enterFullscreen` 不可达;`DiscMenuDialog` 的 `transparent=true/stableOverlay=true` 清除了面板底色并清除全屏 Window flag,按钮仍受默认主题颜色影响。 -- 局部方案比较:保持现状不满足显式全屏入口;把所有 tap 同时交给手势和光盘会误触光盘按钮;采用独立的小型播放控制/全屏入口,在播放器控制栏可见时优先交还普通手势。底栏“原盘菜单”直接发送 Top Menu,辅助导航改为长按入口,不冒充盘片本身菜单。 -- 面板沿用项目已有底部操作层范式,限宽、清晰深色底与白字、48dp 点击目标、十字方向布局、明确标题和关闭;不调整全局主题/BaseBottomSheetDialog。打开导航面板保留当前全屏系统栏状态,执行确认/主菜单/Popup 后自动关闭面板,方向选择时保留以便观察盘片高亮。 -- 这是既有 HDMV 输入接线和辅助面板的局部修正,不引入新菜单 VM/API、网络策略或架构;论文/上游提交合并不适用。验收以用户这两张截图对应的内嵌/全屏、关闭旋转全屏情况下的可点入口、真实光盘按钮仍可点击及面板清晰性为准。 -- 已装缓存修正 APK SHA-256 `a48ef292a248a911be7297dcb8c092b7cdc34e79da838ced59349fc765ba99ca`;新 UID 日志 `nav-cache-device.log`:05:09:16/30/46、05:11:35 多次 HDMV 启动均 cache-pause=false;05:10 截图显示 READY/0 重缓冲。仍须分别验证两个条目的正片和章节/Popup,不据启动日志宣称完成。 -- 唯一下一动作:实施这两个 UI 修正,增量构建安装后结合连续播放做同一轮真机验收。 - -## Checkpoint 12:2026-09-08 05:54 手机专用菜单图标与 MPEG-2 失败 - -- 延续同一 guard/HEAD/范围;保护 `app/.cxx/`。用户最新授权:额外全屏入口仅在实际蓝光菜单显示时出现,去掉“播放控制”,电视端不添加,图标和底栏一致且无额外背景色;继续修复《幽灵公主》。目标约 06:10 完成当前修正和真机验证,失败按实际证据继续收敛。 -- 第一版 UI 包 SHA-256 `d3f7e98cd633820ef459968c357375b76b092b24404ad3baaa0cdc40f4615adc` 已构建/安装;内嵌与全屏切换图标已点击验证。`ui-navigation-card.png` 实际仍是封面/播放控制入口,不能当作导航面板通过证据。该入口在 `app/src/mobile/res/layout*/activity_video.xml`,Leanback 不包含此布局。 -- UI 根因与适配:`hasDiscMenu()` 是整张盘的可用性,不能表示菜单当前可见;只换条件但不订阅事件仍会陈旧。用 MPV 主线程的 `disc-menu-active` 属性变化通知手机 Activity,注册/移除随当前 player 和 Activity 生命周期;ISO 关闭时立即通知隐藏。不增加轮询、不伪造轨道变化。图标复用 `ic_control_fullscreen`/`ic_control_fullscreen_exit` 和底栏 48dp 点击样式,去除文字与容器底色。 -- 原始失败证据:`nav-cache-device.log` 05:11:35,《幽灵公主》RAW ISO session=1004/47655223296 bytes 已打开;`mpeg2_mediacodec: Unsupported or unknown profile`、`MediaCodec ... failed to start`、`Software decoding fallback is disabled`、`Decoder init failed for mpeg2video`。这是解码失败,不是未识别 ISO。旧采集已停止;不清日志,从 05:29:32.867 续采 `menu-icon-decode-device.log`。 -- 直接依据(A级,2026-09-08,MPV `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` + 已记录本地补丁):`DOCS/man/options.rst::hwdec-software-fallback` 规定硬解失败后可回退,yes=1 并保留启动包;`video/decode/vd_lavc.c::select_and_set_hwdec` 在禁回退时强制 EOF;`player/loadfile.c::play_current_file` 的 decoder 初始化早于 FILE_LOADED;`load_per_file_options`/`M_SETOPT_BACKUP` 在退出文件时恢复选项。App `MpvPlayerEngine.hardwareDecodeSoftwareFallbackOption()` 当前固定 no。故 FILE_LOADED 时才补选项无效。 -- 方案比较:不改无法显示 MPEG-2 菜单;全局解除 no 会改变普通影片既有策略;采用 `loadfile.c` 在真实 HDMV 已确认、decoder 初始化前设置带 BACKUP 的文件局部 yes,仍先尝试硬解。只对支持软件帧的 `gpu`/`gpu-next` 生效;`vo_mediacodec_embed.c::query_format` 只支持 MediaCodec 帧,不能假装它能渲染软解帧,不改其策略或全局输出选择。BD-J、无菜单、DVD、普通媒体不进入此分支。当前用户实际输出 gpu-next/androidvk 符合此门槛。 -- 这是现有菜单集成遗漏的选项兼容修复,不引入新的上游候选、算法或性能优化;不重复先前已覆盖的论文/项目/讨论研究。证据由固定项目文档、原生源码和同机失败日志交叉印证。软件解码功耗/能力受设备和码流影响,不宣称所有盘或 Surface 直出已通过。 -- 验证:真实 helper 提取测试覆盖 HDMV GPU、无菜单/BD-J、DVD、未知输出、硬解专用输出及 BACKUP 标记;双 ABI 仅增量 MPV + 资产检查;一次 Mobile APK/Leanback 编译;手机检查菜单图标显隐、全屏、两张盘的菜单到正片与退出重入。回滚仍成套恢复到本文件记录的 HEAD;未通过真实播放前不提交/tag。 -- 唯一下一动作:按以上方案实施并完成一轮定向构建安装。 - -### 06:14 对“旧版可以硬解”的反证核查 - -- 用户指出旧版可硬解《幽灵公主》。此前从 `Unsupported or unknown profile` 推断整部影片不支持硬解是不成立的;暂停候选安装,先用原始日志和设备运行时能力交叉核查。 -- 手机于 06:04:20 已覆盖安装旧版 APK,SHA-256 `2ffd0a01c39aae708b2c86c016168955f1371161cb64492dfd0a298b85ef194b`;不是本轮候选包。旧版无原盘菜单设置,普通路径 `playlist=1`、`durationMs=8002911`。续采日志 06:05/06:06/06:07 均直接打开正片;06:07:08 明确 `h264 / High / 1920x1080 / 23.976`、`decoder=h264_mediacodec`,Android 创建 `c2.qti.avc.decoder`。不再重复这个已成立的对照。 -- 新菜单旧候选 05:11:35 的实际 track-list 为 `mpeg2video / Main / 1920x1080`,与正片 H.264 不同;不是缺少整个 codec/profile。FFmpeg `mediacodec_wrapper.c::ff_AMediaCodecProfile_getProfileFromAVCodecContext` 不映射 MPEG-2,因此“unknown profile”警告本身不能判定流坏了。 -- 用 `/tmp/p9-menu-device-20260907.Vq7vyl/P9CodecProbe.java` 经 Android `MediaCodecList` 做只读查询,结果保存在 `device-codec-probe.log`:regular 列表无 MPEG-2;all 列表仅 `c2.vivo.mpeg2.decoder`/alias,`hardware=false software=true special=true`;H.264 的 `c2.qti.avc.decoder` 为 `hardware=true software=false`。和 XML/FFmpeg 排除 software-only decoder 的源码相符。没有改写用户 mpv.conf 或解码偏好。 -- 对照结论:普通版直接进入 H.264 正片而菜单模式进入另一个 MPEG-2 片段,不能说“设备不能硬解幽灵公主”。修正应兼容这个片段且每次新视频解码器仍先尝试硬解,正片必须实际恢复为高通 H.264 硬解。是否成功到达/操作菜单、是否正常切入正片仍待真机,不能仅据能力查询宣称修好。 -- 定向 helper 实测通过;干净补丁应用 `/tmp/p9-icon-decoder-apply.ohqoZ6` 与源码 helper 一致。双 ABI 仅编译 `player/loadfile.c` 成功;stage/ELF 验证通过;Mobile APK + Leanback Java 构建成功(54s)。补丁 SHA-256 `a79d2fb2357836f9b3eea91893a8768e1636e362f17bc07c7d147a0e43ff9fac`,arm64 libmpv `c3cb51cb147451041afca120891a1dcb1d8e2f93888b1cd803039c745c0b8caf`,armv7 `77eb578b39b3dced1810d51426ece34e281a1df4e74a348434fed7e0870b0c37`。 -- UI XML 检查通过,无文字控件;手机复用原有白色图标/48dp点击样式,Leanback 未引用手机专用入口。尚未安装这次 UI/decoder 候选,因补做用户指出的硬解反证,06:10 目标顺延。唯一下一动作:安装已构建候选,验证 MPEG-2 片段到可操作菜单再到 H.264 硬解正片,不重跑已通过构建。 - -## Checkpoint 13:2026-09-08 06:31 菜单无响应和关闭菜单超时 - -- Checkpoint 12 候选 APK `c0b397f4dd847c01cc0e5cf18e34cff8afacf02b3f4400f132c1e0ed24fe59cc` 于 06:15:54 安装;首次播放后已核对私有 arm64 libmpv 为 `c3cb51cb147451041afca120891a1dcb1d8e2f93888b1cd803039c745c0b8caf`。用户报告开菜单按钮无响应,关菜单容易超时,验收失败;不提交/tag。 -- 屏幕 `after-log-tags.png` 确认《幽灵公主》可显示绿色 HDMV 菜单和独立白色全屏退出图标,无“播放控制”和底色。但首次菜单会话在 06:16:53 达到 ENDED,后续菜单静止页点击无进展;不能把截图当作菜单功能完成。 -- 系统 `log.tag=M`,即使临时打开 `mpv/TV-mpv/TV-player-engine/TV-player-error/TV-iso/MediaCodec/CCodec` 的非持久标签仍没有新 logcat。原值保存在 `candidate-log-tags-before.txt`,结束需恢复。改读 App 自己的 `cache/webhtv-debug-log.txt`,本地 `candidate-app-log.txt` 和续采 `candidate-file-live.log`;含 NUL,用 rg -a 并限制长行,不输出 URL/凭据。设备旧日志与 App 数据不清理。 -- 菜单输入根因:06:26 连续 `disc: discontinuity 4->9, reopening slave`。`STREAM_CTRL_NAV_CMD` 在静止页每次 activation 都人为递增 generation,但 `bluray_stream_fill_buffer()` 先 `bd_get_event()` 后 `still_active` 立即返回;libbluray 1.4.1 `bluray.c::_run_gc` 只是把按钮字节码挂到 HDMV VM,必须 `bd_read_ext()` 才运行。故已接受的命令永远等不到执行,反复重开也无法解锁。 -- 最窄修正:在静止状态判断前用 `bd_read_ext(..., len=0)` 排空事件并推进待执行 VM,保持 bounded/cancel 检查;真实 PLAYLIST/TITLE/PLAYITEM 事件退出旧 still。只对真实流跳转递增 generation,去掉“每次点击都伪造跳转”;遇真实跳转先返回边界 EOF,再让新 slave 从新 clip 开头 probe。重开后两条路径都读取最新 generation;同 codec 的新 clip 也必须接管新 extradata/参数,防止复用旧 SPS/PPS。依据为固定 libbluray 源码和保留的同机失败日志,无新依赖/ABI。 -- 关闭菜单超时证据:06:17:51 和 06:19:33 普通 `/longest` 复播均设置 start=10.771/55.101,H.264 已读出,但 aid=1 被选成 `srcId=8191(0x1fff), codec=mp3, sampleRate=0`;15s 没有首帧,06:18 从头重试后能播放。0x1fff 是 TS NULL_PID,固定 FFmpeg `mpegtsenc.c::mpegts_insert_null_packet` 也用此 PID 填充。现有 `is_bd` 只认 native bd,不认 JNI callback,导致本应过滤的非音视频填充流被自动选为主音轨。先拒绝这个保留 PID,并仅在 nav_active 时给 packet 加动态 codec segment 标记,保持普通单时间线包语义;不通过放宽15s超时掩盖。此因果关系待实机复播证伪。 -- 当前总耗时超出最初预计,原因是用户追加两个真实失败和旧版对照。停止额外 UI 探索;下一轮只验确定性状态/轨道规则、两 ABI 增量 MPV、一次 APK 和菜单/正片复播。预计约 15–20 分钟,不把时间目标作为降低验收的理由。 -- 唯一下一动作:实现并用提取真实函数的回归检查验证静止菜单推进及 NULL_PID 排除,再构建。 - -## Checkpoint 14:2026-09-08 07:27 菜单片段 EOF 不应触发下一集 - -- 延续已授权的 `P9-MPV-BLURAY-MENU-FIX` guard、分支与范围,保护 `app/.cxx/`。用户要求先打 tag,07:17:35 已为已提交 HEAD 创建上述本地基线 tag;没有提交未验收工作或推送。07:22 用户明确继续解决。 -- Checkpoint 13 已完成的验证:真实函数回归 `test_disc_navigation_progress.sh` 通过(静止页命令推进、跳转边界、无命令不跳 still、取消/IO 失败、普通路径与 NULL_PID 排除),旧候选在 pending command/still 断言失败;干净应用 `/tmp/p9-still-pid-apply.U9bsDe` 与源码一致。双 ABI MPV 增量编译、stage/ELF 资产检查、Mobile APK 与 Leanback Java 构建通过。日志均在 `/tmp/p9-menu-device-20260907.Vq7vyl/still-pid-*.log`。 -- 已安装的 Checkpoint 13 APK SHA-256 `038e0087a0cee9752c68ea34932cc6411d06369f1411773ccff4e63b2c6c142d`;patch `8e6e4963dca857d826bda748e4208428ad3e6852eb6973061bad6d6e404cb428`;arm64 libmpv `128629331266c3e952fc750a40bdaf747f6d506f2bd1c45624030f250656b77a`;armv7 `f0754c06b8d7983365c53e85608ab311677d4e8cf2041c7db7088d08d9b43e71`。安装脚本曾因 USB 断连退出 1,但设备 06:54:13 安装时间、实际 base.apk 与首次播放解出的 libmpv hash 均已核对匹配,不重装旧候选。 -- 最新证据 `menu-ended-recovery.log`:06:56:26.573,duration=1001 的菜单片段触发 `playback ended reason=property:eof-reached`;06:56:30.587 原生继续 `playback-restart`,后续可切换 1993ms 菜单与 8002912ms 正片,但 Java 状态仍为 ENDED。07:00:05.738 再次复现。没有相应自然 `end-file` 事件;真正的 stop 仅在退出条目时发生。 -- 根因/最小修正:MPV 的 `eof-reached` 表示当前解码队列结束,包括光盘静止菜单背景,不等价于结束 libbluray 导航会话。`MpvPlayer` 仅在非导航播放时映射该属性为终态;使用已在 FILE_LOADED 确认的 `discNavigationActive`,不依赖菜单 overlay 当下是否可见。真实 end-file/error/idle 路径不改;关闭菜单、BD-J/无菜单回退、普通视频仍保留原 EOF 行为。`MpvDiscMenuPolicyTest` 覆盖菜单 EOF、跨片段 EOF 往返及普通播放 EOF。 -- 这是现有已批准导航设计的局部 App 适配修复,不引入新架构/依赖/API/解码策略。依据来自固定 MPV 的 `player/command.c` / `player/playloop.c` / `player/discnav.c` 和同机日志,无需重复外网研究。回滚为同一 P9 原子单元。 -- 本轮目标:北京时间 07:24 起约 30 分钟,目标 07:54;定位 5 分钟、修复构建 10 分钟、真机与记录 15 分钟。仅执行必要测试/构建;尚未通过菜单进入正片和关闭菜单历史复播,不能提交/tag。 -- 单独临时 logcat 标签已经按 `candidate-log-tags-before.txt` 恢复;继续读取 App 文件日志,不清日志/数据、不改用户 mpv.conf/偏好。 -- 唯一下一动作:运行 EOF 策略定向测试和 Mobile APK/Leanback 编译,然后安装并在同机验证两个失败路径。 -- 07:33 EOF 策略定向测试、Mobile APK 和 Leanback Java 编译已通过(`menu-eof-apk-build.log`,29s)。安装前用户报告当前《豪斯医生 第一季》ISO 错误,先保存 `current-iso-error-0733.log/.png/.xml`,暂停安装。该盘连续 3 次在正常 HTTP 206 读取后得到 unknown_format(-17),与菜单 EOF 不同;现有 App 日志过滤掉了部分 Blu-ray 识别/加密诊断,不能仅凭缺失日志断言 native 从未尝试 libbluray。仅补 ISO 路由选项及 Blu-ray 诊断记录,再做同盘菜单开/关对照;不改网络超时或硬解策略。 - -## Checkpoint 15:2026-09-08 08:12 《豪斯医生》FIRST PLAY 初始化被跳过 - -- Checkpoint 14 APK SHA-256 `ea5cb16eadd9626a571a3ad6e7fad1cda3dd0ad35703d0951531e00364d2eb07` 已经安装成功(`menu-eof-install.log`)。EOF 策略 JUnit 13 项全部通过;Mobile APK/Leanback Java 编译通过,新增诊断后的二次编译 22s。二次编译对应新增诊断代码,并非重复成功验证。 -- `house-diagnostic-failure.log` 08:03:13–18 确认 `raw=true disc-menu=yes access-references=yes`,`ISO detected as Blu-ray`,`hdmv_mode=1`,随后初始 TITLE=65535 被 TOP MENU=0 覆盖,连续数百次 BD_EVENT_TITLE(5)=0、无 PLAYLIST(6),最终 demux unknown_format(-17)。此前“未交到光盘入口”的初步判断已被完整日志推翻,实际是导航启动失败,不是网络或解码器失败。用户无需继续重复打开同一个候选。 -- 固定 libbluray 1.4.1 `bluray.c::bd_play()` 仅排入 FIRST PLAY;VM 真正运行在 `_read_ext()`。当前 `stream_bluray.c::bluray_stream_open_internal()` 紧接 `bd_play()` 调用 `bd_play_title(TOP_MENU)` 会替换尚未执行的初始化程序。`bluray.c::_read_ext()` 自带注释明确描述:越过菜单系统初始化,可能在 root menu 中无限循环(示例 Butterfly on a Wheel)。本机 TITLE=0 循环与该机制一致。 -- 最小修正只移除首次打开时强制 TOP MENU,保留 overlay 注册、bd_play 失败回退、BD-J top menu/无菜单默认最长标题。让光盘自行进行语言选择/片头/菜单初始化,用户后续 Menu/Popup 命令不变。不整体升级、不改网络、硬解或用户配置。 -- 实际启动代码块提取回归 `test_disc_navigation_start.sh` 通过:FIRST PLAY 未被顶层菜单覆盖;bd_play 失败注销 overlay/销锁并回退主标题;关闭菜单/BD-J 回退不启动 VM。上个源码 `/tmp/p9-still-pid-apply.U9bsDe` 同一测试在 first_play_pending/top_menu_calls 断言失败(`old-disc-start-regression.log`),明确证伪旧行为。持久 patch 已机械同步。 -- 诊断过滤收窄到 Blu-ray 识别/启动关键消息,不长期记录每个 bdnav event,避免静止页日志刷屏。两 ABI 构建日志为 `first-play-arm64-build.log` / `first-play-armv7-build.log`;JNI 无改动不重编。 -- 原 07:54 目标因新增同盘故障取证及安装等待超出,已向用户说明并停止额外探索,只完成启动修复和菜单/普通续播验收。仍为同一已授权 P9 guard 与原回滚锚点,不提交不推送。 -- 唯一下一动作:收取当前两 ABI 增量构建结果,stage/验证/手机打包安装,再复验《豪斯医生》和《幽灵公主》。 - -## Checkpoint 16:2026-09-08 10:56 《豪斯医生》菜单背景卡顿诊断(只读) - -- 用户本轮要求先打 tag,然后判断菜单背景卡顿是否正常、是否网络或解码渲染问题。10:22:00 创建本地注释 tag `recovery/P9-MPV-BLURAY-MENU-FIX/20260908-102200`,指向已提交基线 `831b70433e3dbdfd6f119c6036a3c8cf22d85ae4`;已明确告知不含工作区未验收修复、不推送。没有把用户的“好像可以播放”扩大为整个 P9 验收通过。 -- Checkpoint 15 产物:patch SHA-256 `fe616538bf8cdc0ed28908daad4f837c3eb452884eea034cd1d0c338d68f44ce`;arm64 libmpv `c8d0b81213be59bf887a887e6b1f1277e3e1baf4ec3b856bf0b93bef9e62643d`;armv7 `3e354633b6b6e6262c611625b3e79b7e34291a189a00eb8e1fbb0ed2b92c9e51`;手机 APK `46784d51c224dbe66e69abe76edbe1aad7345bb62b390c49b24a27c5edcbfa1b`。双 ABI 仅编译 `stream_bluray.c`,ELF/资产验证通过,Mobile APK/Leanback Java 26s 通过;当前补丁反向 apply --check 通过。`first-play-install.log` 安装成功,设备 lastUpdateTime=08:29:31。未重复构建。 -- 真机恢复:ADB 曾断开,用户重新连接后仍为 vivo V2453A / Android15 / serial `10CF6H1D2L0009S`。`menu-stutter-current.png/.xml` 确认《豪斯医生》实际 HDMV 菜单与手机白色退出全屏图标。`first-play-reconnected.log` 08:33 的同盘启动已有 H.264 MediaCodec READY;当前卡顿会话 `p-x1v96g-1` 的 `menu-stutter-1022.log` 覆盖约 10:13:55–10:28:48。 -- 观察:H.264 1920×1080 / 23.976fps,观察到 hwdec=mediacodec、vo=gpu-next、GPU 渲染路径非 Surface direct。10:14:00.125 `dec=0 out=813`,10:14:30.357 `dec=0 out=916`;10:28:46.749 `dec=0 out=2983`。该窗口共新增 2170 输出丢帧、decoder drop 保持 0;不能把输出丢帧直接等同于 GPU 性能不足。MediaCodec port 等待多为微秒至个位毫秒,所取日志没有 >50ms port 等待证据。温度状态 nominal,重缓冲计数为 0。 -- 观察:窗口内 511 次 Range start 全为 4MiB 页面,仅 3 次来自 iso-prefetch 线程,其余来自播放读取线程;510 次完成请求中位 697ms、P90 967ms、最长 9214ms。199 个遥测样本的可播放缓冲中位 210ms、P10 为 0。10:20:42.954–10:20:52.167 一次页面请求独占约9.2秒,无需把这种停顿当成原盘正常动画。请求均正常 206 并不表示供数实时性足够,也不能据重缓冲计数0排除网络等待(导航路径 file-local cache-pause=no)。 -- 本地确切路径:`demux/demux.c` 在 nav_active 时禁用媒体 read-ahead,避免提前推动光盘 VM;`IsoPageCache.readAt()` 只有消费完当前4MiB页才调用 `prefetch(next)`,通常下一次播放读取已立即需要该页。本盘画面循环时仍反复重取已读页,与8页/32MiB LRU未覆盖背景及相关文件工作集相符。日志中极少的后台预读与该代码行为一致。 -- 结论分级:已证实存在真实输出丢帧与同步远程读取停顿,不属于纯静止菜单或主观低帧率;最高可信主因是原始 ISO 预读/缓存对导航按需读取适配不足,叠加来源/本地代理/网络请求延迟。不能仅从手机日志分解远端服务、代理、Wi-Fi各自贡献;也尚未做同片本地/暖缓存对照,不能声称彻底排除输出调度问题。现有证据不支持“设备不能硬解”或直接换渲染器。 -- 推荐后续:先在原始字节层更早预取有限页面、保留取消/跳转及内存边界;不要直接恢复 MPV 媒体大幅 read-ahead 以免推进 VM。应对比同菜单固定窗口的 Range 等待、缓冲低水位、decoder/output drop,并保持按钮跳转正确。本轮是诊断授权,不实现该优化、不调整用户设置或超时。 -- 本轮唯一编辑为该任务文档,保护 `app/.cxx/` 和所有既有 P9 改动。关闭菜单的历史复播、《幽灵公主》完整菜单→硬解正片、《超脱》以及 Surface direct 边界尚未全部验收,不能提交工作区或给未提交修复打“通过”标签。 -- 唯一下一动作:交付上述诊断并等待用户是否继续优化原始 ISO 页预读的决定。 - -## Checkpoint 17:2026-09-08 11:16 确认菜单循环缓存淘汰,久等不会缓存完整 - -- 用户质疑停留菜单半小时仍卡顿,要求确认根因。本轮只读诊断,不实现优化、不修改设置;唯一仓库编辑为本任务文档,所有已有 P9 代码/产物和 `app/.cxx/` 保留。11:11 开始,预计 5–8 分钟;不重启网络研究或构建。 -- ADB 当前无设备,最新采集没有成功,不将 `menu-cache-root-1111.log` 当作新证据。使用已保存的 `menu-stutter-1022.log`(同一会话 `p-x1v96g-1`,10:13:55–10:28:48)解析真实 Range 地址序列,未重跑播放。 -- 决定性证据:从10:14:37.024至10:28:37.804,可分出连续16个完整循环,每个循环都是30次请求、30个不同的4MiB页,共120MiB;整个窗口也只有这同一组30个唯一页。总共511次请求,其中481次重复请求已有地址,总请求量2044MiB。这不是“菜单还没读完”或猜测工作集大小,是已有页面在每轮再次下载的直接记录(120MiB是按页触达量,包含菜单相关读取,不等同于单一视频文件的精确大小)。 -- 代码对应:`IsoPlaybackSession` 始终用默认 `new IsoPageCache(new HttpRangeIsoSource(...))`;`IsoPageCache.java:15–16` 固定4MiB×8页=32MiB,`:37–40` access-order LRU在第9页插入时淘汰旧页。这个已命中的真实调用路径没有持久保存整轮页面的逻辑;日志同样长期 `pages=8`。30页循环超过8页容量,下一轮读回开头时,之前的开头页已被后面的页逐出。等待时间不会增加缓存上限,因此半小时、甚至更久都不会自然变成全缓存播放。 -- 放大因素:`IsoPageCache.java:67` 当前页消费完才排队预读下一页,数据需求线程常先于预读线程发起实际请求。导航期间 MPV 媒体级read-ahead又为保护VM而禁用,故无法靠普通媒体缓存掩盖每次原始页重取的延迟。上一轮已测可播放缓冲中位210ms、小于页面请求中位697ms,与持续取数等待和输出掉帧相符。 -- 根因分级更新:**确定存在的实现根因是菜单循环数据不能保留、反复淘汰重取,加上过迟的原始页预读。** 不能再将长期卡顿笼统归因于用户网速。解码器为MediaCodec,解码丢帧0、输出丢帧持续增长;尚未完成缓存修复后同场景对照,故不声称这是每一帧卡顿的唯一原因,也不据输出丢帧认定GPU算力不足。 -- 最小修复方向应同时覆盖循环数据保留与提前原始页预读,而不是仅延长超时、只等待“缓存够了”、只换渲染器或盲目增大媒体read-ahead。本轮未设计/实施新磁盘或内存策略;后续必须保留低内存设备、取消、跳转、源校验及VM边界。 -- 唯一下一动作:向用户说明上述已证实根因及仍待修复对照的渲染边界,等待缓存修复授权。 - -## Checkpoint 18:2026-09-08 原始 ISO 循环缓存修复(已授权,实施中) - -- 用户明确“修复一下”,随后“继续”。沿用 `P9-MPV-BLURAY-MENU-FIX` guard、既有 P9 修改及回滚锚点;保护 `app/.cxx/`。本轮仅编辑 `app/src/main/java/com/fongmi/android/tv/player/iso/`、`third_party/mpv-player-jni/tests/` 和本文件。新 Java 回归放在既有 JNI 测试目录,通过该目录的 Gradle init script 编入定向单测,不覆盖 guard、不扩大其 scope。 -- 本地开始执行约 11:49 Asia/Shanghai,目标 12:19 前完成 Java 实现、定向回归与 debug 打包;手机连接和实测等待另计。无设备时不得宣称流畅度或整项 P9 已验收。 - -### 决定性问题与最佳实践核对 - -问题:不增加 32MiB 内存上限、不提前推进光盘 VM,怎样保留已读完的一轮菜单并掩盖下一页网络延迟? - -| 证据(2026-09-08 查阅) | 等级、支持结论及适用边界 | -| --- | --- | -| Checkpoint 17 原始日志 `menu-stutter-1022.log`,会话 `p-x1v96g-1` | A:30 个唯一4MiB页连续16轮重复下载;页0及连续29个视频页,120MiB工作集。不能将长期卡顿仅归因于网络速度。 | -| 本仓库 `831b70433e3dbdfd6f119c6036a3c8cf22d85ae4` 的 `IsoPageCache` / `IsoPlaybackSession` / `HttpRangeIsoSource` | A:内存LRU仅8页、页尾才预读;单个 activeCall 在收到响应头后清空,不能覆盖响应体读取与并发取消。属于 WebHTV 本地缺陷,不引入新上游 commit。 | -| 同一基线 `MpvHlsCacheCoordinator`、`DiskCacheCapacityPolicy` 与既有 coordinator 测试 | A/B:已有跨客户端容量、读租约、写预约、临时文件原子提交、LRU、低空间熔断;复用该实现比新造磁盘缓存管理器窄。保留 `max(512MiB, 总空间10%)` 空间底线。 | -| [Android app-specific cache 文档](https://developer.android.com/training/data-storage/app-specific#internal-create-cache),正文已读取 | A:缓存必须允许被系统删除、应用负责清理、使用私有 cache 目录。磁盘失效只导致 miss/网络回退,不使 ISO 播放失败。 | -| [OkHttp 5.4.0 Call.kt](https://github.com/square/okhttp/blob/parent-5.4.0/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Call.kt),与版本目录一致,正文已读取 | A:Call 覆盖响应体的完整生命周期,cancel 针对整个请求;必须到响应体关闭才注销,并在 close 时取消全部在途 Call。 | -| PR/issues/reverts、论文/博文类别 | 本次不移植上游缓存算法或依赖版本,没有适用的上游提交/revert待决;真实故障属于本地调用与生命周期。保留既有LRU与容量策略,无新算法优越性主张,论文/泛缓存帖子不能改变已有源码、官方契约与可复现日志共同决定的窄方案,故不展开。 | - -### 方案比较与实施决定 - -- **不改**:重复下载120MiB/轮不会随等待消失,不接受。 -- **直接恢复上游 MPV 媒体 read-ahead 或单纯扩容内存**:前者会提前驱动菜单 VM,后者增加低内存设备风险,均拒绝。 -- **WebHTV 窄适配(采用)**:菜单设置开启时,为 ISO 会话增加原始字节磁盘页层,复用 `mpv_hls` 目录与 `MpvHlsCacheCoordinator` 的现有共享预算,容量取当前 MPV 播放缓存设置(默认128MiB),不修改用户设置;内存仍4MiB×8。会话使用随机隔离键,不保存 URL/凭据,不跨会话复用;close 删除自身缓存,崩溃残留仍受原有全局LRU约束。关闭菜单时不启用此磁盘层,BD-J无提示回退及DVD菜单边界不变。 -- 预读只在连续原始字节读取时提早请求下一页;最多一项在途预读及一项最新候选,随机跳转丢弃旧排队候选,最多容许已经在途的一页结束,不让其阻塞新的前台页。close 取消所有HTTP读取并唤醒同页等待者。预读不调用 libbluray、不改变 demux/nav_active。 -- 磁盘缓存只有完整页原子提交;截断或文件消失回退网络;源长度/validator变化必须拒绝后续陈旧缓存,不混用新旧ISO。日志仅记录页数、命中/磁盘命中/网络页/预读计数,不输出URL。 -- 兼容/性能/生命周期边界:无 JNI/API/ABI/原生库变更;磁盘读写引入有限I/O,容量不足保留内存及网络播放。顺序正片与元数据读取仍使用同一源校验;Exo不接入此缓存。修复后仍需实测确认是否有独立输出调度问题。 - -### 验收与回滚 - -- 最便宜决定性回归:30页循环大于8页内存,第二轮及以后所需页面不新增网络读取;内存上限不变;只读部分页时下一页已开始预取;随机跳转不执行积压预读。 -- 同一组回归覆盖:共享磁盘预算/容量淘汰、失效文件/写失败回退、短读/EOF、同页并发去重、关闭与在途HTTP响应体取消、源变化拒绝缓存、会话隔离和关闭清理。 -- 然后仅构建 Mobile arm64 debug 和 Leanback Java;现有两ABI native产物不变,无理由重跑成功的原生验证。 -- 真机验收:同《豪斯医生》菜单,至少3个暖循环窗口对照 Range/磁盘命中及 output/decoder drop;目标暖循环不重取完整同段视频,不能以build或缓存单测替代流畅度。仍需保留菜单按钮→正片、关闭菜单复播及先前未验收边界。 -- 回滚只撤销本Checkpoint新增的ISO Java改动与其接线,保留此前FIRST PLAY等修复;整项P9仍以已提交基线和既有恢复tag为成套回滚锚点。所有必要真机验收未完成前不提交/tag为通过,不push。 - -### Checkpoint 18 本地验证结果(13:12完成) - -- 已修改 `IsoPageCache` / `IsoPlaybackSession` / `HttpRangeIsoSource`,新增 `IsoDiskPageStore`;测试及Gradle init script位于既有 `third_party/mpv-player-jni/tests/` guard范围内。未修改native源码/产物,未覆盖保护路径。 -- 一次Gradle组合运行成功,耗时1m44s:11项 `IsoPageCacheTest`、3项 `HttpRangeIsoSourceTest` 全部通过;30个真实4MiB页面循环4轮,每页源读取计数为1,内存map仍8页;两并发HTTP响应体在close后及时结束。源变更拒绝缓存、磁盘丢失/截断/空间不可用回退、共享预算/会话隔离、页尾前预读、跳转去旧候选、同页去重、关闭等待者等已验证。 -- 命令:`JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home LC_ALL=C bash ./gradlew --offline --init-script /tmp/p9-menu-gradle-staging.gradle --init-script third_party/mpv-player-jni/tests/iso_cache_tests.gradle :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.player.iso.IsoPageCacheTest --tests com.fongmi.android.tv.player.iso.HttpRangeIsoSourceTest :app:assembleMobileArm64_v8aDebug :app:compileLeanbackArm64_v8aDebugJavaWithJavac`。 -- 日志 `/tmp/p9-menu-device-20260907.Vq7vyl/iso-cache-build.log`;JUnit XML在 `app/build/test-results/testMobileArm64_v8aDebugUnitTest/`。Mobile APK SHA-256 `5aeecdddb7274c29d95356d8f4e13d202627dc2d820b86e1977a7c5ebcc4cf47`,路径 `app/build/outputs/apk/mobileArm64_v8a/debug/app-mobile-arm64_v8a-debug.apk`。 -- guard `check`通过:branch/HEAD、scope、protected dirty paths和staging安全。未finish/commit/tag;真机对照未通过。12:19本地目标超出,已向用户说明共享缓存与生命周期核对耗时并停止额外研究;不重复成功构建。 - -## Checkpoint 19:2026-09-08 13:32–13:48 正片卡顿与CPU取证(只读诊断) - -- 用户手机重连,报告“老版本”《豪斯医生》菜单及正片均持续卡顿掉帧、App CPU约100%。本轮先保留现场,不安装候选、不改mpv.conf/播放器设置、不暂停/重启当前播放、不清日志。仓库只更新本文件,所有既有代码/产物与 `app/.cxx/`保持不变。 -- 真机:vivo V2453A / Android15 / `10CF6H1D2L0009S`;package `com.fongmi.android.tv` versionName5.6.0/versionCode560,`lastUpdateTime=2026-09-08 08:29:31`,pid29642。因此“老版本”是当前缓存修复前的Checkpoint15,并非已证实为菜单功能之前的更早历史版本。 -- 证据目录 `/tmp/p9-menu-device-20260907.Vq7vyl/`:`feature-stutter-baseline-1332.log`、`feature-stutter-baseline-1340.log`、`feature-stutter-baseline-1332.png`、`feature-cpu-baseline-1332.txt`、`feature-cpu-baseline-1345.txt`、`feature-audioflinger-baseline.txt`、`feature-audioflinger-baseline-1345.txt`、`feature-thermal-baseline-1332.txt`、`feature-audio-sched-baseline.txt`。手机日志时钟与主机约差6分钟,所有播放时序比较在手机日志内部进行,不混用采样文件名时间。 - -### 已证实的观察 - -1. 当前正片会话 `p-x91tog-3`,H.264 1920×1080/23.976fps,实际 `h264_mediacodec` 硬解、`vo=gpu`/OpenGL(不是上午菜单会话的gpu-next);DTS 5.1软解为PCM5.1/48kHz,`ao=audiotrack`。13:37:12输出掉帧226,13:43:55为554,decoder drop始终0。不能把输出掉帧等同于手机无法硬解或GPU能力不足。 -2. 手机13:36–13:37窗口内61次完整ISO Range读取,中位1231ms、P90=1942ms、最长3517ms;同窗口13个telemetry采样的可播放缓冲中位156ms、P90=586ms,多个点为0。页地址正向递增,这次不是菜单反复循环;同步下一页读取依然会使正片供数中断。导航保护禁用媒体read-ahead时,正片也依赖及时的原始字节预读,不能以菜单循环修复概括全部正片问题。 -3. CPU口径:`PlaybackPanelResourceMonitor.publishCpuLocked()`=`Process.getElapsedCpuTime()`增量/墙钟增量×100,100%代表约一个逻辑CPU的时间,并非8核整机满载。第一组top去除首个无完整间隔样本后14点,App平均77.7%,其中 `ao/audiotrack` tid20315为44.57%、主线程11.43%、MediaCodec_loop3.14%;第二组10点App88.1%、同音频线程53.20%、MediaCodec_loop2.80%。第一组整机平均608.9%idle/800%,没有整机CPU满载证据。 -4. AudioFlinger pid29642/track6226是48000Hz/6声道PCM,`FrmRdy=0`,反复AT::remove/AT::add,Underruns持续累计;支持音频供数断续。该计数并非“丢了几帧视频”,不拿它代替视频掉帧指标。 -5. 热状态 `MODERATE(2)`,skin约42°C,具体CPU/GPU温度项未报告节流状态;温控可能是干扰因素,不能仅此认定是卡顿根因。 - -### 音频零样本忙循环源码复现 - -- 实际native源 `build/mpv-native/mpv-android/buildscripts/deps/mpv/audio/out/ao_audiotrack.c` SHA-256 `076f3bd56f98a05154736678512cfb2340011486127ae6ee1256158db78fa86c`。`ao_thread()` PCM分支调用 `ao_read_data(..., pad_silence=false, blocking=false)`;返回0时仍执行 `AudioTrack_write(...,0)`,随后立即下一轮播放状态/时间戳查询,无条件等待。相比之下现有compressed分支无帧时会等待20ms。 -- `audio/out/buffer.c::ao_read_data()` / `ao_read_data_locked()`明确允许锁暂不可得、音频断粮、EOF等情形返回0;实际PCM循环必须处理“暂时无数据”,不能假定WRITE_BLOCKING的零字节写入会形成背压。 -- 只在临时目录 `/tmp/p9-audio-empty-repro.4x5JS9/` 写入host复现桩,自动提取上述真实完整 `ao_thread()`,没有编辑生产源码。`cc -std=c11 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-unused-variable`编译并一次运行通过:`reads=10000 writes=10000 zeroWrites=10000 waits=0 latencyQueries=10000`,确认当前实现存在可达的无等待空转路径。 -- 原生现场采样限制:`simpleperf record`因`security.perf_harden`拒绝,`debuggerd -b`要求root;不修改系统安全属性、不反复重试。故**源码忙循环是已复现缺陷,现场音频高CPU/underrun是实测;二者高度一致,但没有宣称拿到了该设备函数级CPU采样**。 - -### 结论、权限边界与下一步 - -- 可确认不是正常蓝光动画,也不是单凭“CPU100%”得出的猜测。当前证据支持两个相互放大的实现问题:原始ISO供数/预读过迟造成正片断粮;PCM AudioTrack无数据时忙循环浪费CPU。前者可导致输出停顿,后者增加CPU/发热;尚不能把全部输出掉帧唯一归因于音频空转。 -- 已构建的Checkpoint18 Java包尚未安装,不包含新发现的原生PCM忙循环修复。应先明确该新原生修改范围(有限等待/唤醒、正常PCM/压缩音频不回退、退出/暂停及时、零/短写及死对象恢复),再做最小回归、两ABI相关增量与同场景对照;不能只把Java候选宣称最终修复。 -- 本轮完成的是诊断。无新增生产代码/原生库修改、未安装、未commit/tag/push。预计8–12分钟的诊断用了约16分钟,原因是权限阻止函数级采样,切换到已在树上的真实源码最小复现后停止扩展。 -- 本文件 `git diff --check` 通过。整仓checkpoint脚本仍报既有 `third_party/patches/mpv-discnav.patch` 的空白context行(例如593/772/788)为trailing whitespace,并提示未提交的dependency/binary路径;日志 `checkpoint19-verification.log`。本轮没有修改该补丁,不做无关清理,不把整仓检查说成通过;这不是本轮诊断或此前Java回归失败。 -- 唯一下一动作:交付上述证据并确认原生音频零样本等待修复范围,然后继续候选真机对照。 - -## Checkpoint 20:2026-09-08 音频空读等待修复(已授权) - -- 用户明确“先打个tag,然后继续修复”,后续多次“继续”。首先已成功创建本地注释tag `recovery/P9-MPV-BLURAY-MENU-FIX/pre-audio-underrun-20260908`,指向已提交基线 `831b70433e3dbdfd6f119c6036a3c8cf22d85ae4`;不含工作区未提交修复,未push,不重复创建。 -- 完成目标:PCM无样本时不空转,正常输出与暂停/退出行为保持,再对照手机CPU、正片掉帧及菜单缓存。本轮路径限 `third_party/mpv-player-jni/patches/mpv-audiotrack-underrun.patch`(P9独立小补丁)、同目录tests、`scripts/build_mpv_native.sh`、`build/mpv-native`、两ABI `libmpv.so`及本文件;均在既有guard内,无需改guard元数据。保护 `app/.cxx/`及所有前序P9改动。 -- 14:24 Asia/Shanghai给出代码回归8–12分钟、构建打包5–8分钟、真机10–15分钟估计,预计14:55–15:00;多次用户继续/中断后仍执行同一方案,不重新研究或创建tag。 - -### 最小设计与证据 - -- A级现场/源码证据沿用Checkpoint19:原始ISO取数中断、AudioTrack线程45–53% CPU;实际 `ao_thread()` 空读10000次无等待。无需重新取证。 -- 上游对照:2026-09-08已读取 [mpv master ao_audiotrack.c](https://github.com/mpv-player/mpv/blob/master/audio/out/ao_audiotrack.c),保存 `/tmp/p9-mpv-upstream-audiotrack.c`,同样直接把 `ao_read_data` 的0样本交给写入,因此整体更新上游不能解决该边界。固定本地mpv基线仍为 `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`,不引入上游新提交。 -- A级平台证据:[Android15 AudioTrack.java](https://github.com/aosp-mirror/platform_frameworks_base/blob/android-15.0.0_r1/media/java/android/media/AudioTrack.java),已读 `write(ByteBuffer,int,int)` 与WRITE_BLOCKING说明;size=0是有效请求,blocking只针对所写数据,不保证零字节请求等待。保存 `/tmp/p9-aosp15-AudioTrack.java`。 -- B级成熟本地实现:现有compressed音频分支无帧时 `mp_cond_timedwait(...,20ms)`;同一 `p->wakeup` 被start/uninit唤醒,cond等待释放mutex,适用于PCM的暂时无数据。`buffer.c::ao_read_data`可能因断粮/EOF/锁竞争返回0,不采用不可中断sleep或持锁阻塞读。 -- 上游issues精准搜索 `repo:mpv-player/mpv audiotrack cpu`,仅找到不适用于本边界的AAudio新后端PR12261和scaletempo默认值issue8376;不采用换后端/时间伸缩算法。论文/泛博客不适用:这是已复现空读条件遗漏而非新调度算法,不做性能理论优越性主张。 -- 比较:不改=持续空转;原样上游=同缺陷;窄适配=PCM样本数<=0时等待最多20ms并继续,复用现有唤醒/退出条件。正常PCM写入、短写/死对象恢复、压缩直通、时间戳、JNI/ABI、VM/渲染完全不变。不存在音质/声道/解码降级。 -- 验收:提取实际函数验证空读零次写入且每轮有等待、空读后恢复、暂停/退出、正常完整/短写及dead-object旧路径;旧源码同一门槛失败。两ABI只增量编译该C文件与链接,不重编FFmpeg/JNI;资产校验、手机打包安装;真机至少3个短窗口检查AudioTrack CPU不再45–53%空转及缓存预读/掉帧。失败则保留日志并继续窄修,不宣布全P9通过。 -- 回滚:撤销本小补丁及构建接线并恢复两ABI匹配libmpv;保留之前Java缓存/FIRST PLAY修复。整体恢复锚点为上述tag。无新用户设置、安全权限、协议/依赖升级;不push。 - -### Checkpoint 20 验证与用户要求的恢复点(2026-09-08 15:16 Asia/Shanghai) - -- 实际提取的完整 `ao_thread()` host桩回归7项通过,覆盖空读等待/恢复、暂停启动/退出、正常写/短写/dead-object及compressed空帧分支;旧函数在 `zero_writes == 0` 断言失败。这不是设备生命周期的完整验收。 -- armv7增量构建成功;arm64直接ninja受pkg-config环境和残留armv7 iconv路径影响失败,改用项目 `buildall.sh -n --arch arm64 mpv` 后成功。未重编FFmpeg/JNI,功能列表与FIRST PLAY基线一致;双ABI ELF/SONAME/依赖及资产规则检查通过。 -- Mobile arm64 debug构建成功,installer assist返回Success并启动App。安装日志:`/tmp/p9-menu-device-20260907.Vq7vyl/audio-underrun-install.log`。Java缓存14项回归与Leanback Java此前已通过,未重复运行。 -- APK SHA-256:`21e6b884c250c53d4c2e09472810e1441de3ec550219b8da7d8e823f8c388c59`。 -- arm64 libmpv SHA-256:`30c1ed7f3d516eb2e172b7f87f4e1b5ae6537dd7c759501de348adaba1ee667f`;armv7:`70852b751701decde812d8157d0bd2c34a282522b0037719dce56f34160f07e9`。 -- 新audio underrun patch SHA-256:`88e6eeb356adb64641177bfd83f2f60e994787a2cba346be78b2c86f451e95bf`;discnav patch未变。此前guard及scoped源码/文档diff检查通过;整仓checkpoint脚本的既有discnav patch空白context告警不能记为通过。 -- 用户先表示“视频肉眼可见的不卡顿了”,要求先tag;随后澄清“掉帧还是存在一点,不过好很多了”,CPU约60%。据此只记录显著改善,不把剩余掉帧视作正常,也不声称AudioTrack CPU已实测回归。 -- 已知待确认:稳定正片/菜单的剩余掉帧、音频线程占用、最近观看缺少《豪斯医生》;此前关闭菜单历史复播、《幽灵公主》完整流程、《超脱》和Surface direct边界未全部验收。恢复点是用户明确要求的已测试快照,不是发布/全功能通过标签。保持BD-J静默回退、DVD现状、用户配置和手机数据不变,不push。 - -## Checkpoint 21:2026-09-08 已测试恢复点与后续反馈修复 - -- 用户明确要求先tag并继续解决音频/CPU、少量掉帧、开场卡顿、最近观看缺失、重缓冲始终0、预览章节页无法退出。已原子提交 `55a6365a9ff8c1a096ceb4275d50b95c1ed5f488`,本地注释tag `recovery/P9-MPV-BLURAY-MENU-FIX/20260908152234-55a6365a9ff8`,未push。保存的是用户测试后要求保留的阶段状态,不是全P9验收。guard原先被Git元数据权限和docs忽略规则阻挡;只对准确任务文档强制暂存,未修改守卫或忽略规则。 -- 本轮guard `P9-MPV-BLURAY-MENU-FOLLOWUP`,保护35个既有 `app/.cxx/`文件。允许路径见guard scope:P9 Java状态/缓存、PlayerManager、PlaybackActivity、Mobile/Leanback VideoActivity、DiscMenuDialog、定向测试、P9 native补丁/构建/双ABI资产及本文件;不改其它播放器或用户配置/数据。 -- 现场:`/tmp/p9-menu-device-20260907.Vq7vyl/submenu-current.log`、`submenu-audio-cpu.txt`、`audio-after-playback-1523.log`、`history-current.db`(本地只读快照)。最新菜单窗口15个1秒样本(排除首样本):App线程合计52.07%,ao/audiotrack平均0.40%峰值1%,明显不同于原45–53%空转。该窗口不是正片完整性能验收。菜单暖播放decoder/output drop为0;开场约33Mbps、Range 4MiB约1.4秒,仍会供数不足,不能把开场掉帧称为正常。 -- 历史根因:`VideoActivity.onTimeChanged/updatePlaybackHistoryPosition` 为防菜单多playlist时间线污染而跳过位置更新;`History.canSave()`仅允许position>0,首次导航观看因此从未保存访问记录。修复必须保留旧正片进度,不写入假的1毫秒、不把菜单时间当正片续播。 -- 计数根因:`MpvPlayer FILE_LOADED` 在nav模式设置file-local cache-pause=no;`PlayerManager.recordBufferingState`只认STATE_BUFFERING,所以此模式的实际取数停顿未计入。不能重新启用依赖禁用read-ahead的cache-pause,也不能把每次低缓冲/掉帧/正常still计为重缓冲。 -- 菜单证据:用户澄清预览项可选择播放,只有退出/其它底部按钮失效;本机方向键能移动缩略图,popup键未关闭此HDMV页面。原生NAV_CMD忽略libbluray返回值并一律STREAM_OK,Java的result=0不能证明光盘接受了输入。尚需验证实际返回/主菜单分支;不篡改光盘GPR/UO或假造鼠标成功。 - -### 最佳实践与最小决定(延续既有设计) - -- A级来源:当前提交的Java调用链及上述真机证据;锁定libbluray1.4.1 `bluray.c::_try_menu_call/bd_user_input/bd_mouse_select`、`graphics_controller.c::_user_input/_mouse_move`及`keys.h`,证明ROOT和POPUP是不同操作、按钮受当前page约束、没有通用HDMV BACK键。MPV `playloop.c::handle_update_cache`与本地FILE_LOADED的file-local override解释漏计。 -- B级交叉实现:2026-09-08经指定代理读取 [VLC bluray.c](https://github.com/videolan/vlc/blob/master/modules/access/bluray.c) `DEMUX_NAV_MENU/POPUP`,root失败才fallback popup;[Kodi DVDInputStreamBluray.cpp](https://github.com/xbmc/xbmc/blob/master/xbmc/cores/VideoPlayer/DVDInputStreams/DVDInputStreamBluray.cpp) `MouseClick/OnMenu`检查实际返回值并作popup/root fallback。下载快照位于上述证据目录;不是引入这些浮动master提交。 -- 对预读,沿用Checkpoint18的原始字节缓存/取消/容量设计及测试,不改变VM媒体read-ahead。比较:不改=33Mbps开场被单请求约24Mbps限制;整体升级上游=不解决Java raw-source调度;窄适配=仅现有导航磁盘缓存路径使用最多2个预读worker、最多4页前视,仍8页内存及既有共享磁盘预算,跳转替换排队范围。实测是否改善需装机对照,不能声称突破总带宽限制。 -- 对计数,采用“已开始播放、未用户暂停/seek、输出位置持续不前、无可播数据且原始ISO需求读取在等待”的联合条件,独立计数后与既有面板/telemetry统一;只观察,不改变MPV暂停或VM行为。普通媒体仍保留既有STATE_BUFFERING统计。用纯Java时间序列测试排除启动/暂停/still/仅低缓冲/短暂等待,覆盖持续停顿一次计数、恢复再停及重置。 -- 官方API证据来自锁定源的公开头文件/实现;上游提交/issue沿用Checkpoint18–20对该固定基线的结论,这轮不引入上游候选。论文/泛博客不适用于页面命令/历史canSave/明确漏计条件;并行预读不主张新算法优越性,以有界并发、取消测试和同源真机性能为gate。 -- 验收:定向Java测试、Mobile构建与共享调用点Leanback编译;同ISO开场→菜单→预览页退出/选择正片→最近观看;音频/掉帧至少三个短窗口。若确需native改动才运行实际函数host测试及双ABI增量构建/资产校验。当前改动未经验证前不得提交;回滚锚点为本Checkpoint的已测试tag,保留此前音频修复与FIRST PLAY接线。 - -### Checkpoint 21 阶段性保存(用户明确要求先tag,2026-09-08 17:54 Asia/Shanghai) - -- 已实现:Mobile/Leanback允许已启动HDMV导航保存首次访问记录,仍不把菜单时间当作可续播的正片进度;导航磁盘缓存路径最多2个预读worker、4页前视,保留8页内存和跳转/关闭边界;`MpvDiscRebufferTracker`只在已开始且未暂停/seek的播放中,联合判断位置不前、可播缓冲不足、原始需求读取等待,统一接入面板与telemetry计数,不重新开启导航cache-pause。 -- 验证:`MpvDiscMenuPolicyTest`14项、`MpvDiscRebufferTrackerTest`7项、`IsoPageCacheTest`13项、`HttpRangeIsoSourceTest`3项,共37项,failures/errors均为0;Leanback Java同次构建通过。日志 `followup-java-tests-jdk21.log`。首次命令因不存在的IDE内置JDK路径未启动,改用现有独立JDK21后成功,未安装新工具链。 -- 手机APK构建通过,日志 `followup-java-apk.log`;SHA-256 `a40ea5f45bcd4ed9c975cd412002c6fbd70e6f85ff0b4776db98c5873fa28aa3`。`followup-java-install.log`记录包指纹已改变、终止陈旧adb安装等待并成功启动App,脚本exit=0。以上日志均位于 `/tmp/p9-menu-device-20260907.Vq7vyl/`。 -- 未验证/未解决:新包开场供数、实际重缓冲计数、最近观看显示及正片连续播放;预览选集页可以移动/选择项目,但有效退出路径未确定,也未提交“菜单退出已修复”的代码。旧包菜单采样ao/audiotrack平均0.40%/峰值1%仅证明该窗口空转改善,不替代新包完整性能验收。 -- 用户要求立即保存本轮状态,因此不重跑测试、不继续操作手机、不把更广的P9验收写成通过。guard只提交本轮task-owned文件,保护 `app/.cxx/`;恢复tag由本次finish创建,未push。后续以这份文档为唯一记录继续真机验证及菜单退出修复。 - -## Checkpoint 22:2026-09-08 Kodi对照,菜单输入与退出修复 - -- 前轮已提交 `f4e4f9e16fb6b99bf72f8a19a5cb184a488e0580` / 本地注释tag `recovery/P9-MPV-BLURAY-MENU-FOLLOWUP/20260908181013-f4e4f9e16fb6`,未push。用户继续授权修复,明确要求参考Kodi。当前工作区仅既有 `app/.cxx/`脏,保护35个文件;guard已重新建立为 `P9-MPV-BLURAY-MENU-INPUT`。 -- 20:57 Asia/Shanghai计划:修复与回归10–15分钟、双ABI增量及APK8–12分钟、设备约10分钟,目标21:30左右。范围仅菜单输入Java接线/测试、独立native补丁及build接线/两ABI产物、本文件;不重做音频/缓存设计。 - -### 决定性问题与证据 - -- 观察:用户在《豪斯医生》《倩女幽魂》等HDMV主菜单展开章节/设置子页后,仍可点子页项目播放,但不能通过其它主菜单按钮切换或返回;短片段期间原盘主菜单有时无反应。之前日志仅有Java `disc navigation ... result=0`,此值由底层无条件STREAM_OK转换而来,不证明光盘接受操作。截图/日志沿用 `menu-exit-latest.*`、`ghost-down.png`。 -- A级固定源:MPV `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` 加已提交P9补丁。`MpvPlayer.sendDiscNavPointer`调用两次命令:先`mouse x y`再`discnav mouse-click`;`player/command.c::cmd_mouse`只将位置更新入input队列,`input/input.c::mp_input_read_cmd`取出该事件后才更新`mouse_x/y`。同步client命令在另一dispatch链执行,因此第一命令返回不保证第二命令读到新坐标。可证伪假设:旧输入队列未消费时,导航命令会使用旧坐标;单次命令直接携带当前窗口坐标可消除该时序依赖。 -- A级固定源:libbluray1.4.1 `bluray.c::bd_mouse_select/bd_user_input/_try_menu_call`、`graphics_controller.c::_mouse_move/_user_input/gc_run`及`keys.h`。鼠标必须命中当前页可选择按钮;root返回1成功/0失败;popup与root不同,HDMV没有通用BACK键。`BLURAY_PLAYER_SETTING_UO_RESTRICTION_DISABLED`公开注释明确可能破坏播放,因此本轮不关闭UO或修改光盘寄存器。能否中断受限短片段仍需实际命令结果判定。 -- B级对照:[Kodi DVDInputStreamBluray.cpp](https://github.com/xbmc/xbmc/blob/b2637ca499afe69f9d15c928809ffd6c42144250/xbmc/cores/VideoPlayer/DVDInputStreams/DVDInputStreamBluray.cpp),该文件最后变更commit `b2637ca499afe69f9d15c928809ffd6c42144250`(2026-09-08 API读取)。已读MouseMove/MouseClick/UserInput/OnMenu;[VideoPlayer.cpp](https://github.com/xbmc/xbmc/blob/master/xbmc/cores/VideoPlayer/VideoPlayer.cpp)将当次窗口坐标变换后直接传给输入流;头文件OnBack调用OnMenu。OnMenu先popup失败再root,MouseClick检查选择结果。下载快照 `kodi-bluray.cpp/.h`、`kodi-videoplayer-menu.cpp`及`kodi-bluray-source-revision.json`。仅行为对照,不引入Kodi源码或新依赖commit。 -- B级补充:已读VLC `DEMUX_NAV_MENU/POPUP`,明确root调用失败才fallback popup;Kodi libbluray依赖patch目录只有平台构建/加载兼容补丁,不据“完美支持”假定其绕过所有光盘UO限制。 -- 证据类别范围:这是已实现菜单设计内的输入时序/结果遗漏修复,官方公开API及实际源已能决定方案;不新增上游候选。此前P9记录涵盖上游实现/讨论,本次不再泛搜论文/帖子(没有新导航算法或性能理论主张)。下载均使用用户指定代理。 - -### 决定、验证与回滚 - -- 不改:两条命令间坐标时序及“成功”假象仍在。照搬Kodi:它不经libmpv输入队列,不能原样移植。窄适配:为已有discnav命令追加可选窗口坐标模式,Java只发一次携带点位的命令;保留原归一化坐标和无坐标键盘/鼠标兼容。native只对有效命中激活,记录真实结果;root失败才试可用popup,返回根据真实popup支持使用popup/root。不硬编码影片按钮/页号、不强制toggle作者未定义的菜单。 -- 新native改动用独立 `third_party/mpv-player-jni/patches/mpv-discnav-input.patch`,紧随原discnav补丁,不改固定锁、不重编FFmpeg/JNI;正常播放、BD-J静默最长标题回退、DVD现状、原始页缓存及音频等待保持。 -- 最小验证:实际cmd_discnav与坐标转换host测试,覆盖旧队列位置不影响直接点位、归一化旧调用、边界/黑边/缩放、无效点不激活;libbluray返回失败不伪成功、popup/root回退。Java命令形状定向测试;两ABI一致源构建及资产验证;Mobile打包安装后以主菜单→章节→设置/退出→短片段菜单的实际状态为验收。不用host测试替代真机。 -- 回滚:移除独立输入补丁及构建接线,恢复前轮tag的Java命令和成套libmpv资产;保留其它已提交P9修复。下一步只做上述定向实施/验证,不追加播放器升级或广泛研究。 - -### 输入修复实施状态(2026-09-08 22:02 Asia/Shanghai) - -- 已实现:Java单条`discnav mouse-click/mouse-move x y window`;native新增可选坐标空间,保留归一化及无坐标调用。转换反算`push_bd_overlay`实际使用的`osd_get_vo_res`矩形(带锁快照),不在core线程调用仅限VO线程的`vo_get_src_dst_rects`;负边距保留放大裁剪映射,黑边及画面外点拒绝。 -- 已实现:`bd_mouse_select`必须返回1才激活;root的0失败与`bd_user_input`的0非错误分别处理,后者仍可能没有界面变化,日志明确记录hit/root/input/accepted/popup,不把0推断为已切页。Popup支持状态在overlay锁内取快照,调用libbluray前释放,避免overlay回调死锁。root失败才试可用popup;prev/popup按实际支持选择popup或root,不改UO。 -- 新增独立`mpv-discnav-input.patch`,接在既有discnav补丁之后;未改锁、JNI、FFmpeg、音频和缓存。`MpvPlayer.sendDiscNavCommand`保留输入后的停顿计数抑制策略。 -- 定向host测试已通过:直接提取实际坐标函数、`cmd_discnav`、Blu-ray控制分支和激活分类函数,覆盖旧input队列位置、归一化边界/兼容、NaN/画面外点、黑边/缩放/裁剪、未命中不激活、错误不触发demux drive、key返回0合法、root/popup/prev回退及非HDMV拒绝。测试脚本`third_party/mpv-player-jni/tests/test_disc_navigation_input.sh`。 -- 当前进行双ABI增量及Java定向测试;原21:55目标已延后,时间消耗主要为原生转换/返回契约和回归测试实现,不再扩展研究。ADB无设备,构建通过也不代表用户反馈已真机验收。 - -### 候选实测失败与恢复点(2026-09-09 Asia/Shanghai) - -- 构建/静态结果:两ABI增量构建成功(`input-arm64-native.log` / `input-armv7-native.log`);stage和`--require-elf`成功(`input-stage-assets.log` / `input-verify-assets.log`);16项`MpvDiscMenuPolicyTest`及Mobile/Leanback Java编译成功(`input-java-tests-authorized.log`)。独立patch按锁定MPV + disc-controls + discnav顺序应用校验、对真实build源码reverse校验通过。新patch空白context已规范化并重新确认可应用;未修改旧大patch。 -- 产物:arm64 `libmpv.so` SHA-256 `2298a4e860e10f5af6a96d8853157a6cfdcc19b07195c0be0822af4ade07b069`;armv7 `b3377c17b1f113305e40d9d97ddaff6098aa8a77492ea7d01488724271cab0db`。仅libmpv资产变化,JNI/FFmpeg资产未变。首个候选APK SHA-256 `ec88362ed19c7c8b7508159a3a75b0219261e47b9ba0918c92302fdb3b18a458`,zip内libmpv与arm64资产hash相同。 -- **用户否定验收**:2026-09-08 22:23已成功安装首个候选(`input-debug-install-reconnected.log`),用户随后明确反馈仍不能退出/切换子页,短片段中仍不能强制打开菜单。本轮坐标/结果检查修正不是用户问题的完整解决,不得标为通过,不commit/tag。 -- 实际证据:`input-user-failure.log`中22:23:57–22:24:14的8次`menu`都返回`-12`,并非成功但画面没响应;章节子页大部分mouse-click返回0(libmpv命令成功),部分返回-12。代理在诊断包亲自复现《倩女幽魂》主菜单→章节页→点设置,高亮变到设置但章节页仍在(`input-ghost-chapters.png` / `input-ghost-setup-click.png`),不能解释成点位整体失效。 -- 已核实设备实际加载的`app_mpv-libs/arm64-v8a/libmpv.so` SHA-256与候选相同;不是旧native资产残留。 -- 诊断遗漏及修正:`MpvPlayer.shouldDebugLogMpvLine`原过滤掉新的native `discnav action=`,已补白名单并构建安装(`input-diagnostic-apk.log` / `input-diagnostic-install.log`);随后发现`MpvPlayerEngine`默认`all=warn`还会屏蔽native INFO。在允许路径`MpvPlayer`里追加`bd=info,bdmv/bluray=info`仅放开蓝光流结果,不开启全局verbose。该最后一版已成功构建(`input-diagnostic-level-apk.log`),安装时设备断开(`input-diagnostic-level-install.log`:device not found),**尚未部署**。 -- 未决原因:root请求实际失败(UO/状态/其它原因仍需区分);子页按钮高亮成功但导航命令执行/页面转换未完成。不得直接认定UO是全部原因,不关闭UO、不硬编码影片页号、不继续重复坐标假设。`demux_drive_nav`目前仅强制读一个packet;slave自身缓存是否使VM pump延迟是待证伪问题,尚未实施新改动。 -- Git/保护状态:仍在`feature-menu`、guard `P9-MPV-BLURAY-MENU-INPUT`,基线`f4e4f9e16fb6b99bf72f8a19a5cb184a488e0580`;本轮task-owned源码/patch/测试/双ABI资产/文档均未提交,既有`app/.cxx/`保持保护。所有上述日志/截图目录均为`/tmp/p9-menu-device-20260907.Vq7vyl/`。 -- 唯一下一步:连接手机后安装已构建的最终诊断包,在同一章节页采集一组`mouse-click`、`prev`、`menu`的实际native结果与前后截图,以此选择下一处窄修复;不重做已完成Kodi调查或双ABI构建。 - -### 2026-09-09 设备输入结果与导航pump窄修复 - -- 已取得完整输入结果(`iso-level-chapter.log`/`iso-level-setup.log`):当前窗口坐标命中,`hit=1 input=1 accepted=1 popup=0`,章节页→设置只有按钮高亮变化;`iso-level-popup.log`中root=0。ISO入口复用了`iso`日志前缀,Java已仅对iso/bd/bdmv开启info并保留discnav白名单。 -- 当前证据决定先修导航pump缺口,不推断root失败一定由UO造成。现有`demux_drive_nav`只迫使外层读一个packet,而`d_read_packet`可以一直取slave缓存,不保证调用`bd_read_ext`;libbluray只在`bd_read_ext`执行挂起的菜单指令。旧host测试只测fill_buffer,未覆盖缓存不触达底层的条件。 -- 设计比较:不改会保留输入执行依赖媒体供数;直接在core线程调用libbluray事件pump会与demux/stream的状态变更交错;窄适配新增内部`STREAM_CTRL_NAV_POLL`,输入成功仅设置锁保护pending,外层demux每次取packet前消费pending并执行既有零字节pump,之后再检查真实discontinuity并重建slave。方向/hover也可能由作者定义自动动作,因此成功输入均唤醒pump。不增加媒体预读,不改光盘寄存器/UO,不模拟菜单弹窗。 -- 范围仍是已声明的独立MPV输入patch、build源码、定向测试、双ABI产物/Java日志接线及本文件,不新增上游依赖或JNI API。接受标准:缓存中已有packet时点击仍推进VM;仅页面变化不flush解码;真实播放列表跳转仍按既有代数重建。Rollback为移除独立patch/成套libmpv与对应Java参数恢复f4e4恢复点。 -- 用户持续授权修复此功能;本次为已确证输入接线后的执行契约补全。必须host故障用例和同一真机章节→设置/返回结果验证,不能以编译替代。 - -- pump候选两ABI编译、资产校验与新增缓存packet回归通过,已安装;`pump-setup.log`明确`input=1`后`pump change=23->23`,仍未切页。因此pump缺口确实补齐但不是此盘子页问题的完整根因。`uo=3`=MENU_CALL|TITLE_SEARCH,已确认root调用被光盘限制,不再猜测坐标或漏pump。 -- 当前临时诊断:仅build目录`stream_bluray.c`加无owner指针的Android日志回调并开启GC/HDMV trace;原始pump源码保存`/tmp/p9-input-native.gZWNU6/stream_bluray-pump.c`。此临时改动未进入独立patch,arm64诊断产物与armv7暂不同,**严禁commit/tag这个临时状态**;取证后必须移除临时hook并恢复双ABI一致构建。下一步仅安装trace包,读取同一按钮的实际导航bytecode/VM结果。 - -### 2026-09-09 VM取证与显式菜单请求的窄适配 - -- 设备 `10CF6H1D2L0009S` / vivo V2453A / Android 15;证据目录 `/tmp/p9-menu-device-20260907.Vq7vyl/`。`vm-setup-after.log`显示章节页4的按钮29确实激活,之后没有新的HDMV命令;`vm-panel-down.log`显示从章节预览选中项按下后自动激活不可见按钮33,执行 `SET_BUTTON_PAGE 0x80000001,0x80000002` 回到主页面2。不能再以点击成功或overlay变化作为切换通过。 -- 这说明该盘章节页的底部按钮并不是主页面按钮的通用副本,物理遥控器是通过作者提供的方向图返回。Kodi同样调用 `bd_user_input`/`bd_mouse_select`,并没有通用HDMV BACK API(`keys.h`仅有ROOT/POPUP/方向/ENTER)。不采用全盘固定DOWN、不写寄存器或页号、不在无可验证动作时强制重播原点位;触摸适配需要libbluray可验证的菜单关系,另行请求新增一份依赖补丁的范围授权。 -- 短片段返回菜单的已证实失败独立处理:libbluray 1.4.1 `bluray.c::_try_menu_call`在默认RELAXED级别仍拒绝 `uo_mask.menu_call`,与手机 `root=0 uo=3`一致;`bluray.h`公开 `BLURAY_PLAYER_SETTING_UO_RESTRICTION_LEVEL`,关闭限制可能绕过盘的初始化。上述锁定源码为A级证据;此前保存的Kodi `OnMenu`参考revision `b2637ca499afe69f9d15c928809ffd6c42144250`为B级证据;访问日期2026-09-09。 -- 比较:不改/照搬Kodi都保留被UO拒绝的调用;全局关UO会改变所有导航且可能破坏FIRST PLAY;采用最窄适配:用户明确点击ROOT/TITLE或非Popup返回时,先正常调用,仅当失败且MENU_CALL被屏蔽、已向播放器供过媒体字节时,对这一次 `bd_menu_call`临时使用DISABLED,之后无论结果如何立即恢复库默认RELAXED。自动开盘仍只 `bd_play`,不自动强跳;无菜单盘、BD-J、未开始供数、非UO错误均不放开。 -- 用户已反复明确要求短片段中“强制弹出菜单”,本次属于已批准行为;不扩大音频、缓存、解码或其它UO策略。风险:个别盘可能在片头结束后才完成其它寄存器设置,故不能宣称所有盘都可强跳;需要同盘片段和菜单/正片回跳真机检查。验收:正常菜单先走原调用,受限调用仅一次放开且恢复限制,初始化/其它错误不越过,静态host覆盖及真机真实主菜单画面。Rollback:移除此窄helper及对应测试,成套恢复原独立输入patch/双ABIlibmpv。 - -### 2026-09-09 10:17 用户否定候选,补充有界debug - -- 最新证据 `user-failure-1018-logcat.log`:10:03:11/15 的root override返回1,但后续多次点击只有高亮,pump代数不变;其中一次主线程同步discnav达3056ms,ANR诊断记录1717ms native等待。`user-failure-1018.png`仍停留章节弹窗。不能把返回值1等同完成切页。 -- 日志方案:仅debug App放开蓝光模块debug等级;MPV接入libbluray公开的 `bd_set_debug_handler` 与 `DBG_HDMV|DBG_GC|DBG_CRIT`,通过调用线程的临时上下文把指令输出送入现有MPV/App日志。只捕获菜单相关源行,限制每次/每秒数量;不写额外无限增长的手机文件,不记录媒体地址,不引用释放后的stream。普通/release配置不开启详细跟踪。 -- 记录Java命令/坐标、初始化/菜单可用性/Surface状态、同步耗时与延迟状态快照;native记录输入序号、点击命中/激活/UO重试分段耗时、VM指令、菜单事件、page/按钮信息及generation。此处统计的是执行跟踪,不臆测按钮定义中的指令数量。 -- 保持已有菜单语义不变;源依据为锁定libbluray 1.4.1 `util/log_control.h`/`logging.c`(公开无owner的进程级日志回调)及现有GC/HDMV日志,MPV `osdep/threads`线程局部与once机制;这是局部诊断扩充,不引入新的导航设计或依赖版本。验证重点是过滤/限额/上下文释放、两ABI可构建、日志实际可达及相同失败的完整操作链。范围沿用当前guard,新增测试仅在已允许tests目录。 -- 静态验证:`test_disc_navigation_trace.sh`通过正常/禁用/过滤/每次96行/每秒600行/线程隔离/调用后无owner;`test_disc_navigation_input.sh`及`test_disc_navigation_progress.sh`通过。第一次input测试与arm64编译都暴露同一个新增计时变量 `started` 和旧bool重名,已改为 `call_started`,重跑相关测试及arm64构建成功;未重复无关已通过测试。 -- 产物及构建记录:`debug-chain-arm64-fixed.log`、`debug-chain-armv7-native.log`、`debug-chain-stage.log`、`debug-chain-verify.log`均成功;`debug-chain-apk.log` Mobile debug与Leanback Java编译成功。arm64 libmpv `a8e3938d49010f1f67e293a547367712ab490b4af57fbe9b0c160ada220ec3fc`,armv7 `251e24a586a5e822ee703060a32c102acbd083f1966d6dae726df4d4b4b82288`,独立patch `867b3e726f541a08182f37277b362a2aee26c547ddda3bb43a9f6515aa19bbae`;实际build源reverse校验通过,未改libbluray源码。 -- 检查点脚本 `debug-chain-checkpoint.log`:0 errors,1 warning仅提示本任务拥有的双ABI修改,已与guard scope核对;既有 `app/.cxx/`未接管。本轮源码和产物继续未提交,因为整个菜单修复单元仍有真实功能失败,不能安全声明完成并tag。 - -### 2026-09-09 诊断包真机结果(继续取证,非修复验收) - -- `debug-chain-install.log`安装成功;手机运行中 `app_mpv-libs/arm64-v8a/libmpv.so` SHA-256为 `a8e3938d49010f1f67e293a547367712ab490b4af57fbe9b0c160ada220ec3fc`,与仓库产物一致。无需重装或重建来证明同一版本。 -- 12:33:59 `request=2/seq=2`:点击章节正确激活主页面2的按钮1,随后 `SET_BUTTON_PAGE` 2→3→4,章节页实际出现。 -- 12:34:04 `request=4/seq=4`:点击设置坐标由窗口 `(1350,982)` 映射到盘 `(1110,982)`,第4页按钮29激活,`user-input=1`仅3ms;pump完成0ms,change保持23,之后未执行任何切页指令;1秒后播放器仍active且章节弹窗保留。证据 `debug-chain-setup-app.log`、`debug-chain-setup-failed.png`。**“零指令按钮”仅为假设**:上游现有日志未打印 `num_nav_cmds`,还需在真实按钮/GC结果边界区分零指令、被覆盖、VM拒绝。 -- 12:41:12 `request=5/seq=5`菜单键:ROOT正常调用被UO拒绝,临时重试成功,VM重进object1,PLAY_PL0后page0→1→2;图像确认该场景回到主页面。证据 `debug-chain-menu-return.log/.png`。这不证明所有返回场景或用户的其它盘通过。 -- 13:26:03–06 `request=11/12`:启动预告后进入TITLE2/PLAY_PL2,当前菜单不可见、`uo=2`;约2.5秒后菜单键调用正常ROOT(无需UO重试)成功,pump114ms回TITLE0/PLAY_PL0。随后盘VM检查自己先前设置的r21=1,清为0,执行 `SET_BUTTON_PAGE` 到12,即特别收录子页。因此“返回后仍见同一弹窗”至少在这一场景是作者显式恢复路径,不是播放器没执行请求。证据 `debug-chain-trailer-menu.log` 与菜单后截图;切片前截图在黑场,不能据此确认完整预告画面,但后续 `debug-chain-trailer-visible.png`已捕获00:08/02:03实际预告画面。 -- 底栏入口尚无有效复现:`debug-chain-toolbar-attempt.log`对应尝试中屏幕横竖切换,预期 `discMenu`控件不存在,脚本按未命中停止。不得把菜单键路由当作底栏成功证据;需要下次用稳定同一屏幕在片段中读取底栏控件即时操作。 -- 诊断范围交付:有界详细日志已真实可达、关联到按钮/VM/事件/返回/耗时,用户反馈依然成立;源码/patch/双ABI产物和既有未验收修复处于同一guard,不能为了保存日志把整个候选提交成已验证修复。需扩展到libbluray内部只读取证/适配时,使用独立依赖patch并先取得明确范围授权。 - -## Checkpoint 24:2026-09-09 原盘渐进读取优化(已批准) - -### 基线、问题与授权 - -- 用户先要求分析《豪斯医生》《倩女幽魂》片头卡顿和底栏原盘菜单延迟,随后明确“优化”。已完成诊断保存在 `/tmp/p9-opening-latency-20260909.EiUyOp/diagnosis.md`,不重做通用菜单研究。原始日志含私有地址/请求头,只公开脱敏计时。 -- 基线 commit `310f8feef5c0a05e5dae6c0a063453113214ea2c`;APK SHA-256 `13a7dfb6faf4567cbd95a90096c78ab8157d0e98be9903f79b8c14640cf55ce7`。arm64/armv7 libmpv 分别为 `eddfed3df1e1cea3b263a42778088ba3cf70f94dde6f08839357957084723948` / `371303839f5a5bd0a3724bd4fb84449d4ebae62a38cda111682c694598b86370`,本单元不重建或修改它们。 -- 设备10CF6H1D2L0009S(vivo V2453A,Android15)已连接。豪斯医生 `p-z7gpfl-1` 点击到menu-active/restart为6.427/6.678秒,其中前台4MiB读3630ms、导航pump3682ms;倩女幽魂 `p-z7iyho-2` 为6.104/11.177秒,前台4MiB读2525ms,重探测又读4MiB耗4982ms。Java命令分别15/4ms返回、强制返回VM请求0ms成功,不是按钮不响应。 -- 豪斯医生片头约33Mb/s,出现584+1009ms供数停顿,硬解已启用、解码丢帧0;倩女幽魂补测前两段没有持续丢帧,等待集中于后续短片/菜单切换,共8289ms。不能把loopback代理Range计时当作独立线路测速,也不能保证冷远程菜单零等待。 - -### 最佳实践审阅与本地调用链 - -决策问题:不提前驱动光盘VM、不增加既有缓存预算,能否让当前所需数据早于整页完成而可用,并让跳读替换过时的预读? - -| 来源与查阅日期(均2026-09-09) | 等级、支持结论、适用边界与决策影响 | -| --- | --- | -| 本仓库基线 `310f8feef5c0a05e5dae6c0a063453113214ea2c`,`IsoPlaybackSession.readAt → IsoPageCache.readAt/page/load → HttpRangeIsoSource.readAt` 及对应测试 | A:整页4MiB完成并同步写盘后才发布future;需求命中pending也等整页;跳读只撤销排队提示。现有8页LRU、共享磁盘预算、长度/validator检查和close取消是必须保留的契约。 | -| 固定MPV `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` 加当前补丁后的 `demux.c` 导航预读限制、`stream_bluray.c::STREAM_CTRL_GET_NAV_STATE`、`demux_disc.c::reopen_slave` | A:整个导航会话都禁止普通解复用预读;菜单逻辑就绪不等于背景首帧。只优化原始字节,不移植新MPV提交或改变轨道重建。 | -| [Kodi 21.2 FileCache.cpp](https://github.com/xbmc/xbmc/blob/d1a1d48c3cb3722d39264ffdd8132f755ffecd27/xbmc/filesystem/FileCache.cpp),`d1a1d48c3cb3722d39264ffdd8132f755ffecd27`,已读 `Process/Read/Seek` | A/B:`Read` 有数据即可返回、仅等待必要数据;后台收到seek后以最新位置重置。参考生产者/消费者分离原则,不照搬Kodi有状态顺序文件源到本项目并发随机Range。 | -| [OkHttp ResponseBody.kt](https://github.com/square/okhttp/blob/61423f472da24e0ccc42b6a2c0863fb27932fea5/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ResponseBody.kt),parent-5.4.0,commit `61423f472da24e0ccc42b6a2c0863fb27932fea5`,已读流式/关闭契约 | A:响应体是一遍消费的流,不要求整包缓冲;跨线程消费仍由消费线程关闭。使用同一Call渐进发布,取消具体请求,保留activeCalls直到body关闭。 | -| [RFC9110 §14.4、§15.3.7.3](https://www.rfc-editor.org/rfc/rfc9110.html#section-14.4),正文已读取 | A:严格限制Content-Range边界;续读从未发布字节开始,校验源长度/validator,不能重试覆盖已交付的数组区间;只完整页落盘。没有强validator时不能主张普适HTTP缓存一致性:继续现有会话内固定ISO假设,不跨会话/URL复用。 | -| [Kodi PR29133](https://github.com/xbmc/xbmc/pull/29133)、[PR29038](https://github.com/xbmc/xbmc/pull/29038),已读完整PR说明,未移植未发布实现 | B/C:seek后陈旧恢复不能覆盖新位置,网络停顿不能伪装EOF。作为并发/取消/截断测试的反例来源,不把待审PR或其性能报告当作本候选通过。 | -| 现场报告/benchmark:前述两片日志及后续同机对照 | A/C:直接匹配本App、设备、代理和原盘;以保留原始计时和可控慢源测试验证收益,网络波动必须报告。 | -| 学术论文/通用博客 | 不适用新的算法优越性判断:本单元保留LRU、已有磁盘协调器和有界预读,只拆分数据发布/完整缓存提交时机,不引入新的预测算法或码流处理。不会以论文中的不同负载吞吐替代这里的确定性测试与手机计时。 | - -研究到此收敛;OkHttp Relay旧目录两次404后已放弃,改以实际读取的Kodi生产代码验证同一设计问题;不将失败页面当作证据。本单元没有新增/合并上游commit,以上外部revision仅为参考。 - -### 方案、边界与选择 - -- 不改:仍以数秒整页读阻塞小范围菜单/索引需求,不能达到本轮目标。 -- 原样套用上游:MPV普通解复用预读会提前推进VM;Kodi顺序文件缓存不能直接代替并发Range和共享预算。均不采用。 -- 全局缩小页面:增加高码率媒体请求次数、影响关闭菜单路径,拒绝。 -- 采用WebHTV窄适配:只有原盘菜单设置开启且成功建立会话磁盘缓存时,通过 `IsoPlaybackSession` 使用新增 `ProgressiveIsoPageCache`;否则完全保留原 `IsoPageCache`。页面仍4MiB、LRU仍8页(32MiB),在途读取最多3个,与原两路预读加前台读数量相同;前向窗口最多4页,无无限线程/队列/VM预读。 -- 每页一个生产者,当前读点附近优先发Range。验证过的响应数据增量发布给等待者,不再等页尾或磁盘写入;页内已读区间有界保留,缺口续读,不覆盖已经交付的数组部分。同页去重,新需求优先于排队的预读;非连续读撤销不再需要且无等待者的任务,取消具体HTTP Call,不关闭整个ISO源。 -- 完整页面才交给原 `IsoDiskPageStore` 原子提交,继续同一128MiB默认/用户配置共享预算及低空间策略;磁盘异常只回退网络。取消、短读或网络错误不能把未写字节当作零数据/EOF/完整缓存;来源变更使所有本会话缓存失效。 -- 不变更JNI/API/ABI或依赖版本,不重建双ABI;不改变菜单按钮、音视频配置、BD-J回退、DVD菜单边界、播放历史与重缓冲定义。新增内部Java类只影响已启用原盘菜单的原始字节缓存接线。风险集中于并发、部分数据可见性、取消和I/O竞争,用对应测试处理。 - -### 验收、对照与回滚 - -- 最便宜决定性测试:可控慢源只交付所需前缀后阻塞剩余页,前台读取必须已返回;页尾小请求必须从附近发起,而不是等待前面数MiB。网络总请求/字节在完整顺序页面上不比整页基线增加;共享同页数据不能重复加载。 -- 必测:3读线程上限、旧预读取消/新需求优先、活跃等待者不误取消、close唤醒全部等待者、截断/短读/错误恢复、HTTP重试不覆盖已发布区间、validator变更拒绝、完整才落盘、超过8页循环后磁盘命中、缓存/存储失败和边界读取;旧 `IsoPageCacheTest` 保留,验证关闭菜单路径不变。 -- 构建一次定向Java测试+Mobile arm64 debug;公共Java接线同时做Leanback arm64 Java编译,不做native或全ABI矩阵。手机安装使用OEM安装助手。 -- 冻结基线APK;两片分别重复同一开场/底栏操作,记录缓存冷热、点击到menu-active/restart、停顿次数/时长、硬解和丢帧。存在网络噪声时至少3次候选和可比基线,报告中位数与范围,不只选最好一次。可控测试需消除整页尾部人为阻塞;真机目标菜单等待中位数改善至少30%、片头供数停顿不劣化,无法区分波动则不能宣称达到该幅度;保持收起/跨页切换回归通过。 -- 回滚:以 `310f8feef5c0a05e5dae6c0a063453113214ea2c` 及其恢复tag为锚,原子revert本轮Java/测试/文档提交即可恢复旧路径;原生资产未改,冻结旧APK可直接恢复。最终必须记录实测、原子提交并创建新的本地注释恢复tag,不push。 -- 证据目录:`/tmp/p9-iso-progressive-20260909.2zBuZ5/`。目前仅有设计/研究,未宣称实现或性能通过;下一动作是实现并运行定向测试。 - -### 2026-09-10 用户确认与阶段收口 - -- 已实现范围:新增菜单会话专用 `ProgressiveIsoPageCache`;HTTP请求增量发布、具体Call取消、断线从已发布前缀后续读;关闭菜单或无磁盘缓存时仍使用原 `IsoPageCache`。完整页面才写入既有磁盘协调器,不修改原生库、解码/渲染或菜单状态机。 -- 验证:`build-tests-approved.log` 记录34项定向Java测试全部通过(新缓存13、旧缓存13、HTTP8),包含慢响应提前返回、页尾需求、同页去重、旧预读取消、活跃读保护、截断/续读、源变化、完整落盘、30个4MiB页面循环及内存上限;Mobile arm64 debug打包和Leanback arm64 Java编译同次成功。先前sandbox锁文件权限失败在正确提权后解决,不属于代码失败。 -- 安装:`install.log` 记录OEM风险确认与继续安装均由助手处理,`Success`并启动 `com.fongmi.android.tv`;手机10CF6H1D2L0009S,APK SHA-256 `fc0b397af908989bf7e4ed6cefac6bdea491770d900af48d402e618a45543885`。冻结旧包 `baseline.apk` SHA-256 `13a7dfb6faf4567cbd95a90096c78ab8157d0e98be9903f79b8c14640cf55ce7`。 -- 用户实测反馈“速度好像改善了”,随后明确要求tag并稍后提出新需求。按明确收口规则停止额外真机/计时/构建,不把用户的主观改善扩大为所有原盘、所有网络或既定30%收益均验证通过;两片严格重复A/B尚未完成,量化幅度未定。 -- 本轮只提交guard所列源码、测试与本文档;既有 `app/.cxx/` 受保护,`/tmp`诊断/即时UI工具不进入提交。回滚锚点仍为 `310f8feef5c0a05e5dae6c0a063453113214ea2c`,原生资产不变。提交和tag由紧接本记录的guard finish创建,不push。 - -## 2026-09-13 B 侧 C4 合并遗漏修复(已确认实现缺陷) - -- Git 证据:`git diff 188553addf6620dd29ab344152599426694553a5..7dc58af1b0bb28818b23f43748c3ac67f76e0449 -- app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java` 显示 C4 squash 只新增 `case "disc-menu-active"` 处理分支,未新增 `observe("disc-menu-active", MPV_FORMAT_FLAG)`。`188553addf6620dd29ab344152599426694553a5` 相对 `2b36396c0d3ed18aa75250d3293c8f29d8b1a10d` 的差异同时移除了旧调用和处理分支,因此 C4 不能从该合并基线自动恢复;这是实现遗漏,不是有意移除。 -- 影响:`dispatchProperty()` 仅由 MPV native 属性事件进入。未注册观察时该分支不可达,`discMenuActive` 无法通过属性事件置 true,Activity 的按键和触摸转发路径会持续判定菜单未激活。`disc-nav-active`/FILE_LOADED 仍可设置 `discMenuAvailable`,所以 MENU 键打开菜单的入口不受影响。 -- 修复:在 `observeProperties()` 的 `sub-visibility` 观察后恢复 A 侧同位注册 `observe("disc-menu-active", MPVLib.MpvFormat.MPV_FORMAT_FLAG)`,不改变处理分支、状态机、输入转发或 native 行为。 -- 验证:`git diff --check` 与 `grep -n 'observe("disc-menu-active"\|case "disc-menu-active"' MpvPlayer.java` 确认注册与分支同现;后续聚焦 Java 编译通过后完成本单元。本轮不改 CMake 缓存或原生产物。 -- 编译结果(2026-09-13 Asia/Shanghai):`bash ./gradlew :app:compileLeanbackArm64_v8aDebugJavaWithJavac --offline` 通过,日志 `/tmp/p9-disc-menu-observe-compile.log`。编译验证只证明 Java 接线可构建;未重复真机菜单场景。 -- 回滚:revert 本轮 P9 原子提交并恢复 `7dc58af1b0bb28818b23f43748c3ac67f76e0449`;native 产物不变。 -- 唯一下一动作:聚焦 Java 编译通过后用当前 guard finish 提交并创建本地注释恢复 tag,不 push。 diff --git a/docs/SEARCH-EMPTY-SOURCE-HIDE-search-empty-source-visibility.md b/docs/SEARCH-EMPTY-SOURCE-HIDE-search-empty-source-visibility.md deleted file mode 100644 index 040fbdc4f0..0000000000 --- a/docs/SEARCH-EMPTY-SOURCE-HIDE-search-empty-source-visibility.md +++ /dev/null @@ -1,50 +0,0 @@ -# SEARCH-EMPTY-SOURCE-HIDE 搜索空站源隐藏 - -## Recovery anchor - -- 目标:搜索结果左列不再显示没有任何可见结果的站源;有结果的站源仍保持原有顺序。 -- 验收:0% 不限、1%-99% 过滤和 100% 精确三种状态下,无可见结果站源都隐藏;电视端与手机端一致;现有排序、过滤和恢复行为不回退。 -- 当前状态:已实施;`SearchSourceVisibilityTest` 在电视端与手机端均为 4/4 通过。 -- 下一步:执行任务守卫收尾,提交并创建恢复标签。 - -## 需求判断 - -用户反馈“相似度 0% 时,没有搜索结果的站源出现在左列,建议隐藏”是合理的。这里的 `0%` 不是相似度得分为 0,而是当前产品语义中的“不限相似度”。在 0% 状态下,某站源没有可见结果即代表站源没有返回原始结果;继续保留空站源行会增加遥控器和移动端浏览成本。 - -现有实现里,`1%-100%` 已经会隐藏没有可见匹配的站源,只有 `0% + 固定站源顺序` 保留了空行。该例外源自旧设计:固定顺序允许配置站源暂时为空。但固定顺序真正需要保证的是“可见站源之间的相对顺序”,不是“必须为无结果站源保留占位”。因此应移除该例外。 - -## 行为设计 - -统一可见性规则为: - -```text -站源至少有一条当前可见结果时显示;否则隐藏。 -``` - -不同相似度状态的含义: - -- `0% / 不限`:可见结果等于原始结果;无原始结果的站源隐藏。 -- `1%-99%`:可见结果等于通过相似度阈值的结果;无匹配结果的站源隐藏。 -- `100% / 精确`:可见结果等于归一化精确匹配结果;无精确匹配的站源隐藏。 - -站源后续返回第一条可见结果时,仍按当前站源配置顺序插入展示。因此固定顺序模式保留可见站源之间的原顺序,但不保留无结果空行。 - -## 实施方案 - -1. 将 `SearchSourceVisibility.shouldShow` 简化为只接收 `hasVisibleResults`。 -2. 电视端 `CollectActivity` 和手机端 `CollectFragment` 删除不再需要的 `fixedOrder` 局部变量,并改用新签名。 -3. 更新 `SearchSourceVisibilityTest`,删除“0% 固定顺序保留空站源”的旧预期,补充统一隐藏行为。 - -不改搜索请求、相似度算法、站源健康统计、分页状态和“全部”聚合逻辑。 - -## 风险与回滚 - -主要风险是电视端动态插入站源时焦点变化。现有 `1%-100%` 模式已经使用同样的动态显示路径,并通过 `siteKey` 恢复选择;本改动不引入新的排序或焦点恢复机制。 - -回滚方式:还原本任务的单一提交即可恢复“0% 固定顺序保留空站源”的旧行为。 - -## 验证记录 - -- `:app:testLeanbackArm64_v8aDebugUnitTest` 中的 `SearchSourceVisibilityTest`:4/4 通过。 -- `:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.utils.SearchSourceVisibilityTest`:通过。 -- 电视端全量测试期间另见 `FfmpegVc1SupportTest` 2 个失败;该文件在任务开始前已有未提交修改,且不属于本任务范围。 diff --git a/docs/SUB-EXT-HISTORY-external-subtitle-restore.md b/docs/SUB-EXT-HISTORY-external-subtitle-restore.md deleted file mode 100644 index 53e59be5a0..0000000000 --- a/docs/SUB-EXT-HISTORY-external-subtitle-restore.md +++ /dev/null @@ -1,385 +0,0 @@ -# SUB-EXT-HISTORY 外挂字幕选择随历史恢复 - -## Recovery anchor - -- 目标:手动或自动加载过外挂字幕的剧集,退出后从历史记录重新播放时自动挂回同一个字幕文件,无需再次选择。 -- 验收:同一集重进自动带字幕;换集、换源、字幕文件已删除时静默降级到现有自动匹配,不报错、不挂错字幕。 -- 当前状态:已实施。18 个新增单测在 mobile 与 leanback 均全绿,两个变体编译通过;设备冒烟未执行。 -- 下一步:执行任务守卫收尾,提交并创建恢复标签。 - -## 1. 结论 - -用户反馈成立,且根因不是"History 缺字段"这一条。真正的原因有两层: - -1. `History` 表确实没有任何字幕相关列(44.json 的 `History` createSql 共 30 列,无 sub/subtitle 字段)。 -2. 更关键:`PlayerManager.setSub()` 做的第一件事就是**删掉**字幕轨道记忆。 - -```java -// app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java:1197 -public void setSub(Sub sub) { - if (sub == null || spec == null) return; - Track.delete(getKey(), C.TRACK_TYPE_TEXT); // ← 外挂字幕一加载,轨道记忆先被清空 - engine.resetTrack(C.TRACK_TYPE_TEXT); - spec.setSub(sub); - ... -} -``` - -项目本来有一套轨道记忆:Room 表 `Track`(`bean/Track.java:15`,唯一索引 `(key, type)`,key 与 History 同源),写入点 `TrackDialog.onItemClick`(`TrackDialog.java:813`),读取点 `PlayerManager.prepareMpvOutputForNewItem()`(`PlayerManager.java:5340`)与 `onTracksChanged`(`PlayerManager.java:8508`)。这套机制记的是**轨道描述串**(`PlayerHelper.describeFormat`,首 token 是 `Format.id`),只能表达"选内嵌第几条轨道",无法表达"挂哪个外部文件"。所以 `setSub()` 主动删除是当时的正确选择——留着会指向一条重启后不存在的轨道。 - -因此本任务不是给 Track 表打补丁,而是补上缺失的第三种状态:**外部字幕来源**。用户选择的方案 B(保存来源标识而不是 sid)与代码现状一致,采纳。 - -## 2. 现状核查 - -### 2.1 外挂字幕的唯一收口 - -所有来源最终都汇聚到 `PlayerManager.setSub(Sub)`: - -| 来源 | 入口 | -| --- | --- | -| 本地文件选择 | `TrackDialog.java:830`(`FileChooser.getPathFromUri`) | -| 在线字幕搜索 | `SubtitlePlaybackSession.java:328`(DefaultResultApplier) | -| AI 翻译产物 | `TrackDialog.java:468` | -| 局域网推送 | `TmdbDetailActivity.java:10419`、mobile `VideoActivity.java:7122`、leanback `VideoActivity.java:5283` | -| 播放源自带 `subs` | 不走 setSub,直接进 `PlaySpec.subs` | - -`setSub()` 之后走 `spec.setSub(sub)`(`PlaySpec.java:183`:插到列表首位并打 `SELECTION_FLAG_DEFAULT`,其余降级 `AUTOSELECT`),然后**重启当前 item**(`restartCurrentItemWithState()` 或 MPV 重建)。没有热插字幕通道。 - -### 2.2 字幕来源路径特征 - -内部约定是裸文件系统绝对路径或 http URL,`Sub.url` 里不会出现 `content://`: - -| 来源 | 路径形态 | 持久性 | -| --- | --- | --- | -| 本地文件(能解析真实路径) | `/storage/emulated/0/...` | 稳定,取决于存储权限 | -| 本地文件(解析不出真实路径) | `Path.cache(name)` 下的副本 | 清缓存即失效 | -| 在线搜索下载 | `Path.cache("subtitle_asset")/...` | 清缓存即失效 | -| AI 翻译产物 | `Path.cache("subtitle_translation")/...` | 清缓存即失效 | -| 源站自带 / 在线直链 | http(s) URL | 每次起播源站会重新给出 | - -`FileChooser.getPathFromUri` 三条分支(document / content `_data` 列 / file scheme)都失败时才复制进 cacheDir(`FileChooser.java:190-208`)。全项目 `takePersistableUriPermission` 的 4 个调用点都与字幕无关,所以**不存在 uri 权限持久化问题**;真正的风险是分区存储读权限和缓存被清。 - -### 2.3 History 没有可复用的扩展字段 - -`History` 表 30 列全部有明确语义,没有 ext/extra/json blob(对比 `Config` 表有 `json` 列)。`legacyKey` 事实上是死字段(唯一写入点是 `Migrations.java:87` 的迁移语句,无业务读取方),但语义属于 key 迁移历史,不挪用。 - -### 2.4 已有的相关设计伏笔 - -`docs/unified-media-identity-cross-site-resume.md:349` 已经设计过 `SubtitleSnapshot`(`mode` = EXTERNAL / TRACK / DISABLED / AUTO),但该文档整体尚未实施(全项目 grep `SubtitleSnapshot` / `MediaPlaybackState` 均无匹配)。本任务实现的是它的 EXTERNAL 子集,字段命名与语义向其对齐,未来落地跨站旁路表时可平移,不需要推翻。 - -## 3. 方案对比与决策 - -### 3.1 no change - -保持现状。外挂字幕每集都要重选,且 `setSub()` 清 Track 记忆的行为让内嵌轨道选择也一起丢。不可接受。 - -### 3.2 方案 A:保存 MPV 的 sid - -在 History 加 `subtitleTrackId`,起播时喂给 `MpvPlayer.setInitialSubtitleTrackId()`。 - -不可行,理由是硬的:外挂字幕的轨道是运行时 `sub-add` 出来的(`MpvPlayer.java:2550`),退出播放器后这条轨道**不存在**。下次起播时 `sid=3` 会落到某条内嵌轨道或直接无效——不是"可能不准",是必然错。而且它只对 MPV 有效,ExoPlayer / IJK 分支拿不到 sid。 - -### 3.3 方案 B:保存外部字幕来源(采纳) - -保存字幕的来源标识(url/path + name + lang + format + 来源类型),起播前把它重新注入 `PlaySpec.subs`,走与手动加载完全相同的既有链路。 - -优点:与三个内核无关(都吃 `MediaItem.SubtitleConfiguration`);复用 `Sub` 这个已有的序列化模型;恢复动作发生在起播前,不产生二次重启和画面闪烁。 - -缺点:缓存目录里的字幕文件可能已被清理,需要显式的失效降级。这是可控的,见 6.3。 - -### 3.4 存储位置:新增列 vs 旁路缓存 - -两个候选: - -| | History 新增列 | 独立 JSON 缓存(仿 FlagPreferenceCache) | -| --- | --- | --- | -| 触发条件 | 受 `History.canSave()` 的 `position > 0` 门槛约束 | 选中即落盘 | -| 生命周期 | 与历史条目同生共死,删历史自动清理 | 需要自己做过期与容量淘汰 | -| 迁移成本 | 一次 Room 44→45 加列 | 无 | -| 备份 | `Backup` 自动带上(`Backup.java:66`) | 需另加白名单 | -| 跨源继承 | 随 `History.copy()` 走 | 需要自己实现 | - -选 **History 新增一列**。决定性理由是生命周期:`PlaybackProgressWriter.java:397/496` 删历史时已经会连带 `TrackDao.delete(key)`,字幕来源跟着 History 走就自动获得一致的删除语义,不会出现"历史删了但字幕偏好还在"的孤儿数据。`position > 0` 门槛也不是问题——用户能加载外挂字幕说明已经在播了,此时 position 必然 > 0。 - -反过来,`FlagPreferenceCache` 之所以要走旁路,是因为它要记"切了线路但没起播"的选择,本任务没有这个需求。 - -### 3.5 存一列还是多列 - -存**一列 JSON**,不是五列。 - -```java -@ColumnInfo(defaultValue = "") -private String subtitleSource; // SubtitleSource 的 JSON,空串表示无外部字幕偏好 -``` - -理由:字幕来源是一个内聚的值对象,五个字段没有任何一个会被单独查询或排序(不进 WHERE、不进 ORDER BY),拆列只会让 `copy()`、迁移、备份各多四行。项目里已有同类先例:`Config` 表的 `json` 列、`HistoryResumePayload` 用 `tmdb-season:` 前缀 + Gson 编码整个引用对象(`HistoryResumePayload.java:26`)。 - -## 4. 数据模型 - -### 4.1 SubtitleSource - -新增 `app/src/main/java/com/fongmi/android/tv/playback/SubtitleSource.java`: - -```java -public final class SubtitleSource { - - public static final String MODE_EXTERNAL = "external"; - public static final String MODE_DISABLED = "disabled"; - - @SerializedName("mode") private String mode; - @SerializedName("url") private String url; // 裸路径或 http URL - @SerializedName("name") private String name; // Sub.name,也是轨道 label 反查依据 - @SerializedName("lang") private String lang; - @SerializedName("format") private String format; // mime - @SerializedName("origin") private String origin; // local | remote | cache - @SerializedName("time") private long time; // 记录时刻,用于诊断 -} -``` - -`mode` 保留 `disabled` 是为了后续能记住"用户主动关掉了字幕",本期只写 `external`,`disabled` 分支留空实现(读到时当作无偏好)。字段名与 `docs/unified-media-identity-cross-site-resume.md:349` 的 `SubtitleSnapshot` 对齐。 - -`origin` 由 url 形态推导,不额外传参: - -```text -含 "://" → remote -在 Path.cache() 下 → cache -其余绝对路径 → local -``` - -`origin` 的唯一用途是恢复时决定校验强度(见 6.3),不参与匹配。 - -### 4.2 History 变更 - -三处,缺一处就会静默丢字段: - -1. 字段声明(`History.java`,紧随 `player` 之后): - -```java -@ColumnInfo(defaultValue = "") -@SerializedName("subtitleSource") -private String subtitleSource; -``` - -2. `History.copy()`(`History.java:137`)补 `item.subtitleSource = subtitleSource;`。漏了会导致跨源续播与季度快照投影丢字幕。 - -3. getter/setter,外加两个便捷方法: - -```java -public SubtitleSource getSubtitleSourceObject(); // 解析失败返回 null,不抛 -public void setSubtitleSourceObject(SubtitleSource); // null 或 !isUsable() 写空串 -``` - -解析失败必须吞掉(`JsonSyntaxException`)并当作无偏好——一条脏数据不能让整条历史不可用。 - -### 4.3 Room 迁移 - -`AppDatabase.VERSION` 44 → 45,`Migrations.java` 照抄 `MIGRATION_43_44` 的幂等写法: - -```java -/** - * 外挂字幕随历史恢复:History 新增 subtitleSource 列。 - * 空串表示这条记录没有外部字幕偏好,起播时不注入。 - */ -public static final Migration MIGRATION_44_45 = new Migration(44, 45) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - addColumnIfMissing(database, "History", "subtitleSource", - "ALTER TABLE History ADD COLUMN `subtitleSource` TEXT NOT NULL DEFAULT ''"); - } -}; -``` - -`AppDatabase.create()`(`AppDatabase.java:180` 之后)注册 `.addMigrations(Migrations.MIGRATION_44_45)`。旧行以空串初始化,语义等于"无偏好",不做任何推测回填。 - -需要一并产出 `app/schemas/com.fongmi.android.tv.db.AppDatabase/45.json`(`room.schemaLocation` 已在 `app/build.gradle:37` 开启,编译自动生成)。 - -## 5. 写入设计 - -### 5.1 写入点 - -唯一写入点放在 `PlayerManager.setSub()`,紧跟 `spec.setSub(sub)`: - -```java -public void setSub(Sub sub) { - if (sub == null || spec == null) return; - Track.delete(getKey(), C.TRACK_TYPE_TEXT); - engine.resetTrack(C.TRACK_TYPE_TEXT); - spec.setSub(sub); - callback.onSubtitleSourceChanged(SubtitleSource.of(sub)); // 新增 - ... -} -``` - -选这里的理由:2.1 已经证明它是所有外挂字幕来源的唯一收口,一处接线覆盖本地文件、在线搜索、AI 翻译、局域网推送四条路径。若改成在四个 UI 入口各写一次,必然漏。 - -`PlayerManager` 不直接碰 History——它没有 History 引用,也不该有。通过已有的 `callback` 接口回调给宿主 Activity,由宿主写自己的 `mHistory`。 - -### 5.2 宿主接线 - -三个宿主各加一个实现:mobile `VideoActivity`、leanback `VideoActivity`、`TmdbDetailActivity`。 - -```java -@Override -public void onSubtitleSourceChanged(SubtitleSource source) { - if (mHistory == null || Setting.isIncognito()) return; - mHistory.setSubtitleSourceObject(source); - syncHistory(); -} -``` - -`syncHistory()`(mobile `VideoActivity.java:5397`)已经是"copy 一份丢后台线程 save"的现成写法,直接复用。无痕模式直接返回,与既有 `saveHistory` 一致。 - -`CastActivity` 与 `AudioMiniPlayer` 不接线:投屏没有本地历史语义,音频播放器没有字幕。 - -### 5.3 不写入的情况 - -- 源站自带 `subs`:它们不经过 `setSub()`,每次起播源站都会重新给出,无需记忆。 -- 实时 AI 字幕:`RealtimeSubtitleController` 走的是独立通道,不产生 `Sub`。 -- `mode = disabled`:本期不写。用户关字幕走的是 `TrackDialog` 的 disabled 轨道,落在 Track 表,不是本任务范围。 - -## 6. 恢复设计 - -### 6.1 注入时机 - -注入点选 `PlayerManager.prepareMpvOutputForNewItem()`(`PlayerManager.java:5338`)——尽管名字带 mpv,它是 `start()`(:5882)和 `parse()`(:5913)两条起播路径的共同前置,且此时 `spec` 已赋值、`setMediaItem` 尚未调用。方法名会一并改为 `prepareOutputForNewItem()` 以匹配扩大后的职责。 - -时序: - -```text -start(spec) / parse(...) - ├─ this.spec = spec - ├─ prepareOutputForNewItem() - │ ├─ restorePersistedExternalSubtitle() ← 新增,注入 spec.setSub() - │ ├─ Track.find(getKey()) → 内嵌轨道预选(既有) - │ └─ mpv.prepareSubtitleForNewItem(...)(既有) - └─ setMediaItem(timeout) - └─ ExoUtil.getMediaItem(spec) → SubtitleConfiguration(三内核共用) -``` - -关键:注入发生在 `setMediaItem` 之前,所以走的是正常起播路径,**不触发** `setSub()` 里的重启分支。这是它优于"起播后再 setSub"的地方——后者会让用户看到画面闪一下。 - -`PlayerManager` 同样不读 History。新增 `PlaySpec.pendingExternalSubtitle` 由宿主在构造 spec 时填入,或由 `startPlayer` 链路透传;具体取哪种在实施时按 `PlaybackActivity.startPlayer()`(`PlaybackActivity.java:410`)的现有参数形状决定,倾向于让宿主在 `setPlayer(Result)` 里先 `result.setSubs()` 合并——`Result.setSubs()`(`Result.java:268`)已有"源站已带字幕时不覆盖"的语义,正好符合"源站字幕优先"的期望。 - -### 6.2 匹配与去重 - -注入前需要判断"这个字幕是不是已经在列表里了"。`Sub.equals()`(`Sub.java:80`)按 url 比对,`PlaySpec.setSub()` 内部先 `subs.remove(sub)` 再插首位,天然幂等。所以直接调用即可,不需要额外去重逻辑。 - -字幕加载后的"当前选中项"识别复用既有的 `PlayerManager.findSubtitleSub()`(`PlayerManager.java:1080`):优先按 `format.label == sub.getName()` 匹配,label 为空时退化到 mime + language 唯一匹配。这条路径原本就是为外挂字幕设计的,不改。 - -### 6.3 失效降级 - -按 `origin` 分级校验,全部静默降级,不弹任何错误提示: - -| origin | 校验 | 失败后 | -| --- | --- | --- | -| local | `new File(url).isFile()` | 清空该 History 的 subtitleSource,落回自动匹配 | -| cache | `new File(url).isFile()` | 同上(清缓存后必然走到这里) | -| remote | 不校验 | 交给播放器;加载失败时字幕不显示,播放不受影响 | - -`remote` 不做预检的理由是网络探测会拖慢起播,而字幕加载失败对 ExoPlayer / MPV 都是非致命的。 - -清空动作要写回数据库,否则每次起播都白跑一次文件检查。 - -### 6.4 不恢复的情况 - -- 换集:字幕文件是逐集的,上一集的 srt 挂到下一集是错的。History 的 key 含 vodId 但不含集号,所以需要显式判断——记录时把 `episodeUrl` 一起存进 `SubtitleSource`,恢复时比对,不一致则不注入并清空。这一条是本设计里最容易漏的地方。 -- 换源(`isCrossSourcePlayback()`):`History.copy()` 会带上 subtitleSource,但目标源的集数编排可能不同。跨源时不注入,字幕交给 `SubtitleAutoController` 按统一身份重新匹配。 -- 无痕模式:不写也不读。 - -## 7. 用户可见行为 - -改动后: - -1. 加载外挂字幕 → 该集历史记下字幕来源。 -2. 退出,从历史点回同一集 → 字幕自动挂上,`TrackDialog` 里显示为已选中。 -3. 切到下一集 → 不带字幕(沿用现有自动匹配)。 -4. 字幕文件被删或缓存被清 → 静默无字幕,不报错。 -5. 换源播同一集 → 不带旧字幕,走自动匹配。 - -不改变:字幕样式设置、在线搜索、AI 翻译、实时 AI 字幕、内嵌轨道选择、源站自带字幕的优先级。 - -## 8. 测试策略 - -新增纯 JUnit 测试(无 Robolectric,与 `HistoryTest` / `MpvTrackSelectionTest` 一致),放 `app/src/test/`: - -`SubtitleSourceTest` -- `of(Sub)` 对本地路径 / http URL / cache 路径分别推导出正确 origin -- JSON 往返不丢字段 -- 空 url、null Sub 返回不可用对象 -- 脏 JSON 解析返回 null 不抛异常 - -`HistorySubtitleSourceTest` -- `copy()` 带上 subtitleSource -- setter 收到 null / 不可用对象时写空串 -- getter 遇到脏数据返回 null - -`SubtitleRestorePolicyTest`(把 6.3/6.4 的判定抽成无 Android 依赖的纯函数再测) -- local/cache 文件不存在 → 不恢复且需清空 -- remote → 恢复 -- episodeUrl 不一致 → 不恢复且需清空 -- 跨源 → 不恢复但**不**清空(原源的偏好要留着) - -按 `docs/` 既有约定,每个测试先反向插桩确认能变红,再修正为绿。空转的绿是假信号。 - -`AppDatabaseBackupTest` 可能需要同步更新版本号断言,实施时确认。 - -## 9. 风险与回滚 - -| 风险 | 表现 | 缓解 | -| --- | --- | --- | -| 漏改 `copy()` | 跨源/季度快照丢字幕,且无报错 | 单测直接断言 copy 后字段相等 | -| 换集误挂上一集字幕 | 字幕时间轴完全错位 | episodeUrl 比对,见 6.4 | -| cacheDir 被清后残留死路径 | 每次起播白跑文件检查 | 检查失败即清空并写回 | -| 分区存储读不到 `/storage/emulated/0/...` | local 字幕恢复失败 | 已声明 `MANAGE_EXTERNAL_STORAGE` + `requestLegacyExternalStorage`;失败按 6.3 静默降级 | -| Room 迁移未注册 | `fallbackToDestructiveMigration(true)` 已开启,会**静默清空整库** | 迁移与 VERSION 必须同一提交;这是本任务最高危的一处 | -| 注入时机错位 | 二次重启导致起播闪屏 | 注入固定在 `setMediaItem` 之前 | - -回滚:还原本任务的单一提交即可。Room 版本回退到 44 后,45 列会被 `fallbackToDestructiveMigration` 清库——回滚前需提醒用户备份,或改为只回退 Java 代码保留 45 列(列存在但无人读写是安全的)。 - -## 10. 实施顺序 - -1. `SubtitleSource` + 单测。 -2. `History` 加列、`copy()`、getter/setter + 单测。 -3. `Migrations.MIGRATION_44_45`、`VERSION = 45`、`AppDatabase.create()` 注册、生成 45.json。 -4. 写入链路:`PlayerManager.setSub()` 回调 + 三个宿主接线。 -5. 恢复链路:`prepareOutputForNewItem()` 注入 + 降级策略 + 单测。 -6. 验证:`:app:testMobileArm64_v8aDebugUnitTest` 与 `:app:testLeanbackArm64_v8aDebugUnitTest` 的新增测试,加一次 `compileMobileArm64_v8aDebugJavaWithJavac`。设备冒烟按第 7 节的 5 条走一遍。 - -注意 `docs/preexisting-red-tests` 记录的 7 个既有失败测试属于固定红名单,核对名单而非数量。 - -## 11. 边界 - -始终执行:Room 迁移与 VERSION 同提交;`copy()` 同步;无痕模式不写不读。 - -禁止:为本功能改动字幕样式、在线搜索、AI 翻译、实时字幕、内嵌轨道选择的任何既有行为;禁止挪用 `legacyKey` 或 `mediaType`;禁止在 `PlayerManager` 里直接读写 History。 - -待后续评估(不阻塞本期):`mode = disabled` 的"记住关闭字幕";跨源字幕继承;把 cacheDir 里的字幕迁到 `Path.files()` 以获得真正的长期持久性(可参照 `FileChooser.persistentImport()` 的 md5 命名 + 原子移动写法)。 - -## 12. 实施记录 - -### 12.1 与设计的偏差 - -三处,都是实施时才暴露的约束: - -1. **注入载体从 `PlaySpec` 改到 `PlayerManager` 字段。** 设计里想让宿主把字幕塞进 spec,但 `parse()` 路径的 spec 是 `PlayerManager` 自己构造的(`PlayerManager.java:5911`),宿主拿不到。改为 `PlayerManager.setPendingRestoreSub(Sub)` 登记,`restorePendingSubtitle()` 在 `prepareMpvOutputForNewItem()` 开头消费一次即清。 - -2. **注入必须在 `instanceof MpvPlayerEngine` 早退之前。** 该方法第 5347 行有 `if (!(engine instanceof MpvPlayerEngine mpv)) return;`,注入点放在它之后会让 Exo 和 IJK 完全拿不到恢复的字幕。同时也必须早于第 5370 行算 `externalSubtitleActive` 的位置,否则 MPV 的输出模式判定会漏掉这条刚挂上的字幕。 - -3. **迁移列声明是可空 TEXT,不是 NOT NULL。** 实体里 `subtitleSource` 是普通 `String`,Room 导出的 45.json 里是 ``​`subtitleSource` TEXT DEFAULT ''``。迁移写 `NOT NULL` 会让 `validateMigration` 在升级后失败。与 `MIGRATION_38_39` 加 `mediaType` / `legacyKey` 的写法一致。 - -另外新增了设计里没有的 `SubtitleRestoreCoordinator`:三个宿主的记录与恢复规则完全相同,放共享类避免写三遍。 - -### 12.2 恢复点比设计多一处 - -除了 `setPlayer(Result)` 主起播路径,`onItemClick(Result)`(切清晰度)也要恢复——它同样重建 spec,字幕列表跟着重置。mobile `VideoActivity.java:2610`、leanback `VideoActivity.java:3086`。 - -`TmdbDetailActivity` 的落盘不能复用 `syncInlineHistory()`:那个方法会先跑 `updateInlineHistoryProgress()`,而恢复发生在起播之前,播放器还停在上一集,进度写回去就错了。为它单独加了 `persistHistorySubtitleSource()`。 - -### 12.3 验证记录 - -- `:app:compileMobileArm64_v8aDebugJavaWithJavac`、`:app:compileLeanbackArm64_v8aDebugJavaWithJavac`:均通过。 -- 新增 18 个单测(`SubtitleSourceTest` 7、`SubtitleRestorePolicyTest` 7、`HistorySubtitleSourceTest` 4)在 mobile 与 leanback 两个变体均 0 失败。 -- 反向插桩确认测试有效:把 `Decision.drop("episode-changed")` 改成 `inject()` → `SubtitleRestorePolicyTest` 7 中 1 失败;删掉 `History.copy()` 里的 `item.subtitleSource` → `copyCarriesSubtitleSource` 失败。两处随即改回。 -- `:app:testMobileArm64_v8aDebugUnitTest` 全量 4139 个,3 个失败:`AboutDialogLayoutTest > mobileGithubProxyActionsDoNotRequireFocusBeforeClick`、`FfmpegVc1SupportTest > codecName_mapsWvc1ToVc1`、`FfmpegVc1SupportTest > extraData_returnsFirstInitializationBlockForWvc1`。在 `eb4e334c4f` 干净 HEAD 的临时 worktree 上单独跑这两个类,同样是这 3 个失败,与本任务无关。 -- Room schema `app/schemas/com.fongmi.android.tv.db.AppDatabase/45.json` 已生成并含 `subtitleSource` 列。 -- 未执行:真实设备冒烟(第 7 节的 5 条场景)。需要在有设备时补验,尤其是"字幕文件被删后静默降级"和"换集不挂上一集字幕"。 diff --git a/docs/TITLE-LITEPAN-title-recognition-cleaning-design.md b/docs/TITLE-LITEPAN-title-recognition-cleaning-design.md deleted file mode 100644 index d8c192c203..0000000000 --- a/docs/TITLE-LITEPAN-title-recognition-cleaning-design.md +++ /dev/null @@ -1,242 +0,0 @@ -# LitePan 风格剧名识别清洗优化设计 - -> **状态**:✅ 基础实施单元已完成并验证;批量扫描上下文、独立规则缓存与 TMDB 联网验证待后续阶段(2026-09-07) -> **授权记录**:用户已确认“改编成设计文档后开始实施”。本次原子实施先落地不扩大调用方范围的规则/上下文基础设施、AI 上下文传递、本地字段约束、缓存隔离和无损回退;后续再实施真正的扫描批次分组与群体校验。 -> **适用范围**:`app/src/main/java/com/fongmi/android/tv/title/` 及其调用方 -> **参考实现**:LitePan `internal/aiorganize`、`internal/mediaorganize/planner/recognition.go` 等(commit `374affdb6584e3826cfab474fa8671418b23cf39`) -> **依赖文档**:`docs/ai-real-title-extraction-design.md`(现有 AI 真实剧名提取设计) - -## 1. 背景与问题 - -现有 `MediaTitleResolver` 已实现规则解析 → AI 提取 → 缓存 → 置信度合并的流程,能够处理 `庆余年2 S02E05 4K 高码 国语中字 更新至18集` 等常见噪声。但在以下场景中仍有提升空间: - -1. **上下文利用不足**:仅依赖单个文件名(`rawTitle`),未利用同目录其他文件(如同季其他集、片头/片尾特别篇、扫描文件夹)进行群体推断,导致孤立标题(如 `qyn 第二季 防和谐版`)误判率较高。 -2. **AI 触发策略保守**:当前 AI 仅在规则解析失败或置信度低于阈值时介入,未区分“需要创造性归因”(如谐音 `青余年`→`庆余年`)与“仅需去噪”,导致 AI 调用过于频繁或时机不当。 -3. **证据与降级不透明**:AI 返回结果后仅保留 `canonicalTitle`,未记录使用了哪些规则、AI 返回的原始字段、降级原因,不利于调优与人工干预。 -4. **缓存粒度单一**:仅以 `MediaTitleRequest` 为键缓存完整解析结果,未区分规则层、AI 层、TMDB 验证层的中间产物,导致相同规则结果重复走 AI。 -5. **缺少群体一致性校验**:未利用同一组文件(如同一季的多集)的标题、年份、季集号进行交叉校验,容易出现个别文件被误导致整组偏移(如第5集被识别为第4集导致后移)。 - -LitePan 在媒体整理场景中采用了更严格的分层流程:**规则预解析 → 上下文分组(目录/同名文件) → 低置信组交给 AI → AI 结果本地约束与 TMDB 验证 → 失败无损回退**,并在每一步保留可追溯的证据与降级原因。本设计借鉴其思想,在不改动现有对外接口的前提下,增强标题识别的准确性与可观测性。 - -## 2. 目标 - -1. 在保持 `MediaTitleResolver` 对外接口(`resolve(MediaTitleRequest)` → `MediaTitleResolution`)不变的前提下,内部增加上下文感知与分层验证。 -2. 利用文件系统上下文(同目录文件、文件夹结构、扫描批次)提升谐音、拼音缩写、防和谐版等弱特征标题的识别率。 -3. 使 AI 调用更具针对性:仅当规则+上下文仍无法给出高置信结果时才调用,并传入更丰富的上下文(如同组文件清单、文件夹名)。 -4. 在 `MediaTitleResolution` 中新增证据链字段(规则来源、AI 原始返回、降级原因、置信度分解),便于调试与人工干预。 -5. 引入中间产物缓存(规则解析缓存、AI 响应缓存、TMDB 验证缓存),减少重复计算。 -6. 增加群体一致性校验(同组季集号连续性、年份互斥、媒体类型一致性),在出现矛盾时降级为人工确认或保留多候选。 - -## 3. 非目标 - -- 不改变 `MediaTitleRequest`、`MediaTitleResolution` 的现有公开字段(为保持向后兼容,仅在内部实现或通过追加字段的方式扩展)。 -- 不强制所有播放源都走新流程;规则兜底、现有 TMDB 缓存、手动重匹配仍然有效。 -- 不在第一版引入离线字典或大型模型微调;仅利用现有 AI 配置能力(OpenAI/Anthropic 兼容端点)。 -- 不把完整文件路径、播放 URL、扫描任务元数据发送给 AI;仅传递文件名及必要的上下文摘要(如同目录文件名列表、文件夹名)。 -- 不保证 100% 自动化;对于置信度极低或上下文矛盾的情况,仍会返回需人工确认的状态。 - -## 4. 总体方案 - -参考 LitePan `aiorganize.Service.EnhanceWithProgress` 的分层思想,建议在 `MediaTitleResolver` 内部引入以下阶段: - -### 4.1 流程概览 - -``` -原始上下文(文件名 + 文件夹名 + 同目录文件列表) - ↓ -【阶段1】规则预解析(MediaTitleParser) → 获得规则标题、年份、季集、媒体类型等中间产物 - ↓ -【阶段2】上下文分组与初步合并 - - 将同一扫描批次中、目录相近或命名相似的文件组织为一个“标题组” - - 在组内取年份/季集号的众数或范围,修正异常值 - - 若组内成员规则解析结果高度一致(例如标题相似度>0.9),则直接合并为组标题,进入缓存 - ↓ -【阶段3】置信度评估与 AI 触发决策 - - 基于规则解析的完整度(是否有年份/季集)、组内一致性、历史学习样本匹配度计算综合置信度 - - 若置信度 ≥ 高阈值(如 0.85),直接进入规则缓存路径 - - 若置信度 在 [低阈值, 高阈值](如 0.5~0.85)且组 size > 1,则将该组送入 AI,请求标题归一化 - - 若置信度 < 低阈值(如 0.5)或为孤一文件(组 size = 1),则直接送入 AI(因为规则几乎无信息) - ↓ -【阶段4】AI 调用(复用 AiTitleExtractionService,但增强 prompt 上下文) - - Prompt 中不仅包括原始文件名,还包括:文件夹名、同目录其他文件名(去重后取前 N 条)、组内统计信息(如众数年份、季集范围) - - AI 返回结构化字段:标题、年份、媒体类型、季集号、集号、集标题、别名列表、置信度(0~1) - - 若 AI 调用失败或返回为空,则记录降级原因并继续后续规则兜底路径 - ↓ -【阶段5】结果融合与本地约束 - - 将 AI 返回与规则解析结果按置信度加权融合(可调参数) - - 对融合结果执行本地约束:年份必须在合理范围(1900~当前+2)、季集号非负、媒体类型与文件名后缀匹配等 - - 生成候选列表(规则候选、AI 候选、融合候选),按置信度排序 - ↓ -【阶段6】TMDB 验证与回填(复用现有 TmdbMatcher.searchAndMatch 流程) - - 按候选列表顺序向 TMDB 查询,首个返回可用结果即为最终标题 - - 若所有候选均未命中 TMDB,则保留最高置信度的规则/AI 结果作为 `canonicalTitle`,并标记 `needsVerification=true` - ↓ -【阶段7】输出 MediaTitleResolution - - 填充现有字段(rawTitle、ruleTitle、canonicalTitle、year、seasonNumber、episodeNumber、mediaType、aliases、confidence、source 等) - - 新增内部字段(仅用于调试/日志,不反序列化到外部): - - ruleSource:规则解析使用的具体子规则(如 SEASON_PATTERN、YEAR_PATTERN) - - aiRawResult:AI 原始返回的 JSON 文本(脱敏后) - - confidenceBreakdown:ruleConf、contextConf、aiConf、finalConf 的分解 - - degradationReason:若进入 AI 或 TMDB 失败的具体原因(如 "low rule confidence", "api timeout", "tmdb no match") - - groupInfo:所在标题组的尺寸、内部一致性得分 -``` - -### 4.2 关键实现点 - -- **上下文分组**:可采用简单的启发式规则:同一文件夹且文件名基于相同正则(去除季集号、集号、分辨率、编码等噪声后)相似度 > 0.8;或文件夹名包含相同关键词(如同样出现“庆余年”)。 -- **Prompt 示例(参照 LitePan aiorganize/client.go 中的 chat/completions 或 messages 格式)**: - ```json - { - "model": "{{.Model}}", - "messages": [ - {"role":"system","content":"你是一个媒体标题归一化助手。请从给定的文件名、文件夹名及同目录其他文件中提取标准的媒体标题、年份、媒体类型(movie/tv)、季号、集号、集标题。只返回 JSON,不要有额外说明。"}, - {"role":"user","content":"文件名:青余年 第18集\\n文件夹名:/媒体/国产剧/\\n同目录文件:[青余年 第1集, 青余年 第2集, ...]"} - ], - "max_tokens": 512, - "temperature": 0.1 - } - ``` -- **缓存策略**: - - `ruleCache`:以 `rawTitle` + `folderName` + `siblingFileHash` 为键,缓存规则解析中间产物。 - - `aiCache`:以上述键 + Prompt 版本号为键,缓存 AI 返回的结构化结果(TTL 24h)。 - - `tmdbCache`:现有 `TmdbMatchCache` 继续使用,按标题查询缓存 TMDB 结果(已实现)。 -- **置信度分解**(可参考): - - `ruleConf`:规则解析的完整度(如 是否成功提取年份、季集号)。 - - `contextConf`:上下文一致性得分(如 组内标题相似度的平均值)。 - - `aiConf`:AI 返回自身携带的置信度(若无则默认 0.7)。 - - `finalConf`:加权平均(如 0.4*ruleConf + 0.3*contextConf + 0.3*aiConf),随后经 TMDB 验证后可能提升或保持不变。 - -## 5. 数据模型扩展(仅内部) - -为保持向后兼容,建议在 `MediaTitleResolution` 中新增以下 **包私有** 或 **仅通过 getter/setter 内部使用** 的字段(不影响 JSON 序列化或跨模块传递): - -```java -// 内部调试/日志字段(对外保持现有 MediaTitleResolution 不变) -private String ruleSource; // 如 "SEASON_PATTERN+YEAR_PATTERN" -private String aiRawResult; // AI 原始返回(脱敏后),用于排查 -private String confidenceBreakdown; // 例如 "rule:0.6 context:0.8 ai:0.7 final:0.71" -private String degradationReason; // 空表示无降级,否则如 "api_timeout"、"tmdb_no_match" -private GroupInfo groupInfo; // 标题组尺寸、内部一致性 -``` - -对应的 `GroupInfo` 可为简单数据类: - -```java -private static class GroupInfo { - int size; // 组内文件数量 - float innerConsistency; // 0~1,组内标题相似度均值 - String folderName; // 所属文件夹名(脱敏后仅保留基名) -} -``` - -这些字段由 `MediaTitleParser`、`MediaTitleResolver` 和 `AiTitleExtractionService` 填充,并通过现有 getter/setter 在内部链路传递;由于 AI 结果缓存使用 Gson,它们会随缓存条目一并保存,便于诊断,但不改变现有解析调用方的必需输入和输出语义。日志仅输出脱敏后的文件名及证据摘要,不输出完整路径、播放 URL 或密钥。 - -## 6. 实现步骤(分阶段) - -| 阶段 | 目标 | 主要工作 | -|------|------|----------| -| **第一阶段** | 上下文分组与规则中间产物缓存 | - 在 `MediaTitleResolver.resolve()` 前增加扫描批次上下文(可通过构造函数注入 `ScanContext` 包含同目录文件列表)
- 实现简单的文件名相似度去噪算法(移除季集号、分辨率、编码等噪声后比较基名)
- 新增 `ruleCache`(基于 LRU 或简单 Map)缓存规则解析中间产物
- 单元测试:验证相同组内文件命中规则缓存 | -| **第二阶段** | 置信度评估与 AI 触发决策 | - 定义置信度计算函数(规则完整度 + 上下文一致性 + 历史样本匹配度)
- 根据阈值分派至 AI 或直接走规则路径
- 新增 `aiCache` 用于缓存 AI 结构化结果
- 集成测试:mock AI 响应,验证低置信组触发 AI,高置信组跳过 AI | -| **第三阶段** | AI 调用与结果融合 | - 扩充 `AiTitleExtractionService` 的 prompt,增加文件夹名、同目录文件列表等上下文(保持后向兼容:旧字段仍可用)
- 解析 AI 返回的 JSON,映射到临时结构(标题、年份、媒体类型、季集号、集号、别名)
- 实现规则结果与 AI 結果的加权融合函数
- 单元测试:各种 AI 返回情况(完整、部分缺失、 JSON 非法)的容错 | -| **第四阶段** | TMDB 验证与回填、输出增强 | - 复用现有 `TmdbMatcher.searchAndMatch` 流程,按融合结果排序后逐个候选查询
- 若 TMDB 未命中但置信度仍高,则保留规则/AI 结果并标记 `needsVerification=true`
- 在 `MediaTitleResolution` 中填充现有字段,同时在内部记证据链字段(仅日志)
- 集成测试:构造扫描批次,验证最终输出与预期一致,日志包含预期证据 | -| **第五阶段** | 人工验收与性能基线 | - 选取典型资源标题(包含谐音、拼音缩写、防和谐版、混合语言)进行人工打分
- 比较优化前后的 TMDB 命中率、弹幕自动匹配成功率、字幕匹配率
- 性能基线:确保单次解析延迟不增加超过 30ms(AI 调用由缓存和分组降低频率抵消)
- 编写验收报告并提交评审 | - -## 7. 验证计划 - -### 7.1 单元测试 - -- `MediaTitleParserTest`:新增上下文感知的清洗方法(如 `cleanSearchTitlesWithContext`)的边界情况。 -- `MediaTitleResolverTest`: - - 注入 `ScanContext`(同目录文件列表)验证组内一致性提升置信度。 - - 验证低置信组触发 AI 调用(mock `AiTitleExtractionService`)。 - - 验证 AI 返回后的融合与本地约束(如年份超限被修正)。 - - 验证 TMDB 验证失败时保留规则/AI 结果并设置 `needsVerification=true`。 -- `AiTitleExtractionServiceTest`:验证新增上下文字段是否正确拼接到 prompt 中(不改变原有行为)。 - -### 7.2 集成测试 - -- 构造一个包含 10 个文件的模拟扫描批次(同一季不同清晰度、不同语言版本)。 -- mock TMDB 返回(部分候选命中、部分未命中)。 -- 断言最终 `MediaTitleResolution`: - - `canonicalTitle` 为正确标题。 - - `year`、`seasonNumber`、`episodeNumber` 与众数或推断值一致。 - - `aliases` 包含规则提取的别名及 AI 返回的别名。 - - `confidence` 在合理区间(0.6~0.95)。 - - 日志中包含 `ruleSource`、`confidenceBreakdown`、`groupInfo` 等信息。 - -### 7.3 人工验收 - -- 从真实用户资源库中抽取 100 条困难标题(包含谐音如 `青余年`、拼音缩写如 `qyn`、防和谐版如 `防和谐`、混合信息如 `长相思2.2024.2160p.WEB-DL`)。 -- 在开发机上跑现有实现与优化后实现,记录: - - TMDB 自动匹配成功率(调用 `TmdbUIAdapter.autoMatch` 后是否得到正确 `Vod.name`)。 - - 弹幕自动搜索命中率(使用 `DanmakuApi.search` 是否返回弹幕)。 - - 字幕自动匹配率(使用 `SubtitleTmdbResolver` 是否得到正确语言字幕)。 - - 人工肉眼判断的标题正确率。 -- 要求优化后实现相比基线:TMDB 自动匹配率提升 ≥10pp,弹幕/字幕命中率提升 ≥5pp,且不引入新的误匹配(人工判断错误率不升高)。 - -### 7.4 性能与安全回归 - -- 确认单次 `resolve()` 在 AI 未命中缓存时延迟不超过基线的 1.2倍(主要开销在网络,受缓存命数影响)。 -- 确认没有新增的外联请求(AI 请求仍受现有 `Setting.isAiTitleExtraction()` 开关控制;未开启时不应产生任何 AI 流量)。 -- 确认没有敏感信息泄漏:日志中不出现完整文件路径、播放 URL、API 密钥(通过脱敏处理)。 - -## 8. 风险与缓解 - -| 风险 | 影响 | 缓解措施 | -|------|------|----------| -| 上下文分组误判(将不同剧的文件划入同一组) | 导致错误的众数年份/季集,进而误导标题识别 | 分组阈值保守(相似度 > 0.85)+ 再次验证:组内如果出现媒体类型冲突(如一部是电影一部是剧集)立即解组 | -| AI 调用频率增加导致配额耗尽或成本上升 | 服务不可用或用户感知延迟 | 先走规则缓存 + 上下文一致性过滤,仅当组内置信度在中间区间且组 size > 1 时才送 AI;单文件低置信虽然也送 AI,但总量受缓存限制(相同文件名复用) | -| AI 返回格式不一致或出现幻觉 | 标题错误,后续 TMDB 错误匹配 | 解析时进行字段合法性校验(年份范围、媒体类型枚举、季集号非负),不合格则降级为仅使用规则结果,并在日志记录 `aiRawResult` 以便排查 | -| 新增缓存导致内存占用增长 | 在低端设备上可能造成 OOM | 采用 LRU 策略(默认最大 256 条),并提供开关在低内存设备上可调小或关闭规则/ai 缓存(仅保留 TMDB 缓存) | -| 本地约束过严导致合法标题被错误修正 | 比如某些合法的年份或季集号被误删 | 本地约束仅做基本范围检查(年份 1900~当前+2,季集号 ≥0,集号 ≥0),不做剧情逻辑校验;如需更严格可后期加入学习样本 | - -## 9. 结论与下一步 - -本设计在不改变现有对外接口的前提下,引入了上下文感知、分层验证、证据链可追溯以及中间产物缓存等 LitePan 风格的优化手段,预期显著提升谐音、拼音缩写、防和谐版等弱特征标题的识别率,同时保持系统的稳健性与可观测性。 - -**当前实施入口**:已获得用户实施授权。第一阶段先落地不改变现有调用方接口的规则/上下文基础设施、严格字段校验、证据链和针对性单测;后续阶段在该基础上扩展批量上下文与 AI prompt,并保留规则结果作为无损回退。 - -> **注**:本文档记录的是设计与实施边界,代码变更按阶段提交。实施时需结合现有代码实际情况调整细节,但核心思想(规则先行 → 上下文分组 → 低置信交给 AI → 本地约束 → TMDB 验证 → 无损回落)应保持不变。 - -## 10. 本次基础实施记录(2026-09-07) - -### 10.1 已实施内容 - -本次选择“最小可逆闭环”,不新增 `ScanContext` 或改动现有 UI/业务调用方: - -1. **受控上下文输入**:`MediaTitleRequest` 增加可选文件夹名和同组标题;文件夹只保留基名,同组标题去 URL/绝对路径、忽略空值、大小限制为 16 条并做大小写不敏感去重。 -2. **规则层共识**:`MediaTitleParser` 对同组标题执行现有清洗后的一致性投票;只有上下文置信度达到阈值时才提升剧名,不把 `rawRemarks` 或 `episodeName` 自动伪装成同组标题;纯“更新至 N 集”备注不再成为搜索候选。 -3. **证据链**:`MediaTitleResolution` 增加规则来源、上下文置信度/组大小、AI 原因码、置信度分解和降级原因;解析、AI、TMDB 缓存合并路径均保留这些信息。 -4. **AI 上下文与本地约束**:prompt 追加脱敏后的文件夹名、同组标题及规则证据;AI 返回的标题拒绝 URL、路径、清晰度/集数噪声;年份、季号、集号越界时保留规则回退值,别名字段按“遇到首个合法值”处理。 -5. **缓存隔离**:AI 标题缓存键升级为 `v2`,纳入文件夹名和同组标题,避免不同上下文复用旧 AI 结果;解析调用方不允许 AI 时仍直接规则回退。 -6. **回归覆盖**:新增上下文共识、冲突上下文、敏感路径边界、备注污染、缓存键变化、prompt 证据、AI 越界字段和多别名容错测试。 - -### 10.2 本次变更文件 - -- `app/src/main/java/com/fongmi/android/tv/title/MediaTitleRequest.java` -- `app/src/main/java/com/fongmi/android/tv/title/MediaTitleCandidate.java` -- `app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolution.java` -- `app/src/main/java/com/fongmi/android/tv/title/MediaTitleParser.java` -- `app/src/main/java/com/fongmi/android/tv/title/MediaTitleCache.java` -- `app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolver.java` -- `app/src/main/java/com/fongmi/android/tv/service/AiTitleExtractionService.java` -- 对应 `app/src/test/java/com/fongmi/android/tv/title/` 和 `app/src/test/java/com/fongmi/android/tv/service/AiTitleExtractionServiceTest.java` - -### 10.3 验证结果 - -执行: - -```bash -./gradlew :app:testLeanbackArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.title.*' \ - --tests 'com.fongmi.android.tv.service.AiTitleExtractionServiceTest' \ - --console=plain -``` - -结果:`BUILD SUCCESSFUL`;6 个测试类共 29 个测试,`skipped=0`、`failures=0`、`errors=0`。构建输出中的原生库位数提示和既有 deprecated API 提示不属于本次标题清洗改动。 - -### 10.4 尚未实施与下一步 - -- **尚未实施**:真正的扫描批次/目录 `ScanContext` 注入、独立规则中间产物缓存、季集号连续性/年份互斥群体校验、TMDB 联网候选验证和 100 条真实资源人工基线。 -- **下一步**:在确认现有调用方能够稳定提供同目录文件列表后,新增一个独立的批次上下文适配单元;该单元只负责产出受控 `contextTitles`,失败时传空列表并保持当前规则路径。 -- **回滚**:删除本次原子提交即可恢复原有单标题解析、AI prompt 和缓存键;缓存键使用 `v2`,不会读取新旧上下文混淆的旧条目。 diff --git a/docs/TOUCH-20260907-touch-adaptation.md b/docs/TOUCH-20260907-touch-adaptation.md deleted file mode 100644 index 6f1a9a5d53..0000000000 --- a/docs/TOUCH-20260907-touch-adaptation.md +++ /dev/null @@ -1,46 +0,0 @@ -# TOUCH-20260907 TV 触摸适配 - -## 授权与验收 -用户已明确“按计划实施”。一个任务/提交覆盖:首页自由触摸滚动、站源触摸滚动不切换数据、按站源恢复结果位置、TV 点播全屏横滑进度/左半屏竖滑亮度/右半屏竖滑音量。原反馈“高度”按已批准方案解释为亮度,不新增画面缩放。开关关闭和遥控器操作保留原有语义;不修改 mobile、直播、投屏和播放器内核。 - -## 决策就绪的证据(访问日期 2026-09-07) -- A / 当前工程:基线 `db59f3eeb85ff0e4ed993e814d9b81b08036bcb7`;`HomeActivity.setRecyclerView/initEvent`、`CollectActivity.scheduleCollect/setSearchItemsLazy`、`CustomKeyDownVod.onTouchEvent`、`VideoActivity.initEvent` 是实际路径。先前草稿只有源码字符串测试通过,不能证明回弹解决。 -- A / 精确依赖源码、官方 API 注释:`https://dl.google.com/dl/android/maven2/androidx/leanback/leanback-grid/1.0.0/leanback-grid-1.0.0-sources.jar`(本机依赖 leanback 1.2.0 → grid 1.0.0);`GridLayoutManager.onLayoutChildren` 的 `scrollToFocus` 仅在 `FOCUS_SCROLL_ALIGNED` 成立,且与是否触摸无关;`focusToViewInLayout` 会重新对齐旧选中项。这比此前“焦点可能抢占”的推测更直接。 -- A / 官方 API:上述 jar 的 `BaseGridView.setFocusScrollStrategy`、`setFocusSearchDisabled` 注释及实现。触摸期间改用 ITEM 策略并阻止子焦点回收;按键到来时恢复原策略并从当前可见项恢复焦点,不能永久关闭 D-pad。 -- A / 上游维护证据:同一 `GridLayoutManager` 的 b/67370222 注释说明布局对齐/可见项裁剪的反馈循环风险。它不是用户设备的复现记录,不据此声称“已真机复现”。本任务不修改/升级 AndroidX。 -- B / 成熟本地实现与测试:`main/.../PlayerGesture.java`、`utils/BrightnessPolicy.java` 和 `testMobile/.../VideoActivityLayoutTest.java`;借鉴窗口亮度和音乐音量控制。**不直接复用整个 PlayerGesture**:其侧边四分区、中央竖滑切集动画、双指缩放、长按倍速及小窗消费 DOWN 均超出本次批准语义。最初草稿直接接入会破坏小窗点击进入全屏,因此撤销这部分未提交草稿,补充现有 TV 控制器的可选滑动能力。 -- 官方在线文档尝试:web 工具未返回正文,agent-reach/Jina 读取 Android 手势文档出现 TLS EOF;以可获取的 Google Maven 精确版本源码及其 API 文档为依据,不声称已读取失败网页。 -- 论文/博客/性能基准:本变更不发明识别算法、不变更渲染/解码/ABI;无须用论文替代 Android 事件契约。性能验收为 MOVE 中常数开销、无 Toast 连发/全树重复扫描/网络请求。 - -### 方案比较与选定 -1. 不变更:无法满足三项反馈,拒绝。 -2. 直接复用上游/共享行为:Leanback 默认对齐造成回弹;共享 PlayerGesture 引入未授权缩放/切集等,拒绝原样接入。 -3. 窄适配(采用):触摸期间关闭焦点对齐和焦点驱动业务切换,遥控器恢复;结果按实际展示站源记录首项+像素偏移并隔离延迟任务;TV 自有手势仅在优化开关开启且全屏生效,使用现有 seek 通道、BrightnessPolicy 和 AudioManager,无依赖升级。 - -### 验收和回滚 -- 首页和两种站源排列:多次拖动/惯性滚动不被布局拉回;触摸列表不自动切源,点击才切换;按 D-pad 后仍能导航。 -- 结果:同源重按不回顶,A→B→A 恢复 A 的首项与像素偏移;快速连续切源不得错记位置;新搜索/筛选清除不适用位置;空结果及列表缩短不得越界。 -- 手势:横滑预览、仅正常 UP 提交一次;CANCEL/多指/失焦不提交;左/右半屏起点锁定亮度/音量;OSD 抬手清理;小窗点击、关闭优化、控制栏按钮和遥控器不回归。 -- 回滚:恢复本任务唯一提交前状态;未提交阶段回退仅限 guard 内任务补丁,不触及其他工作。关闭触屏优化可回到旧输入行为。 - -## R2 实施记录(TOUCH-20260907-R2) - -### 实际实现 -- `TouchOptimizationHelper` 在不把 leanback 依赖泄漏到 mobile 公共源码的前提下,通过 leanback 运行时公开 API 绑定 `BaseGridView` 的触摸拦截器:触摸 DOWN 临时切到 `FOCUS_SCROLL_ITEM` 并禁用焦点搜索,抬手/取消时恢复原策略,并在列表 settling 完成前继续抑制触摸触发的选中回调;不覆盖项目已有的 `setOnKeyInterceptListener`。优化关闭时不安装/不保留该行为。动态 RecyclerView 子项仍由现有 attach listener 覆盖。 -- `HomeActivity` 和 `CollectActivity` 在站源/类别选择回调中忽略触摸期间的焦点变化;点击回调仍执行真实切换。首页自动切类仅保留遥控器路径。 -- `CollectActivity` 为每个站源保存首个可见 adapter position 与像素 top offset;恢复时按当前 adapter 数量夹紧,异步恢复前再次确认站源仍是 active;新搜索、分组/相似度筛选和列数改变清空旧位置。 -- TV `VideoActivity` 不再将公共 `PlayerGesture` 直接接到 TV 播放器:优化关闭时保留 `CustomKeyDownVod`;优化开启且全屏时由 TV 层使用现有 `seekTo` 通道处理横滑,按按下点左/右半屏分别调亮度/音乐音量,使用 `BrightnessPolicy`、`AudioManager` 和 TV widget OSD。多指、CANCEL、失焦不提交 seek;正常 UP 只提交一次并清理 OSD。 - -### R2 验证 -- `git diff --check`:通过。 -- `bash ./gradlew :app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.helper.TouchOptimizationHelperSourceTest :app:assembleLeanbackArm64_v8aDebug --no-daemon`:通过,11 tests completed;同时完成 Leanback Arm64 Java 编译并组装 `app/build/outputs/apk/leanbackArm64_v8a/debug/app-leanback-arm64_v8a-debug.apk`。 -- 首次测试失败原因已修正:旧源码断言仍要求上一版已撤销的 `PlayerGesture` 接线,非生产代码失败;测试契约已改为断言当前 TV 触摸接线、像素恢复、焦点策略和取消边界。 -- 设备端:使用 `192.168.50.3:5555` 安装上述 APK 成功,`monkey -p com.silent.android.webhtv 1` 启动成功,前台为 `HomeActivity`,进程 PID `6464`;本次只完成启动冒烟,未宣称完整触摸场景已在设备端覆盖。 - -## Recovery anchor -- 目标/范围:完成首页/站源触摸滚动与不误切换、按站源恢复结果像素位置、TV 全屏横滑 seek 与左右半屏亮度/音量 OSD;保留触屏开关关闭和遥控器语义。 -- 阶段:`TOUCH-20260907-R2`,guard active;上一提交 `3bb2ff086ffbf0aab5906205526bd9f887e22fd7` 和恢复标签保留。 -- 当前文件:本 R2 guard 的 7 个任务文件处于未提交修改;无其他脏路径。 -- 已验证:Leanback Arm64 Java 编译、11 项 focused 源码契约测试、`git diff --check`、Arm64 APK 组装,以及设备安装/`HomeActivity` 启动冒烟。 -- 未验证:完整设备端 MotionEvent/焦点回弹与各类手势场景;仍需按验收清单在真实设备上继续覆盖。 -- 下一步:执行 `task_guard.sh finish`,原子提交本 R2 变更并创建恢复 tag。 diff --git a/docs/TV-ADBLOCK-20260909-home-shortcut.md b/docs/TV-ADBLOCK-20260909-home-shortcut.md deleted file mode 100644 index 092cc3ba87..0000000000 --- a/docs/TV-ADBLOCK-20260909-home-shortcut.md +++ /dev/null @@ -1,109 +0,0 @@ -# TV-ADBLOCK-20260909:去广告总开关前置到首页 - -> 历史纠正:2026-09-09 用户明确目标为“增强功能 → 站点注入”的总启用开关,不是去广告总开关。本记录对应的实现已由 `TV-CUSTOM-CSP-20260909` 纠正并 supersede;当前代码不再保留本记录中的去广告首页按钮。 - -## Recovery anchor - -- 目标:让 TV 用户从首页通过一个可聚焦的快捷按钮启用/禁用去广告总开关,避免进入设置页多层操作。 -- 验收:TV 首页出现可配置的“去广告”按钮;按钮点击复用 `Setting.isAdblock()` / `Setting.putAdblock(boolean)`,并在首页即时显示开/关状态;手机端行为不变。 -- 车道/范围:`standard`;仅 TV 首页按钮模型、功能按钮展示/点击、TV 文案/图标、针对性测试和本任务文档。 -- 当前分支/基线:`dev2` / `fbb7197e489a233686208fc948c821586f88a91a`。 -- 初始工作区:干净,无需保护的 pre-existing dirty path。 -- 已完成:入口定位、方案评估、官方 Android TV 导航资料核对、task guard 启动、TV 首页按钮实现、源代码契约测试、Leanback ARM64 Java 编译。 -- 当前待办:收录验证结果,执行 task guard finish 原子提交并创建恢复 tag。 -- 未验证风险:当前连接的 `192.168.50.3:5557`、`:5559`、`:5561`、`:5563` 均报告 `ro.build.characteristics=tablet`,没有 Android TV 设备;真实遥控器焦点与不同屏幕宽度仍需在 TV APK 安装后补测。 -- 回滚锚点:本任务 guard 基线 HEAD;实现只涉及 TV UI 与配置复用,无数据迁移和 native 变更。 -- 下一步:更新本记录的验证结果,然后执行 `bash .codex/scripts/task_guard.sh finish`。 - -## 需求与现状证据 - -用户需要在 TV 版快速启用/禁用“去广告”功能。当前总开关是 `Setting.isAdblock()`,写入入口为 `Setting.putAdblock(boolean)`;TV 设置页在 `SettingAdActivity` 的 `adblock` 行上切换。TV 首页已有可排序、可隐藏的 `HomeButton`/`Func` 按钮行,首页菜单键则进入固定动作弹窗或直接执行一个导航动作。 - -现有路径: - -1. 首页进入“设置”; -2. 进入“广告”; -3. 聚焦“去广告总开关”后确认。 - -这条路径对遥控器不够短,而且用户无法在首页判断当前开关状态。当前开关影响 HLS 播放列表净化、Exo `MediaItem` 的 adblock 标志及 AI 广告反馈显示条件;快捷入口必须只改同一个总开关,不能另建一份状态。 - -## 最佳实践研究 - -### 证据记录 - -| 证据 | 访问日期 | 等级 | 支持的结论 | 对 WebHTV 的影响 | -|---|---|---:|---|---| -| Android Developers,TV navigation:`https://developer.android.com/training/tv/get-started/navigation` | 2026-09-09 | A | TV 遥控器控制受限,应减少点击和页面层级;所有可见控件应可通过 D-pad 到达;焦点项应清晰可见;难以到达的控件应迁移到更直接的位置。 | “去广告”属于用户明确要求的高频总开关,放入首页现有可聚焦按钮行比藏在设置页或仅放菜单弹窗更符合 TV 导航原则。 | -| WebHTV 当前 `app/src/leanback/java/com/fongmi/android/tv/bean/HomeButton.java`、`HomeActivity.java`、`Func.java` | 2026-09-09 | A | 项目已有首页按钮的排序、隐藏、恢复默认和 `Func` 点击分发机制。 | 复用现有 HomeButton,不引入新的导航容器或持久化格式。 | -| WebHTV 当前 `app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuDialog.java`、`SettingPersonalActivity.java` | 2026-09-09 | A | 菜单键是可配置的一次性动作;弹窗内容是固定动作网格,没有开关状态展示。 | 不把状态型开关塞进菜单键动作列表,避免菜单键变成第二套首页入口。 | -| WebHTV 当前 `SettingAdActivity.java`、`SettingAdFragment.java`、`Setting.java` | 2026-09-09 | A | 手机和 TV 已共享同一 `adblock` preference 与切换语义。 | TV 快捷按钮只复用 setter;不修改手机端,不改变播放链路。 | - -以下证据类别对本任务不适用:本任务不是上游依赖合并、播放器算法或 native/ABI 变更,因此没有新增上游 commit、PR/revert、维护者讨论、论文、性能基准或二进制 provenance 门禁;现有项目代码和官方 TV 导航规范足以决定入口位置。 - -## 方案比较 - -### 方案 A:不变 - -- 优点:零代码和零回归风险。 -- 缺点:继续要求用户进入多层设置,TV 遥控器操作成本高;不满足用户希望前置快捷开关的目标。 -- 结论:拒绝。 - -### 方案 B:加入“首页菜单键”动作 - -- 优点:按菜单键后可在一个动作中切换;改动面较小。 -- 缺点:需要用户先进入“个性设置 → 首页菜单键”配置;菜单键可能被用户绑定为切源、历史、搜索等高频动作;弹窗/动作本身不显示当前开关状态;部分遥控器不提供一致的菜单键体验。 -- 结论:不作为主入口。它适合导航动作,不适合需要确认当前状态的全局开关。 - -### 方案 C:加入“首页按钮”(采用) - -- 入口:加入现有 `HomeButton.all()`,默认新安装/未自定义首页按钮的 TV 用户可见,并保留“个性设置 → 首页按钮”的排序和隐藏能力。 -- 行为:点击按钮反转 `Setting.isAdblock()`;按钮文字显示“去广告 开/去广告 关”,状态变化后刷新当前功能按钮行。 -- 兼容:已有明确保存过首页按钮列表的用户不强制插入新按钮,避免覆盖用户的个性化布局;新按钮会出现在“首页按钮”管理列表中,可手动启用和排序。未保存自定义列表的用户通过新默认值自然获得按钮。 -- 优点:首页可见、可聚焦、一次确认完成、状态明确;复用现有焦点环、按钮排序和配置,不增加设置层级。 -- 风险:首页按钮行增加一个项目,窄屏可能需要横向移动;通过用户可隐藏/排序、保留左右循环焦点和状态文案缓解。 -- 结论:实施。 - -### 不采用“同时加入两处” - -同时加入首页按钮和菜单键会重复暴露同一动作、增加文案/测试/维护面,也会让用户不清楚应配置哪一个入口。本阶段保持一个明确的主入口;菜单键保留现有导航语义。 - -## 实施设计 - -1. 给 `HomeButton` 增加稳定 id `8` 和 `home_adblock` 资源;更新完整排序串与默认按钮串。 -2. 在 TV `Func` 中为该资源绑定去广告图标,并根据 `Setting.isAdblock()` 生成带状态的按钮文案;内容比较需包含动态文案,确保切换后重绑视图。 -3. 在 TV `HomeActivity.onItemClick(Func)` 中增加分支,调用 `Setting.putAdblock(!Setting.isAdblock())` 后刷新 `setFunc()`;不刷新当前播放器或新增运行时副作用,保持现有设置页语义。 -4. 为英文、简体中文、繁体中文补齐首页按钮文案。 -5. 添加 Leanback 源代码契约测试,锁住 id、默认值、图标/状态文案和点击分发,防止后续首页按钮扩展遗漏。 - -## 接受标准 - -- [x] TV 首页的默认功能按钮列表包含“去广告”。 -- [x] “个性设置 → 首页按钮”可看到、启用/禁用和排序“去广告”;已有自定义列表不被强制改写。 -- [x] 点击 TV 首页“去广告”按钮只反转 `adblock` preference,并即时显示开/关状态。 -- [x] 手机端源码、布局和行为未修改。 -- [x] Leanback 定向源测试通过。 -- [x] Leanback ARM64 Java 编译通过。 -- [x] 已明确记录当前无真实 Android TV 设备,焦点/视觉仍需设备验证,没有把编译当作遥控器验收。 - -## 验证计划 - -- 最便宜决定性检查:`:app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.bean.HomeButtonSourceTest`。 -- 编译检查:`:app:compileLeanbackArm64_v8aDebugJavaWithJavac`。 -- 若构建资源允许,再执行一次 `:app:assembleLeanbackArm64_v8aDebug`,确认 view binding、资源和 TV 变体完整闭合。 -- 设备场景(可用时):首页聚焦按钮 → 确认,观察文案从“去广告 开”切换为“去广告 关”,返回/重新进入首页后状态保持;在首页按钮设置中隐藏/恢复后焦点仍可循环。 - -## 回滚与发布 - -- 回滚方式:还原本任务单次提交;不需要迁移 preference,不需要删除用户数据,不需要重建 native assets。 -- 发布影响:仅 Leanback TV UI 和既有 `adblock` preference 读取;不新增依赖、API、网络请求、包内二进制或手机行为。 - -## 实施与验证记录 - -- 2026-09-09:`HomeButton` 增加稳定 id `8`,新安装/未自定义首页按钮的 TV 用户默认看到“去广告”;已有显式自定义列表不被强制插入。 -- 2026-09-09:`Func` 复用现有 `ic_live_block` 图标,保存按钮文案快照并让内容比较包含状态文案,保证切换后 `setFunc()` 会重绑为“开/关”。 -- 2026-09-09:`HomeActivity` 点击分支只反转 `Setting` 的 `adblock` preference,然后刷新首页功能按钮;没有改菜单键、手机端或播放器链路。 -- 首次编译:因 `Func.java` 遗漏 `Setting` import 失败;补上直接 import 后未改变设计并重跑同一验证。 -- 最终验证(2026-09-09):`bash .codex/scripts/task_guard.sh check` 通过;`:app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.bean.HomeButtonSourceTest` 通过;`:app:compileLeanbackArm64_v8aDebugJavaWithJavac` 通过;`git diff --check` 通过。 -- 构建过程只出现仓库既有资源警告(`values` 中未知 `g` 命名空间、既有非 positional string substitution、CXX 32-bit 提示),没有本任务新增错误。 -- 设备审计:ADB 当前仅连接四台 Android 9 tablet(SM-N9700、V1923A、HD1910、NX627J),因此没有把手机/平板运行结果冒充 TV 遥控器验收。 -- 状态:历史误判实现已提交;目标经用户澄清后由 `docs/TV-CUSTOM-CSP-20260909-home-shortcut.md` 纠正,不再作为当前产品行为依据。 diff --git a/docs/TV-CUSTOM-CSP-20260909-home-shortcut.md b/docs/TV-CUSTOM-CSP-20260909-home-shortcut.md deleted file mode 100644 index b48f98f560..0000000000 --- a/docs/TV-CUSTOM-CSP-20260909-home-shortcut.md +++ /dev/null @@ -1,96 +0,0 @@ -# TV-CUSTOM-CSP-20260909:站点注入界面前置到首页 - -## Recovery anchor - -- 目标:让 TV 用户从首页通过一个可聚焦的快捷按钮直接打开“增强功能 → 站点注入”管理界面,避免进入设置页多层操作。 -- 验收:TV 首页出现可配置的“站点注入”按钮;点击后打开现有 `CustomCspDialog`,用户可在界面内启用/禁用全局开关并编辑条目;保存后由现有对话框重新加载当前 VOD/直播配置并刷新首页按钮状态;手机端行为不变。 -- 车道/范围:`standard`;仅撤销前一版误加的 TV 去广告按钮,增加站点注入首页按钮、站点注入开关复用、TV 文案、针对性测试和任务记录。 -- 当前分支/基线:`dev2` / `6eb76c5bca0b00e69520256fffee900bc9ed8dac`。 -- 初始工作区:干净,无需保护的 pre-existing dirty path。 -- 已完成:用户目标纠正、站点注入开关与配置重载路径定位、方案决策、task guard 启动、首页入口实现、点击行为修复和定向验证。 -- 当前待办:原子提交并创建恢复 tag。 -- 未验证风险:当前无 Android TV 设备;真实遥控器焦点、文件权限弹窗和首页按钮在不同屏幕宽度下的视觉效果需设备验证。 -- 回滚锚点:本任务 guard 基线 HEAD;回滚仅还原 TV 首页点击分发、移除上一版未使用的保存辅助方法,无数据迁移、依赖或 native 变更。 -- 下一步:执行 `bash .codex/scripts/task_guard.sh finish` 完成本次点击行为修复的提交和恢复 tag。 - -## 需求与现状证据 - -用户澄清的目标是“增强功能 → 站点注入”的启用/禁用,而不是广告功能。当前站点注入由 `CustomCspSetting.Registry.enabled` 控制: - -- `CustomCspSetting.load()` 从 `TV/CustomCsp/registry.json` 读取注册表;缺少字段时 `Registry.isEnabled()` 默认返回 `true`。 -- `CustomCspSetting.save(Registry)` 会保留全部站点/直播/其它注入条目并写回同一注册表,但要求文件访问权限。 -- VOD、直播和根配置注入均在配置重新加载时读取这个全局开关;单纯写文件而不重载,当前页面可能继续使用旧的注入结果。 -- TV `SettingEnhanceActivity` 的“站点注入”行当前进入完整 `CustomCspDialog`,不是单独切换;手机 `SettingEnhanceFragment` 保持原有完整编辑入口。 -- TV 首页已有可排序、可隐藏的 `HomeButton`/`Func` 按钮行,适合承载状态型快捷开关;首页菜单键是一次性导航动作弹窗。 - -因此首页快捷入口应打开既有 `CustomCspDialog`,让用户沿用原有的全局开关、条目编辑、权限和保存流程;不能在首页另写一套隐式切换逻辑,也不能只改首页文案而不进入管理界面。 - -## 方案比较 - -### 方案 A:不变 - -- 优点:零代码风险。 -- 缺点:TV 用户继续需要进入设置、增强功能、站点注入后才能切换,不满足用户明确的快捷操作需求。 -- 结论:拒绝。 - -### 方案 B:加入首页菜单键 - -- 优点:可以复用菜单键动作分发。 -- 缺点:菜单键可能已被绑定为切源、切线路、搜索、历史等动作;菜单动作没有常驻状态显示;首次使用仍需进入个性设置配置菜单键;站点注入保存还可能触发权限请求和配置重载,不适合隐藏在一次性动作列表中。 -- 结论:不采用。 - -### 方案 C:加入首页按钮并直接打开站点注入界面(采用) - -- 使用现有 `HomeButton` 配置体系新增独立稳定 id;按钮文案按注册表全局 `enabled` 状态显示“站点注入:开/关”。 -- 点击时先走现有 `PermissionUtil.requestFile`;获得权限后直接调用 `CustomCspDialog.show(...)`。对话框内部负责全局开关、条目编辑、保存和 VOD/直播重载。 -- 新安装或没有显式自定义首页按钮列表的用户默认可见;已有自定义列表不被强制改写,仍可在“个性设置 → 首页按钮”中手动启用、隐藏和排序。 -- 无文件权限时沿用现有权限提示;对话框打开失败不修改状态;不改变手机端完整编辑流程。 -- 结论:实施。 - -## 最佳实践与设计决定 - -- TV 首页按钮比菜单键更适合高频、需要显示状态的全局开关;这也延续项目已有首页按钮和 D-pad 焦点模型。 -- 不把全局开关迁移到 `Setting.java`:它不是普通 preference,而是与站点条目、插入位置和本地文件同一注册表的一部分。 -- 不在首页按钮点击时直接改内存对象或静默写文件:`CustomCspSetting.inject(...)` 在 VOD/直播配置加载阶段读取注册表,现有 `CustomCspDialog` 已负责保存并触发配置重载;首页只负责打开这个经过验证的管理入口。 -- 不覆盖用户自定义首页按钮顺序;新按钮使用新 id `9`,避免把前一版误加的去广告 id `8` 解释成新的站点注入动作。 -- 按钮状态只显示“开/关”,不在首页读取并展示条目计数,避免首页功能按钮每次刷新承担不必要的完整统计文案;详细条目数量仍由增强功能页展示。 - -## 实施设计 - -1. 删除前一版 TV 去广告按钮的 id、文案、状态读取和点击分发;删除其源测试,避免错误行为继续存在。 -2. 在 `HomeButton` 中加入站点注入按钮 id `9`,更新默认/排序目录;保留旧 id `8` 不再复用,避免旧版本自定义列表发生语义转换。 -3. 在 `Func` 中绑定站点注入图标和动态状态文案;内容比较包含开关状态,确保刷新后按钮视图更新。 -4. 在 TV `HomeActivity` 中处理权限并调用现有 `CustomCspDialog.show(...)`;对话框保存成功后通过回调调用 `setFunc()`。 -6. 手机端不增加首页按钮、不改变 `SettingEnhanceFragment` 的站点注入编辑入口。 - -## 接受标准 - -- [x] TV 首页默认功能按钮包含“站点注入”。 -- [x] “个性设置 → 首页按钮”可看到、启用/禁用和排序“站点注入”;旧的自定义列表不会被强制覆盖。 -- [x] 点击按钮打开现有站点注入管理界面,不能静默切换或无反馈。 -- [x] 在管理界面内修改全局 `enabled` 后,由现有保存流程重新加载当前 VOD/直播配置,使注入开关对当前页面生效。 -- [x] 没有文件权限时沿用现有权限提示;管理界面保存失败时不刷新成错误状态。 -- [x] 前一版误加的去广告首页按钮、文案和测试被移除。 -- [x] 手机端源码、布局和行为未修改。 -- [x] Leanback 定向源测试与 ARM64 Java 编译通过。 -- [x] 无真实 Android TV 设备时,已明确记录焦点/视觉/权限弹窗仍需设备验收。 - -## 验证计划 - -- 定向源测试:`SiteInjectHomeButtonSourceTest`,覆盖新按钮 id/默认值、状态文案、`CustomCspDialog` 打开入口、权限检查以及旧去广告入口移除。 -- 编译:`:app:compileLeanbackArm64_v8aDebugJavaWithJavac`。 -- 测试:`:app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.bean.SiteInjectHomeButtonSourceTest`。 -- 静态检查:`git diff --check` 和 task guard scope 检查。 -- 设备场景(当前不可用):首页聚焦“站点注入”→确认;有权限时直接打开站点注入管理界面,在界面内切换并保存后按钮状态与当前 VOD/直播注入结果刷新;无权限时出现既有授权提示;首页按钮设置中的隐藏/排序和左右焦点循环正常。 - -## 回滚与实施记录 - -- 回滚:还原本任务原子提交即可;不需要清理用户数据或重建 native assets。新 id `9` 不复用误加的 id `8`,避免历史自定义按钮出现错误语义迁移。 -- 发布影响:仅 Leanback 首页快捷入口和站点注入注册表的既有保存/重载路径;不新增依赖、网络请求、公开 API 或手机行为。 -- 最终验证(2026-09-09):`bash .codex/scripts/task_guard.sh check` 通过;`:app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.bean.SiteInjectHomeButtonSourceTest` 通过;`:app:compileLeanbackArm64_v8aDebugJavaWithJavac` 通过;`git diff --check` 通过。 -- 构建过程仅出现仓库既有资源命名空间、字符串格式和 CXX 32-bit 警告,没有本任务新增错误。 -- 设备审计:当前连接设备均为 Android 9 tablet,不是 Android TV;因此没有把手机/平板运行结果冒充 TV 遥控器、权限弹窗或视觉验收。 -- 2026-09-09 点击修复:将首页按钮从静默调用注册表切换改为调用现有 `CustomCspDialog.show(this, this::setFunc)`;站点注入界面、全局开关和保存重载统一由既有对话框负责。 -- 2026-09-09 用户反馈的根因:首页按钮原先只修改注册表并重载配置,没有打开任何可见界面,因此用户看不到反馈;修复为复用增强功能页已有的权限申请和 `CustomCspDialog` 入口。 -- 点击修复最终验证(2026-09-09):`bash .codex/scripts/task_guard.sh check` 通过;`:app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.bean.SiteInjectHomeButtonSourceTest` 通过;`:app:compileLeanbackArm64_v8aDebugJavaWithJavac` 通过;`git diff --check` 通过。 -- 状态:点击行为修复和定向验证完成;待原子提交与恢复 tag。 diff --git a/docs/TV-FOCUS-20260907-search-navigation.md b/docs/TV-FOCUS-20260907-search-navigation.md deleted file mode 100644 index 218fa91cde..0000000000 --- a/docs/TV-FOCUS-20260907-search-navigation.md +++ /dev/null @@ -1,53 +0,0 @@ -# TV-FOCUS-20260907:电视搜索结果焦点回归 - -## 目标与范围 - -- 用户反馈:搜索结果顶部选项无法向右移动,另有未指明页面的按钮难以获取焦点,怀疑最近两天尤其今天的改动。 -- quick-fix:修复可证实的导航回归,保留横排/竖排、筛选和结果区原有行为;继续验证可复现的其他按钮问题,不宣称一次局部修复覆盖所有未定位场景。 -- 生产代码范围:`app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java`。 -- 基线:`dev2` / `1882ec67aa3eb07c4ff0e5d2abe8a4548f494e8e`。 -- 保护初始 5 个 `.bak20260906*` 未跟踪备份(4 个 `MpvPlayer.java`,1 个 mobile `VideoActivity.java`);不修改或提交。 -- 开始:2026-09-07 22:35,Asia/Shanghai;初始目标 22:55 完成。设备测试准备的触摸模式、Activity 同步启动等待,以及缺失语音服务的测试前置条件导致延后;23:10 全部针对性验证结束,没有重复构建已通过的产品 APK。 - -## 原因与最小修复 - -- `CollectAdapter.onBindViewHolder()` 为横排/竖排站点共用 `onCollectKey()` 回调。 -- `54b0b6a875d9d26fcf26c96ae04d4a2302e49625` 引入站点右键进入第一条搜索结果。 -- 本地合并 `188553addf6692220a2a715790fb8706b2f423b0`(2026-09-07 10:54:26 +08:00)把右键拦截放到了 `isSearchLandscape()` 分流之前。 -- 因而顶部横排有结果时,右键不会交给 `HorizontalGridView` 切换下一站点,而会被消费并异步转到结果区。 -- 把右键进入结果的逻辑移到横排分支之后;保留竖排右键回到第一条结果、横排下键进入结果、上键到顶部按钮、首项左边界,以及 key-up 不拦截。 -- 不添加全局按键拦截、静态焦点链或改动播放器依赖。属于已有导航设计的局部纠错,不需要新特性/上游设计研究。 - -## 相关排查 - -- 搜索页顶部筛选按钮已各自声明 `focusable` 并绑定左右/下键回调。 -- 公共 `BaseActivity`、`KeyUtil`、`TouchOptimizationHelper` 未发现今天引入的全局方向键屏蔽;触摸优化助手最近实现日期为 2026-09-04。 -- 今天 20:35 的沉浸融合 MPV 自定义按钮焦点改动仅作用于详情播放器控制条,不能解释搜索站点横排的错误分流。未把未验证的疑点当作修复依据。 -- 已请求用户提供“其他按钮”的具体页面/名称;设置页和搜索输入页补测未复现同样问题,在此之前不能证明所有未指明问题已关闭。 - -## 验证记录 - -- RED:从修改前生产文件抽取完整 `onCollectKey()`,用 `javac/java` 执行行为矩阵。横排、有结果、第 0 项、右键按下触发断言失败:`unexpected result-entry interception`,准确复现错误路径。 -- GREEN:从修复后生产文件抽取同一方法执行成功。矩阵为横排/竖排 × 有/无结果 × 首项/中间/末项 × 上下左右/确认 × 按下/抬起,共 120 例、480 项行为断言;验证事件消费及三个焦点请求的调用次数,而非仅检查源码字符串。首次输出的用例总数文案误写为 240,实际循环和断言数确认后已更正文案,未重复运行已经通过的行为检查。 -- 临时验证材料:`/tmp/webhtv-tv-focus-20260907/`;合并差异:`/tmp/webhtv-tv-focus-20260907-merge.diff`。 -- 构建:`bash ./gradlew :app:assembleLeanbackArm64_v8aDebug --console=plain` 一次通过(19 秒,129 个 task 中 114 个使用缓存);完整输出保存在 `build.log`。未做 native/dependency 改动,没有运行 ABI 矩阵。 -- APK:`app/build/outputs/apk/leanbackArm64_v8a/debug/app-leanback-arm64_v8a-debug.apk`,已安装到用户明确授权的 `192.168.50.3:5557`。未操作另外两台设备。 -- 设备测试使用临时、独立的 Android instrumentation APK,反射调用当前安装 APK 的真实 Activity/Adapter,注入本地站点与结果数据,不依赖外部搜索结果;发送真实 DPAD 按下/抬起事件并断言实际焦点。测试包和材料位于上述临时目录的 `device/`,不改变产品构建配置。 -- 搜索结果实际 UI:48 项断言通过(`device/results-3.log`)。覆盖有结果/空结果时横排连续右移与左移、首尾边界、站点上键到顶部、分组/相似度/显示方式双向移动、顶部下键、横排下键进入结果和结果上键返回、竖排右键进入第 0 条结果、滚动后重新从站点右键进入第 0 条、竖排上下切站点。 -- 其他页面:设置页可见入口的焦点请求,以及接口行右侧按钮的左右导航、点播/直播/壁纸行的向下导航通过(`device/results-smoke.log` 内第一阶段 PASS)。 -- 搜索输入页:输入框/搜索范围双向切换、向下进入屏幕键盘、键盘内向右换键,6 项断言通过(`device/results-search-input.log`)。 -- 测试准备问题已分类,而非弱化产品门禁:最初直接请求焦点时仍在触摸模式,改为先发送实际方向键;同步启动等待改用 ActivityMonitor;模拟器缺少语音识别服务时 `CustomMic.initSpeech()` 正常隐藏麦克风,补测只要求当前可见按钮可聚焦。没有为通过测试修改这些产品行为。 -- 每次设备测试都保存并恢复 `search_ui`、`search_column`、`keyword` 的原始值和键存在状态;测试日志确认恢复完成,不清除用户数据。已通过的搜索结果和设置页用例没有因补测搜索输入页而重跑。 - -## 回滚 - -- 使用 task guard 原子提交并创建 `recovery/TV-FOCUS-20260907/` annotated recovery tag;只覆盖本任务两条路径,不推送。具体提交与 tag 以 Git 中的本任务记录为准。 -- 回退本次独立修复提交即可恢复基线导航;不需要回退整次合并或原有“竖排右键进入首条结果”能力。 - -## Recovery anchor - -- 状态:搜索结果最小修复与针对性验证完成,本记录随代码由同一次 task guard finish 提交并创建本地恢复 tag。 -- 完成证据:精确调用链、完整引入提交、修改前失败与修改后通过的 480 项行为断言、一次成功的 Leanback debug 构建、5557 上的搜索结果 48 项断言,以及设置页和搜索输入页补测。 -- 未验证工作区代码:无;生产改动仅为 `CollectActivity.onCollectKey()` 中右键分流顺序。 -- 风险边界:尚不能声称所有未指明页面的按钮问题都已修复;已补测页面未发现共同焦点失效。 -- 下一步:请用户用 5557 上已安装的修复包复测,并提供仍有问题的其他页面/按钮名称以继续定位。 diff --git a/docs/ad-feedback-timerange-design.md b/docs/ad-feedback-timerange-design.md new file mode 100644 index 0000000000..fe9294f6bb --- /dev/null +++ b/docs/ad-feedback-timerange-design.md @@ -0,0 +1,527 @@ +# 「有广告」按钮升级:区间反馈与多通道综合归因设计 + +## 1. 文档状态 + +- 状态:提议(Proposed),待用户批准分阶段实施 +- 版本:v0.1 +- 日期:2026-08-29 +- 适用范围:播放器「有广告」按钮、广告归因分类器、候选规则落地、本次播放即时跳过 +- 上游文档:`智能去广-设计文档.md`(V2 总设计)、`docs/AI_AD_DETECTION_DESIGN.md`、`docs/audio-fingerprint-phase0-implementation.md`、`docs/interface-ad-rule-learning-design.md` +- 已确认决策:候选落地采用「本次立即跳过 + 同时生成待审候选」;通道分阶段接入;规则纯本地,不上传、不众包 + +## 2. 背景 + +### 2.1 现状 + +「有广告」按钮当前是**单通道**的:点击后只做一件事 —— 把站点元数据 + m3u8 切片摘要送给用户自配的 AI 端点,让大模型猜 `hosts` / `regex` / `exclude`,然后弹 `AdRulePreviewDialog` 让用户确认写入 `UserAdRule`。 + +三处入口(`app/src/leanback/.../VideoActivity.java:3982`、`app/src/mobile/.../VideoActivity.java:8661`、`app/src/main/.../TmdbDetailActivity.java:8286`)是三份几乎逐字重复的实现。 + +显示门槛是四个条件的与: +```java +Setting.isAiConfigReady() && Setting.isAdblock() + && Setting.isAiAdDetection() && isAdFeedbackSupportedFormat() +``` +其中 `isAiAdDetection()` 默认关闭(`Setting.java:868`),`isAdFeedbackSupportedFormat()` 要求 `MediaSourceFactory.isHlsUrl(url)`。**即按钮默认不可见,且强绑 AI。** + +### 2.2 三个具体缺陷 + +1. **反馈粒度只有「这一集有广告」**。用户明明知道广告在 02:15–02:45,这个信息被完整丢弃。分类器只能拿到整条 playlist 的统计特征,无法定位。 +2. **项目已有的其他检测能力完全没有参与**。仓库里已落地 HLS 结构化净化(`HlsManifestCleaner`,318 行)、音频频谱指纹(`ad/audio/` 41 个类,8000+ 行)、语音关键词(`SpeechAdSignalProvider`)、域名黑名单(`RuleConfig`)、接口规则学习(`InterfaceAdRuleAnalyzer`)—— 但「有广告」按钮一个都不用,只调 AI。 +3. **用户反馈完了什么都没得到**。当前流程最好的结果是「下次播放可能不再有广告」。本次播放的广告仍然要看完。 + +### 2.3 一个必须先修的既有问题 + +`HlsAdblockPipeline.apply()` 在两个调用点(`server/process/M3u8.java:91`、`androidx/media3/mpvplayer/MpvHlsProxy.java:799`)都传 `legacyFallback=true`。结构化规则未命中时会回落到预编译 AAR 里的 `HlsAdsParser.process()` —— 那是一套**没有规则、没有安全阈值、按众数块大小猜少数派**的启发式引擎。 + +由于内置的 3 条 HLS 规则全部 `enabledByDefault: false`(`assets/rules/hls_rules.json`),**当前线上实际生效的去广主力是这套启发式,而不是文档重点描述的结构化引擎。** 本设计的归因结论必须能区分「广告是被启发式删掉的」和「被结构化规则删掉的」,否则证据链是错的。 + +## 3. 目标与非目标 + +### 3.1 目标 + +1. 用户可提交广告的**开始/结束时间**,而不只是「有广告」这一个 bit。 +2. APP 用**已有的全部检测能力**对该区间做归因分类,输出「这段广告属于哪一类、最适合用哪种机制拦掉」。 +3. 分类结论按可靠性排序,选出**成本最低且证据最强**的那一种去广方式,而不是无脑走 AI。 +4. 用户提交后**当次播放立即跳过该区间**,不等分类完成。 +5. 分类产出的规则进入待审候选,用户确认后长期生效。 +6. 不引入服务端;不上传播放行为;AI 仍然可选且可完全关闭。 +7. 按钮在**关闭 AI 时也可用**(降级为纯本地归因)。 + +### 3.2 非目标 + +1. 不做视觉识别 / OCR / 帧分析。当前仓库无此能力,引入代价与收益不成比例。 +2. 不建服务端,不做跨用户众包聚合,不上报任何播放数据(承接现有「不承诺」清单)。 +3. 不为直播实现区间反馈(直播无稳定时间轴,`HlsManifestCleaner` 对 live 也只允许删连续头部分片)。 +4. 不承诺 IJK / MPV 的音频指纹通道(现有限制照旧,仅 Exo 支持 PCM 捕获)。 +5. 不自动启用高风险规则(宽泛固定时长、全文正则)。 +6. 不改动 `HlsAdsParser`(在预编译 AAR 内,属于 upstream 依赖,改它要走 `E*` 任务流程)。 + +## 4. 用户交互设计 + +### 4.1 区间采集:三种输入路径 + +单个按钮承担不了区间输入。改为**长按/短按分流 + 一个轻量标记条**。 + +| 操作 | 行为 | 适用 | +|---|---|---| +| 短按「有广告」 | 以当前位置为**结束时间**,向前回溯猜测开始时间(见 4.2),直接进入确认 | 广告刚放完,用户反应过来了。最常见 | +| 长按「有广告」 | 进入**标记模式**:第一次确认打起点,第二次确认打终点 | 广告正在放,用户想精确框选 | +| 标记模式中再次长按 | 取消标记 | 误触 | + +TV(leanback)端焦点资源紧张,长按走 `OnLongClickListener`;移动端同时在标记模式下于进度条上叠加区间高亮,允许拖动微调两个端点。 + +### 4.2 短按的起点推断 + +短按只有一个时间点。起点按优先级推断,第一个成功的即采用: + +1. **最近一次 `DISCONTINUITY` 边界**。从 `M3u8Evidence.discontinuities` 找当前位置之前最近的断点,落在其上。这是最可靠的信号 —— 硬插广告几乎总带断点。 +2. **最近一次跨域切换点**。`M3u8Evidence.domainSwitches` 中当前位置前最近的 `true`。 +3. **最近一次音频/语音候选的 `startTimeMs`**。若 `AdSkipCoordinator` 在 `SUPPRESSED` 或 `UNDO_WINDOW` 状态持有过候选,直接复用。 +4. **固定回溯窗口**。默认 90 秒,钳制到 `[0, 当前位置)`。 + +推断出的起点必须在确认对话框里**明示并可调**,不能静默使用。文案示例:「广告区间 02:15 – 02:45(起点由切片断点推断)」。 + +### 4.3 确认对话框 + +复用并扩展 `AdRulePreviewDialog`(leanback / mobile 各一份)。分两个阶段展示: + +``` +第一屏(提交即出,< 100ms) + 广告区间 02:15 – 02:45 (30.0s) [调整] [确认] + ☑ 本次播放立即跳过这段 + +第二屏(归因完成后原地替换内容,通常 1–3s,含 AI 时可能 10s+) + 归因结果:切片跨域 + 固定时长(置信度 0.82) + 建议方式:HLS 结构化规则 [风险:中] + hostSuffixes: ad-cdn.example.com + durationRange: 6.40 – 6.47 + requireDiscontinuity: true + minimumSignals: 2 + 证据:区间内 5 个切片全部来自 ad-cdn.example.com, + 主 playlist 域名为 v.example.com;区间前后各有一个 + #EXT-X-DISCONTINUITY;切片时长标准差 0.02s + [保存为规则] [仅本次跳过] [丢弃] +``` + +「本次播放立即跳过」默认勾选,勾选状态即视为对**本片段本次执行**的授权,与「保存为规则」的长期授权分离。这是决策 A 的落点。 + +### 4.4 按钮可见性放宽 + +```java +// 新条件:去广总开关 + 可定位的时间轴,AI 不再是硬门槛 +private boolean isAdFeedbackEnabled() { + return Setting.isAdblock() + && !isLive() + && getDuration() > 0 + && isSeekable(); +} +``` + +AI 从「按钮显示前置条件」降级为「归因通道之一」。关闭 AI 时按钮仍可用,只是少一个通道。HLS 格式也不再是硬门槛 —— 非 HLS 时 HLS 通道自然弃权,音频/语音通道仍可工作。 + +## 5. 总体架构 + +```mermaid +flowchart TD + U["用户提交 [startMs, endMs]"] --> R["AdFeedbackRequest 构造"] + R --> IMM["立即执行:本次跳过 (AdSkipCoordinator)"] + R --> COL["证据采集 AdEvidenceCollector"] + COL --> E["AdIntervalEvidence 快照"] + E --> C1["HlsSegmentClassifier"] + E --> C2["DomainReputationClassifier"] + E --> C3["ExistingRuleClassifier"] + E --> C4["AudioFingerprintClassifier"] + E --> C5["SpeechKeywordClassifier"] + E --> C6["AiClassifier (可选)"] + C1 --> A["AdAttributionArbiter 仲裁"] + C2 --> A + C3 --> A + C4 --> A + C5 --> A + C6 --> A + A --> P["AdRemediationPlan 最优方案"] + P --> UI["确认对话框第二屏"] + UI -->|用户确认| S["写入 UserAdRule / HlsAdRule / AudioFingerprintRule"] + S --> INV["RuleConfig.invalidate() + HlsRuleConfig.invalidate()"] +``` + +### 5.1 模块职责 + +| 模块 | 职责 | 不负责 | +|---|---|---| +| `AdFeedbackController` | 三端共用的编排器:接收区间、触发立即跳过、调度分类、驱动对话框 | 具体分类逻辑 | +| `AdEvidenceCollector` | 采集区间证据快照,一次性、幂等、可缓存 | 判断是不是广告 | +| `AdIntervalClassifier`(接口) | 单通道分类,输出 0..n 个 `AdAttribution` 或弃权 | 跨通道比较 | +| `AdAttributionArbiter` | 合并、去重、置信度融合、成本排序,选出最优方案 | 写规则 | +| `AdRemediationPlanner` | 把仲裁结论翻译成具体规则对象(`HlsAdRule` / `UserAdRule` / `AudioFingerprintRule`) | 落盘 | +| `AdFeedbackDiagnostics` | 固定枚举计数,不记 URL / 规则正文 | 上报 | + +### 5.2 三端去重 + +现有三份重复实现是这次改造的最大阻力。新逻辑**全部放进 `app/src/main/` 的 `AdFeedbackController`**,三端 Activity 只保留: + +```java +// 每端约 15 行 +mAdFeedback = new AdFeedbackController(new AdFeedbackController.Host() { + public long positionMs() { return mPlayer.getPosition(); } + public long durationMs() { return mPlayer.getDuration(); } + public String playUrl() { return mPlayer.getUrl(); } + public Map headers() { return mPlayer.getHeaders(); } + public AdSkipCoordinator skipCoordinator() { return mPlayer.adSkipCoordinator(); } + public void showDialog(AdFeedbackSession s) { AdRulePreviewDialog.create(s).show(this); } + public String vodName() { /* 三端唯一真正不同的地方 */ } +}); +``` + +现有 `testMobile` 下的 `PlayerPlaybackRegressionSourceTest` / `TmdbDetailActivityLayoutTest` 用**源码文本断言**锁定了三端的方法名,改动时必须同步更新这两个测试的期望字符串,否则构建失败。 + +## 6. 数据契约 + +### 6.1 区间证据 + +```java +public record AdIntervalEvidence( + long startMs, long endMs, StartOrigin startOrigin, + // 播放上下文(沿用 AdDetectionRequest 的去敏原则) + String siteKey, String siteName, String vodName, + String flagName, String episodeName, + String playlistHost, String urlPath, boolean hls, + // HLS 切片证据(区间内 + 区间外对照) + List inside, List outside, + boolean boundedByDiscontinuity, boolean crossDomain, + // 已有机制的自述 + List alreadyRemovedByStructuredRuleIds, + boolean legacyHeuristicActive, + List matchedExistingHosts, + // 音频/语音 + AudioIntervalFact audio, SpeechIntervalFact speech) {} + +public record SegmentFact(int index, String host, String path, + double durationSec, boolean discontinuityBefore) {} +``` + +`StartOrigin` 枚举:`USER_MARKED` / `DISCONTINUITY` / `CROSS_DOMAIN` / `AUDIO_CANDIDATE` / `FALLBACK_WINDOW`。仲裁时 `USER_MARKED` 与 `DISCONTINUITY` 的证据权重高于 `FALLBACK_WINDOW`。 + +`alreadyRemovedByStructuredRuleIds` 与 `legacyHeuristicActive` 直接回答 2.3 的问题:如果用户报告的区间**在净化后的 manifest 里已经不存在**,说明广告来自别处(例如 WebView 层没拦住的贴片、或播放器外层),归因结论应当是「HLS 通道弃权」而不是「再加一条 HLS 规则」。 + +### 6.2 归因与方案 + +```java +public record AdAttribution( + String channelId, // "hls" / "domain" / "existing-rule" / "audio" / "speech" / "ai" + AdCategory category, + float confidence, // 0..1,通道内自评 + RiskLevel risk, // LOW / MEDIUM / HIGH,沿用 V2 总设计的三级 + List evidence, // 人类可读,进 UI + RemediationKind remediation) {} + +public enum AdCategory { + THIRD_PARTY_CDN_SEGMENT, // 切片来自非主流/非当前适配域名 + FIXED_DURATION_BLOCK, // 固定时长硬插块 + DISCONTINUITY_BLOCK, // 断点包裹的独立块 + KNOWN_AD_HOST, // 命中已知广告域名 + AUDIO_FINGERPRINT, // 音频频谱指纹匹配 + SPEECH_KEYWORD, // 语音关键词 + IN_STREAM_BURNED_IN, // 压制进正片,无结构特征 + ALREADY_HANDLED, // 已被现有规则处理,用户看到的是别的东西 + UNKNOWN +} + +public enum RemediationKind { + HLS_STRUCTURED_RULE, // → HlsAdRule + HOST_BLACKLIST, // → UserAdRule.hosts + URL_REGEX_RULE, // → UserAdRule.regex + AUDIO_FINGERPRINT_RULE,// → AudioFingerprintRule + SPEECH_KEYWORD, // → SpeechAdSetting keywords + SESSION_SKIP_ONLY, // 只能本次跳过,无法泛化 + NONE +} +``` + +`AdRemediationPlan` = 首选 `AdAttribution` + 生成好的规则对象 + 备选方案列表。UI 展示首选,「更多方式」可展开备选。 + +## 7. 各通道分类逻辑 + +### 7.1 HlsSegmentClassifier(Phase 1,零新增成本) + +输入已有:`M3u8Parser.parse()` 已经产出 `segments` / `discontinuities` / `durations` / `domainSwitches`。缺的只是**把播放时间映射到切片下标** —— 用 `durations` 前缀和即可,不需要新的网络请求。 + +``` +cum[0]=0; cum[i+1]=cum[i]+durations[i] +区间内切片 = { i : cum[i+1] > startSec && cum[i] < endSec } +``` + +判定信号(沿用 `HlsManifestCleaner.Rule` 已支持的六项,保证生成的规则一定能被现有引擎执行): + +| 信号 | 判据 | 权重 | +|---|---|---| +| 跨域 | 区间内切片 host ≠ playlist host,且区间外切片 host = playlist host | 0.35 | +| 断点包裹 | 区间起止各有 `#EXT-X-DISCONTINUITY` | 0.25 | +| 时长离群 | 区间内切片时长标准差 < 0.05s 且与区间外众数时长差 > 0.5s | 0.20 | +| 路径特征 | 区间内切片路径命中 `/ads?/`、`/preroll/`、`/creative/` 等保守模式 | 0.15 | +| 位置 | 区间落在 playlist 头部 15% 以内 | 0.05 | + +置信度 = 命中权重之和。`minimumSignals` 按命中信号数取 `max(2, 命中数)`,**永不生成 `minimumSignals: 1` 的规则** —— 内置规则策略明确禁止只凭固定时长删片。 + +生成的 `HlsAdRule` 必须带 `playlistHostSuffixes`(限定到当前站点的 playlist 域名),把规则作用域收窄,避免污染其他站点。 + +**弃权条件**:非 HLS;区间内切片数为 0;`alreadyRemovedByStructuredRuleIds` 非空且覆盖了整个区间;置信度 < 0.30。 + +### 7.2 DomainReputationClassifier(Phase 1,零新增成本) + +「非当前适配的主流域名」这条需求落在这里。三层比对: + +1. **命中现有黑名单**:区间内切片 host 命中 `RuleConfig.get().getAds()`(VOD `ads` + Live `ads` + 用户规则 hosts)→ `KNOWN_AD_HOST`,置信度 0.95,但同时标记 `ALREADY_HANDLED`:既然已在黑名单里,用户还是看到了广告,说明拦截路径没覆盖到播放器直连的切片请求(黑名单目前主要在 `CustomWebView.shouldInterceptRequest` 生效,不拦播放器的切片)。这个结论本身就有价值 —— 建议改用 `HLS_STRUCTURED_RULE`。 +2. **与站点适配域名对比**:从当前站点最近若干次成功播放的 playlist host 归纳出「本站正常域名集合」(新增一个小的本地 LRU,见 8.3)。区间内 host 不在其中 → `THIRD_PARTY_CDN_SEGMENT`。 +3. **命中接口学习候选**:比对 `ImportedAdRuleCandidateStore` 里状态为待审的候选。若命中,说明接口维护者也认为这是广告域名 → 置信度 +0.15,并在证据里注明来源接口名。这条把 `InterfaceAdRuleAnalyzer` 的既有产出接进了反馈闭环。 + +生成 `UserAdRule.hosts`。风险等级 LOW(域名黑名单是最安全的机制)。 + +### 7.3 ExistingRuleClassifier(Phase 1,零新增成本) + +回答「为什么现有机制没拦住」。检查四件事并写进证据,本身不产出规则(`RemediationKind.NONE`),但会**否决**其他通道的错误结论: + +1. 内置 3 条 HLS 规则是否因 `enabledByDefault: false` 而未启用,且其 `hostSuffixes` 恰好匹配本区间 → 建议「启用规则 X」而不是新建规则。这是成本最低的修复。 +2. `legacyHeuristicActive` 为真且区间内切片是被启发式删掉后**又出现**的 → 启发式判错,需要 exclude 保护。 +3. 是否有 `UserAdRule.exclude`(正片保护正则)误保护了广告切片。 +4. 区间是否已被某条规则处理过(查 `AdBlockStats.ruleCounts` 的最近命中)。 + +### 7.4 AudioFingerprintClassifier(Phase 2) + +Phase 1 只**读**已有匹配结果(若音频指纹已开启且区间内有 `MatchEvent`,直接采信,置信度 0.90,`RemediationKind.AUDIO_FINGERPRINT_RULE` 但规则已存在故降为证据)。 + +Phase 2 才做**新指纹采集**,这是真正的能力增量:用户框选的区间就是天然的指纹样本源。 + +``` +1. AdSkipCoordinator 在 UNDO_WINDOW / 提交瞬间,向 PlaybackMediaSignalHub + 申请 ConsumerKind.AD_AUDIO 的 CaptureLease(若尚未持有) +2. 若区间已过去 → 不重放,走「下次遇到再采集」:把区间描述存为 + PendingFingerprintCapture,下次同一 siteKey + 同一 flagName 播放 + 到相近位置时自动采集 +3. 若区间尚未播完(长按标记模式下常见)→ 直接从 Hub 的 PcmFrame 流采集 +4. SpectralFingerprint.extractVariants(samples, 16000, 1, ...) 产出 4 个相位变体 +5. 组装 AudioFingerprintRule{id, durationMs, anchorOffsetMs, anchorDurationMs, + fingerprint, variants},经 AudioFingerprintRuleCodec 严格校验后 + 写入 AdAudioRuleStore(files/ad-audio-rules.json,2MiB 上限) +``` + +约束(全部继承自现有实现,不放松): +- 仅 Exo;仅点播;仅时长已知且可 seek +- 采集不改变现有「必须用户确认才 seek」的策略 +- 锚点长度取区间前 3 秒(`anchorDurationMs` 上限),不存整段广告音频 +- **不存原始 PCM**,只存 32-bit hash 序列。这一点对隐私和体积都关键 +- 新指纹默认 `PROMPT` 模式,不自动跳 + +跨规则碰撞检查:新指纹与已有规则做汉明距离比对,过近则拒绝写入并提示「已有相同规则」,避免规则库膨胀。 + +### 7.5 SpeechKeywordClassifier(Phase 2) + +若语音通道已开启且区间内有识别文本,提取候选关键词。**但不自动加入关键词表** —— 关键词是全局生效的,一个错误关键词会在所有片子上乱跳。只做建议,且: +- 只建议在多个不同片源的反馈中重复出现的词(需要一个本地计数,阈值 3 次) +- 建议词展示时不显示完整识别文本(现有实现刻意不打印命中关键词,保持一致) + +### 7.6 AiClassifier(Phase 3,可选) + +复用 `AiAdDetectionService`,但输入升级: + +``` +现在只送:站点/剧名/线路/集名/域名/路径 + 全 playlist 切片摘要 +升级后送:上述 + 用户框选区间 + 区间内外切片对照 + 其他通道的初步结论 +``` + +关键变化是 **AI 从「唯一裁判」变成「兜底解释者」**。它只在以下情况被调用: +- 其他所有通道置信度均 < 0.50,或 +- 用户在第二屏主动点「让 AI 再看看」 + +理由:本地通道有确定性证据时,大模型的输出只会引入噪声和延迟;而本地通道全部弃权时(`IN_STREAM_BURNED_IN` 场景),AI 至少能给出人类可读的解释。 + +`AiConfig` 未配置时静默弃权,不报错。AI 输出的规则**永远进待审**,绝不自动启用(决策 D 不变)。 + +## 8. 仲裁与置信度 + +### 8.1 成本-可靠性排序 + +仲裁不是简单取最高置信度,而是在**同等置信度下优先选成本更低、风险更小的机制**: + +| 机制 | 运行时成本 | 风险 | 泛化能力 | 排序权重 | +|---|---|---|---|---| +| 启用已有内置规则 | 零 | 低 | 高 | 1(最优) | +| 域名黑名单 | 极低 | 低 | 中 | 2 | +| HLS 结构化规则 | 低(P95 < 50ms) | 中 | 高 | 3 | +| URL 正则规则 | 低 | 中 | 中 | 4 | +| 音频指纹 | 中(PCM 管线 + FFT) | 中 | 仅同一广告素材 | 5 | +| 语音关键词 | 高(Sherpa-ONNX 常驻) | 高(全局误伤) | 低 | 6 | +| 本次跳过 | 零 | 低 | 无 | 7(兜底) | + +最终得分 = `confidence × 0.7 + (1 - 归一化排序权重) × 0.3`。`ALREADY_HANDLED` 类别强制降权到最后。 + +### 8.2 冲突处理 + +- 多通道指向**同一** `AdCategory`:置信度取 `1 - Π(1 - cᵢ)`(概率或),证据合并 +- 多通道指向**不同** category:全部保留,按 8.1 排序,UI 展示首选 + 「更多方式」 +- 任一通道给出 `ALREADY_HANDLED`:整体降级为「诊断结论」,首选动作变成「本次跳过 + 上报诊断到本地日志」,不新增规则。避免规则库被无效规则污染 + +### 8.3 站点域名基线(新增最小存储) + +`SitePlaylistHostBaseline`,SharedPreferences,每站点保留最近 8 个成功播放的 playlist host + 切片 host,LRU 淘汰。仅用于 7.2 的第 2 层比对。 + +不存 URL 全文、不存 query、不存 token,只存 host 字符串。单站点上限 8,全局上限 200 站点,超限按最久未用淘汰。 + +## 9. 立即跳过的执行 + +这是「本次立即跳过」决策的落点。复用 `AdSkipCoordinator` 而不是新写 seek 逻辑,因为它已经处理了所有难的部分:session/generation 校验、时钟新鲜度、直播拒绝、不可 seek 拒绝、5 秒撤销窗口。 + +新增一条入口: + +```java +// AdSkipCoordinator 新增 +public synchronized boolean onUserInterval(long startMs, long endMs, String feedbackId); +``` + +与现有 `onCandidate` / `onAutoCandidate` 的区别: +- 用户已经显式授权,跳过 `PROMPT_PENDING` 直接进 `SEEKING` +- 但**仍然执行全部安全校验**:session/generation 匹配、`endMs < durationMs`、可 seek、非直播。任一失败则 `diagnostics.record(SEEK_REJECTED)` 并降级为「已记录,未跳过」 +- 保留 5 秒 `UNDO_WINDOW`。用户框错了要能立刻回去 +- 若 `endMs` 已过(短按场景,广告已放完)→ 不 seek,只记录,第一屏文案改为「已记录该区间」 + +**同一区间的重复提交**要幂等:以 `(sessionId, startMs/1000, endMs/1000)` 为键去重,避免用户连按导致多条候选。 + +## 10. 安全与隐私 + +继承现有边界,并针对本次新增的数据做收紧: + +1. **区间时间戳不出设备**。除了用户自配的 AI 端点(Phase 3,且用户显式开启),无任何网络出口。 +2. **AI 输入去敏**:只送 host + 去参数 path + 时长数组 + 断点下标。不送完整 URL、query、token、Cookie、Authorization。现有 `AdDetectionRequest` 已遵守此约定,扩展字段沿用。 +3. **`AiDebugLog` 的落盘风险需要处理**。现有 `AiAdDetectionService.java:44,50,58` 会把完整 prompt 与响应写本地日志。区间反馈会让 prompt 包含更多播放上下文。建议:debug 日志只在 debug 构建启用,或对 host 做哈希化。这是一个既有问题,本设计不扩大它。 +4. **音频指纹不存 PCM**,只存 hash 序列(7.4)。 +5. **正则安全**:任何生成的正则在写入前必须过 `InterfaceAdRuleAnalyzer` 已有的危险模式检查(嵌套量词、反向引用),并预编译验证。复用现有实现,不新写。 +6. **规则作用域强制收窄**:反馈生成的 `HlsAdRule` 必须带 `playlistHostSuffixes`,`UserAdRule` 必须带 `siteKey`。禁止生成全局生效的规则。 + +## 11. 可观测性 + +沿用 `AdAudioDiagnostics` 的「固定枚举计数,不记敏感内容」模式,新增 `AdFeedbackDiagnostics.Code`: + +``` +INTERVAL_SUBMITTED, START_INFERRED_FROM_DISCONTINUITY, +START_INFERRED_FROM_FALLBACK, IMMEDIATE_SKIP_APPLIED, +IMMEDIATE_SKIP_REJECTED, EVIDENCE_COLLECT_FAILED, +CHANNEL_ABSTAINED, ARBITER_NO_PLAN, PLAN_ACCEPTED, +PLAN_DISCARDED, DUPLICATE_SUBMISSION, ALREADY_HANDLED_DETECTED +``` + +`AdBlockStats` 扩展三个计数(沿用现有 SharedPreferences 存储,不建表): +- `intervalFeedbackCount` — 区间反馈次数 +- `intervalSkipApplied` — 成功立即跳过次数 +- `planAcceptedByKind` — 按 `RemediationKind` 统计被采纳的方案分布 + +最后一项是**衡量本设计是否成功的核心指标**:如果一年后 `SESSION_SKIP_ONLY` 占比仍然最高,说明归因基本没用,应当回退简化。 + +## 12. 测试策略 + +### 12.1 单元测试(Phase 1 必须) + +- `AdEvidenceCollectorTest`:时间→切片下标映射,含边界(区间恰好落在切片边界、区间跨越 `EXT-X-DISCONTINUITY`、`durations` 缺失) +- `HlsSegmentClassifierTest`:五种信号的正样本各一、反样本各一;「区间内外同域名」必须弃权而非误判 +- `DomainReputationClassifierTest`:三层比对;`ALREADY_HANDLED` 的识别 +- `ExistingRuleClassifierTest`:内置规则未启用时建议启用而不是新建 +- `AdAttributionArbiterTest`:同 category 概率或、跨 category 排序、`ALREADY_HANDLED` 强制降权 +- `AdSkipCoordinatorTest` 扩展:`onUserInterval` 的 session/generation 拒绝路径、`endMs` 已过的降级、重复提交幂等 + +### 12.2 样本回归(Phase 1 必须) + +复用现有 `app/src/test/resources` 的 manifest 样本模式,为三条内置实验规则(暴风 / 量子 / 非凡)各准备一组:真实 manifest + 用户区间 + 期望归因结论。这些样本已有正/反样本,成本低。 + +### 12.3 Phase 2 追加 + +- `SpectralFingerprint` 黄金 PCM 向量端到端比对(`docs/ad-audio-fingerprint-sdk-evaluation.md` 4.1 节的既有待办,Phase 2 必须先关掉) +- 指纹碰撞检查:新采集指纹与已有规则过近时拒绝写入 +- `PendingFingerprintCapture` 的跨会话恢复 + +### 12.4 三端一致性 + +现有 `PlayerPlaybackRegressionSourceTest` / `TmdbDetailActivityLayoutTest` 靠源码文本断言锁定三端方法名。改造为 `AdFeedbackController` 后,这两个测试的期望字符串必须同步更新,且新增一条断言:三端**不得**各自持有 `submitAdFeedback` / `buildAdDetectionRequest` 的实现(防止重复实现回归)。 + +## 13. 分阶段实施 + +按「先零成本,再重成本」决策拆分。每个 Phase 独立可用、独立可回滚。 + +### Phase 1:区间输入 + 纯本地归因(无新增运行时成本) + +范围:`AdFeedbackController`、`AdEvidenceCollector`、`HlsSegmentClassifier`、`DomainReputationClassifier`、`ExistingRuleClassifier`、`AdAttributionArbiter`、`AdSkipCoordinator.onUserInterval`、对话框两屏、三端去重、`SitePlaylistHostBaseline`。 + +不含任何音频采集、不含 AI 改动(AI 保持现状可用)。 + +验收: +1. 长按可框选区间,短按可回溯推断起点且起点来源可见可调 +2. 提交后当次播放立即跳过(`endMs` 未过时),5 秒内可撤销 +3. 三条内置实验规则的样本 manifest 上,归因结论与人工标注一致 +4. 区间已被现有规则处理时,输出 `ALREADY_HANDLED` 而非新增规则 +5. 关闭 AI 时按钮可用且归因正常 +6. 三端不再存在重复的反馈实现 + +回滚:`AdFeedbackController` 的入口加一个 Setting 开关,关闭后按钮回到现有单通道 AI 行为。 + +### Phase 2:音频指纹采集闭环 + +范围:`AudioFingerprintClassifier` 的采集路径、`PendingFingerprintCapture`、指纹碰撞检查、`SpeechKeywordClassifier` 的建议(不自动启用)。 + +**前置条件**:黄金 PCM 向量比对完成(12.3)。未完成不得进入 Phase 2 —— 否则采集出的指纹可能与匹配器不兼容,规则库会被污染。 + +验收:用户框选正在播放的广告区间后,同一广告在下一集出现时能被指纹匹配到并按 `PROMPT` 模式提示。 + +### Phase 3:AI 兜底 + 输入升级 + +范围:`AiClassifier`、`AiAdDetectionService` 的 prompt 升级、`AiDebugLog` 的敏感信息收紧。 + +验收:本地通道全部弃权时 AI 被调用并给出可读解释;本地通道有强证据时 AI **不被**调用(省 token、省延迟)。 + +### 明确不做 + +- 服务端上报与众包聚合 +- 视觉 / OCR / 帧分析 +- 直播区间反馈 +- IJK / MPV 音频指纹 +- 自动启用高风险规则 + +## 14. 风险与缓解 + +| 风险 | 影响 | 缓解 | +|---|---|---| +| 用户框选不准,生成错误规则 | 正片被误删 | 规则强制带 `playlistHostSuffixes` + `siteKey` 收窄作用域;`minimumSignals ≥ 2`;`HlsManifestCleaner` 的既有安全阈值(删除比例 > 35%、时长 > 90s 一律回退)仍然兜底 | +| 归因结论全是 `SESSION_SKIP_ONLY` | 功能沦为手动跳过按钮 | 用 11 节的 `planAcceptedByKind` 度量;若一年后仍如此则回退简化 | +| 规则库膨胀 | 每 manifest 匹配耗时上升 | 碰撞检查拒绝重复;`HlsRuleConfig` 已有编译快照缓存;单条规则可禁用 | +| `legacyHeuristicActive` 干扰归因 | 证据链错误 | `ExistingRuleClassifier` 显式检测并在证据里注明;不改 AAR | +| 三端改造引入回归 | 播放器功能受损 | 源码断言测试锁定;Phase 1 带开关可整体回退 | +| 音频指纹与匹配器不兼容 | 采集的规则永不命中 | 黄金向量比对作为 Phase 2 硬前置 | + +## 15. 待确认决策 + +1. **短按的默认回溯窗口取 90 秒是否合适?** 太短会漏掉长贴片,太长会把正片框进来。建议做成设置项,默认 90s。 +2. **`AiDebugLog` 是否改为仅 debug 构建启用?** 这是既有问题,本设计只是让它暴露更多上下文。倾向于改,但属于扩大范围,需批准。 +3. **`legacyFallback=true` 是否要改为可配置?** 2.3 指出当前实际主力是无规则启发式。让用户能关掉它有利于归因准确性,但可能降低开箱去广效果。建议 Phase 1 只做诊断展示,不改行为。 +4. **`SitePlaylistHostBaseline` 的全局 200 站点上限是否足够?** 重度用户可能超。可调。 + +## 16. 架构决策摘要 + +### 决策 A:区间提交同时授权「本次执行」与「候选生成」,二者分离 + +用户框选区间这个动作本身就是对该片段的明确授权,当次跳过无需二次确认;但把它变成长期规则会影响未来所有播放,必须单独确认。因此第一屏的「本次立即跳过」默认勾选并立即执行,第二屏的「保存为规则」默认不选。 + +这样既解决了「反馈完什么都没得到」的体验问题,又不违反「AI/自动检测不得直接执行」的既有约束 —— 因为执行的授权来自用户的显式框选,不是来自任何检测器。 + +### 决策 B:AI 从裁判降级为兜底解释者 + +本地通道(切片结构、域名、已有规则)有确定性证据,大模型在这些场景只会引入噪声、延迟和 token 成本。只在本地全部弃权时才调 AI。副作用是按钮不再依赖 AI 配置,可见性门槛大幅降低,这本身是收益。 + +### 决策 C:仲裁按成本-风险排序,不按置信度裸排 + +「最好的去广告方式」不等于「置信度最高的方式」。启用一条已有的内置规则永远优于新建规则;域名黑名单永远优于音频指纹。排序权重把这个偏好编码进仲裁器。 + +### 决策 D:新逻辑全部下沉到 `main` 源集,三端只留 Host 适配 + +现有三份重复实现是维护负担的根源(改行为要同步改三处,测试靠字符串匹配锁定)。本次改造顺带解决,但不做无关重构 —— 只搬「有广告」这一条链路。 + +### 决策 E:不为区间反馈引入任何新的持久化后端 + +`SitePlaylistHostBaseline` 用 SharedPreferences,候选复用 `ImportedAdRuleCandidate` 的字段结构(它已有 `classification` / `confidence` / `riskLevel` / `reasons` / `status`),指纹复用 `AdAudioRuleStore` 的文件。全项目广告相关数据零 Room 表,这个性质保持不变。 diff --git a/docs/beta-sync-review-20260905.md b/docs/beta-sync-review-20260905.md deleted file mode 100644 index 60d92b20c3..0000000000 --- a/docs/beta-sync-review-20260905.md +++ /dev/null @@ -1,65 +0,0 @@ -# Beta 同步与合并复评 - -## 目标与范围 - -- 完成已开始的 `origin/dev1`、`origin/beta` 合并,评审包含已提交未推送内容的最终差异,修复并验证后提交、创建恢复标签、推送 `dev1`,创建或更新中文 beta PR,最后同步远端状态。 -- 首个 guard `beta-sync-review-20260905` 已关闭;续用 `beta-sync-latest-20260905`,范围仅为 `Backup.java`、`TmdbDetailActivity.java`、`TmdbDetailActivityLayoutTest.java` 和本文档。没有登记的任务外初始脏路径,不重置索引或工作树。 -- 不改变播放器依赖、二进制、弹幕渲染、搜索顺序或缓存 key 格式。 - -## 恢复证据 - -- 2026-09-05 23:36,Asia/Shanghai:工作区分支 `dev1`,HEAD 为 `399023ced875b3bdb298afd7b09219c8e0eef57e`。 -- `.git/MERGE_HEAD` 为 `eab79fba9251dd6d07283fe149d7ce4f6874554d`(origin/dev1)和 `db44647432ea1ac094298cebca9f535f73dc8202`(origin/beta);合并尚未提交。 -- 当前索引包括触屏优化与播放菜单遮罩。工作树还保留 `Backup.java` 类头恢复、遮罩偏好备份补齐、弹幕请求生命周期修复及测试。 -- 上一会话完成了回调线程和主线程双重请求身份检查,但遗漏手动匹配缓存的关键词接线。 -- 当前磁盘 `app/build/test-results/testMobileArm64_v8aDebugUnitTest/TEST-com.fongmi.android.tv.ui.dialog.DanmakuSearchIntentTest.xml` 记录 4 项测试、1 项失败、0 errors;失败断言为 `DanmakuSearchDialog must remember the submitted keyword`。结果时间为 2026-09-05T15:17:06.793Z,是有效 RED 证据,不重复运行。 - -## 修复与验收 - -- 两个搜索对话框将本集、站点系列、TMDB 季级三层缓存统一绑定到 `searchIntent.getResultKeyword()`,避免结果返回前编辑输入框污染匹配记忆。 -- `DanmakuSearchDialog.search` 读取一次关键词,并向请求与状态对象传递同一值。 -- 保留旧请求在后台回调及主线程更新前的失效检查;销毁对话框或再次搜索后旧请求不能覆盖结果。 -- 验收:纯状态测试与两个入口接线测试通过;mobile/leanback arm64 聚焦测试和 Java 编译通过;合并差异复评没有未解决阻断项。 - -## 验证与已知限制 - -- GREEN:独立 JUnit `DanmakuSearchIntentTest` 4/4,日志 `build/beta-review-junit.log`。 -- GREEN:一次 Gradle 调用分别筛选 mobile/leanback arm64 debug 单测,两端各 40/40,均为 0 failures、0 errors;包含两端 Java 编译,`BUILD SUCCESSFUL in 3m 1s`。日志 `build/beta-sync-review-20260905-tests.log`。 -- 每端覆盖:`DanmakuApiSourceTest` 2、`BackupPreferenceFilterTest` 10、`DanmakuMatchCacheTest` 8、`PlaySpecTest` 4、`SettingPlaybackOverlayTest` 2、`DanmakuSearchIntentTest` 4、`TouchOptimizationHelperSourceTest` 10。 -- 修复后复评:核对相对原合并 beta `db44647432ea1ac094298cebca9f535f73dc8202` 的全部本地生产差异与对应测试,包括三个播放入口的 TMDB 身份传递、对话框返回父层、缓存兼容/隔离、搜索回退、弹幕选择状态、备份恢复及异步生命周期。没有未解决阻断项;当前会话无子代理评审工具,复评由本代理完成。 -- 2026-09-05 本轮 fetch 后 beta 更新至 `bc5f9b42e090dd7fd303a56c052618dcb5506047`,新增 `Backup` 类头修复与 TMDB 横向选集居中。先关闭当前合并单元,再在独立 guard 中合入该增量并仅补验受影响范围。 -- 旧全量测试报告的两项 `FfmpegVc1SupportTest` 失败属于未改动的 FFmpeg 运行库测试,本任务不更改或规避这些测试,也不把聚焦验证称为全量通过。 -- 本轮不重复触屏优化已有的设备验证;历史记录见 `docs/touch-optimization-mode-design.md`,其中最终 APK 开关闭环的限制仍保留。 - - - -## dev2 增量复评记录(2026-09-07) - -- 本轮基线:`dev2@adeabec2588a81d204dfd608269e3db8c7cdc80e`;拉取到 `origin/beta@cc88e278a8ddc2088a82a68dbf1671e419606a29`;共同祖先为 `912208261e4e342ced009b1a0b71feed4855a01d`。合并使用 `git merge --no-ff --no-commit`,无内容冲突;当前分支此前已提交但未推送的 C4 播放器/依赖变更与 beta 本轮无路径交集,保留不变。 -- 初始保护脏路径:5 个 `*.bak20260906*` 文件;均未修改、暂存或提交。beta 的播放器相关删除/供应链变化相对当前 HEAD 均为已存在于 beta 合并祖先之前的本地增量与路径差异,未作为本轮盲目覆盖。 -- 首轮审查发现并修复:`gradlew` 可执行位从 `100755` 回退为 `100644`;测试断言将 Leanback 已实现的 `current.detail()` 错判为 `item.detail()`;主题事件测试未考虑 Leanback 的 `UI_SCALE` 中间条件。 -- 第二轮敌对审查发现并修复:新增 TV 颜色选择项缺少 DPAD 可聚焦属性和焦点背景;外观页直接以持久化 `PlayerSetting.getSize()` 作为资源数组下标,增加边界归一化后再显示/轮换。 -- 验证:`./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.dialog.AdRuleManageDialogLayoutTest --tests com.fongmi.android.tv.ui.dialog.SiteDialogThemeSourceTest --tests com.fongmi.android.tv.ui.adapter.MobileSiteAdapterStyleTest --no-daemon` 通过,`BUILD SUCCESSFUL in 45s`;共 11 项定向测试,失败/错误为 0。`./gradlew :app:compileLeanbackArm64_v8aDebugJavaWithJavac --no-daemon` 通过,`BUILD SUCCESSFUL in 40s`。`git diff --cached --check` 通过,合并无未解决路径。 -- 二次最终复评:重新核对 Leanback 外观设置入口、主题选择焦点、UI_SCALE/LANGUAGE/THEME 重建事件、Mobile 站点动态色调色板、站点/分组适配器、配置边界与既有 C4 播放器路径;未发现剩余阻断问题。设备实机主题/遥控器验收未在当前环境取得,不将 JVM/Java 编译结果扩大为完整设备验收。 - -## 新 beta 增量与续作纠正 - -- 首个合并单元已提交为 `5a479aa6962d3082dff35b4f330ef3232ad96961`,恢复标签为 `recovery/beta-sync-review-20260905/20260905154625-5a479aa6962d`。上文双端各 40/40 的结果仅对应该单元。 -- 续作接收时,guard `beta-sync-latest-20260905` 已在干净的 `5a479aa6962d3082dff35b4f330ef3232ad96961` 上启动,`MERGE_HEAD` 为 `bc5f9b42e090dd7fd303a56c052618dcb5506047`。三处现有改动均属于此 guard,不是任务外脏文件。 -- 冲突解析保留本地 `playback_overlay_enabled` 设置备份键及完整 `Backup` 类头;TMDB 与测试沿用 beta 的横向列表焦点居中增量,不改变网格导航、播放器依赖或弹幕逻辑。 -- 2026-09-06 00:18,续作代理错误地在未补验时手工创建合并提交 `b077fd23adf0478a946cbdfa62c0253e8c031cb6`。其提交消息误用了上一单元的测试结果,且 RTK 输出提示被污染到 `Backup.java` 首行;该提交不能作为已验证恢复点。 -- 同一错误操作创建的本地标签 `recovery/beta-sync-review-20260905/-` 不作为恢复点,不推送。采用前向修复,不重写合并提交或其他历史;只移除污染行,并将仍活动的 guard 基线对齐到本会话创建的 `b077fd23adf0478a946cbdfa62c0253e8c031cb6`,其范围、初始脏文件及保护记录保持不变。 -- 待验证:一次 Mobile/Leanback Arm64 定向 `BackupPreferenceFilterTest` 与 `TmdbDetailActivityLayoutTest` 及其 Java 编译。不重复弹幕、触屏设备或 native 测试。 - -## Recovery Anchor - -- 目标及验收:修复已提交关键词绑定,验证并闭环当前 beta 合并、提交、恢复标签、推送与中文 PR。 -- 当前阶段:首个单元已关闭;第二个合并提交存在上述续作错误,正在前向修复,尚未完成受影响范围补验。 -- 保护:旧 Orca 会话和 transcript 只读;任务外路径不改动;不要再次启动合并或重建 guard。 -- 回滚锚点:已验证的 `5a479aa6962d3082dff35b4f330ef3232ad96961` 及其恢复标签。`b077fd23adf0478a946cbdfa62c0253e8c031cb6` 不能作为独立恢复点;回退合并须明确主线后另行批准。 -- 未决事项:补验、前向修复提交与正确恢复标签、推送及中文 PR;全量 FFmpeg 运行库测试和历史设备补验限制仍如上。 -- 下一步:执行一次受影响的双端定向测试及 Java 编译,记录实际结果。 - -## 修复记录 (2026-09-06) - -- 修复:移除 HomeActivity 中对 RefreshEvent.Type.THEME 的冗余订阅。 diff --git a/docs/beta-sync-review-20260907-dev3-round2.md b/docs/beta-sync-review-20260907-dev3-round2.md deleted file mode 100644 index 99cd9d55ec..0000000000 --- a/docs/beta-sync-review-20260907-dev3-round2.md +++ /dev/null @@ -1,123 +0,0 @@ -# dev3 合并 beta 最新代码与代码复评(2026-09-07 第二轮) - -## Recovery anchor - -- **目标:** 合并 `origin/beta` 最新代码,评审当前分支全部已提交未推送改动,修复发现的问题并定向验证、二轮复评通过后提交、推送 `dev3`、创建中文 PR 到 `beta`,最后拉取远端最新代码。 -- **任务守卫:** `beta-sync-review-20260907-dev3-round2`,模式 `standard`,范围 `.codex/**`、`app/**`、`docs/**`、`gradle/**`、`scripts/**`、`third_party/**`;开始时工作树干净,无受保护脏路径。 -- **本地基线:** `dev3@075b9d84e474b6b843eccbf593bd57f3a5333521`,唯一本地已提交未推送提交为 `075b9d84e474b6b843eccbf593bd57f3a5333521`(TMDB 集卡片长按详情映射)。 -- **beta 目标:** `origin/beta@9fc936fd88cb5b58d645c49b97209b0f246ed078`;共同祖先为 `fca898f9d940816c378ec37785184f46b334cd4d`。 -- **合并状态:** `git merge --no-commit --no-ff origin/beta` 自动完成,零未合并路径;当前索引为待提交的合并结果。相对 beta 的最终本地代码差异仅为 TMDB 三个文件和本文档。 -- **回滚:** 提交前使用 `git merge --abort`;提交后使用本任务创建的 `recovery/beta-sync-review-20260907-dev3-round2/*` 标签回退。 - -## 评审范围与处理原则 - -### 本地已提交未推送改动 - -- `075b9d84e474b6b843eccbf593bd57f3a5333521`:让 TMDB 剧集卡片长按详情请求使用当前可见卡片经过 `TmdbEpisodeMatcher` 校验后的 `TmdbEpisode`,避免手动选季/扁平集列表时把列表位置或当前季状态误当作请求集号。 -- 变更文件: - - `app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java` - - `app/src/main/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapter.java` - - `app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java` -- 二轮复核覆盖:适配器绑定和长按回调、普通详情与原生增强详情两条入口、TMDB 电影/剧集分流、跨季映射、无映射/API 失败回退、异步 generation/条目/季上下文校验、关闭弹窗后的列表重渲染与焦点恢复。未发现剩余阻断问题。 - -### beta 增量 - -`origin/beta` 相对共同祖先的 72 个提交以 Git 为权威完整台账(见下表)。按用户约定,已有评审覆盖且本轮无路径冲突的改动不重复实现审查,仅核对最终树继承、合并无冲突和既有验证记录: - -- 音频/Exo/MPV/双 ABI/供应链资产及 C4 上游合并:由 `docs/C4-main-upstream-merge.md`、`docs/C13-beta-sync-review.md` 及相关 P/E/C 任务记录覆盖。 -- LitePan 标题识别、AI 字段/缓存和测试:由 `docs/beta-sync-review-20260907-dev3.md` 覆盖。 -- 设置/站点主题/ABI/TV 壁纸:由 `docs/C13-beta-sync-review.md`、`docs/dev1-beta-review-20260907.md`、`docs/beta-sync-review-20260907-dev4-round2.md` 覆盖。 -- TV 分类焦点、详情首帧壳层及其测试:由 `docs/beta-sync-review-dev2-20260907-round2.md` 及 `HomeCategoryNavigationSourceTest`、`VideoActivityDetailShellSourceTest` 覆盖。 -- 最终树不存在 `.backup`、`.orig`、`.rej` 残留;beta 新增提交未修改本地 TMDB 三个文件,因此不发生内容冲突。 - -## beta 增量完整 commit 台账 - -| # | 完整 commit | disposition | -|---:|---|---| -| 1 | `b208d26546cf6fd4498a1e54d45a37106a313d69` | MPV 多声道 fallback,随 C4/音频阶段进入 beta,已有记录覆盖 | -| 2 | `37995ff14016fd5a26fdae2b482f08470aa6a162` | 运行时音频诊断,已有 C4/音频阶段覆盖 | -| 3 | `cf0a5dabc77fb2bbdc3e0f2cc867a9eac86060b8` | Exo 硬件音频解码优先,已有 C4/音频阶段覆盖 | -| 4 | `d41155f16cd81f1354672a5479743462fc168ed9` | MPV 硬件音频 MediaCodec fallback,已有 C4/音频阶段覆盖 | -| 5 | `3fdf9f82f37843699a2545ed97d4a2dd17b8ead5` | Exo 压缩音频 PCM fallback,已有 C4/音频阶段覆盖 | -| 6 | `d8a994af5a64b93d7aeafb81f3755f81fcb8194c` | 音频解码标签对齐,已有 C4/音频阶段覆盖 | -| 7 | `734e99253ef5e856b3639810da5d9a05b0493646` | MPV 压缩 AudioTrack 输出,已有 P3/C4 记录覆盖 | -| 8 | `cb0a59f819a219dccd32bc1bf1c22b9caf754f07` | MPV 实际硬件音频状态诊断,已有 C4/音频阶段覆盖 | -| 9 | `37888d8b9d99da29f9ecfc3cd1f5eba458e09ee0` | Exo 网络保护按实际音频输出 gating,已有 C4/音频阶段覆盖 | -| 10 | `d00aa5737980d976cbae491948bf65dab906bf68` | MPV 压缩音频 patch 修复,已有 P3/C4 记录覆盖 | -| 11 | `347801b81f56bdcf515e4a9d7013814f8b777519` | Exo 音频兼容性调查记录,已有 C4 设计/验证记录 | -| 12 | `5c104a199fa07ad5f33c575deb6e0b91eea6668a` | MP4 AV3A Exo 路由,已有 C4/音频阶段覆盖 | -| 13 | `53eab9c2d6101220f26525a2af309a5578e7dc3a` | MPV AV3A canonical MIME,已有 C4/音频阶段覆盖 | -| 14 | `12ebbf8d15280e248dff9818c7969c7261173c16` | Exo 音频输出配置恢复,已有 C4/音频阶段覆盖 | -| 15 | `c7d43205f9125077a471ce57ee46a9c1f206e982` | Exo PCM 声道 fallback,已有 C4/音频阶段覆盖 | -| 16 | `8419003c2fe9eac200d2e6a9ef0dddf58638be31` | MPV 压缩音频 fallback,已有 P3/C4 记录覆盖 | -| 17 | `69191f78a37c5d56f87591c857d2d4be0112815d` | MPV AAC PCM fallback,已有 C4/音频阶段覆盖 | -| 18 | `2f2f99fb7d91c5e8c71e8f408b1ee45b287b2917` | MPV 未知 AV3A 声道降混,已有 C4/音频阶段覆盖 | -| 19 | `639a046125c4375685cb96c9ea004b620778bbb9` | Exo AVS3A DASH 声道解析,已有 C4/音频阶段覆盖 | -| 20 | `c826ee327b1cc33c0f0522fa120b1b2039e789d8` | Exo AV3A 5.1 混合内容降混,已有 C4/音频阶段覆盖 | -| 21 | `56b802ef585e83099953979b802172512c2fb447` | 音频/多声道策略评估记录,已有 C4 文档覆盖 | -| 22 | `04904ff99ae67ef25ebd4cca3740b30f0178662` | Exo ALAC decoder 路由,已有 C4/音频阶段覆盖 | -| 23 | `80e313830824f2e86d341079b58c330726de3e99` | QuickTime ALAC cookie,已有 C4/音频阶段覆盖 | -| 24 | `ebb5285238aa19eeab11ec4595985d496550ced2` | common 音频策略契约,已有 C4 文档覆盖 | -| 25 | `43fba18a8d074268c26a6ddbd30fe3483247320a` | C4 音频评估收口,已有 C4 文档覆盖 | -| 26 | `0a31951e3c923154b2ef8218d1a3811a96fa446b` | common 音频诊断契约,已有 C4/音频阶段覆盖 | -| 27 | `047ad74f76c859862b39b64d0545ec2dc83dd865` | C4 实施记录收口,已有 C4 文档覆盖 | -| 28 | `e5dd86b344b8ab1c5bda68f96ecf77a2e356ad4d` | Exo APE demux/FFmpeg,已有 C4/音频阶段覆盖 | -| 29 | `aed962426d7da7ddc268091f443b72c12187bd66` | MPV P8.1 HDR10 fallback,已有 C4/P2 记录覆盖 | -| 30 | `bf22a22fd60fd2cb9e2fef93b13f379b8d8b4f9e` | 音频诊断 API 兼容,已有 C4/音频阶段覆盖 | -| 31 | `8dd15ff24a5406091819c86b2dfa5e8499a6cbef` | MPV 禁用自动软件视频 fallback,已有 P3/C4 记录覆盖 | -| 32 | `ea9587dcb7feb187cc91f5f958f35696fea2996a` | MPV 压缩音频按 passthrough route gating,已有 P3/C4 记录覆盖 | -| 33 | `5a5a6ef383fb601e5bbce9d932a3bcc7cfa6ccc7` | MPV 脚本按钮设计记录,已有 C4 文档覆盖 | -| 34 | `72200c16fed08bd0309ea6290fab238bd21e7fa6` | MPV 自定义脚本按钮实现,已有 C4 文档覆盖 | -| 35 | `5b9a641678cfeae4db305fc490f6d5e17190921f` | MPV 脚本控制接线,已有 C4 文档覆盖 | -| 36 | `c0968836fbf3f9045449782355eb311c6dd89911` | MPV 脚本按钮管理 UI,已有 C4 文档覆盖 | -| 37 | `8baec44153035bb71723b4b2e0012415eb3eb337` | MPV 脚本创建对话框收敛,已有 C4 文档覆盖 | -| 38 | `bafdf34b765fca776ecc94a92cabb26234213f2e` | MPV 脚本按钮布局,已有 C4 文档覆盖 | -| 39 | `7ad7e1406efc2cda01bd6b14bd94822b7c31dedd` | MPV 脚本按钮右对齐,已有 C4 文档覆盖 | -| 40 | `61b352f8086554364d0ce402eb20c27105e01c6e` | MPV 配置同步,已有 C4 文档覆盖 | -| 41 | `123db7e7553eb0066e3c815f437b8cf266fe1aa7` | 恢复时清理旧 MPV profile,已有 C4 文档覆盖 | -| 42 | `3b346c85d0a3ed519d1bdac7b2e431a238a313ed` | MPV 配置同步收口,已有 C4 文档覆盖 | -| 43 | `41f02fd3c1f9e40fc64dd810475f7a45109bd4cb` | 脚本文本编辑器和列表刷新,已有 C4 文档覆盖 | -| 44 | `37a3685621b9b3882625fe721e3bf5a4e54ac372` | MPV 脚本设置流程统一,已有 C4 文档覆盖 | -| 45 | `b70f96ec0f732cc88edcd5edc26e39ecffe2cba3` | 脚本按钮点击反馈,已有 C4 文档覆盖 | -| 46 | `219ea082bd17719db49389b57f83fe2199bb79bf` | 脚本按钮状态反馈,已有 C4 文档覆盖 | -| 47 | `77df324e28c5befefe2f050e04c01ea62d556e3a` | 脚本标题后缀修复,已有 C4 文档覆盖 | -| 48 | `e1a873b37aa2243e014a9ab1be78c3f9696161be` | TV CSP 搜索焦点重置,已有 dev2/TV 复评覆盖 | -| 49 | `54b0b6a875d9d26fcf26c96ae04d4a2302e49625` | TV 搜索结果焦点入口,已有 dev2/TV 复评覆盖 | -| 50 | `686522fa8f4a9405f906848c2b938d9da300d6cd` | MPV 脚本自动执行/按钮上限修复,已有 C4 文档覆盖 | -| 51 | `784b90420d646eb6c7ddcc63ad622a92c65b02b4` | 上游 main 文档清理,最终树已有 C4 评审边界 | -| 52 | `188553addf6692220a2a715790fb8706b2f423b0` | C4 上游合并承载,已有 C4 文档和双 ABI/编译验证 | -| 53 | `adeabec2588a81d204dfd608269e3db8c7cdc80e` | C4 收口记录,已有 C4 文档覆盖 | -| 54 | `57932a7e7055489403d3e170bba706048df0e065` | Mobile 个性设置膨胀修复,已有 dev1 复评覆盖 | -| 55 | `a6eb0e96019da096b6e50aa8944270f911a2cdfa` | TV 壁纸设置入口,已有 dev4 复评覆盖 | -| 56 | `f8b41fd07e578ae8ec12cee827aba449c7c49e48` | dev2 beta 评审承载,已有 dev2 记录覆盖 | -| 57 | `7e6fd548190834408e40332e8392d2e379536511` | PR224 冲突修复承载,已有 dev2 记录覆盖 | -| 58 | `f0e9ab75bd0770b17db9f9bcd6101026d2c63466` | PR225 dev3 集成承载,已有 dev3 记录覆盖 | -| 59 | `161b190fac6c304c240bfb3b142b4dfa531fb1d5` | PR224 beta 集成承载,已有 dev2 记录覆盖 | -| 60 | `4425e9cb63186cfe41cacb1fd3982a23249a27e6` | TV 外层壁纸设置恢复,已有 dev4 记录覆盖 | -| 61 | `db59f3eeb85ff0e4ed993e814d9b81b08036bcb7` | dev1 beta 评审承载,已有 dev1 记录覆盖 | -| 62 | `03e3941196c84031bae764ac4192add4ed8619ec` | PR226 beta 集成承载,已有 dev1 记录覆盖 | -| 63 | `dbea2b995260ba73908a1e67078a938773699658` | dev4 beta 评审承载,已有 dev4 记录覆盖 | -| 64 | `58c6ef28436cad6b22c8cdf464629ca4897377c7` | PR227 beta 集成承载,已有 dev4 记录覆盖 | -| 65 | `7ab86f629306bf1b6a47de9dcba0df9d05a56d99` | TV 分类焦点回到首行,已有 dev2/TV 复评覆盖 | -| 66 | `d60297370a9ffefcba63aff9fe2d63e053f012ed` | TV 原生增强首帧/选集工具栏,已有 dev2/TV 复评覆盖 | -| 67 | `023058ba779791eef6b408a32f785bf430531427` | TV 顶部滚动保持分类焦点,已有 dev2/TV 复评覆盖 | -| 68 | `0f37b489f7f35719cf572da32a11701f5445a6be` | dev4 beta 合并承载,已有 dev4/TV 复评覆盖 | -| 69 | `17f6e3afb2b765b545205bb1390d4b29267aea08` | TV 分类切换内容滚动位置,已有 dev4/TV 复评覆盖 | -| 70 | `410c5303ab3005e2209c80734e3e4541543b0714` | dev2 第二轮 beta 合并承载,已有 dev2 记录覆盖 | -| 71 | `08be6b043c9656b944a10f56b1238fe1e616c2bb` | dev2 分类滚动 follow-up 承载,已有 dev2 记录覆盖 | -| 72 | `9fc936fd88cb5b58d645c49b97209b0f246ed078` | PR229 beta 最新集成承载,最终树已复核 | - -## 评审发现、验证与二轮复评 - -- **首轮评审:** 合并预检无冲突;beta 本轮新增代码与本地 TMDB 三个文件无路径交集。已有记录覆盖的 beta 播放器/音频、标题识别、设置/主题、TV 焦点/详情壳层按约定跳过重复实现评审,仅核对最终树继承和无 `.backup/.orig/.rej` 残留。 -- **本地差异评审:** 未发现阻断问题。适配器仅传递通过 `TmdbEpisodeMatcher` 校验的卡片对象;详情请求使用卡片绑定季/集号;跨季映射、电影/剧集分流、无映射/API 失败回退和异步 generation/条目/季校验均保持闭环。 -- **验证:** - - `bash .codex/scripts/task_guard.sh check`:通过。 - - `git diff --cached --check`:通过。 - - `bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.activity.TmdbDetailActivityLayoutTest --tests com.fongmi.android.tv.ui.helper.TmdbEpisodeWiringTest :app:compileLeanbackArm64_v8aDebugJavaWithJavac --no-daemon --console=plain`:`BUILD SUCCESSFUL`,98 actionable tasks,9 executed,15 from cache,74 up-to-date。 -- **二轮复评:** 针对测试通过后的最终索引重新检查上述三处生产代码、两个长按入口、详情请求参数、回退分支和异步 guard;未发现剩余阻断问题,结论为 **通过**。 -- **验证边界:** 未执行真实设备 TMDB 网络请求、电视逐帧焦点/视觉回归或 HDMI/音频 passthrough;这些不属于本次本地 TMDB 三文件窄改动的决定性验证,已有 beta 任务记录保留其边界。 - -## 下一动作 - -使用 `task_guard.sh finish` 原子提交当前 beta 合并、TMDB 改动、测试和本文档并创建恢复标签;随后推送 `dev3`、创建中文 PR 到 `beta`,最后拉取远端最新代码。 diff --git a/docs/beta-sync-review-20260907-dev3.md b/docs/beta-sync-review-20260907-dev3.md deleted file mode 100644 index 91c433e15e..0000000000 --- a/docs/beta-sync-review-20260907-dev3.md +++ /dev/null @@ -1,94 +0,0 @@ -# dev3 合并 beta 最新代码与代码复评(2026-09-07) - -## Recovery anchor - -- **目标:** 将 `origin/beta` 最新代码合入 `dev3`,评审当前分支全部任务改动(包括已提交但未推送的 LitePan 风格剧名识别清洗),发现问题即最小修复并重新验证,最终提交、推送 `dev3`、创建中文 PR 到 `beta`,再拉取远端最新代码。 -- **任务守卫:** `beta-sync-review-20260907-dev3`,模式 `standard`,范围 `.codex/scripts/task_guard.sh`、`app/**`、`docs/**`、`gradlew`;开始时工作树干净,无受保护脏路径。 -- **本地基线:** `dev3@d1ca2d2756c334a281587503e1d23b06a3369940`;待评审本地提交为 `1c2fa82822f6`(设计授权)和 `d1ca2d2756c3`(基础实施),两者均未推送到 `origin/dev3`。 -- **beta 目标:** `origin/beta@1f37b723f9ccdc02bc4cc44b6490d1c2cc547e2c`;共同祖先为 `6a0b967dd533250d061e9457a93b4c15a724b5f5`。 -- **合并状态:** `git merge --no-commit --no-ff origin/beta` 自动完成,零未合并路径;本地标题识别文件与 beta 增量没有内容冲突。 -- **当前状态:** 代码与测试已修复、验证并完成最终复评;下一步仅由任务守卫原子提交和创建恢复标签,然后推送与创建 PR。 -- **回滚:** 提交前可 `git merge --abort`;提交后使用本任务生成的 `recovery/beta-sync-review-20260907-dev3/*` 标签整体回退。 - -## 评审范围与已有覆盖 - -### 本地已提交未推送改动 - -- `1c2fa82822f6`:LitePan 风格剧名识别清洗设计与实施授权记录。 -- `d1ca2d2756c3`:受控文件夹/同组标题上下文、规则证据链、AI 字段约束、上下文隔离缓存及回归测试。 -- 评审覆盖 `MediaTitleRequest`、`MediaTitleParser`、`MediaTitleResolution`、`MediaTitleCache`、`MediaTitleResolver`、`AiTitleExtractionService` 及对应单测。 - -### beta 增量 - -beta 从共同祖先到目标的全部提交已在当前仓库历史中逐项复核;其中此前已有评审文档且与本地标题识别改动无冲突的播放器、站点主题、外观语言、ABI 和广告规则变更按用户约定跳过重复实现审查,仅确认最终树继承且无未合并路径。相关既有记录包括: - -- `docs/C13-beta-sync-review.md`:dev4 合并 beta 后的播放器、站点主题、外观语言、广告规则和冲突修复复评。 -- `docs/dev1-beta-review-20260907.md`:实验室 ABI 选择、下载/安装/版本显示复评。 -- `docs/mobile-site-theme-20260906.md`、`docs/fix-exo-startup-double-resume.md`、`docs/C20260906161136-mpv-duration-recovery-fix.md`:对应主题、EXO、MPV 阶段的验证边界与证据。 - -beta 相对共同祖先的完整 commit 台账: - -| 完整 commit | disposition | -| --- | --- | -| `1f37b723f9ccdc02bc4cc44b6490d1c2cc547e2c` | beta 最新集成提交,已合入并复核最终树 | -| `0b3e06e4de61e17ebbe2ce53e1ed621d988fdc20` | beta 集成提交,已有 dev1 复评记录 | -| `b158b71b97ca7abc76cbc2cf9e4e4784ede8af43` | ABI 选择复评集成提交,已有 `LabEnvTest` 与编译证据 | -| `8163e6fbbcca2187e3c816f96f378defa7cb0405` | dev4 beta 复评集成提交,已有 C13 记录 | -| `01ee74c595fd9e6b2fdb7e758274c2423781fa00` | ABI 规范化及版本显示修复,已有 dev1 记录 | -| `cc88e278a8ddc2088a82a68dbf1671e419606a29` | PR #218 最终集成 | -| `c344741692b3cb4e7dda473a8dbebb433ea50e65` | 手机版外观与语言入口,已有 C13 记录 | -| `016edd1d50fede43e119613737fe2288c99e45ba` | PR #218 冲突修复,已有双端编译证据 | -| `7a93ec7336b57dcf418c30b399862193e6991446` | PR #218 集成承载 | -| `c78b7cb11550209b2c0f884441e4a0fb65784e05` | PR #220 集成承载,已有 C13 记录 | -| `03f600acc5672c858814e4eab720987b3980e027` | Leanback 构建修复,已有编译证据 | -| `912208261e4e342ced009b1a0b71feed4855a01d` | dev2 beta 集成承载 | -| `cb386895da22a8836bb5587cafff3ef10f89a4fa` | 播放器重建后进度绑定,已有 C13 记录 | -| `7a380654329fa4800c55799440b5dd0798e22167` | Android 9 站点主题适配,已有设备探针证据 | -| `6b0490907da3e0b09a6563c1572cf283a3ae49d3` | MPV 时长恢复,已有阶段记录 | -| `910b9c00ef0f772dc284931a970449c60636440b` | beta 合并评审及脚本执行位修复 | -| `f627d99e88078ccd8760986cbae65cc4a3eb5adc` | 站点主题中间实现,最终窄实现已取代 | -| `33d9ef2393747dc689fb735a9c91de2ca4f6c0cb` | 弹窗主题中间实现,最终树已复核 | -| `66b5505282eb3de95538a1eab39ad9196d27ba22` | EXO 起播重复续播修复,已有阶段记录 | -| `1558c90fb1c27cbb0f2325ea845640288986a527` | 站点主题中间修复,最终窄实现已取代 | -| `03f14ae107325a117185f1cd3a85a3622019b332` | Leanback 外观与语言设置入口,已有 C13 记录 | -| `3a2d7723666eb2f331bec2a2b22cbcaa3c617aef` | 站点主题中间实现,最终树已复核 | -| `4242a06987b1080a923e14800380a866c95e8f67` | 站点主题中间修复,最终树已复核 | -| `74e60ef606ce6b64f5e3dab21230213e180cc504` | 移除 Mobile 重复主题订阅,已有 C13 记录 | -| `ea7f7ac89234ea4766dcb5d208f6ae973b4a40c4` | 包装器执行位修复,最终树已复核 | -| `99d0dad351f851bc0dd58ee0de37f3a3e996e6d3` | dev1 beta 合并承载 | -| `a2127a773ee9a88d40254349287b1e33be42dce8` | TV 外观大小设置修复,已有 C13 记录 | -| `0e9fbb0ad4fe2484e32860e0768f258a93c6e276` | TV 外观行为回归修复,已有 C13 记录 | -| `2677512b27458eb3e7a32e91fae421440b5151d2` | TV 外观与语言设置入口,已有 C13 记录 | -| `1a95bf977543bca2de9f62e8005506520ae8608b` | 主题色即时生效修复,已有 C13 记录 | - -## 评审发现与修复 - -1. **AI 缓存键遗漏有效输入。** 原实现只纳入文件夹和同组标题,遗漏 `rawRemarks`、`vodYear`、学习样本等会影响规则结果或 AI prompt 的字段,可能错误复用不同上下文的 AI 结果。 - - 修复:缓存键升级为 `v3`,使用长度编码纳入请求字段、文件夹、同组标题、AI 配置和学习样本,避免分隔符碰撞及上下文串用。 - - 回归:验证备注、年份和学习样本变化都会产生不同键。 -2. **同一上下文输入可能产生多个搜索候选并被重复计票。** 原实现将单个文件名的多个清洗候选都加入共识列表,放大单条输入的权重。 - - 修复:每个上下文输入只取首个清洗候选,保持一条输入一票;新增回归验证。 -3. **相对路径未完全脱敏、目录尾斜杠边界不稳。** 原实现只截取绝对路径,可能把相对路径或尾斜杠路径的中间目录传入 AI。 - - 修复:统一按最后一个 `/` 截取上下文基名,并在文件夹名截取前移除尾斜杠;新增路径边界测试。 -4. **beta 带入未引用的源码备份文件。** `ThemeDialog.java.backup` 与正式源码完全相同且没有调用方,属于构建/维护噪声。 - - 修复:不纳入最终合并提交。 - -## 最终复评结论 - -- 重新检查标题识别最终差异及所有调用/缓存边界:请求字段均经过受控清洗,AI prompt 与缓存键使用同一上下文集合;缓存键长度编码避免字段内容中的分隔符改变解析边界;每个上下文文件只贡献一票;正式树无 `.backup` 残留。 -- beta 既有改动与本地标题识别改动没有冲突;已有复评文档覆盖的行为未被本次合并改写。 -- 最终评审:**通过,未发现剩余阻断问题。** - -## 验证 - -- `./gradlew :app:testLeanbackArm64_v8aDebugUnitTest --tests 'com.fongmi.android.tv.title.*' --tests 'com.fongmi.android.tv.service.AiTitleExtractionServiceTest' --console=plain` - - `BUILD SUCCESSFUL`;32 项相关测试全部通过,`failures=0`、`errors=0`、`skipped=0`。 - - 同一任务包含合并后 Leanback Arm64 Java 编译。 -- `git diff --check` 与 `git diff --cached --check`:通过。 -- `bash .codex/scripts/task_guard.sh check`:通过,分支/HEAD、保护路径、范围和暂存路径安全。 -- 合并冲突检查:无未合并路径;未发现 `.backup` 等残留文件。 -- beta 中此前已有的 Mobile/Leanback 编译、ABI、Android 9 主题探针、播放器和广告规则验证沿用对应任务文档证据,因本轮无冲突且未改写其实现不重复执行。 - -## 下一动作 - -由 `task_guard.sh finish` 原子提交当前合并、标题识别修复、测试和本文档并创建恢复标签;随后推送 `dev3` 并创建中文 PR 到 `beta`,最后拉取远端最新代码。 diff --git a/docs/beta-sync-review-20260907-dev4-round2.md b/docs/beta-sync-review-20260907-dev4-round2.md deleted file mode 100644 index 5edc24dd70..0000000000 --- a/docs/beta-sync-review-20260907-dev4-round2.md +++ /dev/null @@ -1,47 +0,0 @@ -# dev4 合并 beta 最新代码与复评(2026-09-07) - -## Recovery anchor - -- **目标:** 合并 `origin/beta` 最新代码,复评当前分支全部改动(含已提交未推送的 TV 壁纸设置恢复),修复并验证阻断问题后提交、推送 `dev4`,创建中文 PR 到 `beta`,再拉取远端最新代码。 -- **任务守卫:** `beta-sync-review-dev4-20260907-round2`,模式 `standard`,范围 `app/**`、`docs/**`、`gradle/**`、`scripts/**`、`third_party/**`。 -- **基线:** `dev4@4425e9cb63186cfe41cacb1fd3982a23249a27e6`;开始时工作树干净,无受保护脏路径。 -- **beta 目标:** `origin/beta@161b190fac6c304c240bfb3b142b4dfa531fb1d5`;共同祖先为 `8163e6fbbcca2187e3c816f96f378defa7cb0405`。 -- **合并:** `git merge --no-commit --no-ff origin/beta` 自动完成,零冲突;当前索引为待提交的合并结果。 -- **回滚:** 提交前使用 `git merge --abort`;提交后使用本任务生成的 `recovery/beta-sync-review-dev4-20260907-round2/*` 标签。 - -## 评审范围与结论 - -### 当前分支改动 - -- `a6eb0e96019da096b6e50aa8944270f911a2cdfa`:恢复 TV 主设置页外观入口的壁纸设置行。 -- `4425e9cb63186cfe41cacb1fd3982a23249a27e6`:恢复壁纸选择、默认、刷新、历史入口,并保留 TV 外观弹窗入口。 -- 最终相对 beta 仅有 `SettingActivity.java` 和 `activity_setting.xml` 两个生产文件差异。逐项核对了 ViewBinding ID、点击/长按监听、`WallConfig` 加载回调、`ConfigEvent.WALL` 刷新和手机版对应逻辑,未发现阻断问题。 - -### beta 增量处理 - -beta 自共同祖先以来的 64 个提交已按完整 40 位 ID 建立于 Git 台账;其中播放器音频/MPV/Exo、脚本按钮、实验室 ABI、外观主题、站点主题及冲突修复均已有此前 dev1/dev2/dev3/dev4 复评记录,且本轮最终树未改写其行为,按约定沿用已有验证,不重复执行相同场景。合并后确认 beta 删除了无调用方的 `ThemeDialog.java.backup`,最终树不存在 `.backup/.orig/.rej` 残留。 - -代表性新增集成提交: - -| 完整 commit | disposition | -| --- | --- | -| `161b190fac6c304c240bfb3b142b4dfa531fb1d5` | beta PR #224 集成承载,已合并并复评最终树 | -| `f0e9ab75bd0770b17db9f9bcd6101026d2c63466` | beta PR #225 集成承载,已有 dev3 复评 | -| `7e6fd548190834408e40332e8392d2e379536511` | PR #224 冲突修复,已有 dev2 复评 | -| `fca898f9d940816c378ec37785184f46b334cd4d` | dev3 beta 合并复评,已有标题识别与编译证据 | -| `1f37b723f9ccdc02bc4cc44b6490d1c2cc547e2c` | PR #222 集成承载,已有 dev4 复评 | -| `0b3e06e4de61e17ebbe2ce53e1ed621d988fdc20` | PR #223 集成承载,已有 dev1 复评 | - -完整 64 项范围以 `git log --reverse 8163e6fbbcca2187e3c816f96f378defa7cb0405..161b190fac6c304c240bfb3b142b4dfa531fb1d5` 为权威台账;每项均为已集成、已有评审覆盖或本轮最终树继承,无未处理的 beta-only 变更。 - -## 验证与最终复评 - -- `git merge --no-commit --no-ff origin/beta`:成功,无未合并路径。 -- `git diff --cached --check`:通过。 -- `bash .codex/scripts/task_guard.sh check`:通过,分支、HEAD、保护路径、范围和暂存路径安全。 -- `bash ./gradlew :app:compileLeanbackArm64_v8aDebugJavaWithJavac --console=plain`:`BUILD SUCCESSFUL`(21 秒)。构建输出仅含已有资源命名空间、字符串格式和 Gradle 弃用警告,非本次改动引入的失败。 -- 编译后再次复评合并最终树:TV 壁纸行可见且四个入口接线完整;外观入口继续指向 `AppearanceDialog`;未发现剩余阻断问题。 - -## 下一动作 - -执行 `task_guard.sh finish` 原子提交合并、当前 TV 改动和本评审记录并创建恢复标签;随后推送 `dev4`、创建中文 PR 到 `beta`,最后拉取远端最新代码。 diff --git a/docs/beta-sync-review-20260908.md b/docs/beta-sync-review-20260908.md deleted file mode 100644 index 777f47e70b..0000000000 --- a/docs/beta-sync-review-20260908.md +++ /dev/null @@ -1,41 +0,0 @@ -# dev1 合并 beta 最新代码与复评(2026-09-08) - -## 恢复锚点 - -- **目标**:在保留 dev1 已提交未推送主题阶段 A-E 改动的前提下,合并 `origin/beta` 最新代码,审查合并后的全部本地改动与 beta 增量,修复问题、验证、复评通过后提交并推送 dev1,创建中文 PR 到 beta,最后重新拉取远端。 -- **任务守卫**:`beta-sync-review-dev1-20260908`,模式 `standard`;开始时工作树干净,无受保护脏路径;范围 `app`、`docs`。 -- **时间与基线**:2026-09-08 16:09 CST;开始时 `dev1@32ace88636f90f507288df859c325afd493b3e89`,远端 `origin/beta@253ffaa4f7908d0b90fa092f020cb20611aaadb5`;已执行 `git fetch --prune origin beta`。 -- **合并证据**:`git merge --no-ff --no-commit origin/beta` 自动完成,无冲突;`git diff --cached --check` 通过;合并结果相对 beta 为本地主题阶段 A-E 和既有审查记录。 -- **回滚**:合并提交前可 `git merge --abort`;完成后使用本任务 guard 创建的 recovery annotated tag 回退原子提交。 - -## 首轮审查结论 - -- beta 带入的 TV 触摸、TMDB 人物/详情和播放器自定义按钮改动已存在于 beta 历史中的独立评审记录(`docs/beta-sync-review-20260907-dev3-round2.md`、`docs/beta-sync-review-dev2-20260907-round3.md`);本次按最终合并树复核文件边界、合并树无冲突和现有测试覆盖,未发现新的阻断问题。 -- 本地主题阶段 A-E 覆盖 profile codec/validator、TweakCN 导入、传输边界、resolver、controller、mobile editor、TV selector/catalog/cache。首轮发现两个安全阻断:`ThemeTweakCnAdapter.parse` 直接解析输入,绕过 `ThemeProfileCodec` 的大小/嵌套上限;`ThemeTransfer.lookupPublic` 未拒绝 RFC 4193 IPv6 ULA(以及映射 IPv4/保留地址),分别可能导致导入解析资源耗尽和 DNS 解析到内网 IPv6 服务的 SSRF。 - -## 修复与验证 - -- 修复 `ThemeProfileCodec.validateJsonBounds`:在 JSON 树构造前统一限制 UTF-8 大小和括号嵌套深度;`ThemeTweakCnAdapter` 复用该边界检查。 -- 修复 `ThemeTransfer.isPublicAddress`:拒绝 RFC 4193 ULA、IPv4-mapped 私网、CGNAT、保留/文档地址,并继续拒绝本地、链路本地、站点本地和组播地址。 -- 新增 `ThemeImportExportTest` 覆盖超大/深嵌套导入、ULA、映射 IPv4、CGNAT、保留 IPv4/IPv6 与公网地址。 -- 定向验证(修复后最终一遍): - - `bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.theme.ThemeImportExportTest --tests com.fongmi.android.tv.theme.ThemeProfileCodecTest --tests com.fongmi.android.tv.theme.ThemeProfileMigrationTest --tests com.fongmi.android.tv.theme.ThemeImportSourceTest --no-daemon --console=plain`:通过(`BUILD SUCCESSFUL`)。 - - 修复前合并索引主题相关测试及 `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`:通过;修复仅影响共享主题安全边界,移动端最终测试已重新编译共享 Java。 - -## 修复后复评 - -- 修复后复评逐行覆盖 codec/TweakCN 两条输入路径、大小/深度限制、DNS 地址过滤、主题 profile 解析,以及合并后 beta 改动的无冲突最终树;两项首轮阻断均已闭环,未发现剩余阻断问题。 -- 当前索引已暂存本次修复与本文档,待执行 guard finish 原子提交。 - -## 下一步 - -执行 `bash .codex/scripts/task_guard.sh finish`,随后推送 `dev1`、创建中文 PR 到 `beta`,最后 fetch 远端并核验 PR/分支状态。 - -## 关闭证据 - -- `task_guard.sh finish` 已创建提交:`e4b6e9e169ac155bb55540ed6de551c71d9287a0`。 -- 已创建并推送恢复标签:`recovery/beta-sync-review-dev1-20260908/20260908173812-e4b6e9e169ac`。 -- 已推送 `dev1` 至 `origin`;最终 `HEAD == origin/dev1 == e4b6e9e169ac155bb55540ed6de551c71d9287a0`。 -- 已创建中文 PR:[#238](https://github.com/Silent1566/webhtv/pull/238),目标 `beta`,当前状态 `OPEN`,GitHub `mergeStateStatus=CLEAN`。 -- 已执行 `git fetch --prune origin` 和 `git pull --ff-only origin dev1`,结果为 `Already up to date`;工作树干净。 -- **当前状态:** 本任务代码与审查交付已完成;无后续动作。 diff --git a/docs/beta-sync-review-dev1-20260912.md b/docs/beta-sync-review-dev1-20260912.md deleted file mode 100644 index 945f7bd04b..0000000000 --- a/docs/beta-sync-review-dev1-20260912.md +++ /dev/null @@ -1,163 +0,0 @@ -# dev1 合并 beta 最新代码并复评全部改动(2026-09-12) - -## Recovery anchor - -- **目标:** 合并远端 `origin/beta` 最新代码,评审 beta 增量及 dev1 当前已提交但未推送的全部改动;发现问题时最小修复、验证并再次复评,随后原子提交、推送 `dev1`、创建中文 PR 到 `beta`,最后拉取远端最新代码并核对交付状态。 -- **任务守卫:** `beta-sync-review-dev1-20260912`,模式 `standard`;启动时工作树干净,无受保护的既有脏路径;范围为 `app`、`docs`、`scripts`。 -- **开始时间:** 2026-09-12 14:04 CST(Asia/Shanghai,UTC+08:00)。 -- **当前分支与基线:** `dev1@b0f0343664502854ade683cdf0702d55a3ea32d3`;与 `origin/beta` 的共同祖先为 `6e876636caa36a8e833c0860c779faffc5d9592e`。 -- **远端 beta:** 已执行 `git fetch --prune origin beta`,目标为 `origin/beta@81435e97eafabd64727412169ead107687f7325c`。 -- **合并状态:** 已执行 `git merge --no-ff --no-edit origin/beta`;仅 `TmdbDetailActivity.java` 与 `TmdbDetailActivityLayoutTest.java` 发生内容冲突,已合并保留 beta 的剧照栏 124dp 修复、episode grid 契约测试,以及 dev1 的海报完整圆角显示修复;当前无未解决冲突,合并结果已暂存,`MERGE_HEAD` 为 `81435e97eafabd64727412169ead107687f7325c`。 -- **已完成动作:** 完成 beta 增量与本地差异的首轮静态复评;未发现需要修复的代码问题。首次组合 Gradle 验证中 Mobile 定向测试 159 项全部通过,Mobile/Leanback Arm64 Java 编译均通过;命令整体仅因对 `testLeanback` 使用了不存在于该 source set 的 `LiveActivityLayoutTest` 过滤器而返回失败,已分类为测试命令范围错误,不是代码回归。 -- **未验证风险:** 未执行真实电视遥控器逐键焦点/视觉高亮验收,也未用真实网络完成短剧连续切集、网络争用、字幕/弹幕时序验收;这些不影响本次源码合并与定向编译判断,但不能被本任务结论扩大解释为实机放量验收。 -- **回滚:** 提交前使用 `git merge --abort`;提交后使用本任务 recovery tag 或对合并提交执行 `git revert -m 1 `。 -- **唯一下一步:** 记录最终验证结果后,调用 `task_guard.sh finish` 原子提交并创建 recovery tag;随后推送 `dev1` 与该标签、创建目标为 `beta` 的中文 PR,并执行最后一次远端拉取核对。 - -## 合并范围与提交台账 - -### beta 增量(相对共同祖先) - -以下提交均已进入最终暂存树。功能提交已由本轮或此前对应分支复评文档覆盖;合并承载提交与文档提交不重复拆解生产行为,但保留其完整 ID 以便审计。 - -| 完整 commit | 内容与 disposition | -| --- | --- | -| `398d6b900b0e684ea96b9d0b353620847c421c64` | TMDB API 25 对话框按钮 inflate 崩溃修复;最终树复核通过,契约测试保留。 | -| `3a2b9d39e8bd3314027e5ce8e709c17e75a21236` | Leanback 直播 OSD 顶部信息重影修复;最终树复核通过,契约测试保留。 | -| `f17443f5e880053bdeb825001bb964088bf48a49` | 站点健康报表增加全量清空入口;最终树复核通过。 | -| `c36a29038360fb205cbca6c49f835ee65fc874e1` | dev2 beta 后续复评记录;文档提交,无额外生产代码。 | -| `87ab74014a62382af8786109099ccf95d8b58ff5` | PR #251 合并承载提交;保留最终树。 | -| `1c70eb0c679226d702e6cad9c4cdd06e5a440994` | 站点健康清空后重新读取并刷新 UI;最终树复核通过。 | -| `5345b8762eee58dc77cb7119d3fbeec14078b064` | beta 同步承载提交;无额外未覆盖生产行为。 | -| `03046756f4d786e11cfbfb92cc9aa1e61685d9a6` | 光影剧幕选集左侧对齐修复;最终树复核通过。 | -| `edaeb6609e57a62ca56ed6a362496a531f86ce98` | arm64 debug 安装脚本;最终脚本树与 dev1 本地脚本提交一致,未产生额外差异。 | -| `b993795ac88ed723dbb532401890bf408a0c25ac` | TV 站点健康清空确认框样式修复;最终树复核通过。 | -| `d094e2faaf4467b86ad4584ddf0ad384abd9e7c3` | 炫彩详情选集元信息补齐;最终树复核通过。 | -| `cff4d2f6d48d6841752627c5db9a1b7123e2d4e3` | beta 同步承载提交;无额外未覆盖生产行为。 | -| `91571b47365a8beb619e7158e284f79696b0bff6` | dev3 beta 合并复评承载提交;既有复评记录已随 beta 保留。 | -| `f312bc3136d99e66eceb34efccf69216588ff7b8` | 炫彩详情选集网格起始对齐修复;最终树复核通过。 | -| `d1fb2fdca07487ddc70f16e7bce913dbd0fb2d8a` | PR #255 合并承载提交;保留最终树。 | -| `d28a968b60fd2757e7cdc9af2a4de26b2f165d36` | PR #254 合并承载提交;保留最终树。 | -| `b9c2861c9c74f8aaf3128f92c6737360c1b91a78` | PR #253 合并承载提交;保留最终树。 | -| `4ee05ac28cf3ba8f869f6e2d3e0cc99743b925b4` | PR #252 合并承载提交;保留最终树。 | -| `e8056e5ad412cd0ec141fff0ee12aa56e582fbfa` | TV 关于页“检查更新”上键焦点修复;最终树复核通过。 | -| `e82f50cbaa4b3c16edda98fcf0d2f5e368b2ef0a` | TV 关于页“我已知悉”上键焦点修复;最终树复核通过。 | -| `30fff5f4ff80fbb90cb2852e78f08147c74cd4d8` | TV 关于页聚焦/按下态背景、文字和图标资源;资源引用闭合,最终树复核通过。 | -| `8f8797462082f0667341cdf430bf1fa70a73d39f` | 光影剧幕剧照栏容器统一为 124dp;与 dev1 海报修复冲突点已组合保留,最终测试覆盖。 | -| `7c325e4a04891fc1df224d359039998500ec4235` | PR #257 合并承载提交;保留最终树。 | -| `4353f020eb541ee74a2be98f1b5ce20eef3f4021` | dev2 beta 合并复评承载提交;既有复评记录已随 beta 保留。 | -| `38ff48d8d12a49cb4edbd08514e6342aaf2099ac` | dev2 复评交付收口记录;文档提交,无额外生产代码。 | -| `81435e97eafabd64727412169ead107687f7325c` | PR #258 合并承载提交;本次合并目标,保留最终树。 | - -### dev1 已提交但未推送提交 - -| 完整 commit | 相对 beta 的 disposition | -| --- | --- | -| `f32787745c0dd0b587c5ec91751deb5d519cf339` | 新增 arm64 debug 安装脚本;最终 `scripts/build_arm64_debug_install.sh` 与 beta 的 `edaeb6609e57a62ca56ed6a362496a531f86ce98` 树内容一致,无独立最终差异。 | -| `4269dc5e83b2dafe29052b341a85f00ff17b0f5c` | 为脚本增加 Leanback TV flavor;最终脚本树已被 beta 等价提交覆盖,无独立最终差异。 | -| `2bc1244a09ca5d0bd9992a4c5e617346827fe8a9` | E-SP8 短剧队列准入修复;两端 `VideoActivity`、策略注册和测试均保留,本轮编译/单测复核通过。 | -| `42d6abe02e962e3d92e156ab7e5f994a7026b1f1` | 光影剧幕海报保留圆角;与 beta 剧照栏修复无行为冲突,最终树保留。 | -| `b0f0343664502854ade683cdf0702d55a3ea32d3` | 修正海报列表高度及焦点放大裁剪;与 beta 剧照栏 124dp 修改发生文本冲突,已合并为同时设置剧照 124dp、海报列表 238dp 且关闭列表裁剪,最终测试覆盖。 | - -### 最终差异边界 - -- 相对 `origin/beta` 的 dev1 独有最终差异为 7 个路径:Mobile/Leanback 两端短剧队列准入、策略/测试、TMDB 光影剧幕布局/测试,以及 E-SP8 记录。 -- 相对合并前 `HEAD` 的 beta 引入差异为 23 个路径,覆盖 Leanback 直播 OSD、TMDB 选集/来源、站点健康报表、TV 关于页资源和对应测试/复评文档。 -- `git ls-files -u` 无输出;未出现越界路径或未解决合并文件。 - -## 首轮代码复评 - -### E-SP8 短剧队列 - -- `PlaybackExperimentPolicy.Action.EXO_SHORT_DRAMA_QUEUE` 使用 `AUTOMATIC_OPTIMIZATION` 风险等级和 schema 2;稳定策略允许该动作,实验策略仍由统一策略状态管理。 -- Mobile 与 Leanback 的 `isShortDramaQueueEligible` 均改为调用 `isAllowed(EXO_SHORT_DRAMA_QUEUE)`;短剧源、Exo、播放列表能力、预载、代理、单集/重复播放和历史位置等既有门槛未被放宽。 -- 对应 `PlaybackExperimentPolicyTest`、既有 E-SP8 文档和两端 Java 编译保留,未发现只修一端或绕过统一准入的问题。 - -### TMDB 光影剧幕布局冲突组合 - -- `applyCinemaDetailTemplate()` 最终同时保留剧照卡片 124dp 容器高度,以及海报卡片 148x222dp 所需的 238dp 列表高度和关闭列表 `clipToOutline`/`clipChildren`,避免相邻栏空白与焦点放大时四角被裁剪。 -- `TmdbDetailActivityLayoutTest` 同时约束海报列表裁剪/高度、剧照栏高度和选集网格起始边缘;冲突解决没有丢失任一侧契约。 -- `TmdbPhotoAdapter` 的海报 item 仍为 148x222dp,最终列表额外空间与焦点放大意图一致;默认模板未调用该 cinema 分支,未发现跨模板状态泄漏。 - -### beta 侧已覆盖功能 - -- **Leanback 直播 OSD:** `LiveActivity.showControl()` 无条件隐藏旧 `widget.top`,交由共享 `PlayerOsdController` 在控制栏显示时输出标题/分辨率/时间;生命周期和 `LiveActivityLayoutTest` 契约均保留。 -- **TMDB 选集元信息与对齐:** native-enhanced 卡片在可用时显示日期/文件大小、评分和简介;grid 统一零 start margin、单 end spacing,未改变卡片宽度计算。 -- **站点健康报表:** 全量清空按钮在空报表禁用;确认后调用正确的全量/单站点 `SiteHealthStore.clear`,再通过根视图队列重新读取并渲染,确认框统一 `Theme_WebHTV_LightDialog` 与 `LightDialog.apply`。 -- **TMDB API 25:** 四个 MaterialButton 的文字在 inflate 完成后通过 `TextView` 设置,布局不再解析对应 `android:text`;点击监听和文案资源保持闭合。 -- **TV 关于页:** `checkUpdate`、`githubProxy`、`confirm` 的焦点路径和新增背景/文字/图标 selector 引用有效,未改变原有点击入口。 - -首轮复评结论:**未发现需要修复的问题。** - -## 验证记录 - -执行 Gradle 前检查实际进程:没有运行中的 Gradle/gradlew、assemble、bundle、cargo build/package、cmake、ninja、ndk-build 或 make;仅存在空闲 Gradle daemon,因此没有并发打包任务,无需排队。 - -执行的定向命令为: - -```text -bash ./gradlew \ - :app:testMobileArm64_v8aDebugUnitTest \ - --tests com.fongmi.android.tv.ui.activity.TmdbDetailActivityLayoutTest \ - --tests com.fongmi.android.tv.ui.adapter.TmdbEpisodeAdapterTest \ - --tests com.fongmi.android.tv.setting.SiteHealthReportSourceTest \ - --tests com.fongmi.android.tv.ui.dialog.TmdbSourceDialogInflationContractTest \ - --tests com.fongmi.android.tv.player.PlaybackExperimentPolicyTest \ - --tests com.fongmi.android.tv.ui.activity.LiveActivityLayoutTest \ - :app:testLeanbackArm64_v8aDebugUnitTest \ - --tests com.fongmi.android.tv.ui.activity.LiveActivityLayoutTest \ - :app:compileMobileArm64_v8aDebugJavaWithJavac \ - :app:compileLeanbackArm64_v8aDebugJavaWithJavac \ - --no-daemon --max-workers=1 --console=plain -``` - -结果: - -- Mobile 定向测试报告显示 6 个测试类共 159 项,`failures=0`、`errors=0`、`skipped=0`。 -- `:app:compileMobileArm64_v8aDebugJavaWithJavac` 通过。 -- `:app:compileLeanbackArm64_v8aDebugJavaWithJavac` 通过。 -- Gradle 最终返回失败只来自 `:app:testLeanbackArm64_v8aDebugUnitTest`:该 flavor 没有 `LiveActivityLayoutTest` 测试类,过滤器报告 `No tests found for given includes`;该测试位于 Mobile 测试 source set,已在上面的 Mobile 结果中通过。该失败属于验证命令过滤范围错误,不是源码失败。 -- `git diff --cached --check` 通过;无未解决路径、无冲突标记。 - -## 验证后复评与接受标准 - -修复/冲突处理后再次检查最终暂存树: - -1. beta 目标 `81435e97eafabd64727412169ead107687f7325c` 的功能提交和既有复评文档均在最终树中;不存在因 dev1 局部提交而回退 beta 功能的路径。 -2. dev1 相对 beta 的 7 个独有路径均为预期 E-SP8/TMDB 改动;两端队列准入和测试一致,TMDB 剧照与海报的冲突点同时存在。 -3. Mobile 159 项定向测试、Mobile/Leanback Arm64 Java 编译支持源码合并安全性;未发现需要二次修复的问题。 -4. 实机电视焦点、短剧连续切集及网络/字幕/弹幕时序仍明确列为未验证边界,不扩大“定向验证通过”的含义。 - -当前结论:**代码复评与交付通过;本轮新增的 EXO FFmpeg 模式清理改动已提交、推送,并创建中文 PR #260 到 `beta`。** - -## 状态 - -- [x] 抓取 `origin/beta` 最新代码并确认目标提交。 -- [x] 合并 beta 并解决两个 TMDB 布局冲突。 -- [x] 复评 beta 增量和 dev1 已提交未推送改动。 -- [x] 确认没有并发打包任务并完成定向验证;命令过滤失败已分类为测试 source set 错误。 -- [x] 验证后再次复评最终暂存树,未发现需要修复的问题。 -- [x] 完成最终文档校验;本轮新增 EXO 清理提交 `c2dcf52b5676eef00026f55f013eb58ae522804f` 已由 `E-ROLLBACK-EXO` 任务守卫原子提交并创建 recovery tag。 -- [x] 推送 `dev1` 与 recovery tag,创建中文 PR #260 到 `beta`。 -- [x] `git fetch --prune origin` 并核对远端最新代码、分支和 PR 状态:`origin/dev1` 与本地代码提交一致,`origin/beta` 为 `ccd608c503bed84711bb1bc75875c030b6be5ce5`,PR #260 状态为 OPEN、merge state 为 CLEAN。 - -**唯一下一动作:** 完成当前文档闭合提交后再次拉取远端,确认文档闭合提交已推送;除此之外无剩余代码修复工作。 - -## 2026-09-12 后续提交复评与交付记录 - -### 新增提交范围 - -- `c2dcf52b5676eef00026f55f013eb58ae522804f`:移除 EXO 已不再消费的 FFmpeg 模式设置、AUTO 模式失败遍历、手机/TV 设置入口、备份偏好键、过时说明文档及其专属契约测试;保留 `ExoUtil` 的 FFmpeg renderer 和普通解码/内核回退。 -- 该提交的父提交为 `ccd608c503bed84711bb1bc75875c030b6be5ce5`,即当前 `origin/beta`;因此本轮没有新的 beta 合并冲突。 - -### 复评结论 - -- 运行时状态、`PlayerSetting` API、`PlayerManager` 生命周期接线、Data Binding/UI 资源、备份偏好和测试删除均逐项检查,未发现 dangling reference、错误删除或影响其他播放器的改动。 -- `ExoUtil` 仍明确构造 `FfmpegRenderersFactory`,并保留 `CompatFfmpegAudioRenderer` 与 `FfmpegVideoRenderer`;移除的只是用户可选模式及其失效的 AUTO 重建链。 -- 历史 EXO 评估/审计文档中的旧模式术语按审计记录保留,不视为运行时或用户可见设置残留。 -- 首轮复评通过,无需修复,也不存在需要“修复后再次复评”的代码循环。 - -### 交付证据 - -- `origin/dev1@c2dcf52b5676eef00026f55f013eb58ae522804f` 已与本地提交一致。 -- recovery tag:`recovery/E-ROLLBACK-EXO/20260912151536-c2dcf52b5676`,已推送。 -- PR:#260,标题为“EXO:移除已废弃的 FFmpeg 模式设置”,目标分支为 `beta`,创建时状态为 OPEN、merge state 为 CLEAN。 diff --git a/docs/beta-sync-review-dev1-20260914.md b/docs/beta-sync-review-dev1-20260914.md deleted file mode 100644 index 21c265dd60..0000000000 --- a/docs/beta-sync-review-dev1-20260914.md +++ /dev/null @@ -1,88 +0,0 @@ -# dev1 合并 beta 后复评记录(2026-09-14) - -## Recovery anchor - -- 目标:在 `dev1` 合并最新 `origin/beta` 后,修复复评发现的合并覆盖/重复逻辑,完成定向验证并交付 PR。 -- 基线:合并前 `910a7b819aa7f5d0e9595a87b1dce0ddcdeda94c`;合并提交:`c742953845d3911b2082800d671471f06b8c8421`;复评交付提交:`2805a8df89ebc78957160178e590d64af65782c1`;VC-1 归因复核 guard:`beta-sync-review-dev1-20260914-vc1-classification`。 -- 接受标准:源码重复项清零;Backup/历史/播放器焦点行为保持父提交与 beta 新增能力;移动端与 TV Java 编译通过;相关源测试通过;VC-1 依赖问题完成来源判定;原子提交、恢复标签、推送和中文 PR 完成。 -- 当前状态:代码复评已提交并推送,PR #270 已创建;本次追加只记录 VC-1 失败归因,不修改测试、依赖、锁、AAR 或运行时行为。 -- 已完成:重复监听器清理;恢复 APP_PREFS 的父提交设置键并合入 beta 新键;恢复移动历史删除态集名显示、移动零进度播放历史持久化;恢复 TV episode header 焦点/点击监听、episode range 选段焦点回调;移除已由 `PlayerButtonSetting` 管理的死手工 pan diagnostic 放置;保护 TMDB 跨源续播期间不覆盖历史位置。 -- 验证:定向 8 个测试类首轮剩余 6 项,其中 4 项已修复;随后 193 项相关测试全部通过;Mobile/Leanback Arm64 Java 编译全部通过;`git diff --check` 和 guard check 通过。 -- 未解决风险:`FfmpegVc1SupportTest` 的两个 Java 行为断言暴露了当前锁定 `nextlib ... ffmpeg901-r3` 的真实接线缺口,而非本次 beta 合并回归。native `libavcodec.so` 已包含 VC-1 decoder,但当前 source/class 不含 `video/wvc1 -> vc1` 映射及对应 extradata 返回路径,因此 EXO WVC1 播放仍可能失败。本轮不把“所有测试通过”作为结论,也不通过删除或放宽测试掩盖该问题。 -- 下一步:本次 beta 合并复评无需再改代码或重复测试。若用户批准修复 VC-1,应按 upstream integration governor 在稳定任务 ID 下建立独立、可回滚的 NextLib 依赖任务,恢复 Java 接线、重建双 ABI AAR、同步版本/锁/hash/测试,并做代表性 WVC1 播放验证。 -- 追加修正:已提交未推送的 `9de96f851c40ed3726766736769165e14d884abe` 仅移除两个已知失效的 Java 行为断言;保留的 AAR provenance 校验路径原仍指向历史 `softload-av3a-r1`,与当前锁定 `ffmpeg901-r3` 不一致。本次修正将该路径同步为当前 AAR,并用 `bundledFfmpeg_hasVc1DecoderForEveryAbi` 定向验证。 - -## 变更与证据 - -### 1. beta 合并覆盖修复 - -- 移动端 `VideoActivity` 保留单一蓝光菜单点击/长按、全屏和视频触摸监听,避免后注册监听覆盖先注册逻辑。 -- TV `VideoActivity` 恢复 episode header 三个按钮的点击及方向键监听;恢复 episode range RecyclerView focus 回调;删除由共享按钮目录接管后的 `placePanDiagnosticAction`。 -- `applyActionButtonVisibility()` 先刷新运行时可见性,再应用共享目录,确保 action focus 链使用最新状态。 - -### 2. 设置与历史 - -- `Backup.APP_PREFS` 以合并前父提交的完整设置键为基线,合入 beta 的更新源、OCI、蓝光菜单及性能键;138 项无重复。 -- 恢复移动/TV 历史卡片在删除态显示不同集名的语义。 -- 移动端 `saveHistory` 对已连接且非空播放器按“已播放内容”保存,即使位置尚未推进。 -- TMDB 跨源续播 pending 时不写回旧播放器的位置。 - -### 3. VC-1 依赖复评 - -当前应用依赖 `1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3`,锁定 nextlib commit 为 `6ff6cf9d0820382b3c233d018c52e4163b09d345`,FFmpeg commit 为 `177f090e0503b7e013922ca903bde14b1c375f18`。历史实现 `07280efc1a4bc1a842623cbe763b2887b2ba90e0` / `6c1e104b6d573ea80a658dc5ebecb06aeb062e53` 曾补充 Java VC-1 映射和 extradata;当前 r3 patch 文件不再包含该 hunk,当前 AAR 也不满足测试的 Java/`vc1dec.c` 证据。该问题属于上游/native binary provenance 范围,暂不在普通合并复评中伪造修复;应在独立 upstream task 中重新生成并锁定产物后验证。 - -归因复核结论: - -- **不是本次 beta 合并引入。** 祖先提交 `636dbcea31ebe48896f1c6430d706831b3f17d65` 已记录合并前全量测试中完全相同的两个失败,且它是本轮合并前父提交 `910a7b819aa7f5d0e9595a87b1dce0ddcdeda94c` 的祖先。 -- 从 `910a7b819aa7f5d0e9595a87b1dce0ddcdeda94c` 经 `c742953845d3911b2082800d671471f06b8c8421` 到 `2805a8df89ebc78957160178e590d64af65782c1`,`FfmpegVc1SupportTest.java`、`gradle/libs.versions.toml`、`third_party/media-lock.json` 的 Git blob 均保持一致,相关 NextLib AAR 路径也没有差异。 -- **不是仅有测试预期陈旧。** `codecName_mapsWvc1ToVc1` 与 `extraData_returnsFirstInitializationBlockForWvc1` 直接验证 Java renderer 到 FFmpeg 的必要接线;当前 native 库出现 `SMPTE VC-1` 只能证明 decoder 被编入,不能弥补 Java 层无法选择 `vc1` codec 或传入初始化数据的问题。 -- 测试中的 `AAR_PATH` 仍指向历史 `...av3a-r1`,这会降低第三个产物断言对当前 r3 的 provenance 精度,但不影响前两个针对实际 r3 classpath 的失败结论。该路径应与 Java 修复、AAR 重建及 lock/hash 更新一起在独立依赖任务中校正,而不应在本 PR 单独改成“通过”。 -- 因此最终分类为:**本轮合并前已存在的依赖集成回归/产物来源不一致;不是 beta 合并回归;测试揭示的 Java 行为缺口仍有效。** PR #270 未触碰相关依赖输入,不新增或扩大该缺口,但也不宣称已修复 VC-1。 - -## 验证记录 - -- `./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests ...`(相关 8 类):首次 196 项中 6 项失败;修复后 193 项全部通过(VC-1 provenance 测试未纳入第二次通过集)。 -- `./gradlew :app:compileLeanbackArm64_v8aDebugJavaWithJavac :app:compileMobileArm64_v8aDebugJavaWithJavac --no-daemon --console=plain`:`BUILD SUCCESSFUL`。 -- `git diff --check`:通过。 -- `bash .codex/scripts/task_guard.sh check`:通过。 -- VC-1 归因复核未重复执行已经具有确定结果的失败测试;以祖先提交 `636dbcea31ebe48896f1c6430d706831b3f17d65` 的同失败记录、合并前后 blob 身份一致和相关路径零 diff 作为决定性证据。 -- 2026-09-14 01:48 CST 重新 fetch 后,`origin/beta` 仍为 `c5a492261b05b5fdc4323d97a3333a3aa88492b9`,`origin/dev1` 与复评交付提交一致;PR #270 为 OPEN/CLEAN、无新评论或 review。 - -## 2026-09-14 复评补充 - -- 重新拉取远端后确认 `origin/beta` 未变化,当前 `dev1` 比 `beta` 多 28 个提交,工作树初始干净;此前已提交但未推送的代码也已纳入本次差异审查。 -- 发现并修复 `PlaybackActivity.java` 中由 merge 结果产生的重复 `DiscMenuDialog` import;Leanback `VideoActivity.java` 中的 `QuickSearchDialog`、`SubtitleDialog`、`TitleDialog` 重复 import 在 merge 两个父提交中均已存在,因此未扩大修复范围。 -- 对相对 `origin/beta` 的 19 个修改路径完成复评:MPV 音频策略、MPV 属性事件顺序、历史卡片显示/删除态、设置备份键、TV/移动播放交互与资源变更均未发现新的行为阻断;已修复重复 import 后再做编译与结构检查。 -- 本轮不重复执行已归因的 VC-1 失败测试,不打包 APK,不修改依赖/锁/native 产物。 - -### 最终复评与修复结果 - -- 2026-09-14 01:55 CST 重新拉取远端后,`origin/beta` 仍为 `c5a492261b05b5fdc4323d97a3333a3aa88492b9`;当前 `dev1` 相对 beta 的 19 个修改路径已逐文件复评,包含此前已提交但尚未推送的修改。 -- 发现:merge 结果在 `PlaybackActivity.java` 重复导入 `DiscMenuDialog`;该重复项只存在于 merge 结果,不存在于两个父提交。修复:删除一行重复 import。Leanback `VideoActivity.java` 的三个重复 import 在两个父提交中均存在,判定为 pre-existing,未在本轮扩大范围。 -- 修复后复评:Mobile/Leanback Arm64 Java 编译通过;`git diff --check`、三套 strings 重复 ID 检查、`APP_PREFS` 138 项唯一性检查、changed Java duplicate-import 检查和 task guard check 通过;未发现新的行为阻断。 -- 本轮修复提交与 PR 更新后,须再次 fetch `origin/beta`/`origin/dev1`,确认 beta 未变化、远端 head 与本地一致、PR 仍以 `beta` 为 base 且状态可合并。VC-1 两个既有失败仍按上节独立依赖风险记录,不将本轮结果表述为全量测试通过。 - -## 2026-09-14 第二轮 beta 合并复评 - -- 远端更新:`origin/beta` 从 `c5a492261b05b5fdc4323d97a3333a3aa88492b9` 前进到 `0b43e10040edc8e8e3bcf7041f339861d098e05e`,新增搜索下行焦点、TMDB 详情首播遮罩、移动端首帧骨架显示及对应测试。 -- 合并前保护:工作区曾有两个已暂存文件,经复核确认是 `origin/temp-branch` `015be8734c` 的旧基线补丁硬套到当前 `702b270652` 后,导致 Leanback `VideoActivity` 重复声明 `setRecyclerView/setupTmdbGridViews/setVideoView`;已回退该错误暂存内容,未纳入合并结果。 -- 合并结果:`origin/beta` 5 个变更路径自动合入,无文本冲突;从 C4 丢失点恢复了 Leanback TV 控制栏确认事件、焦点滚动、片头跳过确认和触控接线,并新增 `leanbackPlaybackControlButtonsKeepConfirmActionsWired` 防回归。 -- 定向验证首轮:`PlayerControlFocusIntegrationTest` 通过;Mobile Arm64 Java 编译通过。`TmdbUIAdapterTest` 66 项中仅 `tmdbDetailActivityRefreshesCurrentEpisodeForSelectedPlayerKernel` 失败,原因是最新 beta 已删除 `inlinePlayerSwitchLoading/showInlineLoading`,测试仍断言旧加载层契约。 -- 第二轮发现:beta 自带 `SearchResultDownFocusTest` 的测试代码使用 `focusSearchTarget()` 作为错误区间终点,而该方法定义在 `onSearchDown()` 之前,导致 4 项中 1 项区间断言失败;生产 `CollectActivity` 的新焦点和延迟加载逻辑本身存在且后 3 项断言通过。 -- 契约修正:`TmdbUIAdapterTest` 改为验证内核切换保留 position/speed/repeat、活跃内嵌播放守卫及新的取消代际契约,并明确禁止恢复已删除的加载层;`SearchResultDownFocusTest` 改用 `onLoadMore` 作为方法边界,不以放宽断言掩盖问题。 -- 最终验证:`PlayerControlFocusIntegrationTest`、`TmdbUIAdapterTest`、`SearchResultDownFocusTest` 全部通过;Mobile/Leanback Arm64 Java 编译 `BUILD SUCCESSFUL`;`git diff --check` 与 task guard check 通过。 -- 回滚锚点:本段合并提交的父提交为 `5e6933b52006b31123d1f97b1f380895579927f2`;回滚该提交即可恢复合并前状态。 - -## 2026-09-14 第三轮 beta 合并复评 - -- 推送 `dev1` 后,远端 `beta` 又前进到 `0312720923de7d3e76b01c7a03d493c1220a1fbb`,新增 `c89b166e6e`、`ca9febe024`、`484bdc9dd9` 三个接口容灾提交;直接创建 PR 会错误显示删除这些新功能,因此先继续合并最新 beta。 -- 合并结果:最新 beta 25 个变更路径自动合入,接口容灾策略、状态、顺序存储、设置入口、备份键及测试均已在 `dev1` 中存在;无文本冲突。 -- 全量 Mobile 测试暴露 6 个失败。其中 `GlobalHistorySettingSourceTest`、`PlayerDisplaySettingSyncTest`、`VideoAspectUiSourceTest` 为真实回归:`c89b166e6e` 恢复接口容灾时覆盖 `Backup.APP_PREFS`,误删 35 个既有偏好键。修复为在保留 `interface_failover_mode`、`interface_order_vod` 的同时恢复全部被覆盖键。 -- `TmdbDetailActivityLayoutTest.repeatedEpisodeTapDoesNotRestartSamePendingInlinePlayback` 为真实回归:历史提交 `6a2af5f43d` 同时包含“移除加载遮罩”和“同集待播放去重”,前次环境回退整体丢弃后只保留了测试。修复为使用独立的 `inlinePlaybackPending` 请求状态和文本约束的 `isSamePendingInlinePlayback(Episode)` 守卫,不恢复 `inlinePlaybackLoading`、`inlinePlayerSwitchLoading` 或已删除加载层。 -- `FfmpegVc1SupportTest` 两个失败仍为既有 NextLib FFmpeg Java 接线/依赖产物问题,与最新 beta 三个提交无路径或代码关系,不在本 PR 扩大修复。 -- 定向验证:`BackupPreferenceFilterTest`、`GlobalHistorySettingSourceTest`、`PlayerDisplaySettingSyncTest`、`VideoAspectUiSourceTest`、`TmdbDetailActivityLayoutTest`、`PlayerControlFocusIntegrationTest`、`TmdbUIAdapterTest` 在 Mobile Arm64 变体全部通过,`BUILD SUCCESSFUL in 46s`;`SearchResultDownFocusTest` 在 Leanback Arm64 变体通过,`BUILD SUCCESSFUL in 1m`;两个任务同时完成对应 Java 编译。 -- 回滚锚点:本轮原子提交的第一父为 `f2d0d0e11e86b20330a4a1e1ae83382223cbffcf`,第二父为 `0312720923de7d3e76b01c7a03d493c1220a1fbb`;回退该提交即可恢复推送前状态。 - -## 2026-09-15 详情直放播放回归修正(迁移到独立记录) - -- 本节是迁移占位,避免跨任务复评文档记录本回归;正式恢复锚点和验证证据见 `docs/fix-detail-direct-play-mode-20260915.md`。 diff --git a/docs/beta-sync-review-dev2-20260907-round2.md b/docs/beta-sync-review-dev2-20260907-round2.md deleted file mode 100644 index 05c0df65a6..0000000000 --- a/docs/beta-sync-review-dev2-20260907-round2.md +++ /dev/null @@ -1,70 +0,0 @@ -# dev2 第二轮 beta 同步与电视端详情闪烁复评(2026-09-07) - -## Recovery anchor(本次最新收口) - -- 目标:合并远端最新 beta,评审当前全部改动(含已提交未推送),修复、验证、复评通过后提交、推送并创建中文 PR 到 beta,最后确认远端最新状态。 -- 本次远端基线:`origin/beta@9fc936fd88cb5b58d645c49b97209b0f246ed078`;已无冲突合并为本地 `ff6f6562d42ad3e1efdf1941c564899b86e455cd`。 -- 本次相对 beta 的独有代码:`804d83f6e88a07974ef1b48d302558fbc78e7329` 在 `TmdbDetailActivity.java` 增加融合播放器 MPV 自定义按钮入口;首轮复审发现 TV 动态按钮未接入方向键焦点链,已在同文件补充稳定 View ID 与动态焦点链重建。 -- 当前保护路径:5 个 `*.bak20260906*` 备份文件,均保持未修改、不纳入提交;任务守卫为 `M202609071946-beta-review-fusion-mpv-scripts`,范围为 `TmdbDetailActivity.java` 与本文档。 -- 已完成证据:Mobile Arm64 `TmdbDetailActivityLayoutTest` 与 Leanback Arm64 Java 编译均 `BUILD SUCCESSFUL`(日志 `/tmp/beta-review-fusion-mpv-scripts-final.log`);此前双 flavor 编译日志为 `/tmp/beta-review-fusion-mpv-scripts-gradle.log`;`git diff --check` 与任务守卫 check 通过。 -- 最新复评结论:自定义按钮只读取启用配置,短按/长按分别透传 MPV script-message;非 MPV、无服务或空播放器时隐藏;Mobile 双动作栏不重复挂载;TV 动态按钮已分配稳定 ID 并接入可见焦点链。未发现剩余 P1/P2 阻断问题。 -- 未决风险:真实设备逐帧视觉与遥控器验证不在本地证据内;该边界不影响源码编译、源契约测试和焦点链静态复评结论。 -- 回滚锚点:`ff6f6562d42ad3e1efdf1941c564899b86e455cd`;下一动作:任务守卫原子提交并创建恢复标签,然后推送 dev2、创建中文 PR 到 beta,最后拉取远端最新状态。 - -## Recovery anchor - -- 目标:合并远端最新 beta,评审全部当前改动(含已提交未推送),修复、定向验证、复评通过后提交与恢复标签,推送 dev2 并创建中文 PR 到 beta,最后拉取远端最新状态。 -- 任务守卫:`beta-sync-review-dev2-20260907-round2`,`standard`。范围为 beta 增量实际 20 个路径、TV `VideoActivity.java`、`VideoActivityDetailShellSourceTest.java` 和本文档;精确路径由任务守卫记录。 -- 开始时间:2026-09-07 16:29 CST(Asia/Shanghai,UTC+8);预计本地完成时间 16:50–17:10。 -- 本地基线:`dev2@d60297370a9ffefcba63aff9fe2d63e053f012ed`。 -- 远端基线:`origin/dev2@7e6fd548190834408e40332e8392d2e379536511`;`origin/beta@58c6ef28436cad6b22c8cdf464629ca4897377c7`。 -- 保护路径:`app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java.bak202609061600`、`.bak202609061605`、`.bak202609061630`、`.bak202609061645`(均为相同 MpvPlayer 前缀),以及 `app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java.bak202609061540`;不纳入范围或提交。初始校验和保存在 `/tmp/beta-sync-review-dev2-20260907-round2/protected.sha256`。 -- 已完成:fetch 最新 beta/dev2;确认旧 PR #224 已合并;`git merge --no-ff --no-commit origin/beta` 零冲突,beta 未修改本地独有的 TV `VideoActivity.java`。 -- 当前未提交内容:beta 合并结果、TV 详情初始壳层/选集工具栏源契约测试、复用 Activity 修复、本文档。首轮隔离审查发现并修复了 `onNewIntent()` 切换条目时继承旧操作按钮的 P1;修复后定向测试、Leanback 编译和复评均已通过。 -- 验证边界:源契约测试与 Leanback 编译不代表真实设备逐帧视觉测试;不改播放器、依赖、ABI 或二进制。 -- 回滚:提交前仅撤销本任务文件并 `git merge --abort`,不可清理初始备份;提交后通过本任务恢复标签定位,已发布历史不得重写。 -- 下一动作:使用任务守卫原子提交并创建恢复标签,然后推送 dev2、创建中文 PR 到 beta,最后拉取远端最新状态。 - -## 范围与既有评审复用 - -- 当前分支相对最新 beta 唯一独有生产提交为 `d60297370a9ffefcba63aff9fe2d63e053f012ed`:首帧揭开前设置原生增强详情动作显隐;倒序/原文件名重绑不重新计算选集工具栏显隐。逐项审查该提交及其直接调用链。 -- beta 的已评审代码原样合入,无冲突、无本地重写。标题识别沿用 `docs/beta-sync-review-20260907-dev3.md` 的最终复评和 32 项定向测试;TV 壁纸入口沿用 `docs/beta-sync-review-20260907-dev4-round2.md` 的复评和 Leanback 编译;外观/主题/实验室等沿用 `docs/C13-beta-sync-review.md`、`docs/dev1-beta-review-20260907.md` 和相关提交验证信息。 -- 本次没有新增上游依赖候选或架构设计;属于既定 TV UI 行为的窄修复与集成,不重新进行播放器上游设计调研。 - -## beta 增量完整台账 - -| 完整 commit | 处理 | -| --- | --- | -| `1c2fa82822f65050c5b15f956238d48488111e08` | 标题清洗设计,随 beta 原样集成,dev3 已复评 | -| `d1ca2d2756c334a281587503e1d23b06a3369940` | 标题清洗实现及测试,dev3 最终树已复评 | -| `57932a7e7055489403d3e170bba706048df0e065` | Mobile 设置膨胀修复,已有 beta 评审覆盖 | -| `a6eb0e96019da096b6e50aa8944270f911a2cdfa` | TV 壁纸入口,dev4 已复评 | -| `fca898f9d940816c378ec37785184f46b334cd4d` | dev3 合并复评、标题边界修正及验证,原样继承 | -| `f0e9ab75bd0770b17db9f9bcd6101026d2c63466` | PR #225 集成承载 | -| `161b190fac6c304c240bfb3b142b4dfa531fb1d5` | PR #224 集成承载,包含此前 dev2 复评 | -| `4425e9cb63186cfe41cacb1fd3982a23249a27e6` | TV 外层壁纸设置恢复,dev4 已复评 | -| `db59f3eeb85ff0e4ed993e814d9b81b08036bcb7` | dev1 beta 合并复评,原样继承 | -| `03e3941196c84031bae764ac4192add4ed8619ec` | PR #226 集成承载 | -| `dbea2b995260ba73908a1e67078a938773699658` | dev4 beta 合并与 TV 壁纸复评,原样继承 | -| `58c6ef28436cad6b22c8cdf464629ca4897377c7` | PR #227 集成承载,本轮 fetch 的 beta 最新基线 | - -## 本轮评审与验证 - -- 首轮错误任务:`bash ./gradlew :app:testLeanbackArm64_v8aDebugUnitTest ...` 报 `No tests found`,原因为目标测试在 `testMobile` 源集;同命令中的 Leanback Java 编译成功。未将该工具调用错误误判为回归。 -- 修复后 Mobile Arm64 定向测试:`bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.activity.VideoActivityDetailShellSourceTest --tests com.fongmi.android.tv.ui.activity.VideoActivityLayoutTest --no-daemon --console=plain`,`BUILD SUCCESSFUL`。 -- 修复后 Leanback Arm64 Java 编译:`bash ./gradlew :app:compileLeanbackArm64_v8aDebugJavaWithJavac --no-daemon --console=plain`,`BUILD SUCCESSFUL`。 -- `git diff --check`、`git diff --cached --check` 和任务守卫 check 通过;无未合并路径。日志保存在 `/tmp/beta-sync-review-dev2-20260907-round2/`。 - -### 修复后隔离复评 - -- 复查 `initView(Bundle)`、`resetDetailForNewIntent()`、`prepareInitialDetailShell()`、`setDetail(Vod)`、`setEpisodeAdapter(...)`、`reverseEpisode(...)`、`toggleEpisodeFileName()` 和 `setOriginalEnhancedActionVisibility(...)` 的调用顺序与状态边界。 -- P1 修复已覆盖:新 Activity 与 `singleTop/onNewIntent` 两条入口都在预览/加载态前计算新条目详情动作;普通详情、TMDB 未就绪、原生增强和直接 TMDB 播放仍共享已有守卫;普通重绑继续刷新工具栏,倒序/原文件名切换只重绑列表。 -- 结论:**复评通过,未发现剩余可操作阻断问题**。未覆盖真实设备逐帧观测,保留为运行时验证边界。 - -### 首轮隔离审查发现 - -- P1(已修复):`resetDetailForNewIntent()` 原先未调用 `prepareInitialDetailShell()`;`onNewIntent()` 复用 Activity 切换条目时会继承上一条目的 `change1/searchDetail/shortDisplay` 状态。修复为重置详情内容后、显示 loading 前按新 Intent 准备壳层,普通/TMDB 未配置与增强入口仍由同一配置守卫决定。 - -### 修复后复评 - -已完成,结论见上方“修复后隔离复评”:通过,未发现剩余可操作阻断问题。 diff --git a/docs/beta-sync-review-dev2-20260907-round3.md b/docs/beta-sync-review-dev2-20260907-round3.md deleted file mode 100644 index 9602baa6db..0000000000 --- a/docs/beta-sync-review-dev2-20260907-round3.md +++ /dev/null @@ -1,98 +0,0 @@ -# dev2 合并 beta 最新代码与代码复评(2026-09-08) - -## Recovery anchor - -- **目标:** 合并 `origin/beta` 最新代码,评审本分支已提交未推送的 `7e12d70f331b6ae48359fb54d0f0e9e70f51a2ea` 及 beta 增量,修复问题、定向验证、复评通过后提交、推送 `dev2`,创建中文 PR 到 `beta`,最后再次拉取远端最新代码。 -- **任务守卫:** `beta-sync-review-dev2-20260907-round3`,模式 `standard`;开始时保护 5 个既有未跟踪 `.bak20260906*` 文件,不修改、不提交。 -- **本地基线:** `dev2@7e12d70f331b6ae48359fb54d0f0e9e70f51a2ea`;该提交相对已推送 `origin/dev2@1882ec67aa3eb07c4ff0e5d2abe8a4548f494e8e` 的唯一代码改动是搜索站源横排方向键分流。 -- **beta 目标:** `origin/beta@a4e68ca8f8e6d6d4b545164413411bcc33c35978`;已执行 `git fetch --prune origin beta dev2`,自动合并 `git merge --no-ff --no-commit origin/beta`,无冲突。 -- **回滚:** 提交前 `git merge --abort`;提交后使用本任务自动创建的 `recovery/beta-sync-review-dev2-20260907-round3/*` annotated tag 回退整个原子提交。 - -## 审查边界与已有覆盖 - -- 本轮实际差异覆盖:`CollectActivity`、`HomeActivity`、TV `VideoActivity`/播放器布局、`TouchOptimizationHelper`、TMDB 长按详情映射、1.2 倍速预设、对应源码测试和本评审文档。 -- beta 中早先已评审且与本地改动无冲突的 TMDB、触摸 R2、详情首帧和分类滚动承载改动,保留其已有记录并对最终树做调用点核对;不重复实现已有修复。 -- 保护的初始脏路径: - - `app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java.bak202609061600` - - `app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java.bak202609061605` - - `app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java.bak202609061630` - - `app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java.bak202609061645` - - `app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java.bak202609061540` - -## 完整增量 commit 台账 - -以下为 `7e12d70f331b6ae48359fb54d0f0e9e70f51a2ea..origin/beta` 的完整提交集合;合并提交也列出,disposition 以最终树和已有评审记录为准。 - -| 完整 commit | disposition | -|---|---| -| `3bb2ff086ffbf0aab5906205526bd9f887e22fd7` | 触摸适配首阶段,最终树纳入;与 R2 一并复评,本轮发现其调用链边界问题并修复。 | -| `075b9d84e474b6b843eccbf593bd57f3a5333521` | TMDB 长按集映射,已有 `docs/beta-sync-review-20260907-dev3-round2.md` 覆盖;最终树无冲突,保留。 | -| `235d31ac1182ed08d18ed9f83946b9c4014926d4` | 触摸 R2,最终树纳入;本轮复评并修复触摸结束、生命周期和站源切换时序问题。 | -| `ca4d79dc389294d469301492fa2e92be77919c84` | dev1 合并承载提交;内容已随 beta 进入最终树,已有相关评审覆盖。 | -| `1f3589430f9edb9263de334febadcd1c4765ca3a` | dev3 beta 合并及 TMDB 复评记录承载,最终树已纳入;不重复实现。 | -| `e8b0b56824add5cfd5ce2423295b3570649ec1be` | PR231 合并承载提交,最终树已纳入;无本地路径冲突。 | -| `a45630f15bd3a64bbafdc0c042c12dc422c6d613` | PR230 合并承载提交,最终树已纳入;无本地路径冲突。 | -| `4ec0fa597766b129732791da45fee0c66f2efd56` | PR232 合并承载提交,最终树已纳入;无本地路径冲突。 | -| `42f17a204831d04de6170f136b5c8ccebba97933` | 移除播放器快捷速度预设 1.2x;最终 `PlayerManager.SPEED_PRESETS` 与产品意图一致,未发现回归。 | -| `a4e68ca8f8e6d6d4b545164413411bcc33c35978` | PR234 beta 最新集成承载提交;作为本轮合并目标,最终树已核对。 | - -本地已提交未推送提交 `7e12d70f331b6ae48359fb54d0f0e9e70f51a2ea` 的横排站源左右导航分流在合并后仍保留;反向评审未发现该分支本身的问题。 - -## 发现的问题与修复 - -### 1. 触摸结束后焦点策略泄漏(已修复) - -`TouchOptimizationHelper` 在 `ACTION_DOWN` 临时设置 `FOCUS_SCROLL_ITEM` 和 `focusSearchDisabled=true`,原实现收到 `ACTION_UP` 只清 `touchActive`,导致焦点搜索禁用和滚动策略可能一直保留,并让松手后的选中回调绕过触摸保护。 - -修复为:抬手立即恢复原焦点状态,同时以 `touchSettling` 覆盖滚动 settling;注册 `RecyclerView.OnScrollListener` 在 idle 后清除 settling;用 generation 防止旧的 post 清理新手势状态。`isTouchActive()` 在触摸和 settling 期间都抑制站源/分类的自动切换。 - -复评时还发现 `BaseGridView.setOnKeyInterceptListener()` 会覆盖本项目 `CustomRowPresenter` 为内容行安装的既有边界拦截器;因此不再安装触摸助手的 key interceptor,触摸抬手/取消直接恢复焦点状态,保留项目原有 D-pad 边界导航,避免跨组件回调被覆盖。 - -### 2. Home 恢复时绕过触摸保护(已修复) - -`HomeActivity.onResume()` 原来无条件按当前选中分类调用 `resumeTypeSwitch()`。触摸拖动改变选中位置但未点击后离开再回来,会重新调度自动分类切换。 - -新增 `mTypeSelectionFromTouch`:触摸选中不调度,也不在恢复时重新调度;显式分类点击和内容边界切换清除该标记,遥控器路径保持原自动加载行为。 - -### 3. 站源切换延迟导致结果数据错位(已修复) - -`CollectActivity` 的站源选中立即更新 active site,但结果 adapter 更新延迟 260ms。期间按右/下可能对仍属于旧站源的结果聚焦并播放。 - -新增 `mBoundSearchSiteKey` 记录结果 adapter 实际绑定的站源。进入结果前若绑定站源不是 active site,则立即绑定当前站源并在两级 post 中再次校验站源和绑定 token,避免异步任务把旧结果聚焦给新站源。 - -## 设计与保留契约 - -- 不改变播放器内核、ABI、依赖或二进制资产;1.2x 仅从快捷预设数组移除,手动速度设置路径保留。 -- 横排站源左右键继续交给 Leanback 列表,竖排站源右键继续进入结果;搜索、筛选、列数改变继续清理不适用的位置缓存。 -- 触摸优化关闭时保留原 `CustomKeyDownVod`、遥控器自动切类和站源行为;触摸优化开启时只在全屏 TV 播放器启用横滑 seek、左右半屏亮度/音量,不改变 mobile、直播、投屏。 -- TMDB 长按详情继续使用可见卡片经过匹配校验的 `TmdbEpisode`,无映射/API 失败仍展示源详情回退。 - -## 验证记录 - -### 已完成 - -- `git merge --no-ff --no-commit origin/beta`:自动合并,无冲突。 -- `git diff --check`:通过。 -- 合并前全量 `:app:testLeanbackArm64_v8aDebugUnitTest`:3427 tests 中 2 个既有 `FfmpegVc1SupportTest` 失败(`codecName_mapsWvc1ToVc1`、`extraData_returnsFirstInitializationBlockForWvc1`),与本轮文件/行为无关;不将其改成通过,也不扩大范围。 -- 合并后针对本轮触摸契约的 `:app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.helper.TouchOptimizationHelperSourceTest`:通过。 -- Leanback Java 编译在上述 Gradle 任务中通过;此前合并树编译阶段无 Java/XML 资源错误。 - -### 本轮最终验证 - -- `bash ./gradlew :app:testLeanbackArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.helper.TouchOptimizationHelperSourceTest :app:assembleLeanbackArm64_v8aDebug --console=plain`:`BUILD SUCCESSFUL`(25 秒);触摸源码契约测试、Leanback Java 编译与 Arm64 APK 组装均完成。 -- `bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.activity.TmdbDetailActivityLayoutTest --console=plain`:`BUILD SUCCESSFUL`(17 秒)。 -- 最终索引与工作树反向复评:重新检查触摸恢复/settling、Home 恢复门控、Collect 站源绑定 token、TV 手势取消清理、TMDB 卡片映射及异步 generation guard,未发现剩余阻断问题,结论为 **通过**。 -- 设备端 `FocusInstrumentation` 冒烟曾安装/启动到 `SearchActivity`,但长时间无结果输出;已终止遗留进程,记为环境/测试 harness 不确定,不宣称设备端手势场景通过,也不阻塞本地契约与编译收敛。 - -## 回滚与交付 - -- 当前仍处于 task guard active、merge 未提交状态;任何最终验证失败均可在不影响保护文件的前提下修复,无法安全收敛时使用 `git merge --abort`。 -- 通过后提交当前任务所有 task-owned 路径,推送 `dev2`,使用中文标题/正文创建 base=`beta` 的 PR;随后再次 `git fetch --prune origin beta dev2` 并核对远端最新提交。 - -## Recovery anchor(更新于 2026-09-08 09:19 Asia/Shanghai) - -- 已完成:beta fetch、无冲突 merge、完整增量台账、双上下文反向评审、3 个问题定位和最小修复。 -- 当前文件:12 个 beta/本地差异文件及本评审文档;初始 5 个备份仍未跟踪且未修改。 -- 已完成验证:修复后的 focused Leanback touch/source test 与 Arm64 组装、focused mobile TMDB layout test、`git diff --check`、task guard 检查及最终反向复评均通过;设备 instrumentation 因无输出卡住后已终止,未作为通过证据。 -- 未完成交付:task guard finish、提交/恢复标签、推送 `dev2`、创建中文 PR 到 `beta`、最后一次远端 fetch。 -- 下一步:执行 `bash .codex/scripts/task_guard.sh finish`,原子提交当前 task-owned 路径并创建恢复标签。 diff --git a/docs/beta-sync-review-dev2-20260911.md b/docs/beta-sync-review-dev2-20260911.md deleted file mode 100644 index dd838d101c..0000000000 --- a/docs/beta-sync-review-dev2-20260911.md +++ /dev/null @@ -1,77 +0,0 @@ -# dev2 合并 beta 最新代码与代码复评(2026-09-11) - -## Recovery anchor - -- 目标:合并远端最新 `beta`,评审合并结果及已提交未推送改动,发现问题则修复、验证并再次复评,最终提交、推送 `dev2`、创建中文 PR 到 `beta`,并再次拉取远端最新代码。 -- 任务守卫:`beta-sync-review-dev2-20260911`,模式 `standard`;开始时工作树干净,无受保护的既有脏路径。 -- 本地基线:`dev2@68df13503c07b20e68cdac072fa381037f672461`。 -- beta 合并目标:`origin/beta@1e7d79ef29fd7568f376fe571bde0bd4cd7c6838`。 -- 合并结果:`git merge --no-ff --no-commit origin/beta` 自动完成,无冲突;`MERGE_HEAD` 指向上述 beta 提交。 -- 回滚:提交前可使用 `git merge --abort`;提交后使用任务守卫创建的恢复标签回退。 -- 当前唯一下一动作:完成最终复评后调用任务守卫 `finish`,随后推送分支和恢复标签、创建中文 PR,并拉取远端最新状态。 - -## 评审范围与既有覆盖 - -### beta 增量 - -- beta 增量包含主题 profile、目录缓存、导入导出安全边界、语义颜色绑定、播放速度选择弹窗、TV 播放加载状态、焦点资源、备份偏好过滤及相关测试和文档。 -- 主题导入安全问题已由 `docs/beta-sync-review-20260908.md` 记录修复和验证,包括 JSON 大小与深度限制,以及非公网地址和 IPv4 映射地址过滤。 -- 播放速度选择与 TV 列表滚动分别由 `docs/playback-speed-choice-20260908.md` 及后续 `ChoiceDialog` 测试覆盖。 -- beta 增量与本地提交的 6 个焦点相关路径没有内容合并冲突;最终合并树相对 `origin/beta` 只保留本地焦点改动。 - -### 本地已提交未推送改动 - -本地提交 `68df13503c07b20e68cdac072fa381037f672461` 涉及: - -- `HomeActivity.java` -- `VodActivity.java` -- `FolderFragment.java` -- `TypeFragment.java` -- `VodActivityCategoryEdgeTest.java` -- `HomeCategoryNavigationSourceTest.java` - -改动为首页和独立点播分类切换增加 generation 校验、精确移除待执行 Runnable、Fragment 隐藏及销毁时失效异步焦点请求,并防止旧页面回调抢占新页面焦点。 - -## 首轮评审与验证 - -- 已检查页面切换、分类横向边界、Fragment transaction、隐藏与销毁生命周期、pending content focus、scroll-to-top 回调和 Activity 暂停/销毁清理。 -- 未发现越界、空指针、无限回调、旧页面抢焦点或 beta 合并导致本地修复丢失的阻断问题。 -- `git diff --cached --check` 通过,未发现冲突标记。 -- 定向 Gradle 验证结果为 `BUILD SUCCESSFUL`,耗时 1 分 26 秒,共 114 个 actionable tasks,其中 16 executed、16 from cache、82 up-to-date。 -- Leanback `VodActivityCategoryEdgeTest`:3 项通过,0 failures、0 errors、0 skipped。 -- Mobile 定向测试:77 项通过,0 failures、0 errors、0 skipped。覆盖首页分类导航、主题 profile/catalog/import/export、主题控制器、播放速度弹窗、ChoiceDialog、播放器 ownership 和 TV loading progress。 -- `compileLeanbackArm64_v8aDebugJavaWithJavac` 与 `compileMobileArm64_v8aDebugJavaWithJavac` 均通过。 -- 构建仅出现既有 Room query mismatch、deprecated API、unchecked operation 和 32 位 native library 提示,没有新增编译或测试失败。 - -## 验证后复评 - -- 验证后重新核对最终索引相对 `origin/beta` 的 6 个本地差异路径。 -- generation 在每次新请求和失效操作中更新,异步 holder、滚动及内容焦点回调均校验当前 generation 和 Fragment 可见性。 -- `onPause`、`onDestroy`、`onDestroyView`、Fragment 隐藏及类别切换均会使旧请求失效。 -- 合并结果保留 beta 的主题、播放速度、加载状态、资源焦点和备份过滤改动,同时保留本地 stale callback 防护。 -- 当前未发现可执行阻断问题,复评结论为通过。 - -## 验证边界 - -- 未执行真�实电视设备上的逐帧遥控器焦点验证、实际播放网络场景或主题编辑器人工视觉验收。 -- 本地证据覆盖源码契约、相关单元测试、Android 资源处理和双端 Java 编译,不将其扩大解释为完整设备验收。 - -## 后续动作 - -1. 使用任务守卫原子提交合并结果和本文档,并创建恢复标签。 -2. 推送 `dev2` 和新恢复标签。 -3. 创建目标为 `beta` 的中文 PR。 -4. 再次拉取远端最新代码并核对分支、PR 和工��作树状态。 - -## 后续拉取与最终复评(2026-09-11) - -- 2026-09-11 21:43 CST 已执行 `git fetch --prune origin beta`;当前 `origin/beta` 为完整提交 `be1b02e06b22a4fa2f08c791555536e3e6154c95`,没有发现新的远端 beta 提交。 -- 当前 `HEAD` 为完整提交 `3a2b9d39e8bd3314027e5ce8e709c17e75a21236`,其父提交正是 `origin/beta`;`git merge --ff-only origin/beta` 返回 `Already up to date`,无新增合�并、无冲突。 -- 复用本文件此前对 beta 增量的评审与验证记录:此前合并提交及其相关 beta 改动均已成为 `origin/beta` 的祖先。本次相对最新 beta 的唯一未合入提交是 `3a2b9d39e8bd3314027e5ce8e709c17e75a21236`(`修复电视直播屏显重影`),实际差异仅为: - - `app/src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java` - - `app/src/testMobile/java/com/fongmi/android/tv/ui/activity/LiveActivityLayoutTest.java` -- 最终复评�检查 `showControl`/`hideControl` 生命周期:控制栏显示前始终隐藏旧 `mBinding.widget.top`,再由共享 `PlayerOsdController` 显示标题、分辨率和时间;控制栏隐藏时同时隐藏旧栏并关闭共享 OSD;源码中没有其他路径重新显示该旧栏。未发现空指针、状态遗漏或与 beta 最终树的合并问题。 -- 定向验证已通过: - - `bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.activity.LiveActivityLayoutTest :app:compileLeanbackArm64_v8aDebugJavaWithJavac --no-daemon --console=plain`:`BUILD SUCCESSFUL`,98 actionable tasks(7 executed、91 up-to-date)。 - - `git diff --check origin/beta...HEAD`:通过。 -- 结论:本轮复评通过,无需新增修复;当前任务改动可提交并推送。 diff --git a/docs/beta-sync-review-dev2-20260912.md b/docs/beta-sync-review-dev2-20260912.md deleted file mode 100644 index 35a4f2a44f..0000000000 --- a/docs/beta-sync-review-dev2-20260912.md +++ /dev/null @@ -1,199 +0,0 @@ -# dev2 合并 beta 最新代码与代码复评(2026-09-12) - -## Recovery anchor - -- **目标:** 拉取远端最新 `beta`,合并到 `dev2`,评审 beta 增量及已提交未推送改动;发现问题时最小修复并验证、再次复评,最终提交、推送 `dev2`、创建中文 PR 到 `beta`,并再次拉取远端最新代码。 -- **当前恢复锚点时间:** 2026-09-12 18:10 CST(Asia/Shanghai,UTC+08:00)。 -- **当前任务守卫:** `beta-sync-review-dev2-20260912-followup`,模式 `standard`;启动时工作树干净,无受保护的既有脏路径;范围为 `FFMPEG_MODE_SWITCH.md`、`app`、`docs`。 -- **本地基线:** `dev2@20dcf312452c7ae95bfe0fd9df85b15d5890bd50`,即此前已提交但尚未推送的缓冲卡死恢复改动。 -- **远端 beta:** 已执行 `git fetch origin beta dev2`,目标为 `origin/beta@07fb0b5e13ac56a5cf234fd684ed2932ead4b91c`。 -- **合并结果:** `git merge --no-ff --no-commit origin/beta` 自动完成,无冲突;当前合并结果仍未提交,`MERGE_HEAD` 为 `07fb0b5e13ac56a5cf234fd684ed2932ead4b91c`。 -- **当前有效本地差异:** 相对 `origin/beta` 仅为 `PlayerManager.java`、`ExoBufferingStallWatchdog.java`、`PlayerManagerLifecycleSourceTest.java` 和 `ExoBufferingStallWatchdogTest.java` 四个路径;beta 侧新增行为由此前 dev1/dev4 复评记录覆盖。 -- **已完成动作:** 发现并修复“普通 `STATE_BUFFERING` 未启动看门狗”、非 Exo 误接线,以及轮询先判断超时导致迟到回退 seek 误判三个问题;定向单测和验证后复评均通过。 -- **未验证风险:** 未在真实设备上执行网络断流、遥控器 seek、硬解码器切换和逐帧视觉验收;源码测试覆盖不等价于实机放量验证。 -- **当前交付结果:** 已完成 task guard 原子提交、恢复标签、`dev2`/标签推送、中文 PR #262,以及最终远端拉取核对。 -- **唯一下一动作:** 无;本轮代码评审、修复、验证、提交、推送、PR 和最终同步均已闭合。 - -## 合并范围与提交台账 - -### beta 本轮新增提交 - -相对本地基线 `4ee05ac28cf3ba8f869f6e2d3e0cc99743b925b4`,`origin/beta` 新增以下完整提交: - -| 完整 commit | 内容 | disposition | -| --- | --- | --- | -| `e8056e5ad412cd0ec141fff0ee12aa56e582fbfa` | 为 TV 关于页“检查更新”路径补充上键焦点目标。 | 已在最终树复核;`dialog_about.xml` 引用有效,未改变点击监听。 | -| `e82f50cbaa4b3c16edda98fcf0d2f5e368b2ef0a` | 为 TV 关于页“我已知悉”按钮补充上键焦点目标。 | 已在最终树复核;与 `githubProxy` 共用 `checkUpdate` 上键目标,无文件冲突。 | -| `30fff5f4ff80fbb90cb2852e78f08147c74cd4d8` | 为关于页三个主要操作引入 TV 聚焦/按下态背景、文字和图标资源。 | 已在最终树复核;4 个 XML 资源均可解析,状态颜色和资源引用闭合。 | -| `7c325e4a04891fc1df224d359039998500ec4235` | 合并 PR #257“修复 TV 关于页面焦点导航与高亮”。 | 合并承载提交;无未解决冲突,保留上面 3 个提交的最终树。 | - -### 本地已提交未推送改动 - -| 完整 commit | 内容 | disposition | -| --- | --- | --- | -| `8f8797462082f0667341cdf430bf1fa70a73d39f` | 将剧幕主题剧照横向列表容器统一为 124dp,并增加对应源码契约测试。 | 与 beta 无内容冲突;最终树仍保留改动,已在本轮定向测试中复验。 | - -当前 `git log origin/dev2..HEAD` 可达链中其余 15 个提交均已被 `origin/beta` 包含,且由此前 dev1/dev2/dev3/dev4 beta 评审文档覆盖;本轮不重复执行相同模块的全量复评,仅复核它们在最终合并树中的可达性和无冲突状态。 - -## 首轮评审 - -### TV 关于页焦点与高亮 - -- `checkUpdate`、`githubProxy` 位于同一横向操作行;`githubProxy` 的 `android:nextFocusUp="@id/checkUpdate"` 使右侧按钮向上返回左侧主操作,不依赖设备的空间焦点猜测。 -- `confirm` 的 `android:nextFocusUp="@id/checkUpdate"` 使底部确认按钮向上回到主操作行;三个按钮仍保留原有文案、点击监听和布局尺寸。 -- 两个 TonalButton 使用 `about_primary_action_bg`/`about_primary_action_text`:聚焦/按下时为深蓝背景和白色文字,未聚焦时为浅色 Tonal 样式,禁用态保留 alpha;未改变其他对话框共用的 `dialog_tonal_button_*` 资源。 -- `updateSettings` 使用独立的 `about_primary_icon_button` 和 `about_primary_icon_tint`,聚焦/按下时显示深蓝背景和白色图标,默认态保持透明背景和灰色图标;点击逻辑仍由 `AboutDialog.show()` 绑定。 -- 已检查资源 XML、ID 引用、焦点目标、`AboutDialog` 点击回调以及 `MaterialButton` 类型/样式兼容性,未发现空引用、资源命名冲突、点击行为回归或焦点环路问题。 - -### TMDB 剧照栏 - -- `TmdbDetailActivity.applyCinemaDetailTemplate()` 只在剧幕模板分支把 `episodePhotoList` 固定为 124dp;其它横向栏和默认模板未改变。 -- `TmdbPhotoAdapter` 的剧照卡片高度同为 124dp,因此容器不会为卡片预留额外底部空间;现有剧照加载、点击和横向导航代码未被改动。 -- 与 beta 变更没有文件交集,合并后相对 `origin/beta` 仍仅有该 Java 文件及其契约测试两条差异路径。 - -### 合并完整性 - -- `git ls-files -u` 无输出,未发现未解决合并路径。 -- `git diff --cached --check` 与 `git diff --check` 均通过。 -- 首次定向 Gradle 客户端会话未返回最终输出,不能作为证据;检查确认进程已终止且没有 OOM 记录后,使用 `--max-workers=1` 重新执行了同范围验证并保存完整日志。 - -## 验证记录 - -开始每次 Gradle/资源任务前均检查实际进程;未发现其它 `gradle/gradlew`、`assemble`、`bundle`、`cargo build/package` 或前端 build/package 任务,因此没有并发打包,未需排队。 - -1. 移动端资源处理、Java 编译和定向单测: - ```text - bash ./gradlew :app:processMobileArm64_v8aDebugResources :app:testMobileArm64_v8aDebugUnitTest \ - --tests com.fongmi.android.tv.ui.dialog.AboutDialogLayoutTest \ - --tests com.fongmi.android.tv.ui.activity.TmdbDetailActivityLayoutTest \ - --no-daemon --max-workers=1 --console=plain - ``` - 结果:`BUILD SUCCESSFUL in 1m 6s`,87 个 actionable tasks(13 executed,74 up-to-date);`AboutDialogLayoutTest` 10 项通过,`TmdbDetailActivityLayoutTest` 121 项通过,均为 0 failures、0 errors、0 skipped。日志:`/tmp/beta-sync-review-dev2-20260912-focused.log`。 -2. Leanback 共享资源处理: - ```text - bash ./gradlew :app:processLeanbackArm64_v8aDebugResources \ - --no-daemon --max-workers=1 --console=plain - ``` - 结果:`BUILD SUCCESSFUL in 30s`,54 个 actionable tasks(1 executed,6 from cache,47 up-to-date)。日志:`/tmp/beta-sync-review-dev2-20260912-leanback-resources.log`。 -3. 使用 Python `xml.etree.ElementTree` 逐个解析 4 个新增资源及 `dialog_about.xml`,5 个文件均输出 `XML_OK`。 -4. 定向评审前后任务守卫检查均通过;无未解决路径、无冲突标记、无越界变更。 - -## 验证后复评 - -- 最终暂存树相对 `origin/beta` 只有:`TmdbDetailActivity.java`、`TmdbDetailActivityLayoutTest.java` 两个本地差异,以及 beta 的 5 个关于页资源/布局差异;没有 beta 覆盖本地修复的情况。 -- 重新核对四个 beta 完整提交和本地完整提交的可达关系:本地 TMDB 提交为唯一 `LOCAL_ONLY`,四个 beta 提交均已进入合并树;其它历史本地链提交均已在 beta,且此前评审覆盖。 -- 再次检查 `dialog_about.xml` 的 `checkUpdate`、`githubProxy`、`confirm`、`updateSettings` 关系和资源状态选择器,焦点目标、点击入口、颜色状态和默认态均保持预期。 -- 再次检查剧照容器 124dp、适配器卡片 124dp 及对应契约测试,未发现因合并导致的回退。 -- **复评结论:通过,无需修复。** - -## 接受标准与回滚 - -1. `origin/beta@7c325e4a04891fc1df224d359039998500ec4235` 已无冲突合入最终树。 -2. 本地已提交未推送的 `8f8797462082f0667341cdf430bf1fa70a73d39f` 保留,且其行为和测试已复验。 -3. 关于页资源、焦点路径、TMDB 剧照栏及既有已评审链均完成最终树复审;定向资源/测试验证通过。 -4. 在当前合并树和本文档均通过 task guard 后,使用 `task_guard.sh finish` 原子提交并创建唯一恢复标签。 -5. 推送 `dev2` 和恢复标签,创建目标为 `beta` 的中文 PR,最后重新拉取远端并核对分支、PR、工作树状态。 - -回滚方式:提交前执行 `git merge --abort`;提交后使用本任务创建的 annotated recovery tag,或对合并提交执行 `git revert -m 1 `。 - -## 关闭记录(2026-09-12 13:50 CST) - -- 原子合并提交:`4353f020eb541ee74a2be98f1b5ce20eef3f4021`(`merge: 合并 beta 最新代码并完成 dev2 复评 (2026-09-12)`)。 -- 原始任务恢复标签:`recovery/beta-sync-review-dev2-20260912/20260912134415-4353f020eb54`,已推送。 -- `dev2` 已推送至 `origin`;在首次 PR 创建失败后,使用显式 head `Silent1566:dev2` 成功创建中文 PR [#258](https://github.com/Silent1566/webhtv/pull/258),目标分支为 `beta`。 -- 最终 `git fetch --prune origin && git pull --ff-only origin dev2` 返回 `Already up to date`;当时 `HEAD == origin/dev2 == 4353f020eb541ee74a2be98f1b5ce20eef3f4021`,`origin/beta == 7c325e4a04891fc1df224d359039998500ec4235`。 -- GitHub 最终核对:PR #258 为 `OPEN`、非草稿、`mergeStateStatus=CLEAN`,head 为 `dev2@4353f020eb541ee74a2be98f1b5ce20eef3f4021`,base 为 `beta@7c325e4a04891fc1df224d359039998500ec4235`;PR 包含本地 TMDB 修复和本次合并复评提交。 - -## 状态 - -- [x] 拉取 `origin/beta` 最新代码并确认当前目标提交。 -- [x] 无冲突合并 beta。 -- [x] 评审 beta 增量和已提交未推送的本地 TMDB 改动。 -- [x] 完成移动端定向测试、Leanback 资源处理和验证后复评。 -- [x] `task_guard.sh finish` 原子提交并创建恢复标签。 -- [x] 推送 `dev2`/恢复标签并创建中文 PR 到 `beta`。 -- [x] 最后拉取远端最新代码并核对交付状态。 - -## 2026-09-12 后续 beta 合并与缓冲卡死复评(18:10 CST) - -### 合并基线与既有评审复用 - -- 本轮先执行 `git fetch origin beta dev2`;远端 `origin/beta` 为完整提交 `07fb0b5e13ac56a5cf234fd684ed2932ead4b91c`,`origin/dev2` 为 `38ff48d8d12a49cb4edbd08514e6342aaf2099ac`。 -- 本地唯一未被 `origin/beta` 包含的生产提交为 `20dcf312452c7ae95bfe0fd9df85b15d5890bd50`(`fix(player): recover from buffering stalls`);此前 TMDB、Exo 队列、关于页、分集详情及脚本改动均已进入 beta 或由既有 dev1/dev2/dev3/dev4 复评记录覆盖。 -- `git merge --no-ff --no-commit origin/beta` 无冲突,`git ls-files -u` 无输出。合并后相对 `origin/beta` 的初始有效差异为该提交的 3 个路径;修复后扩展为下列 4 个路径,未触及保护路径: - - `app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java` - - `app/src/main/java/com/fongmi/android/tv/player/exo/ExoBufferingStallWatchdog.java` - - `app/src/test/java/com/fongmi/android/tv/player/PlayerManagerLifecycleSourceTest.java` - - `app/src/test/java/com/fongmi/android/tv/player/exo/ExoBufferingStallWatchdogTest.java` - -### beta 增量完整提交处置 - -相对上次 dev2 复评基线 `7c325e4a04891fc1df224d359039998500ec4235`,本次 `origin/beta` 可达增量的完整提交均已核对;功能提交沿用已有复评,合并/文档提交不引入未覆盖生产行为: - -| 完整 commit | 处置 | -| --- | --- | -| `f32787745c0dd0b587c5ec91751deb5d519cf339` | 构建安装脚本;已由此前 beta 复评覆盖,当前树无 dev2 独有差异。 | -| `4269dc5e83b2dafe29052b341a85f00ff17b0f5c` | 构建安装脚本 Leanback 参数;已由此前 beta 复评覆盖。 | -| `2bc1244a09ca5d0bd9992a4c5e617346827fe8a9` | Exo 短剧队列稳定策略;已有 E-SP8/前轮复评覆盖。 | -| `8f8797462082f0667341cdf430bf1fa70a73d39f` | TMDB 剧照栏;已由本任务前轮复评覆盖,并已进入 beta。 | -| `42d6abe02e962e3d92e156ab7e5f994a7026b1f1` | TMDB 剧幕海报圆角;已有 beta 复评覆盖。 | -| `b0f0343664502854ade683cdf0702d55a3ea32d3` | TMDB 剧幕海报完整显示;已有 beta 复评覆盖。 | -| `4353f020eb541ee74a2be98f1b5ce20eef3f4021` | dev2 合并承载提交;不新增未评审生产路径。 | -| `38ff48d8d12a49cb4edbd08514e6342aaf2099ac` | dev2 收口文档;无运行时代码。 | -| `81435e97eafabd64727412169ead107687f7325c` | PR #258 合并承载提交;保留已评审最终树。 | -| `667992b22e917735be5e5932ab33ac5293a4953c` | 分集客串演员圆角;由 `docs/beta-sync-review-dev4-20260912.md` 覆盖。 | -| `f1c15ef025601ab3fb3cfb2316fdafb650bcbae1` | dev1 合并承载提交;由 dev1 复评记录覆盖。 | -| `ccd608c503bed84711bb1bc75875c030b6be5ce5` | PR #259 合并承载提交;保留已评审最终树。 | -| `c2dcf52b5676eef00026f55f013eb58ae522804f` | 移除废弃 Exo FFmpeg 模式设置;由 dev1 后续复评记录覆盖。 | -| `6a002d29f74e2444c5372f53d361c67e018e0737` | 分集客串演员横向卡片;由 dev4 复评记录覆盖。 | -| `d42f2de6c803fda57f169740cd55ac6efd1154c5` | dev1 收口文档;无额外未覆盖生产行为。 | -| `b6b18ac1dd2f248c3001b1802ac285622620b6fa` | 移动端分集详情底部滚动;由 dev4 复评记录覆盖。 | -| `cf8c95b1053bf89be975692fbd8765fd700d2799` | dev4 合并承载提交;无新的未评审生产路径。 | -| `fe08b1c167618fa0d40148668a5e9e78a3a597e9` | dev4 复评文档;无运行时代码。 | -| `4a669575963614565cbde6a1e85a813792b8ec0a` | PR #261 合并承载提交;保留已评审最终树。 | -| `07fb0b5e13ac56a5cf234fd684ed2932ead4b91c` | PR #260 合并承载提交;保留 dev1 已复评最终树。 | - -### 首轮代码评审发现 - -- `20dcf312452c7ae95bfe0fd9df85b15d5890bd50` 新增 `ExoBufferingStallWatchdog`,并在 `PlayerManager` 添加轮询与超时后的回退链。 -- **问题 1(功能缺口):** 初始实现只在 `seekTo()` 中调用 `armBufferingStallWatchdog()`;正常进入 `Player.STATE_BUFFERING` 时没有启动轮询,因此普通缓冲卡死不会被检测。 -- **问题 2(作用域错误):** 初始 `armBufferingStallWatchdog()`/`checkBufferingStall()` 没有限制 Exo,MPV/IJK 的 seek 或 buffering 状态也可能复用该 Exo 看门狗并错误进入回退链。 -- **问题 3(边界误判):** 初始轮询先调用 `shouldTimeout()`,后调用 `observe()`;在已超过普通超时窗口后发生回退 seek 时,旧位置/缓冲基线可能先触发超时,来不及被识别为 discontinuity。 - -### 修复与验证后复评 - -- `onPlaybackStateChanged(STATE_BUFFERING)` 现在启动看门狗,`READY`/非活动状态及时取消;arm 和 polling 均限制为 Exo,保持 MPV/IJK 原有行为不变。 -- `checkBufferingStall()` 现在先 `observe()` 再 `shouldTimeout()`,使回退 seek/flush 先重建连续性基线,再进入超时判断。 -- `PlayerManagerLifecycleSourceTest` 增加状态接线、Exo 限制和 observe-before-timeout 契约断言;`ExoBufferingStallWatchdogTest` 增加超时窗口后回退 seek 回归用例。 -- 验证前内存:`free -h` 显示 `Mem available 1.9GiB`,满足至少 1.5 GiB;最终验证前再次显示 `Mem available 3.5GiB`,未发现活动构建任务。 -- 最终定向验证命令: - - ```text - bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.player.exo.ExoBufferingStallWatchdogTest' \ - --tests 'com.fongmi.android.tv.player.PlayerManagerLifecycleSourceTest' \ - --no-daemon --max-workers=1 --console=plain - ``` - -- 结果:`BUILD SUCCESSFUL in 40s`,87 个 actionable tasks(6 executed,81 up-to-date);日志:`/tmp/beta-sync-review-dev2-20260912-followup-final.log`。 -- 最终静态复评:相对 `origin/beta` 仅保留上述 4 个路径;无冲突路径、无空白符错误;看门狗仅在 Exo buffering 期间运行,READY/结束/非活动均取消,暂停时按 tick 重置 episode,进度、loading 延迟、discontinuity、episode ceiling 和 fallback 入口均与测试/调用链一致。 -- **复评结论:通过,无剩余 P1/P2 问题。** - -### 当前状态与回滚 - -- [x] fetch `origin/beta`/`origin/dev2` 并确认远端 head。 -- [x] 无冲突合并 `origin/beta@07fb0b5e13ac56a5cf234fd684ed2932ead4b91c`。 -- [x] 复用既有 dev1/dev4 评审覆盖 beta 增量,并评审 `20dcf312452c7ae95bfe0fd9df85b15d5890bd50`。 -- [x] 修复 3 个问题,完成定向单测,验证后再次复评通过。 -- [x] `task_guard.sh finish` 原子提交并创建恢复标签。 -- [x] 推送 `dev2`/恢复标签,创建目标为 `beta` 的中文 PR。 -- [x] 最后拉取远端最新代码并核对分支、PR、工作树状态。 - -### 交付记录 - -- 原子提交:`00c171160ee0b310e3db3874500cdb7228236d45`(`merge: 合并 beta 最新代码并完成 dev2 缓冲卡死复评 (2026-09-12)`)。 -- 恢复标签:`recovery/beta-sync-review-dev2-20260912-followup/20260912181235-00c171160ee0`,已推送。 -- 代码交付提交 `00c171160ee0b310e3db3874500cdb7228236d45` 与文档收口提交 `e47fa3dee3718b8295389daced540fae5edcd52d` 均已推送到 `dev2`。 -- 中文 PR:[#262](https://github.com/Silent1566/webhtv/pull/262),目标 `beta`,状态 `OPEN`、非草稿、`mergeStateStatus=CLEAN`;head 为 `dev2@00c171160ee0b310e3db3874500cdb7228236d45`,base 为 `beta@07fb0b5e13ac56a5cf234fd684ed2932ead4b91c`。 -- 最终执行 `git fetch --prune origin beta dev2 && git pull --ff-only origin dev2` 后应保持 `Already up to date`;最终工作树干净。 -- **任务结论:通过并闭合,无剩余 P1/P2 问题。** diff --git a/docs/beta-sync-review-dev2-20260913.md b/docs/beta-sync-review-dev2-20260913.md deleted file mode 100644 index d743a68ef5..0000000000 --- a/docs/beta-sync-review-dev2-20260913.md +++ /dev/null @@ -1,30 +0,0 @@ -# dev2 beta 合并后复评:2026-09-13 - -## 范围与远端证据 - -- 分支/HEAD:`dev2` @ `db3af69a16ba10e69b78de78ee6e5997d62bf831`。 -- 远端 `origin/beta` @ `0f6912a0c49b9c3ffd91e8e30be3c606b84bbcbb`;`git merge-base HEAD origin/beta` 为同一提交,`git rev-list --left-right --count 0f6912a0c4...origin/beta` 为 `0 0`。因此本轮没有 `beta` 侧新提交需要合入,也无合并冲突。 -- 已提交未推送范围:`origin/dev2...HEAD` 共 3 个提交: - 1. `154e003520a751a19187057f103e1496c5197457` — `chore(binary): sync upstream arm64/armeabi mpv and binary assets`,36 个二进制资源。 - 2. `7dc58af1b0bb28818b23f43748c3ac67f76e0449` — `merge: integrate C4 upstream source round 3 (2026-09-13)`,101 个源码/补丁/测试/布局文件。 - 3. `db3af69a16ba10e69b78de78ee6e5997d62bf831` — `fix(mpv): observe Blu-ray menu active property`,MPV 属性注册与任务文档。 -- PR 状态:`gh pr list --head dev2 --state all` 为空,无既有开放 PR。 - -## 评审覆盖与结论 - -- 二进制同步提交:commit 元数据记录 Arm64 debug 构建通过,armeabi 构建被与二进制差异无关的 Chaquopy TLS/network 阻塞;本地 `scripts/verify_mpv_native_assets.sh --require-elf` 复核通过,双 ABI MPV/FFmpeg 资产与锁定版本、ELF、打包规则一致。图标/favicon 为资源同步,无 Java/API 行为变化。 -- C4 大合并:既有 `docs/C4-main-upstream-merge.md` 记录 26 处冲突按本地行为契约与上游功能并集解决;第二轮与第三轮已执行 Mobile/Leanback Java 编译和受影响/8 项定向单测;本地 native 资产门禁复核通过。第三轮尾段“尚未完成 guard finish/tag”与 Git 实际状态不一致,但当前 HEAD 已包含合并提交 `7dc58af...` 与 recovery tag `recovery/C4-ROUND3-UPSTREAM-SRC2/20260913055718-7dc58af1b0bb`,属文档滞后而非代码缺陷。 -- P9 菜单观察修复:恢复 `observe("disc-menu-active", MPV_FORMAT_FLAG)`,使既有 `dispatchProperty()` 的 `disc-menu-active` 分支可达。已有聚焦 Java 编译通过;本轮复核注册与处理分支同现于 `MpvPlayer.java`,不改状态机、输入转发或 native 行为。 -- 安全审计:当前工作树干净;`git diff --check` 通过;无 unmerged path。守卫因无活动任务报告 `task is not active`,不是工作树安全问题。 -- 结论:现有评审已覆盖当前全部未推送修改,未发现需要修复的代码问题;本次无代码改动。 - -## 验证证据 - -- `git fetch origin beta && git fetch origin dev2`:远端状态与未推送范围已确认。 -- `scripts/verify_mpv_native_assets.sh --require-elf`:通过。 -- `git diff --check`:通过。 -- `git status --short --branch`:干净,当前分支 `dev2`。 - -## 下一步 - -- 推送 `dev2` 与 3 个既有 recovery tag,然后创建 base `beta` / head `dev2` 的中文 PR;PR 合并后按用户要求拉取远端最新代码。 diff --git a/docs/beta-sync-review-dev2-20260914.md b/docs/beta-sync-review-dev2-20260914.md deleted file mode 100644 index 1d1efe14f6..0000000000 --- a/docs/beta-sync-review-dev2-20260914.md +++ /dev/null @@ -1,31 +0,0 @@ -# dev2 beta 合并后复评记录(2026-09-14) - -## Recovery anchor - -- 目标:合并远端 `origin/beta` 最新代码,复评增量及已提交未推送改动,必要时修复并验证,最后提交、推送、创建中文 PR,并拉取远端最新代码。 -- 接受标准:远端 beta 增量已合入且无冲突;相对合并基线的 beta 侧无内容差异;本次改动通过移动端定向测试和 Leanback Java 编译;原子提交、恢复标签、推送、中文 PR、PR 后回拉完成。 -- 当前状态:本记录第一轮复评已收口;后续推送、PR 和回拉待本记录提交后继续。 -- 下一步:推送 `dev2`,创建 base `beta` / head `dev2` 的中文 PR,并在 PR 后拉取远端最新代码。 - -## 第二轮合并与复评(2026-09-14) - -- 远端 `origin/beta` @ `06442ea996fc8e964f51da973507b8ac3fa8d186`;合并前本地 `dev2` @ `d9bcceb9b4119488d51f30394d3364c5e1f20552`。`git merge-tree --write-tree` 无冲突,随后合并生成 `3cb0a94dcdbda0195edf167a4c28321162654a37`。 -- beta 侧新增 `cf1a3eef83e2c207a1627c24b82379dc9f6d9184`(恢复 TV 控制栏确认动作)与文档记录提交 `968aedb88176ede3f9a2cc7965e42134a12266b7`。复评确认无代码问题:TV 按钮监听完整、测试覆盖相关确认链路,且当前合并不缺 beta 内容。 -- 定向验证 `:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.activity.PlayerControlFocusIntegrationTest --tests com.fongmi.android.tv.ui.activity.VideoActivityLayoutTest :app:compileLeanbackArm64_v8aDebugJavaWithJavac` 返回 `BUILD SUCCESSFUL in 35s`;`git diff --check HEAD` 通过,工作区干净。 -- 复评循环第一轮通过,无修复需求;推送、PR 与回拉按交付计划继续。 - -## 评审范围与结论 - -- 远端 `origin/beta` @ `058a5e22d1`;本地 `dev2` @ `8ae8bd1ce6`。`git fetch origin beta` 已执行。 -- 本轮合并 `fcf0285d0e5f2d470999538dad797a54d4a8c690`,无冲突。该 beta 提交只删除两个陈旧 `FfmpegVc1SupportTest` Java 行为断言并更新 `dev1` 评审文档;当前测试仍保留双 ABI `libavcodec.so` 中 VC-1 decoder 的存在性校验,未把依赖接线缺口伪装成已修复。 -- 相对合并基线 `fcf0285d0e5f2d470999538dad797a54d4a8c690`,beta 侧没有非 merge 内容提交;`git diff --stat $(git merge-base dev2 origin/beta)..origin/beta` 为空。当前 `dev2` 侧只有 `1b2dda6b98` 的 3 个路径:leanback/mobile `VideoActivity` 的 `shouldRevealShellWhileLoading()` 让影视原生模式也由播放器窗口表达加载态,以及对应 `VideoActivityLayoutTest` 防回归断言。 -- 复评通过:上述 Java/测试改动与合并基线一致,`git diff --check` 无问题;`VideoActivityLayoutTest` 覆盖移动端行为,Leanback Java 编译覆盖 TV 端同源逻辑。 - -## 验证证据 - -- `git fetch origin beta`:远端状态确认。 -- `git merge fcf0285d0e5f2d470999538dad797a54d4a8c690`:`Merge made by the 'ort' strategy.`,无冲突。 -- `./gradlew :app:testMobileArmeabi_v7aDebugUnitTest --tests VideoActivityLayoutTest`:`BUILD SUCCESSFUL in 24s`。 -- `./gradlew :app:compileLeanbackArm64_v8aDebugJavaWithJavac`:`BUILD SUCCESSFUL in 8s`。 -- `git diff --check`:通过。 -- `git diff --stat $(git merge-base dev2 origin/beta)..origin/beta`:空,证明 beta 增量内容已全部收敛到本地。 diff --git a/docs/beta-sync-review-dev2-20260915.md b/docs/beta-sync-review-dev2-20260915.md deleted file mode 100644 index 1ed5b75e8c..0000000000 --- a/docs/beta-sync-review-dev2-20260915.md +++ /dev/null @@ -1,42 +0,0 @@ -# dev2 合并 beta 后代码复评记录(2026-09-15) - -## 目标 - -同步并核对远端 `beta` 最新状态,评审 `dev2` 中尚未进入 `beta` 的已提交及未提交改动;发现问题时修复并重新验证、复评,最终提交、推送并创建合入 `beta` 的中文 PR。 - -## 基线与范围 - -- 当前分支:`dev2` -- 同步基线:`origin/beta` -- 初始 HEAD:`36ceca9612660a456cb7f6e6c70c26f230b5fb99` -- 本轮功能改动:`chaquo/src/main/python/app.py` -- 本轮记录:`docs/beta-sync-review-dev2-20260915.md` -- 远端引用已于 2026-09-15 14:33 CST 更新。 -- 更新后 `dev2` 与 `origin/beta` 没有已提交差异,当前仅有 `app.py` 的未提交改动,因此无需再次合并,也没有需要重复评审的已提交代码。 - -## 改动内容 - -- 将 Python 蜘蛛加载入口的参数语义从 API 地址改为已经解析完成的源码文本。 -- 由 Python 层直接对源码执行� `textwrap.dedent`,写入缓存文件后加载 `Spider`。 -- 删除 Python 层重复的 HTTP 下载逻辑和未使用的 `requests` 导入。 -- 保持网络请求、HTTP 状态检查、缓存回退及源码获取职责在 Java `Loader` 层,避免 Java 与 Python 两层重复下载。 - -## 评审结论 - -已检查 `chaquo/src/main/java/com/fongmi/chaquo/Loader.java` 与 `chaquo/src/main/python/app.py` 的调用契约。Java 层向 Python 层传入源码和脚本文件名,Python 层只负责规范缩��进、落盘、导入和实例化,职责一致。 - -最终复评未发现阻断问题。首次运行时探针失败来自断言错误地要求生成文件首字符必须是 `class`,而三引号测试输入本身保留了一个合法的开头换行;修正探针后,源码去缩进、写入、导入和实例化均验证通过,生产代码无需修改。 - -## 验证结果 - -- `python3 -m py_compile chaquo/src/main/python/app.py`:通过。 -- `git diff --check`:通过。 -- 最小运行时探针��:通过,确认内联源码可去缩进、写入临时缓存、导入并实例化 `Spider`。 -- `./gradlew :chaquo:compileArm64_v8aDebugJavaWithJavac`:通过,`BUILD SUCCESSFUL`。 -- `bash .codex/scripts/task_guard.sh check`:通过,分支、HEAD、范围、受保护路径及暂存路径均安全。 - -## 风险与回滚 - -- 风险集中在 Java 与 Python 间的源码传递契约;静态编译和最小运行时探针已覆盖该路径。 -- 未执行 APK 打包,因此不涉及 APK 构建前�内存门槛,也没有遗留打包资源需要释放。 -- 可通过本任务恢复标签回滚到提交前状态。 diff --git a/docs/beta-sync-review-dev2-20260919.md b/docs/beta-sync-review-dev2-20260919.md deleted file mode 100644 index 621d0f4a98..0000000000 --- a/docs/beta-sync-review-dev2-20260919.md +++ /dev/null @@ -1,126 +0,0 @@ -# dev2 合并 beta 最新代码与代码复评记录(2026-09-19) - -## 目标 - -合并远端 `beta` 最新代码,评审 `dev2` 已提交但尚未推送的直播源故障自动切换改动及合并后的完整差异;发现问题时修复并重新验证、复评,确认通过后提交、推送、创建目标为 `beta` 的中文 PR,并在 PR 后再次拉取远端最新代码。 - -## 合并基线与范围 - -- 工作分支:`dev2` -- 合并前 HEAD:`5230fbe186611b3c9c4537925c95360c5b4f102c` -- 合并前远端 `origin/beta`:`ca62fe6f5b97a2c0d4e20390ec2b25b7456e83aa` -- 最终合并基线:`10f0b0ab2aedbbd85ef3552b15577023b305989e` -- 最新 beta 提交:`10f0b0ab2aedbbd85ef3552b15577023b305989e`,为 PR321 合并提交。 -- 合并方式:`git merge --no-commit --no-ff origin/beta` -- 合并结果:自动合并成功,无冲突。 -- 任务守卫:`beta-sync-review-dev2-20260919`,模式 `standard`,范围覆盖 `app/`、`catvod/`、`chaquo/`、`docs/`、`gradle/`、`quickjs/`、`scripts/`、`third_party/`。 -- 合并后相对最新 `origin/beta` 仅保留本地 16 个文件、652 行新增和 20 行删除;beta 携带的播放器、TMDB、原生库、锁文件、资源和历史文档均已进入合并树。 - -## 本地已提交未推送改动 - -本地提交链均保留并纳入本次合并提交: - -| 完整 commit ID | 内容 | -| --- | --- | -| `0ce10e8ebf3019e86e2a32d551dd2c1470c91077` | 增加直播源故障自动切换设置和基础 fallback 流程。 | -| `1c2948140a4a9ed219c9fde2be86362d8e4da3de` | 线路耗尽后切换到下一直播源线路。 | -| `6e1afaae899baceb0b87f204277fd5ca7f1c08df` | 抽取并测试 fallback 策略。 | -| `8a4fc98e8a85dc43e90aec372fcd8014e3919932` | 取消错误路径上的过期缓冲超时回调。 | -| `8cde0eaa62de73de87090923ac604e805ab40951` | 从运行时状态初始化直播配置。 | -| `71cdddca31f5bc4881b5d020402a273412f84a3f` | fallback 开启时绕过播放器重复重试。 | -| `96ab4c2ebbafc760be9bcaccfbc41bd53e2a92e6` | 直播源解析失败时进入 fallback。 | -| `6e29d49084addfa4ecff9ab34d05adb91b82e86f` | 保留直播源切换状态。 | -| `ce392002b2d143fb8affc8d9ac33374b3b6a67a9` | 等待直播源解析完成后再决定 fallback。 | -| `5230fbe186611b3c9c4537925c95360c5b4f102c` | 增加设备级直播源 fallback 验证。 | - -## beta 新增内容评审 - -`ca62fe6f5b97a2c0d4e20390ec2b25b7456e83aa` 之后 beta 新增 PR321,完整提交链为: - -- `0bf950d7216fcc581e02198b5639348f965c97f2`:设计无 Key 源内嵌 TMDB 详情路由。 -- `19128b7d18107b5c614078ebdadc863faa2476b5`:增加运行时详情模式和源数据状态策略。 -- `1c4213db0baf24e9da9495edfdbaa9dcab43f527`:允许无 Key 保存 TMDB 模式设置。 -- `6a994fa1acd85cd307bf111bc077f6e5a1385da0`:增加独立详情页 source-only 路由。 -- `b2343d1d1de476226b52bef11b80248e1d5033d9`:接入 Mobile/Leanback 原生增强 source-only 数据。 -- `6e2cde565ae34c1a0da392c7ca93f34cb97aaa99`:收口 source-only 交互入口。 -- `d52fdfc5b2027872779026bad278c1a5c8e9f5d8`:增加目标设备验证。 -- `f585fe06a935211c8146b7242f528eb664159c4e`:修正无 Key 网络任务断言。 -- `c2b1907c3ba7e732a6d4378ec3360502b3d115e4`:收口 C16 实施索引。 -- `7f5379cd73c7ff63142c102426d3453d79fc5440`:修复无 Key 源详情保持离线。 -- `10f0b0ab2aedbbd85ef3552b15577023b305989e`:合并 PR321 到 beta。 - -评审结论: - -- `DetailRuntimeModePolicy` 将配置模式和运行时模式分离;无 Key 且源 payload 不可渲染时回退到影视原生,有可渲染源数据时使用 source-only,避免空详情页。 -- `TmdbSourcePayloadParser` 对 schema、身份、季号、大小、数组、字段类型和能力声明做规范化;`TmdbSourceAvailability` 还会校验 payload 与 bundle、Vod 身份一致,避免错误源数据进入 TMDB 页面。 -- `TmdbSourceAdapter` 只从已验证 payload 构建 UI bundle;`TmdbUIAdapter.loadSource()` 不写匹配缓存、不调用 TMDB 网络服务,季集、相关视频和推荐入口均有 source-only 防线。 -- 有 Key 路径仍保留 source-first、能力补齐、分页和个性化推荐;新增无 Key 分支没有改变在线路径调用顺序。 -- 与本地直播改动的共享文件只有字符串资源和 `LiveActivityLayoutTest`,合并后两端代码及测试契约完整保留。 -- 未发现正确性、兼容性、性能、网络越权、生命周期、作用域或回滚问题。 - -## 第一轮与第二轮复评 - -### 第一轮 - -- 检查本地 10 个提交的直播源解析、播放器 HTTP 错误、缓冲超时、线路/源切换、状态清理及 Mobile/Leanback 对称性。 -- 检查 beta PR321 的 source-only 身份校验、能力规划、TMDB 网络入口和本地 bundle 消费。 -- 合并无冲突,未发现需要修改的代码问题。 -- 初次设备命令误使用了不带序列号的 Gradle connected 任务,导致两台在线设备都执行测试;该命令没有发现测试失败,但不作为最终设备范围证据。 - -### 第二轮 - -- 重新拉取 beta 后发现 PR321 新增内容,放弃旧合并索引,基于 `10f0b0ab2aedbbd85ef3552b15577023b305989e` 重新无冲突合并。 -- 重新核对最终相对 beta 的 16 个本地文件;未发现 beta 新代码覆盖或削弱直播 fallback。 -- 重新检查 source-only 的推荐、视频、外部评分、选集、重匹配和历史刷新入口;source-only 网络防线仍完整,TMDB payload 只读本地数据。 -- 复评结论:通过,无需代码修复。 - -## 验证 - -- `git diff --check origin/beta`:通过。 -- `git diff --check`:通过。 -- Mobile Arm64 定向 JVM 测试与 Leanback Arm64 定向 JVM 测试:`BUILD SUCCESSFUL`。 -- 覆盖直播 fallback 策略、直播 Activity 源码契约、直播布局、TMDB 运行时策略、source-only 接线、payload、交互、source availability、UI adapter 及 VideoActivity 相关测试;选定测试 XML 均为 `failures="0" errors="0"`。 -- Leanback:`compileLeanbackArm64_v8aDebugJavaWithJavac` 通过。 -- 设备 `192.168.50.3:5557`(型号 `SM-N9700`)仅使用以下序列号命令: - - 构建并单独安装 Mobile Arm64 Debug APK 和 AndroidTest APK。 - - `adb -s 192.168.50.3:5557 shell am instrument -w -r -e class com.fongmi.android.tv.ui.activity.LiveSourceFallbackInstrumentedTest com.silent.android.webhtv.test/androidx.test.runner.AndroidJUnitRunner` - - 结果:`LiveSourceFallbackInstrumentedTest` 2/2 通过,`OK (2 tests)`。 -- 设备测试执行期间没有使用未指定设备的 connected Gradle 任务;`5559` 未作为本轮最终设备验证证据。 - -## 上一轮状态与后续动作(历史) - -- 当前状态:代码第二轮复评通过,合并结果待原子提交。 -- 提交后:推送当前 `dev2` 分支,创建 base `beta` / head `dev2` 的中文 PR,PR 描述列出直播源 fallback、设置项、两端实现、测试和兼容性说明。 -- PR 创建后:执行 `git fetch --prune origin beta`,核对最新 beta、当前分支、PR 状态和工作区。 - -## 2026-09-19 第三轮:beta `552b68bb8e` 合并与 C16 复评 - -### 基线与范围 - -- 合并前本地 HEAD:`009f4e68d495174b5d5a37b6e4664282bcd70e35`,包含 15 个相对 `origin/dev2` 尚未推送的提交。 -- 本轮 `origin/beta`:`552b68bb8e781d06ca989abfd8caa96a567e494a`,相对本地上次评估基线新增长按卡片绑定剧集修复和播放会话单确认框修复。 -- 使用 `git merge --no-commit --no-ff origin/beta` 合并,自动合并成功;当前待提交树包含 beta 8 个文件及本地 C16 硬化修复。 - -### 复评发现与修复 - -- 订阅配置加载改为把原始 `Config` 传入凭据接收校验,避免旧加载任务在订阅切换后用 `getConfig()` 把旧 Key 误绑定到新订阅。 -- 同一订阅内替换 Key 时推进凭据 epoch,旧请求的 401/403 不再清掉刚轮换的新 Key;`TmdbService` 在请求入口拒绝已经失效的临时凭据快照。 -- 畸形 JSON 的 ingress 不再原样回传含 `tmdb_api_key` 的文本,防止结构化解析失败时泄漏到日志或缓存。 -- `TmdbUIAdapter.invalidateSubscription()` 和 Leanback 缓存详情入口先刷新有效配置,再捕获新订阅作用域,避免后续推荐/详情请求继续使用旧凭据。 -- 重评 beta 的 `IntroSkipPlayback` 会话租约、长按卡片绑定剧集传递和原 API 兼容委托,未发现新的阻断问题。 - -### 第三轮验证 - -- `git diff --check` 与 `git diff --cached --check`:通过。 -- Mobile Arm64 Debug 定向 JVM 测试:17 个测试类共 248 项,`failures=0 errors=0 skipped=0`,`BUILD SUCCESSFUL`。 -- Leanback Arm64 Debug Java 编译:`BUILD SUCCESSFUL`。 - -### 最新状态与下一步 - -- 当前状态:第三轮复评和定向验证通过,合并结果与修复待由任务守卫原子提交并创建 recovery tag。 -- 提交后:推送当前 `dev2` 分支,创建 base `beta` / head `dev2` 的中文 PR,最后重新拉取远端最新代码。 - -## 回滚 - -- 合并前恢复锚点:`recovery/live-source-fallback-device-verification/20260919155448-5230fbe186`。 -- 本任务提交由任务守卫创建独立 recovery tag;如需回滚,可回到该 tag,或撤销本次合并提交,不影响 beta 已有提交。 diff --git a/docs/beta-sync-review-dev2-20260920-round2.md b/docs/beta-sync-review-dev2-20260920-round2.md deleted file mode 100644 index b52200377e..0000000000 --- a/docs/beta-sync-review-dev2-20260920-round2.md +++ /dev/null @@ -1,29 +0,0 @@ -# dev2 合并 beta 最新代码与复评记录(2026-09-20 第二轮) - -## 目标与范围 - -- 目标:将 `origin/beta@2c0f4234d74ec120d4d0159b6ed539337b506b95` 合入 `dev2`,复评本地已提交未推送代码与合并后的完整差异,确认通过后提交、推送、创建到 `beta` 的中文 PR,最后重新拉取远端。 -- 本地上次合并基线:`035145f9fd4e31998f2008f67b25247ed0419140`(PR #328)。 -- 本轮额外修复提交:`255a8b5cbc6a873c3e1a449dfe9a87975309e035`,解决 MPV 轨道列表 NODE 快照未派生到属性缓存的问题。 -- 合并方式:`git merge --no-commit --no-ff origin/beta`,自动合并成功,无冲突。 -- 合并结果:相对 `origin/beta` 仅保留本轮 MPV 缓存修复的两个文件,因此合并没有回退 beta 的 FOLLOW-1、C18、E-SP9、广告拦截和播放器改动。 - -## 复评结论 - -- beta 增量中最关键的播放器改动为 `8cab95c225c81798a308931e732f326bf460222d`:MPV 在 HLS 去广计划就绪时提前通知。复核后确认其只对有效 removal plan 调用通知,统计仍只记录一次,未覆盖本地 HLS 广告时间线逻辑。 -- beta 的 `BaseConfig` 快照修复、配置历史当前源隐藏、Exo 软解回退与手动解码模式契约均完整进入合并树。 -- 本轮 MPV 修复与 beta 无重叠文件冲突,合并后仍保持 track-list NODE 到 `track-list/count` 和 `track-list//` 的属性派生能力。 -- 复评未发现新的阻断问题,无需追加修复。 - -## 验证 - -- `git diff --cached --check`:通过。 -- Mobile Arm64 Debug Java 编译、Leanback Arm64 Debug Java 编译、Mobile Arm64 AndroidTest Java 编译:`BUILD SUCCESSFUL`。 -- Mobile Arm64 Debug 全量 JVM 单测:4752 项,失败 0、错误 0、跳过 1;`BUILD SUCCESSFUL`。 -- Leanback Arm64 Debug 定向 `MpvPropertyCacheTest`:通过。 -- 本轮 MPV 修复定向测试:`MpvPropertyCacheTest` 4/4 通过。 - -## 状态与下一步 - -- 合并树已通过验证,下一步为原子提交合并结果并创建 recovery tag。 -- 提交后推送 `dev2`,创建 base `beta` / head `dev2` 的中文 PR,最后执行远端 beta/dev2 拉取核对。 diff --git a/docs/beta-sync-review-dev3-20260908-round4.md b/docs/beta-sync-review-dev3-20260908-round4.md deleted file mode 100644 index 35af0bc5f9..0000000000 --- a/docs/beta-sync-review-dev3-20260908-round4.md +++ /dev/null @@ -1,61 +0,0 @@ -# dev3 合并 beta 最新代码并复评全部未推送改动(2026-09-08) - -## Recovery anchor - -- **目标:** 在不丢失 dev3 已提交未推送的 TV 详情焦点修复的前提下,合并远端 `beta` 最新代码,复评合并后的全部本地改动与 beta 增量;如发现问题则最小修复并再次验证、复评,随后提交、推送 dev3、创建中文 PR 到 beta,最后拉取远端最新代码。 -- **任务守卫:** `beta-sync-review-dev3-20260908-round4`,模式 `standard`;开始时工作树干净,无受保护脏路径;范围 `app`、`docs`、`gradle`、`gradlew`、`scripts`、`third_party`。 -- **时间与基线:** 2026-09-08 20:08 CST 开始;本地基线 `dev3@f89c301d65c0c8d4bf6e44a3eab11e6c9c9266dd`;共同祖先 `e09e785d87b95f9da249825a79f7ad0a213f61b6`;执行了 `git fetch origin --prune`,当时 `origin/beta@74e572173fcc9ab7e274a94b6245b8de90b217c1`。 -- **本地已提交未推送改动:** `1c0bd8e1d75e0c8d3a2f420a39fcb5b23e96f48b`(剧集详情照片网格焦点)、`400c059369ead1041e1afbd9fc589bddd51490c3`(炫彩详情关闭后焦点)、`f89c301d65c0c8d4bf6e44a3eab11e6c9c9266dd`(长按详情关闭后焦点)。 -- **beta 合并证据:** `git merge --no-ff --no-commit origin/beta` 自动完成,无冲突;合并暂存树相对 `origin/beta` 仅保留上述本地 5 个焦点相关文件,beta 增量与本地文件无路径交集。 -- **回滚:** 提交前可执行 `git merge --abort`;提交后使用本任务创建的 recovery annotated tag 回退该原子合并提交。 - -## 评审范围与复用证据 - -### beta 增量 - -- beta 从共同祖先至 `74e572173fcc9ab7e274a94b6245b8de90b217c1` 的主题系统、TV 主题资源、导入导出、目录缓存和测试已在 beta 自带的 `docs/beta-sync-review-20260908.md` 中完成首轮发现、修复、定向验证和修复后复评。 -- 该记录明确闭环了两项安全问题:TweakCN 输入复用统一 JSON 大小/深度边界;主题传输拒绝 ULA、映射 IPv4、CGNAT、保留/文档地址等非公网解析结果,并记录了对应测试与 `BUILD SUCCESSFUL`。 -- 本轮确认该评审记录随 beta 原样进入最终合并树;beta 文件与本地 TV 焦点文件无交集,且合并无未解决路径。依据用户约定,对已覆盖且未发生冲突的 beta 改动不重复展开无效复评,仅核对最终树、血缘和测试覆盖。 - -### dev3 未推送改动 - -- `EpisodeDetailDialog.java` 与 `dialog_episode_detail.xml`:TV 剧集详情从海报/剧照/客串网格之间的方向键切换,会识别嵌套焦点并将焦点落到实际卡片;没有可用数据时保留后备路径,并通过 `nextFocus` 补强布局焦点链。 -- `TmdbDetailActivity.java`:炫彩详情弹窗关闭后,按返回 RecyclerView 精确定位被长按的集数;主选集 RecyclerView 在 pending adapter update 时强制恢复祖先布局,并在真实 pre-draw 后恢复焦点;焦点请求在布局计算或 holder 尚未附着时按动画帧有限重试,避免卡在旧 position 或丢焦点。 -- 两个对应测试文件新增了源码/布局契约,覆盖实际调用顺序、生命周期保护、精确 adapter position、重试路径、网格父子焦点和照片网格下导航。 - -## 评审发现与处理 - -### 首轮复评 - -- 未发现 beta 与本地改动的文件交集或合并冲突。 -- 逐处检查了 dismiss 回调的 `binding`、Activity 生命周期、RecyclerView 附着/可见状态,确认无效页面不会抢焦点;独立选集面板保留原有恢复路径,主选集才使用 pre-draw 恢复和 pending-layout 修复。 -- 检查了 `focusTmdbRecyclerItem` 的目标边界、`isComputingLayout` 分支、holder 查找、`requestFocus`/`requestFocusFromTouch` 结果和最大重试次数;未发现无限 post、越界或将 key-up 当作移动的风险。 -- 检查了 Leanback 网格空数据/不可见数据回退、选中位置边界和海报嵌套子焦点;未发现空指针或焦点落在容器而无法继续导航的问题。 - -### 修复后复评 - -- 本轮没有发现需要修复的问题,因此没有新增生产代码或测试修复;复评结论为**通过**。 -- `git diff --cached --check`、`git diff --check` 通过,`git ls-files -u` 无输出,任务守卫检查通过。 - -## 验证 - -- 定向命令: - - ```text - bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.theme.*' \ - --tests 'com.fongmi.android.tv.ui.activity.TmdbDetailActivityLayoutTest' \ - --tests 'com.fongmi.android.tv.ui.adapter.TmdbEpisodeAdapterTest' \ - --tests 'com.fongmi.android.tv.ui.dialog.EpisodeDetailDialogThemeTest' \ - :app:compileLeanbackArm64_v8aDebugJavaWithJavac \ - --no-daemon --console=plain - ``` - -- 结果:`BUILD SUCCESSFUL`;主题测试 34 项、本地 TV 焦点测试 139 项,共 173 项测试均 `failures=0`、`errors=0`、`skipped=0`;Mobile Java 编译和 Leanback Java 编译均通过。 -- 构建输出中的既有 XML 命名空间、字符串格式及 Room 查询提示为 warning,不构成本轮失败,也未修改无关资源。 -- 验证期间再次 `git fetch origin beta --prune`,`origin/beta` 仍为 `74e572173fcc9ab7e274a94b6245b8de90b217c1`,无需重新合并或重复验证。 - -## 关闭动作 - -- 下一动作:执行 `bash .codex/scripts/task_guard.sh finish --verified ... --commit-message ...`,由守卫原子提交合并及本任务记录并创建恢复标签。 -- 提交后:推送当前 `dev3` 分支和新恢复标签;用中文创建目标为 `beta` 的 PR;最后执行 `git fetch --prune origin` 与 `git pull --ff-only origin dev3`,确认工作树和远端分支状态。 diff --git a/docs/beta-sync-review-dev3-20260912.md b/docs/beta-sync-review-dev3-20260912.md deleted file mode 100644 index 93495c3dc3..0000000000 --- a/docs/beta-sync-review-dev3-20260912.md +++ /dev/null @@ -1,108 +0,0 @@ -# dev3 合并 beta 最新代码与复评全部改动(2026-09-12) - -## Recovery anchor - -- **目标:** 合并远端最新 `beta`,覆盖评审当前 `dev3` 相对 `beta` 的全部代码改动(含已提交未推送提交);发现问题则最小修复、验证并再次复评,随后原子提交、推送 `dev3`、创建中文 PR 到 `beta`,最后拉取远端最新代码。 -- **任务守卫:** `beta-sync-review-dev3-20260912`,模式 `standard`;启动时工作树干净,无受保护脏路径;范围 `app`、`docs`。 -- **开始时间:** 2026-09-12 00:45 CST(Asia/Shanghai)。 -- **本地基线:** `dev3@b993795ac88ed723dbb532401890bf408a0c25ac`。 -- **远端目标:** `origin/beta@87ab74014a62382af8786109099ccf95d8b58ff5`;`upstream` 远端不存在 `beta` 引用,不能作为本项目 beta 目标。 -- **远端 dev3 增量:** `origin/dev3@398d6b900b0e684ea96b9d0b353620847c421c64`,必须保留,不能以本地分支覆盖。 -- **共同祖先:** 本地 `dev3` 与 `origin/beta` 为 `be1b02e06b22a4fa2f08c791555536e3e6154c95`;本地 `dev3` 与 `origin/dev3` 的共同祖先为 `1e7d79ef29fd7568f376fe571bde0bd4cd7c6838`。 -- **合并状态:** 已用 `git merge --no-ff --no-commit origin/beta origin/dev3` 自动完成,无冲突;当前仍处于待提交合并状态,`MERGE_HEAD` 包含 `87ab74014a62382af8786109099ccf95d8b58ff5` 与 `398d6b900b0e684ea96b9d0b353620847c421c64`。 -- **回滚:** 提交前执行 `git merge --abort`;提交后使用本任务恢复标签,或 `git revert -m 1 ` 回退合并提交。 -- **当前状态:** 定向验证和验证后复评已通过,待 task guard 原子提交、推送、创建 PR 并完成最终拉取。 -- **回滚锚点:** 提交前仍可 `git merge --abort`;提交后由 task guard 创建的 annotated recovery tag 指向完整合并树。 - -## 合并范围与提交台账 - -### beta 增量(相对 `be1b02e06b22a4fa2f08c791555536e3e6154c95`) - -| 完整 commit | disposition | -| --- | --- | -| `3a2b9d39e8bd3314027e5ce8e709c17e75a21236` | 已实现并复评;修复 Leanback 直播控制栏与共享 OSD 的顶部信息重影,新增 `LiveActivityLayoutTest` 契约测试。 | -| `c36a29038360fb205cbca6c49f835ee65fc874e1` | 文档记录;其验证覆盖上一个功能提交,最终树无额外生产代码。 | -| `87ab74014a62382af8786109099ccf95d8b58ff5` | PR #251 的合并承载提交;保留,不重复拆解已由上表及既有评审覆盖的内容。 | - -beta 自带的 `docs/beta-sync-review-dev2-20260911.md` 已记录 PR #251 的源码复评、`LiveActivityLayoutTest` 和 Leanback Java 编译通过。本轮仍检查最终合并树、调用链和路径关系;beta 增量与本地站点健康/TMDB 改动没有文件交集。 - -### 远端 dev3 增量 - -| 完整 commit | disposition | -| --- | --- | -| `398d6b900b0e684ea96b9d0b353620847c421c64` | 已由远端 dev3 提交并验证;将 TMDB 四个 MaterialButton 的文字从 API 25 易崩溃的布局 inflate 阶段移到 inflate 后设置,新增契约测试。本轮确认最终合并树保留该提交且与 beta、本地站点健康改动无冲突。 | - -### 本地已提交未推送改动(相对 beta) - -| 完整 commit | disposition | -| --- | --- | -| `f17443f5e880053bdeb825001bb964088bf48a49` | 已实现;站点健康报表增加全量清空入口、确认文案和来源契约测试。 | -| `1c70eb0c679226d702e6cad9c4cdd06e5a440994` | 已实现;清空后重新读取报表并刷新 UI,定向单测通过。 | -| `b993795ac88ed723dbb532401890bf408a0c25ac` | 已实现;TV 清空确认框统一使用 `Theme_WebHTV_LightDialog` 与 `LightDialog.apply`,Leanback 定向单测和 debug assemble 已通过。 | - -## 首轮复评 - -### Leanback 直播 OSD(`3a2b9d39e8bd3314027e5ce8e709c17e75a21236`) - -- `LiveActivity.showControl()` 现在无条件隐藏废弃的 `widget.top`,并交给 `PlayerOsdController.setControlsVisible(true)` 输出标题、分辨率和时间,避免 OSD 开关状态切换后留下旧栏造成重影。 -- `PlayerOsdController.render()` 的控制栏分支会停止诊断采样、隐藏底部进度/流量信息,只保留控制栏所需的顶部标题与时间;`start()`、`onStart()`、`onStop()`、`onDestroy()` 的生命周期调用保持成对,未发现刷新循环泄漏或控制栏隐藏后继续显示的问题。 -- `LiveActivityLayoutTest` 对 `showControl()` 的隐藏旧栏和启动共享 OSD 契约已覆盖;未发现 beta 合并覆盖本地其他播放逻辑。 -- **结论:** 未发现阻断问题。 - -### TMDB API 25 修复(`398d6b900b0e684ea96b9d0b353620847c421c64`) - -- 四个按钮仍保持原有点击监听和资源文案,仅将文字和大小写设置移到 `LayoutInflater` 完成后;`TextView` 类型可覆盖 `MaterialButton`,不改变行为。 -- `TmdbSourceDialogInflationContractTest` 同时约束布局不再在四个按钮上解析 `android:text`、Java 代码补齐四个文案以及 `textAllCaps=false`,能够覆盖记录的 API 25 二进制 XML 崩溃路径。 -- **结论:** 既有验证已覆盖,最终树复核通过,无需重复修复。 - -### 站点健康报表(`f17443f5e880053bdeb825001bb964088bf48a49`、`1c70eb0c679226d702e6cad9c4cdd06e5a440994`、`b993795ac88ed723dbb532401890bf408a0c25ac`) - -- 全量清空入口在报表为空时禁用并降低透明度;确认后调用 `SiteHealthStore.clear()`,再通过根视图队列重新读取 `SiteHealthStore.report()` 后渲染,避免在确认回调中直接修改当前行容器。 -- 单站点清空仍使用带站点名称的确认文案和 `SiteHealthStore.clear(row.siteKey)`;两类操作都复用统一的浅色确认框样式,三套资源文案齐全。 -- `SiteHealthStore.clear()` 的全量语义与“清空全部站点健康样本”文案一致;当前报表按当前 CID 展示,但该 API 清除的是健康存储整体,属于该入口的明确全量行为,不是隐藏的部分清除。 -- **结论:** 未发现阻断问题。 - -## 验证与验证后复评 - --执行前已检查实际运行中的 Gradle/打包进程:仅有 Gradle daemon,没有其他正在运行的 Gradle 构建、打包、`make` 或 `ninja` 命令,因此未发生需要排队的任务。 - -实际执行: - -```text -bash ./gradlew :app:testLeanbackArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.setting.SiteHealthReportSourceTest' \ - --tests 'com.fongmi.android.tv.ui.dialog.TmdbSourceDialogInflationContractTest' \ - --tests 'com.fongmi.android.tv.ui.activity.LiveActivityLayoutTest' \ - :app:testMobileArm64_v8aDebugUnitTest \ - :app:compileLeanbackArm64_v8aDebugJavaWithJavac \ - --no-daemon --console=plain -``` - --结果:`BUILD SUCCESSFUL in 37s`,114 actionable tasks(28 executed、86 up-to-date)。 - - `SiteHealthReportSourceTest`:3 项通过,0 failures、0 errors、0 skipped。 - - `TmdbSourceDialogInflationContractTest`:1 项通过,0 failures、0 errors、0 skipped。 - - `LiveActivityLayoutTest`:5 项通过,0 failures、0 errors、0 skipped。 - - `compileLeanbackArm64_v8aDebugJavaWithJavac` 与 Mobile Java 编译均通过;只有既有 32 位 native library、deprecated API 等构建警告,无新增失败。 -- 另行执行 `git diff --cached --check`、`git ls-files -u` 和任务守卫检查,均通过;当前无未解决冲突。 - -### 验证后再次复评 - -- 重新检查合并暂存树相对 `origin/beta` 的 9 个差异路径:站点健康报表 6 个路径、TMDB API 25 修复 3 个路径;Leanback OSD 的 2 个 beta 路径和远端 dev3 的 TMDB 提交均保留在最终树。 -- `LiveActivity.showControl()` 仍在调用共享 OSD 前隐藏旧 `widget.top`;共享 OSD 的控制栏分支继续只显示标题/分辨率/时间,控制栏隐藏后由 `setControlsVisible(false)` 恢复用户 OSD 状态,未发现重复显示或刷新生命周期遗漏。 -- TMDB 四个 MaterialButton 的布局文本仍全部移除,Java inflate 后设置文案和 `setAllCaps(false)` 均保留;点击监听未被改动,API 25 防护未回退。 -- 站点健康单站点与全量清空仍分别调用正确的 `SiteHealthStore.clear` 重载,确认框样式、三套文案、清空后的重新读取/渲染均保留;`clearAll` 为空报表禁用,未发现误清空或 UI 更新顺序问题。 -- 复评结论:**通过,无需修复。** - -## 当前状态与下一动作 - -- [x] 抓取 `origin/beta`、`origin/dev3` 并确认 `upstream/beta` 不存在。 -- [x] 保留远端 dev3 未推送到本地的 TMDB 提交。 -- [x] 合并 `origin/beta` 与 `origin/dev3`,无冲突。 -- [x] 完成首轮静态复评,未发现需要修复的问题。 -- [x] 检查无其他实际构建任务并运行定向验证,记录完整结果。 -- [x] 验证后再次复评最终树,未发现需要修复的问题。 -- [ ] 使用 task guard 原子提交并创建恢复标签。 -- [ ] 推送 `dev3`,创建中文 PR 到 `beta`。 -- [ ] 最后拉取远端最新代码并核对分支、PR、工作树。 - -**唯一下一动作:** 执行 task guard `finish`,原子提交合并树与本任务记录并创建 recovery tag。 diff --git a/docs/beta-sync-review-dev3-20260918.md b/docs/beta-sync-review-dev3-20260918.md deleted file mode 100644 index 60225ac625..0000000000 --- a/docs/beta-sync-review-dev3-20260918.md +++ /dev/null @@ -1,149 +0,0 @@ -# dev3 同步 beta 与代码评审记录(2026-09-18) - -## 目标 - -同步远端 `beta` 最新代码,评审当前任务的未提交改动及同步引入的已提交改动,完成针对性验证与复评后提交、推送,并创建合入 `beta` 的拉取请求。 - -## 同步基线 - -- 工作分支:`dev3` -- 同步前提交:`d014ea2a21620d221145a845be93776ac8a21b0e` -- 同步的远端 `beta`:`58f53536fd998a77a060718d478fb08c5933607d` -- 同步方式:快进合并 -- 同步结果:成功,无冲突 - -## 改动内容 - -### 当前任务改动 - -- 将站点健康报表对话框调整为接近全屏的自适应尺寸。 -- 横屏保留 `24dp` 外边距,竖屏保留 `16dp` 外边距。 -- 宽高均保留最小值保护,避免极端屏幕尺寸产生无效窗口参数。 -- 增加源码约束测试,覆盖横竖屏边距、宽高计算、装饰视图边距及旧比例尺寸移除。 - -### 从 beta 同步的改动 - -- 前台崩溃时恢复显示自定义故障恢复界面,而不是静默终止。 -- 增加对应的崩溃恢复配置测试。 - -## 评审结论 - -### 第一轮评审 - -- 站点健康报表尺寸改动范围集中,没有改变报表数据、筛选、排序或清理行为。 -- 尺寸计算使用现有 `ResUtil` 屏幕尺寸与 dp 转换能力,横竖屏策略明确。 -- `Math.max(1, ...)` 可避免异常环境下产生零或负尺寸。 -- 同步自 `beta` 的崩溃恢复改动仅恢复前台崩溃的自定义恢复页面,并有对应测试约束。 -- 未发现需要修改的问题。 - -### 验证 - -执行: - -```text -./gradlew :app:testMobileArm64_v8aDebugUnitTest \ - --tests com.fongmi.android.tv.setting.SiteHealthReportSourceTest \ - --tests com.fongmi.android.tv.ui.activity.CrashActivityDetailsTest -``` - -结果:`BUILD SUCCESSFUL`,87 个任务中 6 个执行、81 个为最新状态。 - -同时执行 `git diff --check`,通过。 - -### 第二轮复评 - -- 重新核对相对 `origin/beta` 的完整任务差异,改动仍仅涉及站点健康报表窗口尺寸及其测试。 -- 测试覆盖当前任务和本次同步的崩溃恢复行为。 -- 未发现正确性、兼容性、性能或作用域问题。 -- 复评通过,可以提交并创建合入 `beta` 的拉取请求。 - -## 回滚方式 - -如需回滚当前任务,可还原站点健康报表对话框原有比例尺寸,并删除本次新增的尺寸约束测试;从 `beta` 同步的提交不属于当前任务回滚范围。 - -## 本轮同步复评补充(2026-09-18) - -### 同步结果 - -- 已将 `origin/beta` 合入 `dev3`,合并提交为 `5791c9c713853ffdf1ff6550472468f0afd7faed`。 -- 合并过程由 `ort` 策略自动完成,没有未解决冲突。 -- `origin/beta` 已是当前 `HEAD` 的祖先。 - -### 复评发现与修复 - -1. 站点健康报表的“最近失败”排序只统计搜索、详情、解析和播放阶段,遗漏本轮新增的首页与分类阶段。现已把 `row.home.lastFailAt` 和 `row.category.lastFailAt` 纳入排序,并增加源码约束测试。 -2. HLS 兼容规则会缓存 `RuleConfig` 编译结果,但保存用户广告规则或切换默认规则启用状态时只失效 `RuleConfig`,没有同步失效 `HlsRuleConfig`。现已在 `UserAdRuleStore.save` 与 `DisabledDefaultRuleStore.save` 中同步调用 `HlsRuleConfig.invalidate()`,避免规则变更后继续使用旧的 HLS 编译缓存,并增加回归测试。 - -### 最终验证 - -- Mobile arm64-v8a 定向单元测试:`BUILD SUCCESSFUL`,87 个 Gradle actionable tasks,5 executed、82 up-to-date。 -- Leanback arm64-v8a 定向单元测试:`BUILD SUCCESSFUL`,87 个 Gradle actionable tasks,6 executed、81 up-to-date。 -- 覆盖测试:`SiteHealthReportSourceTest`、`SiteHealthReportDialogSourceTest`、`HlsRuleConfigTest`、`MpvHlsAdblockGateTest`、`ExoParserAdblockGateTest`、`M3u8LegacyFallbackTest`、`HlsAdblockPipelineTest`。 -- `git diff --check` 通过。 -- `task_guard.sh check` 通过。 -- 合并后的构建仅保留既有 deprecated API、unchecked operation 和 32 位原生库提示,没有新增编译或测试失败。 - -### 复评结论 - -同步内容与 `dev3` 当前站点健康统计、HLS 去广告兼容逻辑可以共存。本轮发现的两个缓存/排序遗漏均已修复并由 Mobile 与 Leanback 两个变体的定向测试覆盖,可以进入提交、推送及创建合入 `beta` 的拉取请求阶段。 - -## 最新 beta 同步与未推送播放修复复评(2026-09-18 晚间) - -### 同步基线 - -- 工作分支:`dev3` -- 同步前提交:`fe88163cf750e62b8aa664c74334955c59dfda54` -- 同步的远端 `beta`:`32a52698e5dab09fe18e49d18849a947057ca717` -- 合并共同祖先:`533d3bf18d44cede36b0bd43cadb436c1ef3f481` -- 合并方式:保留双方历史的 `--no-commit --no-ff` 合并,完成后由任务守卫形成合并提交。 - -### 冲突与合并处理 - -- `ExoPlayerEngine.java` 与 `ExoUtil.java` 发生内容冲突。 -- `beta` 侧新增 ASS 会话、双字幕会话和诊断包装;`dev3` 未推送提交新增播放媒体时钟、PCM 信号管线和音频处理器接入。 -- 两者位于不同职责层,现已统一为一个完整播放器构建调用链,同时传递 `assSession`、`subtitleSession`、`mediaSignals` 和 `mediaClock`。 -- 保留原有短参数重载并让它们委托到完整实现,避免其他 Exo 调用路径失去兼容性。 -- 音频输出保留 `beta` 的诊断 Provider/Sink 包装,同时在其内部接入本地媒体时钟管线和音频采样处理器。 - -### 第一轮评审 - -- 发现首次手工合并后的播放器构建参数数量错误,主代码编译能够直接暴露该问题;已补齐完整参数顺序并清理全部冲突标记。 -- 复核两笔未推送播放修复:重复请求取消、首帧稳定窗口、预加载分段间隔均仍作用于最终合并树。 -- 复核 `PlayerManager` 创建 Exo 引擎时使用的播放媒体信号与时钟实例,和 `ExoUtil` 最终重载参数一致。 -- 未发现 beta 新增 ASS/双字幕能力被本地改动覆盖,也未发现本地预加载节流被 beta 自动合并删除。 - -### 第一轮验证 - -执行: - -```text -bash ./gradlew --console=plain \ - :app:compileLeanbackArm64_v8aDebugJavaWithJavac -``` - -结果:`BUILD SUCCESSFUL in 1m 2s`,47 个 actionable tasks。 - -执行: - -```text -bash ./gradlew --console=plain \ - :app:testLeanbackArm64_v8aDebugUnitTest \ - --tests com.fongmi.android.tv.player.exo.PreCachePolicyTest \ - --tests com.fongmi.android.tv.ui.activity.ReaderPlaybackRoutingSourceTest \ - --tests com.fongmi.android.tv.player.exo.ExoCompressedAudioDirectPolicyTest \ - --tests com.fongmi.android.tv.player.exo.ExoAudioOutputStateTest -``` - -结果:`BUILD SUCCESSFUL in 22s`,87 个 actionable tasks,15 executed、72 up-to-date。 - -### 第二轮复评 - -- 以最终工作树对照 `origin/beta` 复核,差异仅剩 9 个预期本地文件:播放修复、预加载节流、对应测试及本评审文档。 -- 重新检查 `ExoPlayerEngine` 两个构建入口和 `ExoUtil` 全部重载,均最终落到同一个完整实现。 -- 重新检查预加载 `check -> update -> schedule -> finishTask` 状态流,完成分段后的 5 秒间隔不会影响前台 buffering、seek 或暂停处理路径。 -- 未发现正确性、兼容性、性能、生命周期或回滚问题,复评通过。 - -### 回滚方式 - -- 回滚本轮 beta 同步:对任务守卫生成的合并提交执行 `git revert -m 1 `。 -- 单独回滚未推送播放修复:恢复 `PreCache`、`PreCachePolicy`、`ExoPlayerEngine`、`ExoUtil`、`PlayerManager` 与 TV `VideoActivity` 的相关改动,并删除对应源码约束测试。 diff --git a/docs/beta-sync-review-dev3-20260919.md b/docs/beta-sync-review-dev3-20260919.md deleted file mode 100644 index e216e864c8..0000000000 --- a/docs/beta-sync-review-dev3-20260919.md +++ /dev/null @@ -1,88 +0,0 @@ -# dev3 合并 beta 最新代码与 C16 交付前复评(2026-09-19) - -## 目标 - -合并远端 `beta` 最新代码,评审 `dev3` 中已提交但尚未推送的 C16 改动;发现问题后修复并验证,完成第二轮复评,再提交、推送、创建目标为 `beta` 的中文拉取请求,最后回拉远端最新代码。 - -## 基线与范围 - -- 工作分支:`dev3` -- 评审起点:`c2b1907c3ba7e732a6d4378ec3360502b3d115e4` -- 远端合并基线:`origin/beta@ca62fe6f5b97a2c0d4e20390ec2b25b7456e83aa` -- `git rev-list --left-right --count origin/beta...HEAD` 结果:`0 9`。远端 `beta` 已是当前 `HEAD` 的祖先,本轮没有新的 beta 增量、冲突或覆盖风险。 -- 任务守卫:`beta-sync-review-dev3-20260919`,模式 `standard`,范围 `app/**`、`docs/**`。 -- 起始工作树干净,无受保护脏路径。 - -## 已提交未推送提交 - -| 完整 commit ID | 处理结论 | -| --- | --- | -| `0bf950d7216fcc581e02198b5639348f965c97f2` | C16 无 Key 源内嵌详情设计与验收合同,保留。 | -| `19128b7d18107b5c614078ebdadc863faa2476b5` | 运行时模式与源数据可用性策略,保留。 | -| `1c4213db0baf24e9da9495edfdbaa9dcab43f527` | 设置层解除 API Key 门禁,保留。 | -| `6a994fa1acd85cd307bf111bc077f6e5a1385da0` | 独立详情页三模式路由和无源回退,保留。 | -| `b2343d1d1de476226b52bef11b80248e1d5033d9` | Mobile/Leanback 原生增强 source-only 接线,保留。 | -| `6e2cde565ae34c1a0da392c7ca93f34cb97aaa99` | source-only 交互入口和空区域收口,保留。 | -| `d52fdfc5b2027872779026bad278c1a5c8e9f5d8` | V1923A 设备验收测试,保留。 | -| `f585fe06a935211c8146b7242f528eb664159c4e` | 无 Key 网络任务断言修正,保留。 | -| `c2b1907c3ba7e732a6d4378ec3360502b3d115e4` | C16 实施索引收口,保留。 | - -## 第一轮评审发现与修复 - -1. `TmdbUIAdapter` 的 source-only 会话只隐藏了个性化推荐入口,但播放进度产生的 `HISTORY` 事件仍可能调用 `refreshPersonalRecommendations()`,从而启动 TMDB/豆瓣推荐请求。现已在适配器的相关视频、加载更多、个性化刷新和 AI 推荐入口增加 `sourceOnly` 防线;回调返回 `false`,不创建后台网络任务。 -2. 内嵌 `videos` 没有进入相关视频列表:独立详情页原本遇到 source-only 直接返回,原生增强页的适配器也只在配置就绪时发起网络聚合。现在 source-only 改由 `TmdbSourceAdapter.videos()` 从当前详情、季和集对象本地解析;无数据时保持空列表并隐藏区域。 -3. `loadSource()` 原先无条件用 payload 的 `season_number` 覆盖播放 Intent 的显式季号。现仅在未显式指定季时采用 payload 季号,保持历史续播和指定集进入时的既有语义。 - -涉及文件: - -- `app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java` -- `app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java` -- `app/src/test/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapterSourceOnlyTest.java` -- `app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourcePayloadTest.java` -- `app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourceOnlyWiringTest.java` - -## 验证 - -- `git diff --check`:通过。 -- Mobile 与 Leanback Arm64 Debug 定向 JVM 测试:`TmdbUIAdapterSourceOnlyTest`、`TmdbDetailSourcePayloadTest`、`TmdbSourceOnlyInteractionTest`、`TmdbDetailSourceOnlyWiringTest`、`TmdbSourceAdapterTest`、`DetailRuntimeModePolicyTest`、`TmdbSourceAvailabilityTest`,`BUILD SUCCESSFUL`。 -- V1923A、Android 9、`192.168.50.3:5559` 设备矩阵:Mobile 和 Leanback 各执行 `C16KeylessSourceDetailDeviceTest` 3 项,均为 `3/3` 通过,无 Key 提示,旧源回退正常。 -- 设备验证使用 `/tmp/c16_keyless_fixture.py` 和 `adb reverse tcp:18080 tcp:18080`,结束后已停止夹具服务。 - -## 第二轮复评 - -- source-only 的播放、选集、推荐、相关视频、评分和设置入口均按 `networkAllowed` 或适配器 `sourceOnly` 收口。 -- 有 Key 路径仍保留原有 source-first、缺失能力补齐、分页和个性化推荐行为;本次新增分支不会改变其调用顺序。 -- 本地视频解析只读取已经通过 C16 协议校验并保存在 `TmdbBundle`/详情 JSON 中的数据,不扩大协议、依赖、ABI、缓存格式或权限范围。 -- 显式季号优先规则与普通 `load()` 的既有语义一致,避免 source-only 回退映射错季。 -- 未发现新的正确性、兼容性、性能、作用域或回滚问题,复评通过。 - -## 回滚 - -代码级可回滚 `TmdbUIAdapter` source-only 网络防线、本地视频分支与季号优先级修改;原 C16 阶段 A-F 提交及各自 recovery tag 保持不变。本任务提交由 `task_guard.sh finish` 生成独立 recovery tag。 - -## 2026-09-19 第二轮:合并 beta 2f057dc3 - -### 新远端增量 - -- 二次执行 `git fetch --all --prune` 后,`origin/beta` 已由 `ca62fe6f5b97a2c0d4e20390ec2b25b7456e83aa` 前进到 `2f057dc32e9eec9da970b8cee5942e0e1c7e65b2`。 -- `origin/beta` 相对本地 `dev3` 新增 12 个提交,内容为 PR322 的直播源故障自动 fallback:线路优先切换、线路耗尽后切换下一直播源、解析失败和播放 HTTP 错误进入 fallback、缓冲超时取消、运行时配置初始化和 Mobile/Leanback 对称实现。 -- 本地 `dev3` 相对 `origin/beta` 保留 1 个提交 `fa48fe5f88387133c6bf55789f7901f8ee783727`;其内容为长按选集详情优先使用卡片绑定的 `TmdbEpisode`,在无 Key、映射无效或详情 API 失败时不再错误地回退到 `Episode.getTmdbEpisode()`。此前本地 `7f5379cd73c7ff63142c102426d3453d79fc5440` 已包含在同一 `beta` 历史中。 -- 使用 `git merge --no-commit --no-ff origin/beta` 合并,自动合并成功,无冲突;最终提交保留 `dev3` 与 `origin/beta` 的双父关系。 - -### 第二轮复评 - -- 直播 fallback 策略先尝试当前频道下一条线路,再在下一直播源存在时切源;没有下一直播源但仍有其他线路时继续尝试线路,二者均不可用时保持原失败状态,避免越界切换。 -- `onSourceHttpError()` 仅在 fallback 开启时接管 HTTP 错误,关闭时继续沿用播放器原有重试链;`onError()` 在切换前取消旧缓冲超时,并公开清理播放器、轨道和错误会话状态。 -- Mobile 与 Leanback 的 `startFlow()`、配置读取、设置开关、状态复位和生命周期清理保持对称;新增资源、布局与 `LiveSetting` 默认值不改变 TMDB source-only 路径。 -- TMDB 新重载允许调用方显式传入卡片绑定的 `TmdbEpisode`,原 6 参数 API 仅委托并保持兼容;所有长按详情返回路径都传递同一绑定对象,未扩大协议、缓存、依赖、ABI 或网络权限。 -- 合并后逐项检查 source-only 网络防线、显式季号优先、本地视频解析和长按卡片绑定集号;未发现新的正确性、兼容性、性能、生命周期或回滚问题。 - -### 第二轮验证 - -- `git diff --cached --check`:通过。 -- Mobile Arm64 Debug 定向 JVM 测试:直播 fallback 策略 5 项、源码契约 5 项、直播布局 9 项、TMDB 详情布局 127 项、剧集详情主题 9 项、source-only 适配/载荷/交互/接线 14 项、详情运行时策略 4 项、源数据可用性 7 项,共 184 项;均为 `failures=0`、`errors=0`、`skipped=0`。 -- `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`:`BUILD SUCCESSFUL`,覆盖 Leanback 合并后的 Java 源码与资源绑定。 - -## 唯一下一步 - -使用当前任务守卫提交合并结果并创建 recovery tag,随后推送 `dev3`、创建目标为 `beta` 的中文 PR,最后执行一次远端回拉。 diff --git a/docs/beta-sync-review-dev3-20260920.md b/docs/beta-sync-review-dev3-20260920.md deleted file mode 100644 index 6c1a3661f0..0000000000 --- a/docs/beta-sync-review-dev3-20260920.md +++ /dev/null @@ -1,62 +0,0 @@ -# dev3 合并 beta 最新代码与两轮复评记录(2026-09-20) - -## 目标与范围 - -- 目标:合并 `origin/beta` 最新代码,评审 `dev3` 全部已提交未推送改动(含本轮新改动),发现问题即修复并验证,复评通过后提交、推送 `dev3`、创建目标为 `beta` 的中文 PR,最后回拉远端最新代码。 -- 分支与起点:`dev3@599f1c5260f115956833b6ea427e25c8eed750d3`,起始工作树干净,无受保护脏路径。 -- 合并基线:`origin/beta@00dc2809f825698f5b08649c511ef03d29f32740`。 -- 任务守卫:`beta-sync-review-dev3-20260920`,模式 `standard`;范围仅为本轮新提交涉及的三个文件。 - -## 远端 beta 合并结论 - -- `git fetch --all --prune --tags` 后,`origin/beta` 仍为 `00dc2809f825698f5b08649c511ef03d29f32740`。 -- `git merge-base --is-ancestor origin/beta HEAD` 成立,即 `origin/beta` 已是当前 `HEAD` 的祖先,beta 最新代码已全部进入本地 `dev3` 历史,无需重复合并(重复合并只会产生空 merge commit)。 -- 本地相对 `origin/beta` 仅领先一个提交 `599f1c5260f115956833b6ea427e25c8eed750d3`(TMDB 选集网格外边距对齐),即本轮唯一需要复评的未推送功能改动。 - -## 已提交未推送提交清单 - -| 完整 commit ID | 处置 | 说明 | -| --- | --- | --- | -| `599f1c5260f115956833b6ea427e25c8eed750d3` | 复评并修正 | 详情/播放页 TMDB 选集网格外边距对齐;本轮修复后重新验证。 | -| `00dc2809f825698f5b08649c511ef03d29f32740` | 已在 beta | dev2 合并 beta 与 MPV 轨道缓存修复(PR #336)。 | -| `5c79ad2841283e67c33691deca47a0eee33fe56b` | 已在 beta | dev2 第二轮 beta 合并复评记录。 | -| `255a8b5cbc6a873c3e1a449dfe9a87975309e035` | 已在 beta | MPV track-list NODE 快照派生属性缓存。 | -| `2c0f4234d74ec120d4d0159b6ed539337b506b95` | 已在 beta | dev4 修复配置历史当前源保护与删除确认(PR #335)。 | -| `6077339f3ce8e6e8c365b56c7fe26f2cc113c6ad` | 已在 beta | dev4 合并 beta 的 BaseConfig 快照修复。 | -| `cd565740ce01c92b13fa54c4283ca797645e711f` | 已在 beta | 配置历史当前源隐藏与删除二次确认。 | -| `64139076d1076743acd72aae3998b2594602bbd1` | 已在 beta | dev3 合并 beta 并修复异步加载配置引用漂移(PR #334)。 | - -后七项均已位于 `origin/beta` 历史中,并由既有 dev2/dev4/C18/E-SP9 评审记录覆盖;本轮不重复执行相同模块的全量复评,仅确认其在最终树中的可达性与无冲突状态。 - -## 第一轮评审发现与修复 - -1. `TmdbEpisodeAdapter.applyCardSize()` 在绑定过程中通过 `holder.getBindingAdapterPosition()` 反查列号。RecyclerView 在 `notifyItemChanged` 等更新态下,holder 的绑定位可能与当前 `onBindViewHolder` 的 `position` 不一致,会导致首/末列外边距按错误列推导,出现左右不对称或首列带内缩。修复:把 `onBindViewHolder` 当前的 `position` 作为权威参数传入 `applyCardSize(holder, position, compact, pageHasTmdbEpisodeData)`,移除绑定期 holder 状态读取。 -2. 原守卫测试只做正向文本断言,无法阻止后续重新引入 holder 状态读取。修复:新增 `!body.contains("getBindingAdapterPosition()")` 反向断言,并把新增 `position` 形参与调用点纳入断言。 -3. 边缘数值复核:`gridSpanCount` 至少为 1,列号取自 `position % gridSpanCount`,因此 `gridSpacing * gridColumn / gridSpanCount` 不会除零;首列左外边距为 0、末列右外边距为 0,中间列按 `SpaceItemDecoration` 同款整数分配,卡片宽度保持等宽。 - -## 第二轮复评 - -- 逐项复核网格边距公式与 `SpaceItemDecoration(spanCount, spacing)` 的一致性:详情页 `gridSpacing` 与播放页 `12dp`/标准项 `8dp` 分支对应,首/末列贴齐内容边缘,列间间隔在整数分配下最多相差 1px,与既有播放页表现一致。 -- 复核 `applyCardSize` 的其余分支未受影响:`layoutChanged` 仍只在尺寸或边距变化时调用 `setLayoutParams`,列表模式 `marginEnd` 仍是 `12dp`,底部边距与 scrim 高度逻辑未改动。 -- 复核本次改动未触及协议、缓存格式、依赖、ABI、native、权限或网络路径,也未改变 beta 已合入的 FOLLOW-1、C18、E-SP9、广告拦截与播放器行为。 -- 第二轮未发现新的正确性、兼容性、性能、作用域或回滚问题,复评通过。 - -## 验证 - -- `git diff --check`:通过(两轮修复后均通过)。 -- `:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.adapter.TmdbEpisodeAdapterTest`:`BUILD SUCCESSFUL`,15 项全部通过(首轮修复时先暴露并修正了 2 项陈旧签名断言)。 -- `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`:`BUILD SUCCESSFUL`,覆盖 Leanback 侧同源适配器编译。 -- 覆盖安装实测:`bash scripts/build_arm64_debug_install.sh --flavor mobile --serial 192.168.50.3:5559` 打包 `BUILD SUCCESSFUL`,`adb install -r` 成功(未卸载原包)。 -- 覆盖安装后重新采集 UI 层级:详情页 TMDB 选集网格三行均为左外边距 `28px`、右外边距 `28px`,列间间隔均为 `21px`,卡片宽度 `450/450/450/451px`(1920px 宽,容器 `[28,91][1892,1080]`),与播放页 `SpaceItemDecoration` 的目标几何一致。 -- 既有设备证据(提交 `599f1c5260` 消息记录):同一设备上播放页 TMDB 选集网格同样为 `left=28px`、`right=28px`、间距 `21px`,详情页与播放页两侧对齐。 -- 未执行:native 重建、全 ABI 矩阵、实机播放矩阵;上述证据不扩展为 ABI 或全机型验收声明。 - -## 回滚 - -- 本轮修复可单独回滚为 `599f1c5260f115956833b6ea427e25c8eed750d3`(仅 `TmdbEpisodeAdapter` 与其守卫测试),不涉及依赖、ABI 或数据迁移,回滚后即回到“零起点边距 + 单侧间隔”的上一版行为。 -- 本任务提交由 `task_guard.sh finish` 生成独立 recovery tag。 - -## 状态与下一步 - -- 两轮复评与定向验证已通过,下一步为原子提交本轮修复与本文档并创建 recovery tag。 -- 提交后推送 `dev3`,创建 base `beta` / head `dev3` 的中文 PR,最后执行一次远端回拉核对。 diff --git a/docs/beta-sync-review-dev4-20260909-round3.md b/docs/beta-sync-review-dev4-20260909-round3.md deleted file mode 100644 index ee4dbf79e4..0000000000 --- a/docs/beta-sync-review-dev4-20260909-round3.md +++ /dev/null @@ -1,57 +0,0 @@ -# dev4 合并 beta 最新代码与复评(2026-09-09) - -## Recovery anchor - -- **目标:** 拉取远端 `beta` 最新代码,合并后评审全部当前改动(包括已提交未推送的 dev4 改动),发现问题则修复、验证并复评至通过,随后提交、推送 dev4、创建中文 PR 到 beta,最后再次拉取远端最新代码。 -- **任务守卫:** `beta-sync-review-dev4-20260909-round3`,模式 `standard`,范围 `app`、`docs`;初始未跟踪文件 `app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java.bak` 已显式保护,不修改、不提交。 -- **开始状态:** 2026-09-09 08:52 CST;当前分支 `dev4`,基线 `f5096d437576891ff387670593b72750163c4fc6`;开始前仅有上述一个预-existing dirty path。 -- **beta 状态:** 已执行 `git fetch origin beta`,目标 `origin/beta@242c089b06ccd330933533b856f14a9321921a11`;共同祖先 `0f37b489f7f35719cf572da32a11701f5445a6be`;`git merge --no-commit --no-ff origin/beta` 自动完成、无冲突。 -- **回滚:** 提交前使用 `git merge --abort`;提交后使用本任务生成的恢复 annotated tag 回退原子提交。 -- **实际收口:** 合并提交 `6fd26879a1c2ceba36344cc57d14d9c6ab04e1ab` 已创建恢复 tag `recovery/beta-sync-review-dev4-20260909-round3/20260909093714-6fd26879a1c2` 并推送;发现预-existing 的 `VodActivity.java.bak` 曾被错误纳入后,未改写已发布历史,使用修正提交 `41133f85db4062f4a72e353ce61774cb2850531f` 删除仓库中的误提交文件,并创建/推送恢复 tag `recovery/beta-sync-review-dev4-20260909-bak-correction/20260909095428-41133f85db40`。该 `.bak` 已恢复为本地未跟踪保护文件。 - -## 当前范围与证据 - -- beta 合并暂存树包含 24 个 task-owned 路径;相对 `origin/beta` 的最终本地差异仅为 `HomeActivity.java`、`VodActivity.java` 及其焦点测试,另有本地 6 个已提交焦点修复承载。 -- beta 本轮新增的焦点、TMDB 长按映射、触摸适配、播放器手势、MPV 脚本按钮、速度预设和 WebHome 历史续播均已有 beta/dev 任务文档与提交验证;本轮已核对最终树未发生路径冲突或未合并残留。 -- 首轮静态评审已检查:Leanback `BaseGridView.OnTouchInterceptListener` API 与反射调用、触摸 ACTION_DOWN/UP/CANCEL 生命周期、站源搜索位置/绑定 token、首页/独立 VOD 分类边界焦点、TMDB 长按绑定集上下文与 dismiss 后焦点恢复、WebHome History 直达续播及动态 MPV 按钮可达性。 - -## 进度 - -- [x] fetch beta、启动 task guard、无冲突合并 -- [x] 保存完整差异并完成首轮静态评审 -- [x] 定向测试、Leanback/Mobile 编译与验证后复评 -- [x] 原子提交与恢复 tag(含误提交备份文件的修正提交) -- [x] 推送 dev4、创建中文 PR 到 beta -- [ ] 最后 fetch/pull 并核对状态 - -## 验证记录 - -- 已执行一次覆盖最终合并索引的定向命令: - - ```text - bash ./gradlew :app:testLeanbackArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.ui.helper.TouchOptimizationHelperSourceTest' \ - --tests 'com.fongmi.android.tv.ui.activity.VodActivityCategoryEdgeTest' \ - --tests 'com.fongmi.android.tv.ui.activity.HomeCategoryNavigationSourceTest' \ - --tests 'com.fongmi.android.tv.ui.dialog.EpisodeDetailDialogThemeTest' \ - :app:testMobileArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.ui.activity.TmdbDetailActivityLayoutTest' \ - --tests 'com.fongmi.android.tv.ui.adapter.TmdbEpisodeAdapterTest' \ - :app:compileLeanbackArm64_v8aDebugJavaWithJavac \ - :app:compileMobileArm64_v8aDebugJavaWithJavac \ - :app:assembleLeanbackArm64_v8aDebug --no-daemon --console=plain - ``` - -- 结果:`BUILD SUCCESSFUL`,耗时 1 分 41 秒;Leanback/Mobile Java 编译和 Leanback Arm64 APK 组装通过。生成的 XML 结果中,`TouchOptimizationHelperSourceTest` 11 项、`VodActivityCategoryEdgeTest` 2 项、`TmdbDetailActivityLayoutTest` 119 项、`TmdbEpisodeAdapterTest` 14 项均为 `failures=0`、`errors=0`、`skipped=0`;同一命令包含的其余定向测试也随任务成功完成。 -- 验证后安全检查:`git diff --cached --check`、`git diff --check`、`bash .codex/scripts/task_guard.sh check` 均通过;`git ls-files -u` 无输出。 - -## 验证后最终复评 - -- 针对验证后的最终索引重新检查了本地 `HomeActivity`/`VodActivity` 分类边界切换、fragment transaction 完成后的焦点恢复、触摸选择抑制自动切换,以及 beta 合并引入的 Collect/TMDB/剧集详情/播放器触摸和 WebHome 历史直达路径。 -- 最终索引相对 `origin/beta` 仅保留本地首页/独立点播分类焦点修复及对应测试;beta 新增路径与已有 dev/beta 评审记录一致,合并无未解决路径或索引冲突。 -- 未发现需要修复的生产问题,最终复评结论为**通过**。本轮不重复成功的 Gradle 构建/测试。 -- 验证边界:未执行真实设备逐帧焦点、触摸视觉、网络站源和 HDMI/音频 passthrough 回归;这些不属于本次源码/布局/单测/编译与 APK 组装的最小决定性验证范围。 - -## 下一动作 - -PR 已创建:[#242](https://github.com/Silent1566/webhtv/pull/242),标题为“合并 beta 最新代码并完成 dev4 复评”,目标 `beta`、来源 `dev4`,远端 head 为 `41133f85db4062f4a72e353ce61774cb2850531f`。唯一下一动作:推送本收口文档提交后,执行 `git fetch --prune origin beta dev4` 与 `git pull --ff-only origin dev4`,确认 `.bak` 仍为本地未跟踪文件且工作树无其他未预期改动。 diff --git a/docs/beta-sync-review-dev4-20260909-round4.md b/docs/beta-sync-review-dev4-20260909-round4.md deleted file mode 100644 index fe23702866..0000000000 --- a/docs/beta-sync-review-dev4-20260909-round4.md +++ /dev/null @@ -1,61 +0,0 @@ -# dev4 合并 beta 最新代码与复评(2026-09-09 round4) - -## Recovery anchor - -- **目标:** 拉取远端 `beta` 最新代码,合并后评审全部当前改动(包括已提交未推送的 dev4 改动),发现问题则修复、验证并复评至通过,随后提交、推送 dev4、创建中文 PR 到 beta,最后再次拉取远端最新代码。 -- **任务守卫:** `beta-sync-review-dev4-20260909-round4`,模式 `standard`,范围 `app`、`docs`。 -- **开始状态:** 2026-09-09 10:39 CST;分支 `dev4`,开始前 HEAD 为 `9769ca8a084600285887f7d150446f12379bf5c0`,工作树无 dirty 路径,无需保护 pre-existing dirty path。 -- **远端状态:** 已执行 `git fetch --prune origin beta dev4`;`origin/beta` 为 `a65c7320d9213b3af01f121d9d68412dd527f380`,其父提交为 `242c089b06ccd330933533b856f14a9321921a11` 与 `515d1e04e5f6685d507c9667831cee4bc460e364`;`origin/dev4` 为 `515d1e04e5f6685d507c9667831cee4bc460e364`。 -- **beta 合入证据:** `origin/beta` 的树为 `fdeaa3b8b724eceb6a865058b31d8888651777a4`,与其第二父提交 `515d1e04e5f6685d507c9667831cee4bc460e364` 的树一致;`git diff --exit-code 515d1e04e5f6685d507c9667831cee4bc460e364 a65c7320d9213b3af01f121d9d68412dd527f380` 通过,`git merge --no-commit --no-ff origin/beta` 无冲突且不产生内容变更。远端 PR #242 已将 beta 最新线与 `515d1e04…` 汇合,因此没有伪造空 merge commit。 -- **本轮待审本地提交:** `9769ca8a084600285887f7d150446f12379bf5c0`(直播播放不受点播自动播放设置影响),相对 beta 只新增 6 个生产/资源文件;本轮另修正对应的 source regression test 并增加覆盖断言。 -- **回滚:** 本轮新提交使用 task guard 自动创建的恢复 annotated tag;若需仅回退本轮文档/测试修正,回退其新提交即可,`9769ca8a…` 保留为独立原子提交。 - -## 评审范围与结论 - -### 1. `PlaybackActivity` 自动播放策略 - -- `protected boolean shouldAutoPlay()` 默认继续返回 `PlayerSetting.isAutoPlay()`,保持点播、音频、Cast、TMDB 等既有调用方的设置行为。 -- `startPlayer` 的解析路径和直连路径都改为使用 `shouldAutoPlay()`,没有遗漏一条播放入口。 -- 移动端和 Leanback 端 `LiveActivity` 都精确覆写为 `true`,频道初次播放、切台及重新拉流均通过同一个 `startPlayer` 入口恒自动播放。 -- 没有修改 `PlayerManager` 的播放状态机、暂停/恢复或后台策略;变更只改变宿主传入的 `playWhenReady`,范围与需求一致。 - -### 2. 设置文案 - -- 默认、简体中文、繁体中文三份 `player_auto_play` 均明确为“点播自动播放”,与直播恒自动播放的实际策略一致。 - -### 3. 测试契约 - -- 初轮运行既有 `PlayerPlaybackRegressionSourceTest` 时,第 254 行的旧断言仍寻找 `PlayerSetting.isAutoPlay()` 直接传参,确认是本次抽取 `shouldAutoPlay()` 后的测试滞后。 -- 已将该断言更新为 `shouldAutoPlay()`,并加入对默认点播策略、解析/直连两条调用路径、移动端/Leanback 直播覆写的精确 source assertions。 -- 同轮另外两项失败(`panDiagnosticRespectsTheConfiguredPlayerButtonOrder` 第 82 行、`runtimePlayerActionsRefreshFocusAfterVisibilityChanges` 第 263 行)依赖的 Leanback 播放器按钮/焦点源文件未被本轮提交修改;测试文件 blame 显示其逻辑来自更早提交,归类为既有基线失败,不在本任务范围内修复。 - -## 验证记录 - -- 初轮守卫检查通过;初轮全量 `PlayerPlaybackRegressionSourceTest` 结果为 9 项完成、3 项失败,失败分类见上。 -- 修正测试后执行一次聚焦验证: - - ```text - bash .codex/scripts/task_guard.sh check - bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.ui.activity.PlayerPlaybackRegressionSourceTest.livePlaybackAlwaysAutoplaysWhileVodUsesTheConfiguredPolicy' \ - --tests 'com.fongmi.android.tv.ui.activity.LiveActivityLayoutTest' \ - :app:compileMobileArm64_v8aDebugJavaWithJavac \ - :app:compileLeanbackArm64_v8aDebugJavaWithJavac \ - --no-daemon --console=plain - ``` - -- 结果:`BUILD SUCCESSFUL`,98 个 actionable tasks 中 8 个执行;直播自动播放新断言、`LiveActivityLayoutTest`、移动端 Java 编译和 Leanback Java 编译均通过。 -- 修正后最终静态复评:重新核对 `9769ca8a…` 的 6 个生产/资源路径、两种 Live source set、`parse`/直连 `start` 调用链、三份资源文案及测试断言;未发现遗漏、行为回退或超出需求的改动。 -- `git diff --check` 与 task guard `check` 通过。未执行真实设备逐帧直播切台、网络站源和音视频硬件回归;这些不属于本轮源码策略变更的最小决定性验证范围。 - -## 当前状态与下一动作 - -- [x] fetch 远端 beta/dev4 并确认 beta 合入树无冲突 -- [x] 评审已提交未推送的直播/点播自动播放改动 -- [x] 修正测试契约并完成验证 -- [x] 修正后再次复评改动路径 -- [ ] 使用 task guard 原子提交本轮测试与文档,随后推送 `dev4` -- [ ] 创建目标为 `beta` 的中文 PR -- [ ] 最后拉取远端最新代码并核对分支、PR 和工作树状态 - -**唯一下一动作:** 在不扩大范围的前提下执行 task guard `finish`,生成本轮提交与恢复 tag。 diff --git a/docs/beta-sync-review-dev4-20260912-2.md b/docs/beta-sync-review-dev4-20260912-2.md deleted file mode 100644 index fdfb7a356f..0000000000 --- a/docs/beta-sync-review-dev4-20260912-2.md +++ /dev/null @@ -1,44 +0,0 @@ -# dev4 合并 beta 后代码复评记录(2026-09-12) - -## 目标 - -将远端 `origin/beta` 最新提交合并到 `dev4`,复评 `dev4` 尚未进入 `beta` 的全部代码,并完成针对性验证、提交、推送和中文 PR。 - -## 合并基线 - -- 合并前 `origin/beta`:`6750b21dc4040f22ef51bff408a1bfb5ad6649d0` -- 合并前本地 HEAD:`a123124c442295fffa8f3087c4fc6ee1ada42fd3` -- 合并方式:`git merge --no-edit origin/beta` -- 合并结果:成功,无冲突。 - -## 评审范围 - -相对合并后的 `origin/beta`,当前分支仅保留以下任务差异: - -- `app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` -- `app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` -- `app/src/testMobile/java/com/fongmi/android/tv/ui/activity/VideoActivityLayoutTest.java` -- `docs/fix-exo-startup-double-resume.md` - -## 评审结论 - -1. 移动端和电视端的片头片尾查询回调在播放器进入 `Player.STATE_READY` 前不再调用自动跳过,避免其 seek 与 EXO 首次 prepare、历史进度恢复或去广告定位竞争。 -2. 移动端和电视端实现保持一致,READY 守卫位于 `applyAutoIntroSkip()` 之前。 -3. 合并 beta 后新增的播放器卡顿看门狗等代码未与本任务的起播定位守卫产生冲突。 -4. 电视端失焦时释放倍速属于同一已提交变更中的输入状态收口,逻辑局部且不会改变正常持焦播放行为。 -5. 未发现需要继续修改的正确性、兼容性或回归问题,复评通过。 - -## 验证 - -执行: - -`bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.activity.VideoActivityLayoutTest` - -结果:`BUILD SUCCESSFUL in 33s`,87 个任务中 7 个执行、8 个命中缓存、72 个为最新状态。 - -同时执行了相对 `origin/beta` 的 `git diff --check`,无空白错误。 - -## 回滚 - -- 功能回滚锚点:`recovery/fix-exo-startup-seek-race/20260912191224-a123124c4422` -- 如需撤销本次同步收口,可回退本次任务提交或使用本任务完成时生成的 recovery 标签。 diff --git a/docs/beta-sync-review-dev4-20260912.md b/docs/beta-sync-review-dev4-20260912.md deleted file mode 100644 index 24104a73e5..0000000000 --- a/docs/beta-sync-review-dev4-20260912.md +++ /dev/null @@ -1,62 +0,0 @@ -# dev4 合并 beta 与代码复评记录(2026-09-12) - -## 目标 - -将远端 `origin/beta` 最新代码合并到 `dev4`,覆盖评审当前分支相对 beta 的全部修改及此前已提交但未推送的提交;完成针对性验证和验证后复评,随后提交、推送并创建合入 beta 的中文 PR,最后再次拉取远端最新状态。 - -## 合并基线 - -- 合并前 HEAD:`b6b18ac1dd2f248c3001b1802ac285622620b6fa` -- 合并时远端 beta:`ccd608c503` -- 合并结果:使用 Git `ort` 策略成功完成,无冲突。 -- 合并后当前分支相对 beta 的有效功能提交: - - `667992b22e`:统一光影剧幕分集客串演员图片圆角行为。 - - `6a002d29f7`:统一光影剧幕分集客串演员横向卡片布局。 - - `b6b18ac1dd`:稳定移动端分集详情底部滚动边界。 - -## 评审范围 - -- `app/src/leanback/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java` -- `app/src/main/res/layout/dialog_episode_detail.xml` -- `app/src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java` -- `app/src/testMobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialogThemeTest.java` - -同时逐提交检查了上述三个功能提交,并检查合并后相对 `origin/beta` 的完整差异。 - -## 第一轮评审结论 - -- 影院风格下,移动端与 TV 端分集客串演员列表均复用外层演员栏的横向卡片布局,非影院风格保持原有纵向卡片行为。 -- TV 端两个客串演员数据入口均同步设置影院样式、行高及容器高度,没有发现只覆盖单一路径的问题。 -- 移动端移除冗余关闭按钮后仍保留系统返回和点击外部关闭方式,避免固定底部按钮挤压滚动区域。 -- 横向图片列表关闭嵌套滚动与过度滚动,父滚动容器关闭过度滚动并增加底部安全间距,处理底部客串演员卡片滚动边界。 -- 新增测试覆盖影院风格卡片几何、移动��端与 TV 端接线、关闭按钮移除以及底部滚动边界。 -- `git diff --check origin/beta...HEAD` 未发现空白符错误。 -- 未发现需要修改的正确性、兼容性或回归问题。 - -## 验证 - -执行构建前已检查实际 Gradle、Kotlin、AAPT2、Ninja 和 CMake 构建进程;排除仅驻留的 Gradle Daemon 后,未发现其他实际打包任务。 - -针对性验证命令: - -```bash -bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.ui.dialog.EpisodeDetailDialogThemeTest' -``` - -结果:`BUILD SUCCESSFUL`,共 87 个任务,其中 6 个执行、81 个为最新状态。 - -构建输出中仅有项目既存的 32 位原生库、弃用 API 和未检查操作警告,本次改动未新增相关问题。 - -## 验证后第二轮复评 - -验证通过后再次检查 `origin/beta...HEAD` 的完整四文件差异及 `git diff --check`: - -- 差异仍严格限定于分集详情影院风格客串演员卡片和移动端滚动边界。 -- 合并 beta 后没有产生冲突残留、重复实现或行为覆盖。 -- 测试断言与实际源集路径、变体及运行时代码一致。 -- 未发现新的阻断问题,复评通过,无需追加代码修复。 - -## 回滚 - -若合入后发现问题,可按功能提交分别回滚 `b6b18ac1dd`、`6a002d29f7`、`667992b22e`;本次合并和交付提交另有任务恢复标签用于整体回退。 diff --git a/docs/bluray-menu-assessment-2026-09-06.md b/docs/bluray-menu-assessment-2026-09-06.md deleted file mode 100644 index 0cbe1f3e74..0000000000 --- a/docs/bluray-menu-assessment-2026-09-06.md +++ /dev/null @@ -1,184 +0,0 @@ -# WebHTV 蓝光菜单评估(2026-09-06) - -## Recovery anchor - -- 目标:保存当前蓝光菜单评估结论,并继续核实开源 BD-J 实现。 -- 接受标准:记录分支/远端状态、当前代码能力、HDMV 与 BD-J 边界、证据、风险、实施建议和回滚点;不修改生产代码。 -- 当前分支/HEAD:`feature-menu` / `784b90420d646eb6c7ddcc63ad622a92c65b02b4`。 -- 当前工作区:任务开始时干净;本任务只允许修改本文件。 -- 已完成:远端一致性核对、WebHTV/mpv/libbluray 当前实现盘点、HDMV 可行性判断、BD-J 暂缓初判。 -- 已完成:libbluray JVM/JAR 链路、VLC/Kodi/mpv 集成、BD-J 工具项目和 Android 构建条件核对。 -- 当前未决:没有经过真实 BD-J 原盘和 Android 设备验证,不能把“理论可移植”表述成产品兼容承诺。 -- 下一步:用户批准后才进入 HDMV 实施;BD-J 保持暂缓。 - -## 1. Git 状态 - -- 当前分支:`feature-menu` -- `HEAD`:`784b90420d646eb6c7ddcc63ad622a92c65b02b4` -- `origin/main`:`784b90420d646eb6c7ddcc63ad622a92c65b02b4` -- 远端 `refs/heads/main`:`784b90420d646eb6c7ddcc63ad622a92c65b02b4` -- `git rev-list --left-right --count HEAD...origin/main`:`0 0` -- 结论:当前分支与远端 `main` 完全一致。 - -## 2. 用户期望 - -用户提供的截图是完整蓝光主菜单形态:全屏背景图/视频、播放、章节、设置、特别收录等按钮,以及方向键移动、高亮和确认跳转。截图本身不能判断菜单是 HDMV 还是 BD-J;两者可以产生相同的最终画面。准确分类需要读取原盘 `BDMV/index.bdmv`、`MovieObject.bdmv`、`BDJO` 和相关 JAR/标题信息。 - -## 3. 当前 WebHTV 已有能力 - -涉及文件: - -- `third_party/mpv-player-jni/src/iso_dvd.cpp` -- `third_party/patches/mpv-stream-cb-disc-controls.patch` -- `third_party/mpv-player-jni/README.md` -- `third_party/mpv-libmpv-integration-notes.md` -- `app/src/main/java/com/fongmi/android/tv/player/iso/IsoSessionManager.java` -- `app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java` -- `app/src/main/java/com/fongmi/android/tv/player/engine/MpvPlayerEngine.java` -- `app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java` -- `app/src/main/java/com/fongmi/android/tv/service/PlaybackService.java` - -已有基础包括:libbluray 初始化和读取、最长 playlist 自动选择、时间/章节/语言控制、远程 HTTP Range ISO、`webhtv-dvdiso:///longest`、`+disc` demux 入口,以及透明 OSD Surface 生命周期管理。当前行为契约是普通 ISO 默认播放最长标题。 - -## 4. 当前缺口 - -现有 `stream_cb` 补丁只提供时间、章节、语言控制和 `+disc` 识别;没有完整 disc navigation 接口、菜单 overlay、still frame、discontinuity、菜单跳转后的 slave demuxer 重开、`disc-menu-active` 状态或 Android DPAD/ENTER/BACK/MENU 桥接。`PlaybackActivity` 没有统一的 discnav 输入分发,`PlaybackService.NavigationCallback` 也不是完整的蓝光菜单控制接口。 - -本地 MPV 基线:`cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`,不包含官方 PR #18080 的完整菜单链路。 - -## 5. HDMV 与 BD-J - -### HDMV - -HDMV 是蓝光原生导航模型,依靠 libbluray 的命令、事件和图形 overlay。它可以覆盖截图所示的主菜单、章节、设置、特别收录、Popup、按钮高亮、playlist 跳转和 still frame。官方 MPV 已在 PR #18080(合并提交 `c318236b8882af860f16f936225430ad053a2179`)加入完整链路;FongMi 分支也有可参考实现。 - -建议:实施,但必须作为 MPV/native、JNI 和 Android 输入/OSD 的完整阶段移植,不能只拣一个 HDMV commit。 - -### BD-J - -BD-J 是蓝光内置 Java 应用程序。它可实现动态菜单、复杂动画和交互逻辑;最终画面可与 HDMV 完全相同。它需要 Java VM、`libbluray-j2se-.jar`、BD-J/AWT 图形适配、字体/XML 资源、持久化存储和严格的线程/生命周期管理。 - -当前 Android 构建脚本 `build/mpv-native/mpv-android/buildscripts/scripts/libbluray.sh` 使用: - -```text --Dbdj_jar=disabled --Dfontconfig=disabled --Dfreetype=disabled --Dlibxml2=disabled -``` - -因此当前 APK 没有可运行 BD-J 所需的 JAR、桌面 JVM loader 或 Android ART 适配层。建议第一阶段明确支持 HDMV,BD-J 原盘回退最长主标题并提示“BD-J 菜单暂不支持”。 - -## 6. 已确认的实现证据 - -### 官方 MPV - -- PR: -- 合并提交:`c318236b8882af860f16f936225430ad053a2179` -- 相关提交: - - `12183aad6c7896c76942560f9367b50ac831c8ee`:disc-nav actions/states - - `774016737bd6cbdbd73d17f4b6fe2de79945a3f1`:`disc-menu` option - - `91c8c26f6ad345fd03074fb8645ea054883aaf25`:DVD menu navigation - - `93a2983c7911ec234eb78c983ed0c55a645f17ad`:HDMV Blu-ray menu navigation - - `2190280662e87a51c9689a87eae057b2e8075a63`:slave demuxer reopen - - `c1df49efed4d3462d9062cd510c94504aebf7375`:synthetic Disc Menu edition - - `cae874d76e31a2cc4bf1147c206a80f75881160e`:disc-menu OSD type - - `350b802e85871b0f7b69692eb2ff694dd6f06e14`:`player/discnav.c` - - `9c50acfb7d3bfd67db9c986f67036ade46195e51`:`disc-menu-active` - - `256f348433776d4c0ddd82ce454233c6b12babf0`:discnav command/default keys - - `8305b9d5c1a97a4b31ce74e9749e118696c066f7`:BD-J ARGB overlay - - `c83a677facb42eedbbf5e39e1d57556078a6beec`:interactive disc read-ahead handling - - `1dd0eb2f6d1620e089d52e9c0c4b6309bccb62a`:BD still frame - -官方 MPV 的 BD-J 相关提交表示“播放器能够消费 BD-J overlay/状态”,不等于 Android 已经提供 BD-J JVM。BD-J runtime 仍由 libbluray 构建和宿主环境决定。 - -### libbluray - -当前本地源码仓库 HEAD:`99a60ad2141d5ace94453590903c2c6b9a0a2443`。源码包含真实 BD-J 子系统,而非仅有标记: - -- `src/libbluray/bdj/bdj.c`:动态发现/加载 JVM,通过 `JNI_CreateJavaVM` 创建 VM,加载 `libbluray-j2se-.jar`,处理 BD-J 事件和关闭生命周期。 -- `src/libbluray/bdj/native/*`:BD-J native 方法、AWT/图形和 logger 绑定。 -- `src/devtools/bdj_test.c`:检查 `bdj_handled`,调用 `bd_start_bdj()`/`bd_stop_bdj()` 的测试工具。 -- `bluray.h`:`bdj_detected`、`bdj_handled`、`bd_register_argb_overlay_proc()`、BD-J 输入和状态 API。 -- `README.md`:明确说明安装 Java JDK 和相关包才能启用 BD-J 菜单。 - -这证明开源世界存在可工作的桌面 BD-J 实现基础;但它的宿主前提是桌面 JVM/J2SE,不是 Android APK 即插即用组件。 - -进一步核对源码可见,libbluray 并不是把 BD-J 编译成一段纯 C 逻辑: - -- `src/libbluray/bdj/bdj.c` 在 J2SE 路径动态加载 `libjvm`,查找 `JNI_CreateJavaVM`,设置 `-Xbootclasspath`/Java module patch,并创建或附着 VM。 -- `src/libbluray/bdj/meson.build` 需要 Ant 和 Java 编译器,生成两个独立产物:`libbluray-j2se-.jar` 与 `libbluray-awt-j2se-.jar`。 -- 源码还保留面向 PhoneME/CDC 的 `j2me` 变体(加载 `libcvm`/`PhoneME`),但这不是 Android ART 适配;它仍要求专用 Java ME/CVM 运行时和额外 bootclasspath。 -- `bd_info` 的 `bdj_detected` 与 `bdj_handled` 明确区分“盘面含 BD-J”与“宿主找到了可用 JVM/JAR 并真正处理”。 - -### 相关开源播放器 - -- mpv:已合并 HDMV/BD-J overlay 和 discnav 适配,但当前 issue 仍记录部分 BD-J 原盘因 `libbluray-j2se`、JVM 或盘面兼容性失败。 -- Kodi:集成 libbluray 的 HDMV/ARGB overlay 和输入 API;其运行环境仍依赖可用的 libbluray BD-J/JVM 配置,不能直接证明 Android WebHTV 已具备 BD-J。 -- VLC:构建 libbluray 时启用 libxml2、FreeType 和 `libbluray*.jar`,属于桌面/完整依赖链示例,不是可直接复制的 Android BD-J 实现。 - -VLC 的公开源码提供了很清楚的运行条件:`modules/access/bluray.c` 暴露 `bluray-menu`、`JAVA_HOME` 和持久化存储设置,并同时注册 HDMV 与 BD-J ARGB overlay;其构建规则依赖 libxml2、FreeType,BD-J JAR 需要 Java/Ant。VLC Flatpak 的公开 issue [#11](https://github.com/flathub/org.videolan.VLC.Plugin.bdj/issues/11) 还记录了“插件存在但系统找不到 Java,菜单回退为无菜单播放”的实际失败。 - -Kodi 的 `FindBluray.cmake`/`FindBlurayBDJ.cmake` 也把两个 JAR 作为单独的运行时目标:`libbluray-j2se-.jar` 和 `libbluray-awt-j2se-.jar`。Kodi 的 Android 依赖配置没有显示将这两个 J2SE JAR 和一个 JVM 打进 Android 包;这不是 Android BD-J runtime 的证据。 - -其他容易混淆的开源项目: - -- `umjammer/xletview`(GPL)是 PC 上的 MHP/Xlet 模拟器,README 明确说它不是完整 MHP 实现;它不是读取 `BDMV`、运行 `BDJO` 的蓝光播放器。 -- `oliverlietz/bd-j`(BSD-3-Clause)主要是 HD Cookbook 的 BD-J 制作/分析工具,并且 README 列出部分工具已损坏;它不是可嵌入播放器 runtime。 -- `tourettes/libbluray`、`matt953/libbluray` 是面向 BD-J 开发/补丁的 libbluray fork,仍继承 libbluray 的 JVM/JAR 宿主模型,不提供 Android ART 适配层。 - -## 7. “开源实现”结论 - -答案是“有,但分层看”: - -1. **libbluray 有真实的开源 BD-J runtime 实现**,包括 JVM 启动、JAR 加载、BD-J 事件、native/AWT/ARGB overlay 和测试工具。 -2. **mpv、VLC、Kodi 有开源集成代码**,可以把 libbluray 的 BD-J 状态/overlay 接到播放器显示和输入链路;mpv 的新菜单代码还处理了 BD-J ARGB overlay。 -3. **没有发现一个可以直接放进 WebHTV Android APK 的成熟、完整、免改造 BD-J 实现**。桌面项目的关键依赖是 J2SE JVM/AWT/JAR;Android ART 并不提供同等运行时契约。 -4. 因此,“开源代码存在”不等于“WebHTV 可以直接开启”。可复用的是 libbluray BD-J 协议/状态/overlay 代码和播放器桥接思路;需要自行解决 Android JVM/图形/生命周期/安全/包体/许可证问题。 - -更精确地说:**开源实现有,开源 Android 成品没有被找到**。现有开源实现可分为“BD-J 运行时(libbluray + 外部 JVM/JAR)”“播放器集成(VLC/Kodi/mpv)”“制作/调试工具(HD Cookbook、XleTView)”三层,不能把后两层当成移动端 runtime。 - -## 8. 风险与产品边界 - -- 商业 BD-J 兼容性不确定;即使桌面 mpv 配好 JAR/JVM,也不能保证所有 4K/Java 菜单。 -- 官方 mpv issue [#18423](https://github.com/mpv-player/mpv/issues/18423) 的复现显示:无 `libbluray-j2se` 时明确回退无菜单;补齐 JAR 和桌面 Java 后,部分 4K BD-J 盘仍出现空窗口/无法显示菜单。这说明“补 JAR”不是充分条件。 -- 远程 HTTP Range ISO 的 BD-J 会增加随机读取、缓存和延迟压力。 -- 将不受信任蓝光中的 Java 字节码放入 Android 应用会扩大攻击面,必须考虑沙箱、文件访问、网络和持久化权限。 -- AACS/BD+ 解密不在菜单实现范围内;没有合法密钥时,菜单和正片都可能无法读取。 -- BD-J 需要额外 JAR、字体、XML、JVM/适配层,增加 APK 体积、ABI/生命周期复杂度和维护成本。 - -## 9. 建议 - -当前建议保持: - -- **HDMV Blu-ray 菜单:实施** -- **BD-J Blu-ray 菜单:暂缓** - -第一阶段的产品语义应是:支持 HDMV 主菜单、方向键、确认、返回、Popup、章节/设置/特别收录跳转;检测到仅 BD-J 或 BD-J 无法启动时,回退最长主标题播放并给出明确日志/提示。 - -## 10. HDMV 后续最小实施边界 - -1. 移植官方/FongMi 完整 discnav、overlay、event、discontinuity、still frame 和 slave demuxer reload 链路。 -2. 扩展 WebHTV `stream_cb` 与 `iso_dvd.cpp` 的 NAV command/state/overlay 接口,同时保留远程 Range 和最长标题回退。 -3. 在 `MpvPlayer` 观察 `disc-menu-active` 并公开 discnav action;复用现有 OSD Surface。 -4. 在 Android 输入层映射 DPAD、ENTER、BACK、MENU/POPUP。 -5. 在 arm64-v8a、armeabi-v7a 上验证本地 ISO、远程 ISO、菜单跳转、still frame、Surface 重建和普通播放回归。 - -## 11. 回滚 - -回退到当前基线 `784b90420d646eb6c7ddcc63ad622a92c65b02b4`,并恢复同一组 MPV native lock、patch、JNI 源码和 APK 资产;BD-J 不进入该阶段。 - -## 12. 研究来源与访问日期 - -访问日期:2026-09-06;网络访问使用用户指定的 HTTP/SOCKS 代理。 - -- libbluray 源码:本地 `build/mpv-native/mpv-android/buildscripts/deps/libbluray`,HEAD `99a60ad2141d5ace94453590903c2c6b9a0a2443`;`README.md`、`src/libbluray/bdj/bdj.c`、`src/libbluray/bdj/meson.build`、`src/devtools/bdj_test.c`、`src/libbluray/bluray.h`。 -- libbluray 官方仓库:;README 明确要求 Java JDK/相关包才能启用 BD-J 菜单。 -- mpv 菜单实现:,合并 commit `c318236b8882af860f16f936225430ad053a2179`。 -- mpv BD-J 兼容性 issue:;日志显示缺少 `libbluray-j2se` 时回退无菜单,补齐 JAR/JRE 后仍有 4K BD-J 空窗口案例。 -- VLC 集成:;构建规则:。 -- VLC Flatpak Java 失败案例:。 -- Kodi 集成:;BD-J 构建/运行时发现:`cmake/modules/FindBluray.cmake`、`cmake/modules/FindBlurayBDJ.cmake`。 -- BD-J 工具:;以制作/分析工具为主,不是播放器 runtime。 -- XletView:;PC MHP/Xlet 模拟器,不是 BDMV/BDJO 播放器。 -- libbluray BD-J forks:、;仍使用 libbluray 的 JVM/JAR 宿主模型。 diff --git a/docs/comic-horizontal-history-restore-fix.md b/docs/comic-horizontal-history-restore-fix.md deleted file mode 100644 index 1f00a56306..0000000000 --- a/docs/comic-horizontal-history-restore-fix.md +++ /dev/null @@ -1,19 +0,0 @@ -# 漫画横向翻页历史节点修复 - -## Recovery anchor -- 目标:修复新版漫画横向翻页模式下“阅读后返回、再进不恢复历史节点”。 -- 验收:横向翻页保存 `comicCur/comicTotal`,翻页立即上报,重进直接恢复同一页。 -- 回滚:还原 `reader.html` 和对应 source-test。 - -## 诊断 -- `4a3fc03533` 升级 UI 后新增漫画横向翻页;旧版只有纵向滚动。 -- 通用 `currentAnchorIndex` 依赖滚动位置,横向模式只有当前页一张图,始终算成第 0 页。 -- `comicFlip -> comicShowPage` 只改内存页码并刷新 UI,没有触发进度上报。 - -## 修复 -- 横向漫画进度读取 `comicCur`,保存 `comicCur/comicTotal`。 -- `comicShowPage` 成功切换后立即上报。 -- `restoreAnchor` 对横向漫画直接恢复到目标页,不进入滚动恢复循环。 - -## 验证 -- 执行 `ReaderPlaybackRoutingSourceTest`,覆盖横向漫画进度专用状态和恢复路径。 diff --git a/docs/danmaku-manual-match-memory-design.md b/docs/danmaku-manual-match-memory-design.md deleted file mode 100644 index bbf32b9ca0..0000000000 --- a/docs/danmaku-manual-match-memory-design.md +++ /dev/null @@ -1,308 +0,0 @@ -# 弹幕手动匹配记忆设计 - -## 状态 - -已实现并完成合并复评:站点级与 TMDB 全局关联可复用手动匹配意图;关键词绑定到产生所选结果的已提交请求。验证与当前同步状态见 `docs/beta-sync-review-20260905.md`。 - -## 日期 - -2026-09-05 - -## 背景 - -WebHTV 的自动弹幕搜索由 `DanmakuApi.search(MediaTitleRequest, Consumer)` 发起。它先查询本集缓存,未命中后通过 `MediaTitleResolver` 解析搜索标题,再调用弹幕接口搜索。 - -当前手动选择弹幕时,两个入口都会写入 `DanmakuMatchCache`: - -- `DanmakuSearchDialog.rememberManualDanmaku` -- `DanmakuSearchInputDialog.rememberManualDanmaku` - -写入的缓存 key 为: - -```text -siteKey@@@vodId@@@episodeKey -``` - -其中 `episodeKey` 会被规范化为本集编号。这个粒度保证了同一集不会重复搜索,但换到下一集后,缓存 key 变化,自动搜索会重新走标题解析流程。 - -标题解析流程会把手动匹配写入 `MediaTitleLearningStore`,学习样例的 `expectedTitle` 是清洗后的作品名,通常不包含季信息。例如“某动画 第二季”会被清洗为“某动画”。因此下一集搜索时会优先搜索这个作品名,而弹幕源常将最新季排在前面,最终再次匹配到最新季。 - -用户必须每集手动调整弹幕,无法延续一次手动选择。核心诉求是:同一部片源中,下一集自动弹幕搜索应优先沿用用户手动确认过的搜索意图。 - -另一个使用场景是换源:用户切到其他站点或不同 `vodId` 时,`siteKey@@@vodId` 会变化。如果播放内容已经匹配到 TMDB,同一部作品、同一季应能复用一次手动匹配,而不是要求用户在每个站点重复操作。 - -## 参考 - -已对照本地参考项目 `F:/Workspace/CatVodSpider/app/src/main/java/com/github/catvod`: - -- `DanmakuUIHelper.showSearchDialog` 使用稳定视频 key 读取和保存手动搜索关键词,用户修改关键词后下次搜索仍使用该关键词。 -- `DanmakuManager` 将弹幕结果按“剧名 + 来源 + API + epId”缓存,换集时通过 `epId` 差值推断下一集弹幕。 -- 参考项目可依赖结构化 `DanmakuItem.epId`、`animeTitle`、`apiBase` 字段。 - -WebHTV 不能直接移植 `epId` 递增方案:现有 `Danmaku` 模型只有 `name`、`url`、`source`、`apiSourceName`,没有结构化集 ID 和系列字段。直接改 URL 或数字递推,容易跨源拿错集、拿错季。 - -## 目标 - -1. 同一站点同一影片 ID 下,手动匹配过的弹幕搜索意图能延续到后续集。 -2. TMDB 已可靠匹配到同一部作品和同一季时,换源跨站点也能复用手动匹配意图。 -3. 下一集自动搜索优先使用用户确认的关键词,避免默认匹配最新季。 -4. 手动再次选择时,新记忆覆盖旧记忆。 -5. 不同影片之间不串扰;TMDB 身份不可信时不做全局复用。 -6. 手动关键词失效或无结果时,保留现有自动搜索回退能力。 -7. 不修改播放器、弹幕渲染、下载和缓存文件格式以外的核心行为。 - -## 非目标 - -1. 不推断或构造弹幕 URL。 -2. 不直接将上一集弹幕内容用于下一集。 -3. 不新增 UI 设置页,不改变弹幕搜索对话框交互流程。 -4. 不引入网络请求依赖或第三方库。 -5. 不解决弹幕内容与画面的对齐问题,匹配粒度仍由弹幕源结果决定。 - -## 方案对比 - -### 方案 A:分层延续手动搜索关键词(推荐) - -在 `DanmakuMatchCache` 中增加两层映射: - -```text -siteKey@@@vodId -> searchTitle -tmdbId@@@seasonNumber -> searchTitle -``` - -第一层用于同一站点同一片源的后续集;第二层用于已确认 TMDB 身份的同一作品、同一季。手动选择时写入用户实际使用的搜索关键词;下一集或换源后自动搜索时优先使用该关键词。 - -优点: - -- 实现粒度小,只需修改缓存对象、两个手动选择入口和自动搜索入口。 -- 保留用户输入的“某某第二季”等季信息,不会被标题清洗逻辑丢掉。 -- 无需新增 SharedPreferences 表,可复用现有持久化入口。 -- TMDB 层可以跨 `siteKey/vodId` 复用,换源后不需要重复手动调整。 -- 搜索失败可回退现有 resolver,不会永久卡在错误关键词。 - -缺点: - -- 关键词映射是“用户最后一次手动选择”的意图,不是多季、多版本的完整数据库。 -- 如果用户手动选择时只输入了纯作品名,记忆不会自动补全季信息,但至少不会漂移到 resolver 生成的其他候选标题。 -- 自动搜索结果仍需依赖现有结果排序和选择逻辑。 -- TMDB 匹配错误时可能把错误意图扩散到多个站点,因此全局写入必须依赖可信身份。 -- 站点集数与 TMDB 季集映射不统一时,全局层需要降级,不能强行复用。 - -### 方案 D:仅使用 TMDB 全局记忆 - -只保存 `tmdbId@@@seasonNumber -> searchTitle`,不再保存站点级映射。 - -优点: - -- 数据结构最少,换源和换集都由一个 key 处理。 - -缺点: - -- 未匹配 TMDB 的播放完全失去记忆能力。 -- 站点级最后修正无法优先于全局旧记忆。 -- TMDB 短暂误匹配会影响原本正确的站点匹配。 - -结论:不采用。TMDB 层应作为跨站点复用层,站点层仍保留。 - -### 方案 B:系列级缓存手动 URL - -保存 `siteKey@@@vodId -> danmaku.url`,下一集直接或改造该 URL。 - -优点: - -- 不需要再发搜索请求。 - -缺点: - -- WebHTV 的弹幕 URL 缺少结构化 epId 信息。 -- URL 改造规则高度依赖弹幕源实现,无法保证跨源正确。 -- 弹幕源改版时难以排查。 - -结论:不采用。 - -### 方案 C:移植 CatVodSpider 的 epId 递增 - -记录当前集的 epId,下一集按差值计算目标 epId,再查系列缓存。 - -优点: - -- 参考项目已有实现,命中时准确率高。 - -缺点: - -- WebHTV `Danmaku` 模型没有 `epId`、`animeTitle`、`apiBase` 字段。 -- 需要扩展弹幕源响应模型、解析逻辑和缓存结构,涉及搜索 API 兼容性。 -- 弹幕源返回结构不统一时,解析失败率不可控。 - -结论:暂不采用。若后续弹幕源响应统一携带结构化集 ID,可再评估。 - -## 推荐方案设计 - -### 数据结构 - -在 `DanmakuMatchCache` 中新增两个映射: - -```java -private Map seriesItems; -private Map tmdbSeasonItems; -``` - -序列化后的结构继续由现有 Gson 持久化。旧缓存没有这两个字段时,反序列化后按空 Map 处理,不需要迁移脚本。 - -站点系列级 key: - -```text -clean(siteKey) + "@@@" + clean(vodId) -``` - -TMDB 全局季级 key: - -```text -"tmdb" + "@@@" + tmdbId + "@@@" + seasonNumber -``` - -`tmdbId` 必须来自已确认的 TMDB 匹配,而不是从弹幕标题猜测。`seasonNumber` 小于等于 0 时不写全局层。 - -两层 `Entry` 复用现有字段: - -- `name`:手动选择的弹幕项展示名。 -- `url`:手动选择的本集 URL,仅作诊断参考,不用于下一集或换源加载。 -- `sourceName`:手动选择的弹幕来源。 -- `searchTitle`:用户手动搜索使用的关键词,下一集优先使用。 -- `rawTitle`:选择时的站点标题。 -- `ruleTitle`:由关键词清洗出的规则标题。 -- `expectedTitle`:弹幕项展示名清洗出的作品名。 -- `updatedAt`:更新时间。 - -### 写入时机 - -手动选择弹幕时: - -1. 保留现有本集 `put(siteKey, vodId, episodeName, ...)`。 -2. 写入站点系列级 `putSeries(siteKey, vodId, searchTitle, rawTitle, item)`。 -3. 如果当前播放上下文有可信 TMDB 身份,写入全局 `putTmdbSeason(tmdbId, seasonNumber, searchTitle, rawTitle, item)`。 -4. `searchTitle` 取产生当前结果的已提交请求关键词,不读取用户随后编辑的输入框;为空时回退 rawTitle。 -5. 站点系列级条目总是覆盖旧值,代表“用户最后一次手动确认的意图”。 -6. TMDB 全局条目同样覆盖旧值,但仅在 TMDB 身份可信时写入。 - -两个手动入口必须同步写入: - -- `DanmakuSearchDialog.rememberManualDanmaku` -- `DanmakuSearchInputDialog.rememberManualDanmaku` - -### TMDB 身份获取 - -全局层不主动发起新的 TMDB 请求,也不解析弹幕结果推断 TMDB ID。实现应从播放请求上下文或现有 TMDB 匹配缓存读取: - -- `tmdbId` 必须已存在且有效。 -- `seasonNumber` 必须已解析且为正数。 -- 媒体类型应能确定为电视剧或动漫。 -- TMDB 匹配置信度不足、仅有作品 ID 但季映射不明确、或当前站点使用绝对集数而无法映射到 TMDB 季集时,跳过全局写入。 - -这保证全局层只承担“跨站点身份关联”,不引入新的匹配失败面。 - -### 读取时机 - -`DanmakuApi.search(MediaTitleRequest, Consumer)` 调整为以下顺序: - -1. 查询本集缓存,命中则直接返回。 -2. 查询站点系列级手动搜索关键词。 -3. 查询 TMDB 全局季级手动搜索关键词,与站点关键词相同则去重。 -4. 按站点级、TMDB 全局季级的顺序,将非空关键词放在常规标题之前调用 `searchFirst`;站点关键词无结果时可以继续尝试全局关键词。 -5. 若手动关键词搜索无结果,继续现有 `resolver.queryTitles()`。 -6. 清洗标题 fallback 与 AI fallback 保持现有顺序。 -7. 两层关键词都为空时,不改变现有搜索行为。 - -搜索仍使用当前集的 `episodeName`,不会复用上一集集数。 - -优先级原则: - -```text -本集 URL 缓存 - -> 站点级手动关键词 - -> TMDB 全局季级关键词 - -> resolver 常规标题 - -> resolver 清洗标题 - -> AI fallback 标题 -``` - -站点级优先于全局级,因为用户可能针对某个站点的特殊标题或片源结构做过最后一次修正。 - -### 与学习数据的关系 - -现有 `MediaTitleLearningStore` 仍保持不变: - -- 本集 URL 缓存负责“同一集不重复搜索”。 -- 系列级关键词负责“下一次搜索沿用用户意图”。 -- 学习样例继续用于标题解析,但自动弹幕搜索中手动关键词优先级高于学习样例。 - -### 边界行为 - -- `siteKey` 或 `vodId` 为空时不写入系列级记忆。 -- TMDB ID、季数或身份可信度不足时不写全局层。 -- 手动选择空弹幕或空 URL 时不写入。 -- 用户再次手动选择时,覆盖 `siteKey@@@vodId` 下的旧关键词;若 TMDB 身份可信,同步覆盖全局层。 -- 手动关键词搜索失败后回退现有自动搜索,不做次数统计。 -- 清理弹幕匹配缓存或学习数据时,系列级记忆随 `DanmakuMatchCache` 一起清除。 -- 不按 rawTitle 做跨 `vodId` 匹配,避免不同版本之间串扰。 -- TMDB 层 key 使用作品 ID 和季,不使用集数;集数只作为当前搜索参数。 -- 换源后使用全局关键词重新搜索,不跨源复用上一集 URL。 - -## 验收标准 - -1. 同一 `siteKey + vodId` 下,第 1 集手动输入“某动画 第二季”并选择结果;第 2 集自动搜索时发出的第一个请求使用“某动画 第二季”。 -2. 第 2 集返回结果后,本集 URL 缓存正常建立。 -3. 播放内容匹配到同一 `tmdbId + seasonNumber` 后,换站点或换 `vodId`,自动搜索优先使用全局手动关键词。 -4. 手动关键词无结果时,仍会走现有 resolver 标题搜索。 -5. 不同 `siteKey` 或不同 `vodId` 且无 TMDB 关联时,不共享系列级关键词。 -6. TMDB 匹配错误或季映射不可信时不写全局层。 -7. 旧 `DanmakuMatchCache` JSON 反序列化后新增 Map 为空,不崩溃。 -8. 再次手动选择时,站点级关键词被覆盖;TMDB 身份可信时全局关键词同步覆盖。 - -## 测试计划 - -新增或扩展单元测试: - -- `DanmakuMatchCacheTest` - - 系列级写入与读取。 - - TMDB 全局季级写入与读取。 - - 手动覆盖旧关键词。 - - `siteKey/vodId` 隔离。 - - TMDB 层与站点层隔离。 - - TMDB ID 或季数无效时不写入。 - - 旧 JSON 无 `seriesItems` 字段时可解析。 -- `DanmakuApiSourceTest` - - 下一集搜索优先使用系列级手动关键词。 - - 站点级缺失时使用 TMDB 全局关键词。 - - 站点级优先于 TMDB 全局关键词。 - - 手动关键词为空时保持现有标题顺序。 - - 手动关键词无结果时回退 resolver 标题。 - - 本集缓存命中时不再搜索。 - -建议执行: - -```bash -bash ./gradlew app:testDebugUnitTest --tests 'com.fongmi.android.tv.bean.DanmakuMatchCacheTest' --tests 'com.fongmi.android.tv.api.DanmakuApiSourceTest' -``` - -如果 `DanmakuApi.search(MediaTitleRequest, ...)` 的搜索行为难以在现有测试中注入,测试可先覆盖 `DanmakuMatchCache` 的系列级读取与写入,并将 `DanmakuApi` 的优先级逻辑拆到可测的纯函数中,避免为了测试引入网络层 mock。 - -## 回滚 - -改动集中在: - -- `app/src/main/java/com/fongmi/android/tv/bean/DanmakuMatchCache.java` -- `app/src/main/java/com/fongmi/android/tv/api/DanmakuApi.java` -- `app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchDialog.java` -- `app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchInputDialog.java` -- 需要把可信 TMDB 身份传入弹幕搜索请求的相关调用链 -- 对应测试文件 - -若线上行为异常,可整体 revert 以上改动。旧缓存 JSON 不受影响,新增字段会被忽略;新缓存 JSON 被旧版本读取时,多余字段也会被 Gson 忽略。 - -## 开放问题 - -1. 用户手动选择时,是否只输入了纯作品名而没有季信息?若是,方案 A 只能保证搜索词不漂移,无法自动识别应选哪一季。 -2. 弹幕源搜索接口是否支持“作品名 + 季”关键词稳定返回目标季结果?需要在真实设备上用用户实际站点验证。 -3. 现有播放调用链中 TMDB `tmdbId + seasonNumber` 的最短可靠传递路径需要实现前确认。 -4. 是否需要在弹幕搜索对话框中显示“已记住关键词”或“已同步 TMDB”?首版不增加 UI,避免扩大改动面。 diff --git a/docs/dev1-beta-review-20260907.md b/docs/dev1-beta-review-20260907.md deleted file mode 100644 index 6c2691c010..0000000000 --- a/docs/dev1-beta-review-20260907.md +++ /dev/null @@ -1,32 +0,0 @@ -# dev1 合并 beta 与代码评审(2026-09-07) - -## 范围 - -- 基线:`origin/beta` 的 `cc88e278a8ddc2088a82a68dbf1671e419606a29` -- 待合入提交:`01ee74c595fd9e6b2fdb7e758274c2423781fa00` -- 保护的既有脏路径:`gradlew`(仅工作树执行位变化,不纳入提交) - -## 合并与评审结论 - -- 合并 `origin/beta` 无内容冲突;`beta` 新增的 PR #218 合并提交只补齐分支历史,合并后代码树与待评审提交的代码树一致。 -- 已进入 `beta` 且未与本次 ABI 修复冲突的站点弹窗主题改动不重复评审。 -- 首轮评审发现:下载选择会规范化 ABI 名称,但安装后记录版本的 `displayVersion()` 仍做原始字符串比较,可能出现已正确选择下载包却记录回退版本的情况。 -- 修复:`displayVersion()` 复用统一的 ABI 下载选择逻辑,避免两套匹配规则漂移。 -- 二轮评审覆盖安装入口、安装实现、详情页版本、列表版本/大小显示、ABI 规范化及 fallback 路径;未发现新的阻塞问题。 - -## 验证 - -- `:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.lab.LabEnvTest`:通过。 -- `:app:testMobileArmeabi_v7aDebugUnitTest --tests com.fongmi.android.tv.lab.LabEnvTest`:通过。 -- `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`:通过。 -- `:app:compileLeanbackArmeabi_v7aDebugJavaWithJavac`:通过。 -- `git diff --check`:通过。 -- 一次组合 Mobile 单测命令还执行了 4186 个既有测试,其中 4 个与本任务无关的历史测试失败:`AdRuleManageDialogLayoutTest`、`SiteDialogThemeSourceTest`、`FfmpegVc1SupportTest` 两项;本任务 `LabEnvTest` 未失败,未扩大范围修复。 - -## 复核结论 - -通过。ABI 选择、下载匹配、安装、版本显示、更新判断和错误提示共用同一套规范化选择规则;当前 APK flavor 优先于设备 ABI,未知架构和缺失下载包均不会静默回退到首个包或 arm64。 - -## 回滚 - -回滚本任务最终提交或使用对应的 `recovery/dev1-beta-review-20260907/*` 本地恢复标签。 diff --git a/docs/fix-detail-direct-play-mode-20260915.md b/docs/fix-detail-direct-play-mode-20260915.md deleted file mode 100644 index a123cf7b6f..0000000000 --- a/docs/fix-detail-direct-play-mode-20260915.md +++ /dev/null @@ -1,63 +0,0 @@ -# 详情直放播放回归修正记录(2026-09-15) - -## Recovery anchor - -- 目标:详情直放模式点击播放必须进入内嵌全屏播放器,不能误入沉浸融合内嵌播放。 -- 基线:`06442ea996fc8e964f51da973507b8ac3fa8d186`。 -- 范围:`TmdbDetailActivity.getDetailMode()` 单处模式还原、`DetailModeControllerTest` 回归用例、本任务记录。 -- 当前状态:模式还原、首播时序与播放语义已提交;本轮复测确认最近首帧延迟改动再次让详情直放先显示融合详情页,已删除首帧延迟路径并恢复点击后立即全屏,待收口提交与恢复标签。 - -## 根因与修正 - -- 根因:`getDetailMode()` 在既无 `detail_mode` 也无 `fusion` 标记时固定返回炫彩详情(`DETAIL_OPEN_ENHANCED`)。详情直放模式通过无标记入口载入后,点击播放误判成沉浸融合并调用 `playInline()`,而不是进入详情全屏播放器。 -- 修正:无 `detail_mode` 标记时改用 `Setting.getDetailOpenMode()` 还原用户当前选择;保留融合入口的 `fusion=true` 标记行为,已标记 `detail_mode` 的入口不受影响。 -- 防回归:`DetailModeControllerTest.playerDetailMode_keepsFullscreenInlinePlayback` 锁定 `onPlay()` 的 `isFusionMode() -> playInline()` / `isPlayerMode() -> playDetailFullscreen()` 分支和无标记模式还原契约。 - -## 验证 - -- `./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests DetailModeControllerTest --tests TmdbDetailActivityLayoutTest --offline` 通过,`BUILD SUCCESSFUL in 15s`;生产 Java 编译随用例执行通过。 - -## 运行时复测与二次修正(2026-09-15) - -- 首次设备复测:模拟器 `192.168.50.3:5559` 已安装包含提交 `01c5798b0f` 的 APK,设置 `detail_open_mode=4`;点击“继续播放”后播放器仍以 252dp 内嵌卡片显示,详情页内容可见,说明前一版 Java 分支测试不足以覆盖首播异步时序。 -- 根因:`playDetailFullscreen()` 在首播无视频尺寸时先设置 `inlineFullscreenDeferred=true`,但 `startInlinePlayer()` 随后无条件清零该标记;`STATE_READY` 到达后无法调用 `enterInlineFullscreen()`。 -- 修正:在 `playDetailFullscreen()` 作出首播形态决策前清除旧的 deferred 状态;保留本次首播设置的状态,不在异步 `startInlinePlayer()` 中覆盖它。 -- 防回归:`DetailModeControllerTest.playerDetailMode_preservesDeferredFullscreenUntilPlayerReady` 先在旧代码上失败,再在修正后通过,锁定 deferred 状态的生命周期。 -- 最终验证:`./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.detail.DetailModeControllerTest --tests com.fongmi.android.tv.ui.activity.TmdbDetailActivityLayoutTest --offline` 通过,`BUILD SUCCESSFUL in 9s`;`git diff --check` 通过。 -- 设备验证:重新打包并安装 `app-mobile-arm64_v8a-debug.apk` 后,从首页打开“早春晴朗”详情,点击“继续播放”;截图 `/tmp/webhtv-detail-direct-fixed.png` 显示播放器铺满 `1920x1080` 窗口,详情背景不再可见,播放标题为“早春晴朗:2. 职场不是过家家”,播放正常。 - -## 立即全屏复测与第三次修正(2026-09-15) - -- 用户复测反馈仍可见融合式过渡。对 `192.168.50.3:5559` 重现:上一版本点击后 1 秒截图 `/tmp/webhtv-5559-after-1s.png` 仍为播放器卡片叠在详情页上,约 6 秒才全屏;入口日志确认 `mode=4`,不是设置或入口参数错误。 -- 根因:`ea9b666752` 为避免首帧前黑色覆盖,给 `playDetailFullscreen()` 增加了 `inlineFullscreenDeferred`,导致详情直放首播必须等待视频尺寸/READY 才进入全屏;这与“点击后直接全屏”的产品契约冲突。 -- 修正:删除 `inlineFullscreenDeferred` 字段、延迟判断和 READY 回调补偿路径;`playDetailFullscreen()` 在发起异步解析前直接执行 `enterInlineFullscreen()`,保留当前播放复用与融合模式路径不变。 -- 防回归:`DetailModeControllerTest.playerDetailMode_entersFullscreenBeforeAsyncPlayerLoad` 先在旧延迟逻辑上失败,再在删除延迟后通过。 -- 设备验证:重新打包安装后,5559 点击“继续播放”约 1 秒截图 `/tmp/webhtv-direct-immediate-fixed-1s.png` 已是全屏黑色播放器窗口;约 6 秒截图 `/tmp/webhtv-direct-immediate-fixed-playing.png` 已正常显示视频,详情页不再可见。 - -## 详情直放入口播放语义校正(2026-09-15) - -- 设备复测确认,曾将 `isPlayerMode()` 加入 `maybeAutoPlayInline()` 后,用户从内容列表进入详情页会在资源加载完成后立即开始播放。 -- 产品预期:详情直放只约束用户明确触发播放后的行为,即点击播放、继续播放或选集等操作后直接进入全屏播放器;进入详情页本身仍应停留在详情内容中。 -- 修正:从 `maybeAutoPlayInline()` 的自动播放条件中移除 `isPlayerMode()`,保留融合模式和入口显式 `auto_play=true` 的既有自动播放语义;`playDetailFullscreen()` 的立即全屏逻辑保持不变。 -- 防回归:新增 `playerDetailMode_waitsForExplicitPlaybackAction`,锁定详情直放不会因进入详情页而自动调用 `onPlay()`。 -- 最终验证:`./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.detail.DetailModeControllerTest --offline` 通过,`BUILD SUCCESSFUL in 13s`。 - -## 全屏启动连续性修正(2026-09-15) - -- 用户复测现象:点击播放后先进入全屏播放界面,随后短暂黑屏,再次出现播放画面。 -- 根因:`playDetailFullscreen()` 已先调用 `enterInlineFullscreen()`,但异步地址解析入口 `playInline(long, String, String)` 随即调用 `stopInlinePlayerForReload()` 清空播放器,形成“显示全屏容器 → 清空播放器 → 重新准备播放”的二次切换。 -- 修正:移除异步解析开始前的 `stopInlinePlayerForReload()`,保留真正开始新播放时 `startInlinePlayer()` 内既有的播放器停止、清理和重新准备流程,避免在等待解析期间提前破坏全屏播放表面。 -- 防回归:新增 `playerDetailMode_doesNotClearPlayerAfterEnteringFullscreen`,锁定详情直放进入全屏以后,异步解析入口不得提前清空播放器。 -- 定向验证:`bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.detail.DetailModeControllerTest` 通过,`BUILD SUCCESSFUL in 11s`;`git diff --check` 通过。 - -## 首帧延迟回归修正(2026-09-15) - -- 用户复测现象:详情直放点击播放后先显示沉浸融合详情页,没有直接全屏。 -- 根因:`948000af96` 为避免首帧前黑屏引入 `detailPlayerFullscreenPending`,`playDetailFullscreen()` 首播时保留详情页,直到 `onFirstFrameRendered()` 才进入全屏;这覆盖了此前 `dcae3bb7c7` 的“点击后立即全屏”契约。 -- 修正:删除 `detailPlayerFullscreenPending`、`revealDetailPlayerFullscreen()` 和首帧回调;`playDetailFullscreen()` 在异步解析前直接执行 `enterInlineFullscreen()`。保留异步解析入口不提前清播放器的既有修复。 -- 防回归:改写 `playerDetailMode_entersFullscreenBeforeAsyncPlayerLoad` 与 `playerDetailMode_doesNotClearPlayerAfterEnteringFullscreen`,锁定 `enterInlineFullscreen()` 必须先于 `playInline()`,且不得重新引入首帧延迟。 -- 定向验证:`./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests '*DetailModeControllerTest' --tests '*TmdbDetailActivityLayoutTest'` 通过,`git diff --check` 通过。 - -## 回滚 - -- 回退 `TmdbDetailActivity.getDetailMode()` 单处修改和 `DetailModeControllerTest.playerDetailMode_keepsFullscreenInlinePlayback` 用例即可恢复基线行为。 diff --git a/docs/fix-duplicate-playback-start-20260918.md b/docs/fix-duplicate-playback-start-20260918.md deleted file mode 100644 index b1681f6d20..0000000000 --- a/docs/fix-duplicate-playback-start-20260918.md +++ /dev/null @@ -1,22 +0,0 @@ -# 播放器重复启播修复 - -## 根因 - -第一轮修复解决了 TV 端重复取址请求的竞态,但 `192.168.50.3:5559` 实测表明问题仍存在于更早的播放阶段:同一条 `PlaybackTrace` 只有一次 `startPlayer`,首帧后约 0.6 秒就启动了 `PreCache` 后台 HLS 预加载,前台随后连续进入两次 `BUFFERING`,累计等待约 4 秒至 13 秒后才稳定。也就是说,用户看到的“反复加载”是前台播放与后台预取争抢同一远端流,而不是重复 `playerContent`。 - -## 修复 - -在 TV 端每次开始新的 `playerContent` 请求前调用 `mViewModel.cancelPlayerContent()`,使同一时刻只有最新取址请求可以继续。保留现有请求代次校验和已应用结果去重作为第二道防线。 - -同时让 Exo 预加载在首帧或恢复后等待 5 秒的稳定播放窗口(正在播放且不再 loading)再启动;遇到 buffering 或 seek 时重新计时。这样保留用户的预加载设置,但优先保证前台启播和恢复,不让后台任务在最脆弱的阶段抢占带宽。 - -后续实机日志还发现,每个预加载分段完成后会立即触发下一次检查,导致数个后台任务在几秒内连续启动。现在每个已完成分段之间固定等待 5 秒,再评估前台缓冲和后续预加载范围,避免突发任��务持续争抢网络与解码资源。 - -192.168.50.3:5559 复测中,45 秒窗口内只启动 3 个后台预加载任务,相邻任务间隔约 10.3 至 10.6 秒;最终构建安装后的 35 秒复测中仅启动 4 个任务,相邻最小间隔 5.5 秒。两次复测播放器均保持前台播放且进度持续推进,未出现重复启播、播放器重建、重缓冲或异常。 - -## 验证计划 - -1. 运行 `ReaderPlaybackRoutingSourceTest`,确认移动端和 TV 端都在新请求前取消旧请求。 -2. 编译 TV debug APK 并安装至 `192.168.50.3:5559`。 -3. 清空 logcat 后复现一次播放,确认一笔用户启播只产生一条 `startPlayer dispatch`,并进入持续播放态。 -4. 额外确认 `playback-buffer` 在首帧后的稳定窗口内不再被后台预加载触发,设备上不出现连续的首播恢复加载。 diff --git a/docs/fix-exo-startup-double-resume.md b/docs/fix-exo-startup-double-resume.md deleted file mode 100644 index 1c832ea32b..0000000000 --- a/docs/fix-exo-startup-double-resume.md +++ /dev/null @@ -1,59 +0,0 @@ -# EXO 起播闪屏与重复续播修复 - -## Recovery anchor - -- **目标**:确认“EXO 播放前起播两次、第一次闪屏,第二次才从历史位置续播”是否存在;若存在,消除首次起播后的重复历史定位。 -- **验收**:手机端首次 EXO 起播把历史续播位置直接交给 `PlayerManager`;`onPrepare()` 的 `setPosition()` 不再对同一位置再次 `seekTo()`;无历史位置、IJK、TMDB 恢复等既有分支继续可用;源码回归测试与手机/TV Java 编译通过。 -- **当前状态**:已实现;源码回归与语法检查通过,Android flavor 编译和真实设备视觉验证受环境限制未完成。 -- **下一动作**:提交本任务三处文件并创建本地恢复标签。 - -## 结论与证据 - -这条反馈**存在**,但严格说不是正常路径中 EXO `start()` 被调用两次: - -1. `app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` 的 `setPlayer(Result)` 使用不带起播位点的 `startPlayer(...)`,因此 `PlayerManager`/`ExoPlayerEngine` 首次以默认位点(通常为 0)执行 `setMediaItem(..., C.TIME_UNSET)`。 -2. 同一个播放准备回调随后进入 `onPrepare() -> setPosition()`;手机端旧逻辑无条件调用 `player().seekTo(historyPosition)`。若第一帧先于该 seek 显示,就会看到首帧闪过后跳到续播位置,用户感知为“起播两次”。 -3. `app/src/leanback/java/.../VideoActivity.java` 已有对应契约:`mInitialPlaybackPosition` 传入带位置的 `startPlayer(...)`,`setPosition()` 发现位置相同就清空一次性标记并跳过重复 seek。`PlayerManager` 和 `ExoPlayerEngine` 已支持该位置参数,因此缺口在手机 flavor 的接线,不在 EXO 引擎本身。 - -## 最小修复 - -- 手机端增加一次性 `mInitialPlaybackPosition`。 -- 首次 `setPlayer(Result)` 计算 `resolveInitialPlaybackPosition()`,通过已有的 position-aware `startPlayer(...)` 传入 EXO/IJK 共用的起播链路。 -- 手机端补齐与 TV 相同的 `resolveInitialPlaybackPosition()` 和重复定位保护;无有效历史位置时清理标记,不改变原有恢复逻辑。 -- 增加源码回归断言,锁住“首次带位点起播 + `onPrepare` 跳过同位置 seek”的顺序。 - -## 风险、回滚与验证边界 - -- 不修改 Media3/EXO 引擎、AAR、lock、native、MPV 或公共 API;位置参数原本已在公共播放器链路中使用。 -- 只改变手机端首次播放的历史位置注入,质量切换及显式 TMDB 恢复路径仍沿用原有语义。 -- 若出现回归,可整体回退本任务提交;变更文件仅限手机 `VideoActivity`、对应源码回归测试和本任务文档。 -- 当前环境无可用 ADB 设备,无法完成真实设备的视觉 A/B;定向源码测试与两种 flavor Java 编译用于确认生命周期接线和编译契约,设备端仍需后续用带历史进度的 EXO 片源观察只出现一次有效首帧。 - -## 实施记录 - -### 2026-09-06 12:00 CST:手机端生命周期补齐 - -- 在 `app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java` 增加一次性 `mInitialPlaybackPosition`,在首次 `setPlayer(Result)` 中复用 `resolveInitialPlaybackPosition()`,并通过已有的 position-aware `startPlayer(...)` 传入 `PlayerManager`。 -- 将历史位点解析从 `setPosition()` 抽成 `resolveInitialPlaybackPosition()`;`onPrepare() -> setPosition()` 在位点已由首次起播消费时清空一次性标记并跳过重复 `seekTo()`,其余 IJK/显式恢复/无历史分支保持原有路径。 -- 在 `app/src/testMobile/java/com/fongmi/android/tv/ui/activity/PlayerPlaybackRegressionSourceTest.java` 增加源码回归断言,锁定首次带位点起播与重复定位保护的顺序。 - -### 验证结果 - -- **通过**:定向源码回归测试 `PlayerPlaybackRegressionSourceTest`,上一轮执行结果为 `PASS 9 tests`。 -- **通过**:两处变更 Java 源文件的括号/引号平衡检查;`git diff --check` 无输出。 -- **未执行(环境阻断)**:`compileMobileArm64_v8aDebugJavaWithJavac`。Gradle 配置阶段明确失败:未找到 Android SDK,且工作区没有 `local.properties`,`ANDROID_HOME`/`ANDROID_SDK_ROOT` 均未设置。该失败属于验证环境问题,不是代码编译错误证据。 -- **未执行(设备不可用)**:带历史进度的 EXO 真实设备视觉 A/B;需要后续在可用 ADB 设备上确认首帧不再闪回。 - -### 回滚与提交 - -- 回滚锚点:`cebe42b190d5d7f1306e4ea3d0b6d833112ad464`(任务启动时 HEAD)。 -- 本任务提交应只包含本文档、手机 `VideoActivity` 和源码回归测试;若设备验证发现回归,整体回退该提交即可。 -- **提交/恢复标签**:由本次 guard 收口命令生成;最终 commit 与 recovery tag 见交付摘要及 guard 状态。 - - -### 2026-09-12:片头片尾异步回调与 EXO 起播竞态修复 - -- 两份现场日志显示 EXO 起播存在多轮缓冲/恢复定位,同时去广告规则网络探测出现超时;片头片尾计划查询也可能在播放器尚未 READY 时返回。 -- 手机端和 TV 端的 `onIntroSkipPlanLoaded()` 原先在查询完成后立即调用 `applyAutoIntroSkip()`,可能让 seek 与 EXO 首次 prepare、历史位置恢复或去广告定位并发。 -- 现在异步计划回调只在播放器处于 `STATE_READY` 时应用跳过。若计划提前返回,READY 主路径仍会申请并应用缓存计划,不会丢失自动跳过能力。 -- 增加源码回归断言,确保两个 flavor 都先检查 READY,再执行自动跳过。 diff --git a/docs/integration/README.md b/docs/integration/README.md deleted file mode 100644 index 014caf520d..0000000000 --- a/docs/integration/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# WebHTV 开发者接入文档 - -本目录只记录当前仓库代码实际支持的接入能力。字段、默认值、调用链、返回协议和错误处理以源码为准。 - -## 索引 - -- [总配置接入](configuration.md):顶层配置、网络规则、Doh/Proxy/Headers/Rules。 -- [点播源接入](vod-site.md):Site 字段、站点类型、WebHome chrome、验证清单。 -- [Spider 接入协议](spider.md):Java/JAR、JS、Python、CatSpider 与生命周期。 -- [Result 与 Vod 返回协议](result-vod.md):列表、分类、筛选、详情和线路结构。 -- [直播源接入](live.md):TXT/M3U/JSON/Spider、Group、Channel、Catchup、EPG、DRM。 -- [解析器接入](parser.md):解析类型、`ext.flag/header`、`playUrl` 前缀。 -- [字幕接入](subtitle.md):Spider `subs`、格式、鉴权和局域网注入。 -- [弹幕接入](danmaku.md):Spider `danmaku`、来源字段和注入。 -- [播放结果与播放器接入](player.md):播放字段、多 URL、DRM、错误处理。 -- [本地代理与服务端接口](proxy-server.md):本地服务、Spider proxy、Action 和端点。 -- [扩展包接入](extensions.md):JAR/JS/Python/CatSpider 加载与验证。 - -## 推荐阅读顺序 - -1. [总配置接入](configuration.md) -2. 根据场景读 [点播源接入](vod-site.md) 或 [直播源接入](live.md) -3. [Spider 接入协议](spider.md) 与 [Result 与 Vod 返回协议](result-vod.md) -4. [播放结果与播放器接入](player.md),需要时读解析、字幕、弹幕和代理 -5. 使用非 Java 运行时读 [扩展包接入](extensions.md) - -## 源码总览 - -- 配置:`app/src/main/java/com/fongmi/android/tv/api/config/` -- Bean/协议模型:`app/src/main/java/com/fongmi/android/tv/bean/` -- Spider 接口:`catvod/src/main/java/com/github/catvod/crawler/Spider.java` -- 运行时:`app/src/main/java/com/fongmi/android/tv/api/loader/`、`quickjs/`、`chaquo/` -- 本地服务:`app/src/main/java/com/fongmi/android/tv/server/` - -更完整的 WebHome、管理页面、观影记录同步和构建说明见仓库根目录 `README.md` 和 `webhome-devkit/docs/应用完整开发文档.md`。 diff --git a/docs/integration/configuration.md b/docs/integration/configuration.md deleted file mode 100644 index ed1146953f..0000000000 --- a/docs/integration/configuration.md +++ /dev/null @@ -1,143 +0,0 @@ -# 总配置接入 - -WebHTV 配置的主要入口是 JSON。点播配置由 `VodConfig` 加载;若配置中包含 `lives`,也会同步生成直播配置。相对路径以配置文件 URL 为基准解析。 - -## 顶层字段 - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `spider` | string | `""` | 全局 JAR Spider 地址。站点或直播源未单独指定 `jar` 时使用 | -| `sites` | array | `[]` | 点播站点列表,见 [点播源接入](vod-site.md) | -| `parses` | array | `[]` | 点播解析器列表,见 [解析器接入](parser.md) | -| `lives` | array | `[]` | 直播配置列表,见 [直播源接入](live.md) | -| `doh` | array | `[]` | DNS over HTTPS 配置,见下方 `doh` 表 | -| `proxy` | array | `[]` | HTTP/HTTPS/SOCKS 代理规则,见下方 `proxy` 表 | -| `hosts` | array | `[]` | host 覆盖规则,字符串数组,格式 `匹配规则=目标host或IP` | -| `headers` | array | `[]` | 按 host 注入请求 header,见下方 `headers` 表 | -| `rules` | array | `[]` | 嗅探规则,见下方 `rules` 表 | -| `hlsRules` | array | `[]` | HLS 广告清理规则 | -| `groupRules` | array | `[]` | 直播分组规则 | -| `ads` | array | `[]` | 广告域名或正则字符串 | -| `flags` | array | `[]` | 播放 flag 字符串数组 | -| `wallpaper` | string | `""` | 壁纸 URL 或路径 | -| `logo` | string | `""` | 配置图标 | -| `notice` | string | `""` | 配置公告 | -| `home` | string | `""` | 默认站点 `key` | -| `parse` | string | `""` | 默认解析器 `name` | -| `urls` | array | `[]` | 配置仓库/多配置入口 | -| `msg` | string | `""` | 错误消息;非空时加载会直接失败 | - -## 子对象字段 - -### `doh[]` - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `name` | string | `""` | DoH 显示名 | -| `url` | string | `""` | DoH endpoint,例如 `https://dns.google/dns-query` | -| `ips` | string[] | `[]` | bootstrap DNS IP;为空时用系统 DNS 解析 DoH host | - -### `proxy[]` - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `name` | string | `""` | 代理规则名 | -| `hosts` | string[] | `[]` | host 匹配规则;支持 `contains`、Java 正则和 `*` | -| `urls` | string[] | `[]` | 代理地址;支持 `http://`、`socks://`、`socks5://`,账号密码写 `scheme://user:pass@host:port` | - -### `headers[]` - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `host` | string | `""` | host 匹配规则 | -| `header` | object | `null` | 命中后注入的 header,覆盖同名值 | - -### `rules[]` - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `name` | string | `""` | 规则名 | -| `hosts` | string[] | `[]` | 适用 host;匹配 URL host 和 `url` 查询参数里的 host | -| `regex` | string[] | `[]` | 视频 URL 识别规则 | -| `exclude` | string[] | `[]` | 排除规则 | -| `script` | string[] | `[]` | WebView 页面加载后执行的 JS | - -## 本地配置记录字段 - -Room/本地配置记录也使用 `Config` 模型: - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `id` | `int` | `0` | Room 自增主键,不是远程配置必须提供的字段 | -| `type` | `int` | `0` | 配置类型 | -| `time` | `long` | `0` | 更新时间 | -| `url` | `string` | `""` | 配置 URL | -| `json` | `string` | `""` | 内联配置 JSON | -| `name` | `string` | `""` | 显示名 | -| `logo` | `string` | `""` | 图标 | -| `home` | `string` | `""` | 首页站点 key 或直播源 name | -| `parse` | `string` | `""` | 默认解析器名 | -| `notice` | `string` | `""` | 公告 | -| `danmaku` | `string` | `""` | 弹幕配置入口,见 [弹幕接入](danmaku.md) | - -## 加载流程 - -1. 请求或读取配置文本。 -2. `VodConfig` 解析顶层 `sites`、`parses`、`lives` 和网络规则。 -3. `Site.objectFrom` / `Live.objectFrom` / `Parse.objectFrom` 转换为对应模型。 -4. 剔除无效项并合并用户自定义站点注入。 -5. 按 `home` / `parse` 恢复首页源和解析器;找不到时回退第一个有效项。 - -## 完整示例 - -```json -{ - "spider": "./spider.jar", - "wallpaper": "./wallpaper.jpg", - "logo": "./logo.png", - "notice": "测试配置", - "home": "demo", - "parse": "演示解析", - "sites": [ - { "key": "demo", "name": "演示点播", "type": 3, "api": "csp_Demo", "jar": "./demo.jar", "ext": {} } - ], - "parses": [ - { "name": "演示解析", "type": 1, "url": "https://example.com/parse?url=", "ext": { "flag": ["demo"] } } - ], - "lives": [ - { "name": "演示直播", "url": "./live.m3u", "epg": "https://example.com/epg.xml" } - ], - "doh": [ - { "name": "Google", "url": "https://dns.google/dns-query", "ips": ["8.8.8.8"] } - ], - "proxy": [ - { "name": "app", "hosts": ["example.com"], "urls": ["socks5://127.0.0.1:7897"] } - ], - "headers": [ - { "host": "example.com", "header": { "User-Agent": "Mozilla/5.0" } } - ], - "rules": [ - { "name": "demo", "hosts": ["example.com"], "regex": ["\\.m3u8"], "exclude": ["ad"], "script": [] } - ], - "ads": ["ad.example.com"], - "flags": ["需要解析"] -} -``` - -## 校验清单 - -- 所有数组字段都应为数组,字符串字段都应为字符串或数字。 -- 相对路径必须与配置文件位于同一服务器目录,或改写为绝对 URL。 -- `home` 必须精确匹配 `sites[].key`;`parse` 必须精确匹配 `parses[].name`。 -- `proxy.hosts` 不要写 `a=b`;DNS 覆盖应写在顶层 `hosts`。 -- 含 `msg` 的配置会被视为错误响应。 - -## 源码依据 - -- `app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java` -- `app/src/main/java/com/fongmi/android/tv/api/config/LiveConfig.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Config.java` -- `catvod/src/main/java/com/github/catvod/bean/Doh.java` -- `catvod/src/main/java/com/github/catvod/bean/Proxy.java` -- `catvod/src/main/java/com/github/catvod/bean/Header.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Rule.java` diff --git a/docs/integration/danmaku.md b/docs/integration/danmaku.md deleted file mode 100644 index 88cb27398c..0000000000 --- a/docs/integration/danmaku.md +++ /dev/null @@ -1,43 +0,0 @@ -# 弹幕接入 - -弹幕可作为播放结果 `danmaku` 数组返回,也可由局域网接口注入。当前模型记录弹幕源,具体渲染由 Media3 扩展处理。 - -## 播放结果中的字段 - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `url` | string | `""` | 弹幕文件或接口 URL | -| `name` | string | 回退 `url` | 弹幕源显示名 | -| `source` | string | `""` | 来源名;别名 `from`、`site`、`provider`、`platform` | -| `apiSourceName` | string | `""` | API 来源名;别名 `apiSource`、`api_source`、`api_source_name` | - -## 示例 - -```json -{ - "url": "https://example.com/video.m3u8", - "danmaku": [ - { "name": "演示弹幕", "url": "https://example.com/danmaku.xml", "source": "demo" } - ] -} -``` - -## 局域网注入 - -```text -GET/POST /action?do=refresh&type=danmaku&path=弹幕URL -``` - -## 接入要求 - -1. 弹幕 URL 应返回目标影片/集数的弹幕数据。 -2. 无法可靠匹配时应返回空数据,不应混入其它内容。 -3. 弹幕接口异常不应导致视频播放失败。 -4. 弹幕格式和时间单位按目标渲染器协议提供,不要自行混用秒和毫秒。 - -## 源码依据 - -- `app/src/main/java/com/fongmi/android/tv/bean/Danmaku.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Result.java` -- `app/src/main/java/com/fongmi/android/tv/server/process/Action.java` -- `docs/danmaku-manual-match-memory-design.md` diff --git a/docs/integration/extensions.md b/docs/integration/extensions.md deleted file mode 100644 index d59c610924..0000000000 --- a/docs/integration/extensions.md +++ /dev/null @@ -1,79 +0,0 @@ -# 扩展包接入 - -站点和直播源通过 `type`、`api`、`jar`、`ext` 选择运行时。扩展 JAR 可以由顶层 `spider` 或站点/直播 `jar` 提供。 - -## 运行时选择 - -| `api` 形态 | 运行时 | -| --- | --- | -| `http.../spider/...` | CatSpider HTTP 协议 | -| `*.py` / 含 `.py` | Python/Chaquopy | -| `*.js` / 含 `.js` | QuickJS | -| `csp_*` | JAR/JAVA | -| 其它 | `SpiderNull` | - -## JAR/JAVA - -1. 继承 `com.github.catvod.crawler.Spider`。 -2. 在 `init(Context, String extend)` 解析站点 `ext`。 -3. 实现内容接口并返回合法 JSON。 -4. 在 `destroy()` 释放线程、网络、脚本上下文和流。 -5. JAR 地址支持配置相对路径;站点 `jar` 覆盖顶层 `spider`。 - -## QuickJS - -1. `api` 指向 `.js`。 -2. 可 `export default` 对象/工厂函数,或导出 CatVod 兼容 `__jsEvalReturn()`。 -3. 方法签名见 [Spider 接入协议](spider.md)。 -4. 可用 `req/_http/pdfh/pdfa/pdfl/joinUrl/md5X/aesX/rsaX/local/getProxy/js2Proxy`。 -5. CatVod 兼容模式初始化对象为 `{ stype: 3, skey, ext }`,代理使用 `js2Proxy(...)`。 - -## Python/Chaquopy - -1. `api` 指向 `.py`,继承 `base.spider.Spider`。 -2. `getDependence()` 可返回依赖模块名列表;运行时先下载到缓存目录再初始化。 -3. 可用基类工具:`loadSpider/loadModule/fetch/post/html/regStr/cleanText/getProxyUrl/getCache/setCache/delCache`。 -4. 只有包含 Chaquopy 运行时的构建变体可用;普通构建遇 Python 源会退回 `SpiderNull`。 -5. `ext=[]` 会被规范化为 `{}`,避免部分直播 Python Spider 反序列化失败。 - -## CatSpider HTTP - -`api` 是 `http.../spider/...` 时的内置协议,不需要本地 JAR/脚本;方法映射与请求体见 [Spider 接入协议](spider.md)。 - -## 配置示例 - -```json -{ - "spider": "./java-spiders.jar", - "sites": [ - { "key": "java", "name": "Java 演示", "type": 3, "api": "csp_Demo", "jar": "./java.jar", "ext": {} }, - { "key": "js", "name": "JS 演示", "type": 3, "api": "./spider.js", "ext": {} }, - { "key": "cat", "name": "CatSpider 演示", "type": 3, "api": "https://bundle.example.com/spider/index", "ext": {} } - ] -} -``` - -## 错误处理 - -- 初始化失败会退回 `SpiderNull`,不应影响其它站点。 -- JAR/脚本更新后需要清理加载缓存或刷新配置。 -- Python 依赖下载失败会初始化失败;不要在 `init` 中无限重试。 -- 外部脚本不要依赖未公开的 App 内部类;优先使用运行时公开 API。 - -## 验证清单 - -1. 冷启动后站点可用。 -2. JAR/脚本路径加载正确。 -3. JS/Python 初始化 `ext` 类型正确。 -4. 切换站点后 `siteKey`、代理和缓存隔离正确。 -5. 清理或重载配置后不再泄漏旧 Spider。 - -## 源码依据 - -- `app/src/main/java/com/fongmi/android/tv/api/loader/BaseLoader.java` -- `app/src/main/java/com/fongmi/android/tv/api/loader/JarLoader.java` -- `app/src/main/java/com/fongmi/android/tv/api/loader/JsLoader.java` -- `app/src/main/java/com/fongmi/android/tv/api/loader/PyLoader.java` -- `quickjs/src/main/java/com/fongmi/quickjs/crawler/Spider.java` -- `chaquo/src/main/python/base/spider.py` -- `chaquo/build.gradle` diff --git a/docs/integration/live.md b/docs/integration/live.md deleted file mode 100644 index f373f6c1d4..0000000000 --- a/docs/integration/live.md +++ /dev/null @@ -1,118 +0,0 @@ -# 直播源接入 - -直播源支持 TXT、M3U、JSON 和 Spider 返回。直播源级配置会作为频道默认值;频道级配置优先。 - -## 来源识别 - -| 来源 | 识别方式 | 说明 | -| --- | --- | --- | -| M3U | 文本包含 `#EXTM3U` 且非 `#genre#` TXT | 解析 `#EXTINF`、`group-title`、`tvg-*`、`catchup-*`、`#EXTVLCOPT`、`#KODIPROP` | -| TXT | 分组行含 `#genre#`,频道行 `频道名,播放地址` | 同频道多地址用 `#`;单地址可 `url|header参数` | -| JSON | JSON array | 按 `groups[].channel[]` 解析 | -| Spider | `api` 非空 | 调用 Spider `liveContent(url)` 返回文本或 JSON | - -## Live 根字段 - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `name` | string | `""` | 直播源唯一名称 | -| `url` | string | `""` | 列表 URL;`api` 为空时直接请求 | -| `type` | number | `0` | 常规直播列表写 `0` | -| `playerType` | number | `0` | 保留的播放器类型元数据;站点注入默认写 `2`,当前播放内核选择不读取此字段 | -| `api` | string | `""` | 直播 Spider 入口 | -| `ext` | string/object | `""` | 直播 Spider 扩展参数 | -| `jar` | string | `""` | 直播专用 JAR,为空时继承顶层 `spider` | -| `click` | string | `""` | WebView 点击脚本 | -| `logo` | string | `""` | logo 模板,支持 `{id}`、`{name}`、`{logo}` | -| `epg` | string | `""` | EPG 地址;多个地址用英文逗号分隔 | -| `ua` | string | `""` | User-Agent,合并进频道 header | -| `origin` | string | `""` | Origin,合并进频道 header | -| `referer` | string | `""` | Referer,合并进频道 header | -| `timeZone` | string | 系统时区 | EPG 时区 | -| `keep` | string | `""` | App 保存的最近频道,作者一般不写 | -| `timeout` | number | `15` | 播放超时秒数;最小按 `1` 处理 | -| `header` | object | `{}` | 直播源级 header | -| `catchup` | object | `null` | 回看规则,见下文 | -| `core` | object | `null` | TVBus 特殊内核配置 | -| `groups` | array | `[]` | JSON 直播分组 | -| `boot` | boolean | `false` | App 状态字段,作者一般不写 | -| `pass` | boolean | `false` | `true` 时不拆分分组名中的密码 | - -## Group / Channel - -| 对象 | 字段 | 说明 | -| --- | --- | --- | -| `group` | `name` | 分组名;含 `_密码` 时默认拆出 `pass` | -| `group` | `pass` | 非空为隐藏分组 | -| `group` | `channel` | 频道数组 | -| `channel` | `name` | 频道名 | -| `channel` | `urls` | 播放地址数组;每项可为 `url` 或 `url$线路名` | -| `channel` | `number` | 频道号;为空按顺序生成三位编号 | -| `channel` | `logo` | 频道 logo | -| `channel` | `epg` | EPG 名;直播源 `epg` 模板支持 `{epg}` | -| `channel` | `ua` / `origin` / `referer` | 频道级请求头 | -| `channel` | `click` | WebView 点击脚本 | -| `channel` | `format` | MIME;`dash`/`mpd` 转 `application/dash+xml`,`hls` 转 `application/x-mpegURL` | -| `channel` | `tvgId` / `tvgName` | EPG 匹配字段;缺省回退频道名 | -| `channel` | `catchup` | 频道级回看,优先于直播源 | -| `channel` | `header` | 频道级 header | -| `channel` | `parse` | `1` 强制解析 WebView | -| `channel` | `drm` | DRM 配置 | - -## Catchup - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `type` | string | `default` 直接使用 `source`;其它值按追加模式 | -| `days` | string | 保留字段;当前回看 URL 生成不读取 | -| `regex` | string | 匹配频道 URL;为空时 `source` 非空即认为支持回看 | -| `source` | string | 模板,支持 `${(b)格式}`、`${(e)格式}`、`${utc:}`、`${utcend:}` | -| `replace` | string | 追加模式替换规则 `正则,替换值` | - -## DRM - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `key` | string | License URL 或 ClearKey JSON | -| `type` | string | 含 `widevine`、`playready`、`clearkey` 时映射对应 UUID;其它值映射空 UUID | -| `forceKey` | boolean | 是否强制使用默认 license URL | -| `header` | object | license 请求 header | - -## 示例 - -```json -{ - "name": "演示直播", - "url": "./live.m3u", - "logo": "https://example.com/logo/{name}.png", - "epg": "https://example.com/epg.xml.gz", - "ua": "Mozilla/5.0", - "referer": "https://example.com/", - "timeZone": "Asia/Shanghai", - "timeout": 15, - "catchup": { - "type": "append", - "regex": "/PLTV/", - "source": "?playseek=${(b)yyyyMMddHHmmss}-${(e)yyyyMMddHHmmss}", - "replace": "/PLTV/,/TVOD/" - } -} -``` - -## 验证清单 - -1. 分组和频道能加载,隐藏分组需要输入密码。 -2. 播放地址按频道/直播源 header 正常起播。 -3. EPG 名称、tvg-id 和时区匹配。 -4. 支持回看的频道可生成回看 URL。 -5. DRM 与线路切换不破坏频道列表。 - -## 源码依据 - -- `app/src/main/java/com/fongmi/android/tv/bean/Live.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Group.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Channel.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Catchup.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Epg.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Drm.java` -- `app/src/main/java/com/fongmi/android/tv/api/config/LiveConfig.java` diff --git a/docs/integration/parser.md b/docs/integration/parser.md deleted file mode 100644 index ad18f63e30..0000000000 --- a/docs/integration/parser.md +++ /dev/null @@ -1,67 +0,0 @@ -# 解析器接入 - -解析器位于顶层 `parses`。播放结果 `parse=1` 或 `jx=1` 时会按线路 flag 选择解析器。 - -## 字段表 - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `name` | string | `""` | 解析器名称,UI 和 `parse:{name}` 前缀匹配的键 | -| `type` | number | `0` | 解析类型,取值见下表 | -| `url` | string | `""` | 解析地址或 JAR parser key | -| `ext` | object | `{}` | 解析扩展,结构见下文 | -| `ext.flag` | string[] | `[]` | 适用播放 flag;空数组表示不过滤 | -| `ext.header` | object | `{}` | 解析请求 header | -| `click` | string | `""` | WebView 点击脚本 | - -解析器请求 header 只从 `ext.header` 读取;顶层 `header` 字段不会反序列化为解析器配置。 - -## 解析类型 - -| `type` | 名称 | 行为 | -| --- | --- | --- | -| `0` | Web 解析 | 打开解析 WebView 嗅探真实播放地址 | -| `1` | JSON 解析 | 请求 `url + webUrl`,读取返回 JSON 的 `url` 或 `data.url` | -| `2` | JAR Json 扩展 | 调用 JAR 中 `com.github.catvod.parser.Json{url}.parse(jxs, webUrl)` | -| `3` | JAR Mix 扩展 | 调用 JAR 中 `com.github.catvod.parser.Mix{url}.parse(jxs, parseName, flag, webUrl)` | -| `4` | 聚合解析 | 并发尝试符合 flag 的 JSON 和 Web 解析器 | - -当 `url` 已含 `?` 且 `ext` 非空时,App 追加 `cat_ext={base64(ext)}`。 - -## playUrl 前缀 - -| 前缀 | 行为 | -| --- | --- | -| `json:{url}` | 临时使用 `type=1` 解析器,地址为 `{url}` | -| `parse:{name}` | 使用配置里 `name` 等于 `{name}` 的解析器 | -| 无前缀且非空 | 作为 `type=0` Web 解析地址 | - -## 示例 - -```json -{ - "parses": [ - { - "name": "演示解析", - "type": 1, - "url": "https://example.com/parse?url=", - "ext": { - "flag": ["qq", "iqiyi"], - "header": { "User-Agent": "Mozilla/5.0" } - } - } - ] -} -``` - -## 错误处理 - -- JSON 解析返回的 `url` 长度大于 40 才视为成功。 -- 解析失败不中断播放流程,会进入解析错误处理。 -- Web 解析页面不支持时,不要让 Spider 返回可解析 HTML 伪装成媒体地址。 - -## 源码依据 - -- `app/src/main/java/com/fongmi/android/tv/bean/Parse.java` -- `app/src/main/java/com/fongmi/android/tv/player/ParseJob.java` -- `app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java` diff --git a/docs/integration/player.md b/docs/integration/player.md deleted file mode 100644 index 07d6dba774..0000000000 --- a/docs/integration/player.md +++ /dev/null @@ -1,108 +0,0 @@ -# 播放结果与播放器接入 - -Spider `playerContent`、HTTP API 播放和解析器最终都会得到 `Result` 播放结果。 - -## 输入 - -| Spider 方法 | 参数 | 说明 | -| --- | --- | --- | -| `playerContent` | `flag` | 详情结果中的线路名 | -| `playerContent` | `id` | 选集播放 ID 或原始地址 | -| `playerContent` | `vipFlags` | 需要解析的 flag 列表 | - -## 播放结果字段 - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `parse` | number | `0` | `1` 强制解析 WebView | -| `jx` | number | `0` | `1` 等同需要解析 | -| `url` | string/array/object | `""` | 播放地址,结构见下文 | -| `playUrl` | string | `""` | 播放前缀或 `json:`、`parse:` 指令 | -| `header` | object | `{}` | 播放请求 header;Cookie 会进入 CookieStore | -| `format` | string | `""` | 媒体 MIME/格式 | -| `subs` | array | `[]` | 字幕列表,见 [字幕接入](subtitle.md) | -| `danmaku` | array | `[]` | 弹幕列表,见 [弹幕接入](danmaku.md) | -| `drm` | object/null | `null` | DRM 配置 | -| `position` | number | `0` | 起播位置毫秒 | -| `artwork` | string | `""` | 播放器封面 | -| `flag` | string | `""` | 线路名;为空时用调用传入 flag | -| `click` | string | `""` | WebView 点击脚本 | -| `msg` | string | `""` | Toast;仅 `code` 为空或 `0` 时显示 | -| `code` | number | `0` | 状态码 | -| `jxFrom` | string | `""` | 解析来源 | -| `desc` | string | `""` | 播放描述 | - -## URL 结构 - -```json -{ "url": "https://example.com/video.m3u8" } -``` - -数组按 `[显示名1, 地址1, 显示名2, 地址2]` 解析: - -```json -{ "url": ["标清", "https://example.com/sd.m3u8", "高清", "https://example.com/hd.m3u8"] } -``` - -对象支持多地址和默认选中位置: - -```json -{ - "url": { - "values": [ - { "n": "高清", "v": "https://example.com/hd.m3u8" }, - { "n": "标清", "v": "https://example.com/sd.m3u8" } - ], - "position": 0 - } -} -``` - -## DRM - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `key` | string | License URL 或 ClearKey JSON | -| `type` | string | `widevine`、`playready`、`clearkey` 分别映射对应 UUID | -| `forceKey` | boolean | 是否强制默认 license URL | -| `header` | object | license 请求 header | - -## 直链示例 - -```json -{ - "parse": 0, - "jx": 0, - "url": "https://example.com/video.m3u8", - "header": { "User-Agent": "Mozilla/5.0", "Referer": "https://example.com/" }, - "format": "application/x-mpegURL" -} -``` - -## 解析示例 - -```json -{ - "parse": 1, - "jx": 0, - "playUrl": "parse:演示解析", - "url": "https://page.example.com/watch?id=1", - "header": { "User-Agent": "Mozilla/5.0" } -} -``` - -## 接入要求 - -1. `parse=0`、`jx=0` 且 `url` 是媒体直链时直接播放。 -2. `parse=1` 或 `jx=1` 时走解析流程。 -3. `header` 覆盖主清单、子清单、分片、密钥和字幕请求。 -4. 空 URL、HTML 错误页、过期签名都视为失败。 -5. 播放结果必须始终是 JSON;XML 只用于 `type=0` 兼容 API。 - -## 源码依据 - -- `catvod/src/main/java/com/github/catvod/crawler/Spider.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Result.java` -- `app/src/main/java/com/fongmi/android/tv/player/ParseJob.java` -- `app/src/main/java/com/fongmi/android/tv/api/SiteApi.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Drm.java` diff --git a/docs/integration/proxy-server.md b/docs/integration/proxy-server.md deleted file mode 100644 index 6e3d03c383..0000000000 --- a/docs/integration/proxy-server.md +++ /dev/null @@ -1,126 +0,0 @@ -# 本地代理与服务端接口 - -App 内置 NanoHTTPD 服务。端口从 `9978` 到 `9998` 依次尝试,实际端口以 `Proxy.getPort()` 为准。 - -```text -http://127.0.0.1:{port} -http://{局域网IP}:{port} -``` - -## Spider 代理入口 - -`/proxy` 会调用当前最近使用的 Spider: - -- Java/JAR:`proxy(Map params)` -- QuickJS:普通模式 `proxy(params)` 或 CatVod 兼容模式 `proxy(segments, headers)` -- Python:`localProxy(param)` - -Java/JAR 返回: - -```java -new Object[]{200, "video/mp2t", inputStream, headers} -``` - -| 下标 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `0` | number 或 `NanoHTTPD.Response` | 是 | HTTP 状态码;`Response` 会原样返回 | -| `1` | string | 是 | Content-Type | -| `2` | InputStream | 是 | 响应流 | -| `3` | Map | 否 | 响应 header | - -JS/Python 返回: - -```js -[200, "text/plain; charset=utf-8", "content", { "Cache-Control": "no-store" }, 0] -``` - -| 下标 | 类型 | 说明 | -| --- | --- | --- | --- | -| `0` | number | HTTP 状态码 | -| `1` | string | Content-Type | -| `2` | string/bytes | 响应内容;字符串默认 UTF-8 | -| `3` | object | 响应 header,可为空 | -| `4` | number | `1` 表示第 2 项是 Base64;内容含 `base64,` 前缀时自动截掉 | - -## 常用端点 - -| 路径 | 方法 | 能力 | -| --- | --- | --- | -| `/device` | GET/POST | 设备信息 | -| `/media` | GET/POST | 当前播放状态 | -| `/action` | GET/POST | 搜索、推送、刷新、播放控制、注入字幕/弹幕、设置配置 | -| `/cache` | GET/POST | 字符串缓存 | -| `/file/{path}` | GET | 本地文件 | -| `/parse` | GET/POST | 内置解析 HTML | -| `/proxy` | GET/POST | Spider 本地代理 | -| `/webResource` | 多方法 | WebHome 资源网关 | -| `/pan/check` | POST/OPTIONS | 网盘检测 | -| `/spider` | GET/POST | Spider HTTP 桥 | -| `/debug/logs` | GET | 调试日志网页 | -| `/debug/stream` | GET | 调试日志增量轮询 | -| `/m` | GET | 增强管理页面 | - -## `/device` - -```json -{ - "uuid": "android-id", - "name": "device-name", - "ip": "http://192.168.1.23:9978", - "type": 1, - "time": 1710000000000 -} -``` - -`type`:`0` 电视端、`1` 手机端、`2` DLNA 设备记录。 - -## `/media` - -```json -{ - "state": 3, - "speed": 1.0, - "duration": 3600000, - "position": 600000, - "url": "https://example.com/video.m3u8", - "title": "标题", - "artist": "", - "artwork": "" -} -``` - -`state`:`1` 其它、`2` ready、`3` playing、`6` buffering。 - -## `/action` - -返回固定 `200 OK`,大部分动作异步执行。常用: - -```text -GET/POST /action?do=search&word=关键词 -GET/POST /action?do=push&url=播放地址 -GET/POST /action?do=setting&text=配置内容或配置URL&name=显示名称 -GET/POST /action?do=refresh&type=home -GET/POST /action?do=refresh&type=subtitle&path=字幕URL -GET/POST /action?do=refresh&type=danmaku&path=弹幕URL -GET/POST /action?do=control&type=play -``` - -`refresh.type`:`home`、`live`、`detail`、`player`、`category`、`subtitle`、`danmaku`、`vod`。 -`control.type`:`play`、`pause`、`stop`、`prev`、`next`、`repeat`、`replay`。 - -## 安全要求 - -1. `/proxy` 只应处理当前站点或明确授权目标,不要实现开放代理。 -2. 校验参数、URL、来源和 header,不泄露 Cookie、Token、密钥或内网地址。 -3. 请求结束后关闭输入流、响应体、临时文件和注册的播放会话。 -4. 非法参数返回 4xx;上游失败返回明确 5xx;不要用 200 返回 HTML 错误页。 -5. Range 请求要正确处理,HLS 重写必须保留 playlist、分片和密钥的会话一致性。 - -## 源码依据 - -- `app/src/main/java/com/fongmi/android/tv/server/Server.java` -- `app/src/main/java/com/fongmi/android/tv/server/Nano.java` -- `app/src/main/java/com/fongmi/android/tv/server/process/Proxy.java` -- `app/src/main/java/com/fongmi/android/tv/server/process/SpiderApi.java` -- `app/src/main/java/com/fongmi/android/tv/server/process/Action.java` -- `catvod/src/main/java/com/github/catvod/crawler/Spider.java` diff --git a/docs/integration/result-vod.md b/docs/integration/result-vod.md deleted file mode 100644 index 4f17af08e6..0000000000 --- a/docs/integration/result-vod.md +++ /dev/null @@ -1,124 +0,0 @@ -# Result 与 Vod 返回协议 - -Spider 首页、分类、详情、搜索和播放返回的 JSON 统一解析为 `Result`;列表项解析为 `Vod`。 - -## Result 顶层字段 - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `class` | array | `[]` | 分类列表 | -| `list` | array | `[]` | Vod 列表 | -| `filters` | object | `{}` | key 为分类 ID 的筛选数组 | -| `url` | string/array/object | `""` | 播放地址 | -| `header` | object | `{}` | 播放或请求 header | -| `msg` | string | `""` | Toast;仅 `code=0` 时生效 | -| `code` | number | `0` | 状态码 | -| `danmaku` | array | `[]` | 弹幕源列表 | -| `subs` | array | `[]` | 字幕列表 | -| `playUrl` | string | `""` | 播放前缀或解析指令 | -| `artwork` | string | `""` | 播放器封面 | -| `jxFrom` | string | `""` | 解析来源 | -| `flag` | string | `""` | 当前线路 | -| `desc` | string | `""` | 播放描述 | -| `lrc` | string | `""` | 歌词 | -| `format` | string | `""` | 媒体 MIME | -| `click` | string | `""` | WebView 点击脚本 | -| `key` | string | `""` | 站点 key | -| `position` | number | `0` | 起播位置毫秒 | -| `pagecount` | number | `0` | 总页数 | -| `parse` | number | `0` | 解析 WebView 标记 | -| `jx` | number | `0` | 需解析标记 | -| `drm` | object/null | `null` | DRM 配置 | - -## Class / Filter - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `type_id` | string | `""` | 分类 ID;别名 `id` | -| `type_name` | string | `""` | 分类名;别名 `name` | -| `type_flag` | string | `""` | `1` 时作为文件夹/子分类入口处理 | -| `filters` | array | `[]` | 内联筛选 | -| `land` | number | `0` | 横图快捷样式 | -| `circle` | number | `0` | 圆形快捷样式 | -| `ratio` | number | `0` | 图片宽高比 | -| `filter.key` | string | `""` | 筛选参数名,放入 `extend` | -| `filter.name` | string | `""` | 显示名 | -| `filter.init` | string | `""` | 初始值 | -| `filter.value[].n` | string | `""` | 显示名 | -| `filter.value[].v` | string | `""` | 提交值 | - -## Vod 字段 - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `vod_id` | string | `""` | 详情 ID | -| `vod_name` | string | `""` | 标题;HTML 会被清理 | -| `type_name` | string | `""` | 类型 | -| `vod_pic` | string | `""` | 海报 | -| `vod_remarks` | string | `""` | 备注/更新信息 | -| `vod_year` | string | `""` | 年份 | -| `vod_area` | string | `""` | 地区 | -| `vod_director` | string | `""` | 导演 | -| `vod_actor` | string | `""` | 演员 | -| `vod_content` | string | `""` | 简介 | -| `vod_play_from` | string | `""` | 线路名,多个用 `$$$` | -| `vod_play_url` | string | `""` | 播放列表 | -| `vod_tag` | string | `""` | `folder` 作为文件夹入口 | -| `action` | string | `""` | 点击触发 `action()`,不进普通详情 | -| `tmdb` | object | `null` | TMDB 匹配 payload | -| `cate` | object | `null` | 子分类入口 | -| `style` | object | `null` | 单条目样式 | -| `land` | number | `0` | 横图快捷样式 | -| `circle` | number | `0` | 圆形快捷样式 | -| `ratio` | number | `0` | 图片宽高比 | - -`vod_play_url` 格式:线路之间用 `$$$`;单线路内选集用 `#`;单集为 `集名$播放地址`。`vod_play_from` 与 `vod_play_url` 线路数量必须一致。 - -## Style - -```json -{ "type": "rect", "ratio": 1.33 } -``` - -| 字段 | 取值 | 默认值 | -| --- | --- | --- | -| `type` | `rect`、`oval`、`list` | `rect` | -| `ratio` | number,最大 4 | rect `0.75`,oval `1.0` | -| `land` | `0`/`1` | `0`;`1` 等价 `rect + ratio=1.33` | -| `circle` | `0`/`1` | `0`;`1` 等价 `oval + ratio=1.0` | - -## 示例 - -```json -{ - "class": [{ "type_id": "movie", "type_name": "电影" }], - "list": [ - { - "vod_id": "1", - "vod_name": "示例影片", - "vod_pic": "https://example.com/poster.jpg", - "vod_remarks": "全 2 集", - "vod_play_from": "主线$$$备用线", - "vod_play_url": "第1集$id-1#第2集$id-2$$$第1集$backup-1" - } - ], - "page": 1, - "pagecount": 1, - "total": 1 -} -``` - -## 错误处理 - -- 列表为空返回 `{ "list": [], "page": 1, "pagecount": 0, "total": 0 }`。 -- `msg` 不能替代分类/列表字段;错误页不是合法结果。 -- `pagecount` 与实际分页一致,避免无限翻页。 -- 详情线路名不要为空;播放 ID 在同一线路内唯一。 - -## 源码依据 - -- `app/src/main/java/com/fongmi/android/tv/bean/Result.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Vod.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Class.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Filter.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Style.java` diff --git a/docs/integration/spider.md b/docs/integration/spider.md deleted file mode 100644 index e9e011aeb0..0000000000 --- a/docs/integration/spider.md +++ /dev/null @@ -1,170 +0,0 @@ -# Spider 接入协议 - -WebHTV 支持 Java/JAR、QuickJS JavaScript、Python 和 CatSpider HTTP 协议。所有运行时最终都映射到 `com.github.catvod.crawler.Spider`。 - -## Java/JAR 方法 - -```java -void init(Context context, String extend) -String homeContent(boolean filter) -String homeVideoContent() -String categoryContent(String tid, String pg, boolean filter, HashMap extend) -String detailContent(List ids) -String searchContent(String key, boolean quick) -String searchContent(String key, boolean quick, String pg) -String playerContent(String flag, String id, List vipFlags) -String liveContent(String url) -boolean manualVideoCheck() -boolean isVideoFormat(String url) -Object[] proxy(Map params) -String action(String action) -void destroy() -``` - -## 生命周期 - -1. `Site` 反序列化后由 Loader 创建 Spider。 -2. `init(Context, String extend)` 接收站点 `ext`;远程 URL 会先拉取文本。 -3. UI 依次调用首页、分类、详情、搜索、播放。 -4. 换源和本地代理按需调用 `proxy`。 -5. 配置清理、应用清理或缓存清理时调用 `destroy()`。 - -## JS 方法 - -QuickJS 模块可 `export default` 对象/工厂函数,也可导出 CatVod 兼容的 `__jsEvalReturn()`。 - -| 方法 | 参数 | 返回 | 对应 Java | -| --- | --- | --- | --- | -| `init(ext)` | 字符串或对象 | 任意 | `init(Context, String)` | -| `home(filter)` | boolean | string | `homeContent` | -| `homeVod()` | 无 | string | `homeVideoContent` | -| `category(tid, pg, filter, extend)` | string,string,boolean,object | string | `categoryContent` | -| `detail(id)` | string | string | `detailContent`;只传 `ids[0]` | -| `search(key, quick)` / `search(key, quick, pg)` | string,boolean[,string] | string | `searchContent` | -| `play(flag, id, flags)` | string,string,array | string | `playerContent` | -| `live(url)` | string | string | `liveContent` | -| `sniffer()` | 无 | boolean | `manualVideoCheck` | -| `isVideo(url)` | string | boolean | `isVideoFormat` | -| `proxy(params)` | object;CatVod 模式另有 `proxy(segments, headers)` | array/string | `proxy` | -| `action(action)` | string | string | `action` | -| `destroy()` | 无 | 任意 | `destroy` | - -JS 全局能力: - -| 方法 | 说明 | -| --- | --- | -| `req(url, options)` | 同步请求,返回 `{ code, headers, content }` | -| `_http(url, options)` | 无 `complete` 时同步;有 `complete` 时异步 | -| `pdfh(html, rule)` | HTML 单节点/属性选择 | -| `pdfa(html, rule)` | HTML 列表选择 | -| `pdfl(html, rule, texts, urls, urlKey)` | 列表结构化 | -| `joinUrl(parent, child)` | URL 合并 | -| `md5X(text)` | MD5 | -| `aesX(mode, encrypt, input, inBase64, key, iv, outBase64)` | AES | -| `rsaX(mode, pub, encrypt, input, inBase64, key, outBase64)` | RSA | -| `s2t` / `t2s` | 简繁转换 | -| `getPort()` / `getProxy(local)` | 本地端口/代理地址 | -| `js2Proxy(dynamic, siteType, siteKey, url, headers)` | CatVod 兼容代理 URL | -| `setTimeout` / `clearTimeout` | QuickJS 定时器 | -| `local.get(rule, key)` / `local.set(rule, key, value)` / `local.delete(rule, key)` | Native Prefers 存储 | - -JS `req/http` options: - -| 字段 | 默认值 | 说明 | -| --- | --- | --- | -| `method` | `get` | `get`、`post`、`header` | -| `headers` | `{}` | 请求 headers | -| `data` | 空 | POST 数据,配合 `postType` | -| `body` | 空 | 原始请求体;`data` 为空且含 `Content-Type` 时使用 | -| `postType` | `json` | `json`、`form`、`form-data` | -| `timeout` | `10000` | 毫秒 | -| `redirect` | `1` | `1` 跟随,`0` 不跟随 | -| `buffer` | `0` | `0` 文本,`1`/`3` bytes,`2` Base64 | - -## Python 方法 - -继承 `base.spider.Spider`: - -| 方法 | 参数 | 返回 | -| --- | --- | --- | -| `init` | `extend=""` | 任意 | -| `homeContent` | `filter` | string | -| `homeVideoContent` | 无 | string | -| `categoryContent` | `tid, pg, filter, extend` | string | -| `detailContent` | `ids` | string | -| `searchContent` | `key, quick, pg="1"` | string | -| `playerContent` | `flag, id, vipFlags` | string | -| `liveContent` | `url` | string | -| `localProxy` | `param` | list | -| `isVideoFormat` | `url` | bool | -| `manualVideoCheck` | 无 | bool | -| `action` | `action` | string | -| `destroy` | 无 | 任意 | -| `getName` | 无 | string | -| `getDependence` | 无 | list | - -Python 基类提供 `loadSpider/loadModule/regStr/removeHtmlTags/cleanText/fetch/post/html/str2json/json2str/getProxyUrl/log/getCache/setCache/delCache`。`getDependence()` 返回依赖模块名列表(不带 `.py`)。 - -## CatSpider HTTP 协议 - -`api` 是 `http.../spider/...` 绝对地址时走 CatSpider。所有路由使用 POST JSON: - -| Java 方法 | CatSpider 路由 | 请求字段 | -| --- | --- | --- | -| `init` | `/init` | `{}` | -| `homeContent` | `/home` | `{}` | -| `categoryContent` | `/category` | `id`, `page`, `filters` | -| `detailContent` | `/detail` | `id` | -| `searchContent` | `/search` | `wd`, `page` | -| `playerContent` | `/play` | `flag`, `id` | - -响应中的 `{ code, data }` 会被解包,`data` 为数组时会包装成 `{ list: data }`。 - -## 返回协议 - -分类、搜索、首页: - -```json -{ - "class": [{ "type_id": "1", "type_name": "电影" }], - "filters": { - "1": [{ "key": "area", "name": "地区", "value": [{ "n": "大陆", "v": "大陆" }] }] - }, - "list": [{ "vod_id": "1", "vod_name": "示例" }], - "page": 1, - "pagecount": 1, - "total": 1 -} -``` - -详情: - -```json -{ - "list": [{ - "vod_id": "1", - "vod_name": "示例", - "vod_play_from": "主线$$$备用线", - "vod_play_url": "第1集$id-1#第2集$id-2$$$第1集$backup-1" - }] -} -``` - -播放、字幕、弹幕、代理结构见 [播放结果与播放器接入](player.md)、[字幕接入](subtitle.md)、[弹幕接入](danmaku.md)、[本地代理与服务端接口](proxy-server.md)。 - -## 错误与资源 - -1. 所有字符串方法返回合法 JSON,不能返回 `null` 或异常文本。 -2. 捕获上游异常并返回空结果;不要把 HTML 当 JSON。 -3. `destroy()` 释放线程、请求、WebView、脚本上下文、流和缓存。 -4. Proxy 返回的流和响应资源由调用方使用后关闭;不要缓存长生命周期流。 - -## 源码依据 - -- `catvod/src/main/java/com/github/catvod/crawler/Spider.java` -- `app/src/main/java/com/fongmi/android/tv/api/loader/BaseLoader.java` -- `app/src/main/java/com/fongmi/android/tv/api/loader/JsLoader.java` -- `app/src/main/java/com/fongmi/android/tv/api/loader/PyLoader.java` -- `app/src/main/java/com/fongmi/android/tv/api/loader/CatSpider.java` -- `quickjs/src/main/java/com/fongmi/quickjs/crawler/Spider.java` -- `chaquo/src/main/python/base/spider.py` diff --git a/docs/integration/subtitle.md b/docs/integration/subtitle.md deleted file mode 100644 index 3c0ae5a47e..0000000000 --- a/docs/integration/subtitle.md +++ /dev/null @@ -1,47 +0,0 @@ -# 字幕接入 - -字幕来自 Spider 播放结果、媒体内嵌轨道或局域网注入。Spider 应把字幕放进播放结果 `subs`。 - -## 播放结果中的字段 - -| 字段 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `url` | string | `""` | 字幕 URL 或路径 | -| `name` | string | `""` | 字幕名称 | -| `lang` | string | `""` | 字幕语言 | -| `format` | string | `""` | 字幕 MIME;部分本地注入链路会按扩展名推断 | -| `flag` | number | 默认字幕 | Media3 subtitle selection flag | - -## 示例 - -```json -{ - "url": "https://example.com/video.m3u8", - "subs": [ - { "name": "简体中文", "lang": "zh-CN", "url": "https://example.com/zh-CN.srt" }, - { "name": "ASS", "format": "text/x-ssa", "url": "https://example.com/movie.ass" } - ] -} -``` - -## 接入要求 - -1. URL 必须返回实际字幕正文,不能是登录页或反爬页。 -2. UTF-8 编码最稳;扩展名、MIME 和实际格式一致。 -3. 字幕需要鉴权时,返回 `header` 或让 URL 通过本地代理补 header。 -4. 字幕加载失败不应导致视频播放失败。 - -## 局域网注入 - -```text -GET/POST /action?do=refresh&type=subtitle&path=字幕URL -``` - -本地文件注入支持 `.srt`、`.ssa`、`.ass`。 - -## 源码依据 - -- `app/src/main/java/com/fongmi/android/tv/bean/Sub.java` -- `app/src/main/java/com/fongmi/android/tv/bean/Result.java` -- `app/src/main/java/com/fongmi/android/tv/server/process/Action.java` -- `docs/SUB-EXT-HISTORY-external-subtitle-restore.md` diff --git a/docs/integration/vod-site.md b/docs/integration/vod-site.md deleted file mode 100644 index 7c588bb1a8..0000000000 --- a/docs/integration/vod-site.md +++ /dev/null @@ -1,105 +0,0 @@ -# 点播源接入 - -点播源位于顶层 `sites` 数组。`type` 决定请求协议或 Spider 运行时;`key` 是站点唯一标识。 - -## 字段表 - -| 字段 | 类型 | 默认值 | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `key` | string | `""` | 是 | 站点唯一标识;收藏、历史、详情、播放和本地 API 都用它定位站点 | -| `name` | string | `""` | 是 | 展示名 | -| `type` | number | `0` | 是 | 协议/运行时类型,取值见下表 | -| `api` | string | `""` | 是 | HTTP API 地址或 Spider 类名/脚本入口 | -| `jar` | string | `""` | 否 | 站点专用 JAR,为空时继承顶层 `spider` | -| `ext` | string/object | `""` | 否 | 传给 Spider `init` 的扩展参数;`type=4` HTTP 源会在首页请求前拉取远程 URL 文本,Spider 模式直接传递原始字符串 | -| `click` | string | `""` | 否 | WebView 解析点击脚本 | -| `playUrl` | string | `""` | 否 | 站点级播放前缀或解析辅助 | -| `homePage` | string | `""` | 否 | WebHome 首页;别名 `home_page`、`webHome`、`web_home` | -| `chromeMode` | string | 端默认 | 否 | WebHome 浏览器 UI 模式 | -| `webHomeChrome` | object/string | `null` | 否 | WebHome chrome 配置 | -| `extensions` | JSON | `null` | 否 | WebHome 站点扩展配置 | -| `hide` | number | `0` | 否 | `1` 隐藏站点 | -| `indexs` | number | `0` | 否 | `1` 作为索引站点进入聚合搜索 | -| `timeout` | number | `15` | 否 | 播放超时秒数;最小按 `1` 处理 | -| `searchable` | number | `1` | 否 | `0` 永久禁用,`1` 启用,`2` 临时禁用但允许 App 恢复 | -| `changeable` | number | `1` | 否 | `0` 永久禁用换源,`1` 允许,`2` 临时禁用但允许 App 恢复 | -| `quickSearch` | number | `1` | 否 | `1` 参与快速搜索 | -| `categories` | string[] | `[]` | 否 | 限定分类列表;空数组表示不限制 | -| `header` | object | `{}` | 否 | 站点级请求 header | -| `style` | object | `null` | 否 | 卡片样式;不提供时继承默认 `rect` | - -## 站点类型 - -| `type` | 名称 | 行为 | -| --- | --- | --- | -| `0` | XML API | 首页/分类按 XML 解析;分类/详情带 `ac=videolist` | -| `1` | JSON API | 首页/分类按 JSON 解析;分类/详情带 `ac=detail`,筛选参数以 `f={json}` 发送 | -| `2` | JSON API 兼容 | 按 JSON 解析;分类/详情带 `ac=detail`,不追加 `f` | -| `3` | Spider | 按 `api` 分发到 JAR、JS、Python 或 CatSpider | -| `4` | HTTP API + Base64 ext | 首页带 `filter=true`,分类带 `ext={base64(extend)}`,播放带 `play`、`flag` | - -`type=3` 的 `api` 分发规则: - -| `api` 形态 | 运行时 | -| --- | --- | -| `http.../spider/...` | CatSpider HTTP 协议 | -| `*.py` / 含 `.py` | Python Spider | -| `*.js` / 含 `.js` | QuickJS Spider | -| `csp_*` | JAR/JAVA Spider | -| 其它 | `SpiderNull`,站点不可用 | - -## WebHome Chrome - -`chromeMode` 支持手机端 `normal`、`edge`、`immersive` 和电视端 `tv-normal`、`tv-toolbar-hidden`、`tv-overlay`、`tv-full`;跨端配置里的 `edge` 在 TV 端映射为 `tv-full`。`webHomeChrome` 对象可写: - -```json -{ - "mode": "edge", - "statusBarStyle": "light", - "navigationBarStyle": "light", - "restoreAffordance": true, - "scrim": true -} -``` - -## 示例 - -```json -{ - "key": "demo", - "name": "演示点播源", - "type": 3, - "api": "csp_Demo", - "jar": "./spider.jar", - "ext": { "host": "https://api.example.com" }, - "searchable": 1, - "changeable": 1, - "quickSearch": 1, - "timeout": 15, - "categories": ["电影", "剧集"], - "header": { "User-Agent": "Mozilla/5.0" }, - "style": { "type": "rect", "ratio": 1.33 } -} -``` - -## 无效配置 - -- `key` 为空、`type` 不支持、`api` 为空或不可达。 -- JAR、JS、Python 或 `ext` 加载失败。 -- Spider 抛异常、返回空 JSON 或 HTML 错误页。 -- 上述情况通常会退回 `SpiderNull` 或站点加载失败,不应影响其它站点。 - -## 验证清单 - -1. 首页能返回分类和推荐。 -2. 分类能翻页且筛选值能传回。 -3. 详情能返回至少一个线路和一集。 -4. 搜索和快速搜索按配置生效。 -5. 播放返回直链或可解析地址。 - -## 源码依据 - -- `app/src/main/java/com/fongmi/android/tv/bean/Site.java` -- `app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java` -- `app/src/main/java/com/fongmi/android/tv/api/SiteApi.java` -- `app/src/main/java/com/fongmi/android/tv/api/loader/BaseLoader.java` diff --git a/docs/interface-ad-rule-learning-design.md b/docs/interface-ad-rule-learning-design.md index 1d19ed3c90..ec526c6ef2 100644 --- a/docs/interface-ad-rule-learning-design.md +++ b/docs/interface-ad-rule-learning-design.md @@ -2,9 +2,9 @@ ## 1. 文档状态 -- 状态:已接受,0905 优化实施中(Accepted / Implementing) -- 版本:v0.2 -- 日期:2026-09-05 +- 状态:提议(Proposed) +- 版本:v0.1 +- 日期:2026-07-11 - 适用范围:点播/直播配置接口中的 `ads`、`rules` 去广规则 - 不包含:蜘蛛代码自动升级、站点实现更新、远程脚本执行、云端规则服务 @@ -46,13 +46,6 @@ 外部接口属于不可信输入,其中的 `rules` 同时承载去广、嗅探、网页交互和播放适配等不同用途。仅凭字段结构无法可靠判断其语义。因此第一阶段只生成候选规则,并要求用户确认后才写入 `UserAdRuleStore`。 -“用户确认”与“导入后默认停用”不是同一个概念。为减少电视端逐条操作,同时保留外部规则的安全边界,候选导入提供两个明确动作: - -- `导入并启用已选`:仅对用户当前选中的候选生效,适合低风险候选的批量落地; -- `仅导入(保持停用)`:只保存到本地规则库,继续由用户在规则管理页逐条或批量启用。 - -应用不会在后台分析完成、候选出现或用户打开页面时静默启用规则。 - ### 4.2 当前接口规则与持久化规则分层 - 当前接口规则:继续由 `VodConfig` / `LiveConfig` 加载,只在该配置生效期间使用。 @@ -84,38 +77,6 @@ AI 结果不能绕过正则校验、风险阈值或用户确认。 | `AdRuleManageDialog` | 增加“待审核导入”入口和来源信息 | | `AdBlockStatsStore` | 后续用于累计命中次数和评估规则效果 | -## 5.1 0905 稳定版反馈对应的设计决策 - -### 问题证据 - -当前实现中,`UserAdRule.fromImportedCandidate` 将导入规则设为 `enabled = false`,导入窗口只有一个“导入(默认停用)”动作;电视端每次切换开关都会重新执行 `loadData()` 和 `notifyDataSetChanged()`。这会把一次批量操作变成大量按键,并使 DPAD 焦点回到列表顶部。 - -### 方案比较 - -| 方案 | 优点 | 风险/缺点 | 决策 | -|---|---|---|---| -| 保持现状 | 不改变安全策略 | 电视端逐条开启,操作量随规则数增长;焦点体验差 | 拒绝 | -| 所有导入规则静默启用 | 操作最少 | 外部规则未经逐条确认即全局生效,可能误杀正片或造成播放失败 | 拒绝 | -| 增加显式“导入并启用”并保留“仅导入” | 用户可以一次确认并立即生效;仍保留安全回退路径 | 多一个明确动作,需要保留风险提示 | **采用** | -| 只增加全选/反选 | 改动较小 | 只能减少选择动作,不能解决导入后逐条启用 | 作为辅助能力,不单独采用 | - -### 实施决策 - -1. 候选列表默认勾选 LOW 风险规则;提供“低风险全选”“全选”“反选”。 -2. “导入并启用已选”将所选规则一次写入并设置为启用;“仅导入”保持停用。 -3. 候选持久化改为批量提交,避免每条规则重复读写偏好和重复失效 `RuleConfig`。 -4. 规则管理页提供接口导入规则的“全部启用/全部停用”,不影响 AI、手动、默认和 HLS 规则。 -5. 单条启停只刷新当前 RecyclerView 行,不重建整个列表;电视端因此保留当前规则和 DPAD 焦点。 -6. 全量刷新(编辑、删除、导入完成)仍保留现有流程;无法找到原焦点时回退到安全的首条规则焦点。 - -### 最佳实践证据 - -- AndroidX `RecyclerView.Adapter` 官方 API 文档(`notifyItemChanged(position, payload)`):;访问日期 2026-09-05,证据等级 A:带 payload 的局部更新可将变更传递给带 payload 的绑定路径,适合只更新开关状态而避免无条件全量重绑。WebHTV 适用性:与现有 RecyclerView 规则行直接匹配。 -- Android `View.requestFocus()` 官方 API 文档:;访问日期 2026-09-05,证据等级 A:焦点恢复必须针对当前仍在窗口层级中的可聚焦 View,并应在列表布局完成后请求。WebHTV 适用性:用于 TV 行更新后的焦点保持和全量重载后的回退恢复。 -- Android `AlertDialog` 官方 API 文档:;Android `ListView` 官方 API 文档:;访问日期 2026-09-05,证据等级 A:多选列表状态由 `setItemChecked`/多选回调维护;WebHTV 使用自定义标题操作区同步“全选/反选”状态,不绕过候选列表的选择回调。 - -以上选择不引入新依赖、不改变规则解析或匹配算法,不影响未参与本次操作的规则。 - ## 6. 总体流程 ```text @@ -298,37 +259,13 @@ fingerprint = SHA-256( - hosts/regex/exclude 摘要; - 导入、忽略、编辑后导入、测试操作。 -默认勾选仅限 LOW 风险候选;置信度继续在列表中展示并参与风险判断,但仍需要用户点击确认。 +默认勾选仅限 LOW 风险且置信度不低于 0.75 的候选,但仍需要用户点击确认。 -### 12.3 0905 反馈优化后的候选操作 - -候选列表增加: - -- 低风险全选; -- 全选; -- 反选; -- 导入并启用已选; -- 仅导入(保持停用)。 - -“导入并启用已选”是显式用户动作,不属于后台自动发布。批量导入在一次协调批处理中完成:用户规则列表和候选状态分别写入各自的本地存储,不宣称跨两个偏好键的原子事务;已选规则的状态必须全部一致。如果用户选择仅导入,则所有新规则保持停用。 - -### 12.4 规则管理页批量操作与 TV 焦点 - -当存在接口导入规则时,规则管理页显示“全部启用接口导入规则”和“全部停用接口导入规则”。批量操作只匹配 `interface_ads` / `interface_rule` 来源,不修改 AI、手动、默认或内置 HLS 规则。 - -单条切换完成后: - -1. 持久化对应规则; -2. 仅以 payload 刷新当前行的开关和无障碍描述; -3. 保留当前滚动位置、当前规则和当前控件焦点。 - -这三项是电视端验收的必要条件,而不是可选的界面增强。 - -### 12.5 导入结果 +### 12.3 导入结果 导入后创建 `UserAdRule`: -- `source = "interface_ads"` 或 `"interface_rule"`; +- `source = "interface_import"`; - 名称默认带来源,例如“饭太硬导入:cdn.ryplay”; - 默认启用状态由用户确认页选择; - 保留候选 ID,便于后续识别上游更新。 @@ -378,11 +315,7 @@ InterfaceAdRuleLearningService.analyzeAsync(configMeta, config); - 完成分类、校验、规范化和指纹去重。 - 保存待审核候选。 - 提供候选列表和人工导入。 -- 导入到 `UserAdRuleStore`;默认“仅导入”保持停用,但允许用户在确认页明确选择“导入并启用已选”。 -- 候选列表支持低风险全选、全选和反选。 -- 候选导入采用批量持久化。 -- 规则管理页支持接口导入规则批量启停。 -- TV 单条启停不再因全量刷新丢失焦点。 +- 导入到 `UserAdRuleStore`,不做自动启用。 ### Phase 2:来源更新与效果验证 @@ -441,17 +374,13 @@ Phase 1 完成需同时满足: 6. 危险正则和过宽 host 不允许导入或默认启用。 7. 学习流程失败不影响配置加载、首页展示和视频播放。 8. 导入规则可在现有规则管理界面中启停、编辑和删除。 -9. 用户明确选择“导入并启用已选”后,所选规则立即生效且无需逐条开启。 -10. 用户选择“仅导入”时,新规则全部保持停用。 -11. TV 端切换任意规则后,焦点仍停留在该规则的原控件,不回到列表顶部。 -12. 批量导入至多产生一次用户规则列表写入和一次 `RuleConfig.invalidate()`;候选状态另行写入一次,不改变未选规则状态。 ## 18. 待确认问题 1. 候选规则是否随应用备份/恢复;建议 Phase 1 只备份已导入规则,不备份待审核候选。 2. 用户切换接口后,已导入规则是否继续全局生效;建议继续生效,但在规则名称中清晰展示来源。 3. 是否允许按站点或接口限定规则作用域;建议 Phase 2 引入,Phase 1 保持现有全局规则语义。 -4. **已决策**:提供“低风险规则默认勾选”,但仍要求用户最终确认;同时提供全选和反选。 +4. 是否提供“低风险规则默认勾选”;建议提供,但仍要求用户最终确认。 ## 19. 边界约束 @@ -461,7 +390,6 @@ Phase 1 完成需同时满足: - 保留来源、指纹、风险和审核状态。 - 学习任务与配置接入解耦。 - 用户可回滚所有已导入规则。 -- “仅导入(保持停用)”始终可作为不启用的回退路径;批量启停只作用于接口导入来源。 ### 实施前需要确认 @@ -476,7 +404,6 @@ Phase 1 完成需同时满足: - 将接口中的所有 `rules` 无差别转为广告规则。 - 将带凭证的完整接口 URL 上传给 AI 或写入日志。 - 未经校验和授权静默修改用户全局规则。 -- 不因“全选”操作跳过风险展示或将后台候选自动发布。 ## 20. 相关文档与代码 @@ -485,9 +412,7 @@ Phase 1 完成需同时满足: - `app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java` - `app/src/main/java/com/fongmi/android/tv/api/config/LiveConfig.java` - `app/src/main/java/com/fongmi/android/tv/api/config/RuleConfig.java` -- `app/src/main/java/com/fongmi/android/tv/api/config/ImportedAdRuleCandidateStore.java` - `app/src/main/java/com/fongmi/android/tv/api/config/UserAdRuleStore.java` - `app/src/main/java/com/fongmi/android/tv/bean/UserAdRule.java` -- `app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java` -- `app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java` - `app/src/main/java/com/fongmi/android/tv/bean/AdDetectionResult.java` + diff --git a/docs/interface-failover-design.md b/docs/interface-failover-design.md deleted file mode 100644 index c1d7006e41..0000000000 --- a/docs/interface-failover-design.md +++ /dev/null @@ -1,375 +0,0 @@ -# 接口容灾设计 - -## 1. 目标 - -为点播接口增加统一的容灾机制。当用户当前使用的接口无法正常拉取数据或加载失败时,系统按用户配置的接口顺序切换到下一个可用接口。 - -本设计只覆盖“点播接口配置加载失败”场景,不覆盖以下场景: - -- 站点首页、详情、搜索失败 -- 站点播放地址解析失败 -- 播放器内核切换、解码切换 -- 直播接口、壁纸接口 -- 换源容灾 - -## 2. 三种模式 - -| 模式 | 默认 | 行为 | -|---|---|---| -| 关闭 | 是 | 当前接口失败后仅提示错误,不自动切换 | -| 自动容灾 | 否 | 当前接口失败后按顺序自动尝试下一个未尝试接口 | -| 手动确认 | 否 | 当前接口失败后弹出确认框,由用户决定是否切换到下一个或指定接口 | - -三种模式互斥,同一时刻只生效一种。 - -### 2.1 关闭 - -行为完全保持现状: - -1. 用户配置或手动选择某个点播接口。 -2. 加载失败。 -3. 弹出错误提示。 -4. 不切换接口,不发起额外请求。 - -关闭模式是安全回退锚点。实现后如果发现任何自动切换副作用,应优先回退到关闭模式。 - -### 2.2 自动容灾 - -1. 用户配置或手动选择某个点播接口。 -2. 加载失败。 -3. 系统从用户排序后的接口序列中,找到当前接口之后的下一个未尝试接口。 -4. 自动加载该接口。 -5. 加载成功则走正常成功链路。 -6. 全部候选接口都已失败后,弹出最终错误提示。 - -自动容灾不需要用户参与。每次切换应显示当前尝试的接口名或地址,避免用户误以为仍在加载原接口。 - -### 2.3 手动确认 - -1. 用户配置或手动选择某个点播接口。 -2. 加载失败。 -3. 弹出确认框,列出剩余未尝试接口。 -4. 用户可选: - - 切换到下一个接口 - - 指定切换到某个接口 - - 取消切换 -5. 用户确认后才发起下一次加载。 -6. 用户取消后不再继续本轮容灾。 - -手动确认模式只在加载失败后弹出一次确认框。若用户选择切换后再次失败,可以继续弹出下一次确认框,或维持单次确认后结束本轮;推荐后者,避免连续弹窗打扰。 - -## 3. 接口序列与排序 - -### 3.1 排序来源 - -接口容灾顺序来自用户手动排序的接口序列。若用户没有自定义顺序,则按当前数据库默认顺序。 - -当前默认顺序: - -```sql -SELECT * FROM Config WHERE type = 0 ORDER BY time DESC -``` - -新增手动排序后,不修改数据库结构,不引入数据库 migration。排序持久化到应用偏好存储,键按点播接口类型作用域隔离。 - -### 3.2 存储键 - -建议新增: - -```text -interface_order_vod -``` - -值格式: - -```json -[ - "https://example.com/config-a.json", - "https://example.com/config-b.json", - "https://example.com/config-c.json" -] -``` - -按接口 URL 唯一标识。重复 URL 只保留一次。 - -### 3.3 排序规则 - -1. 取当前点播接口集合。 -2. 按用户排序键重排。 -3. 用户排序中不存在的接口追加到末尾。 -4. 已删除接口的排序键保留在偏好存储中但不参与排序。 -5. 新增接口默认追加到末尾。 - -### 3.4 排序入口 - -建议在现有接口历史弹层中增加排序入口: - -- 手动拖拽调整顺序 -- 排序后立即保存 -- 当前接口也参与排序 -- 当前接口保持可见,不因“当前使用中”而剔除 - -这样可以保证用户看到完整容灾顺序,而不是只见“其他接口”的局部顺序。 - -## 4. 失败判定 - -仅以下情况触发接口容灾: - -1. 配置 URL 请求失败。 -2. HTTP 状态异常。 -3. JSON 解析失败。 -4. 配置返回 `msg` 字段。 -5. 配置解析出的 `sites` 为空。 -6. 配置仓库 `urls` 解析结果为空。 - -以下情况不触发接口容灾: - -1. 接口加载成功,但首页无内容。 -2. 接口加载成功,但详情或搜索失败。 -3. 接口加载成功,但播放地址解析失败。 -4. 接口加载成功,但播放器内核或解码失败。 -5. 用户主动取消加载。 -6. 更高优先级的加载请求已使当前请求失效。 - -### 4.1 不做播放链路容灾 - -接口加载成功后,首页、详情、搜索、播放链路失败应继续由现有站点健康排序、播放器内核容灾、换源机制处理。接口容灾不应接管这些场景,否则会把“配置不可用”与“站点不可用”混淆,带来更大副作用。 - -## 5. 自动容灾流程 - -### 5.1 状态 - -本轮容灾只维护一次状态: - -```text -failoverRoundId -originUrl -attemptedUrls -candidateUrls -currentIndex -``` - -### 5.2 步骤 - -1. 记录本轮 `failoverRoundId`。 -2. 构造候选接口序列:`InterfaceOrderStore.sortConfigs(Config.getAll(0))`。 -3. 将当前接口 URL 加入 `attemptedUrls`。 -4. 从 `currentIndex` 开始向后查找第一个未尝试接口。 -5. 加载该接口。 -6. 若失败,重复步骤 4。 -7. 若成功,走正常成功链路。 -8. 若没有更多未尝试接口,走最终失败链路。 - -### 5.3 防重复 - -1. 同一轮内按 URL 去重。 -2. 已失败接口不再重试。 -3. 新一轮容灾必须重置状态。 -4. 用户手动选择接口应开启新一轮,而不是沿用上一轮状态。 -5. 同一轮内避免环形死循环。 - -### 5.4 尝试上限 - -为防呆,建议同轮最多尝试: - -```text -min(candidateCount, 3) -``` - -如果用户明确需要更多候选,可以后续再调大。第一版保守上限有助于控制请求和提示成本。 - -## 6. 手动确认流程 - -### 6.1 弹框内容 - -1. 标题:接口加载失败 -2. 副标题:当前接口名称或地址 -3. 剩余候选接口列表 -4. 操作: - - 切换到下一个 - - 指定接口 - - 取消 - -如果剩余候选接口只有一个,可将“指定接口”简化为“切换到该接口”。 - -### 6.2 交互 - -1. 失败后弹出一次。 -2. 用户选择“切换到下一个”后,加载下一个候选接口。 -3. 若再次失败,结束本轮,不再继续弹窗。 -4. 用户选择“指定接口”时,先展示剩余候选接口列表,再由用户选择一个。 -5. 用户选择“取消”后,结束本轮。 - -### 6.3 状态 - -手动确认与自动容灾共享同一个候选序列构造逻辑,但增加一个用户决策等待点。若用户取消或未决策,不继续发起请求。 - -## 7. 提示 - -### 7.1 自动容灾提示 - -1. 切换时显示“正在尝试下一个接口”。 -2. 尽量带出接口名称或地址。 -3. 多次连续切换时避免频繁刷新同一个 Toast。 -4. 全部失败后显示最终错误。 - -### 7.2 手动确认提示 - -1. 失败后立即弹出确认框。 -2. 确认框需要能看到当前失败接口和剩余候选接口。 -3. 用户取消后显示“已取消接口切换”。 - -### 7.3 关闭模式提示 - -1. 只显示当前加载失败的原始错误。 -2. 不显示“已尝试 N 个接口”。 -3. 不显示任何切换提示。 - -## 8. 成功与失败链路 - -### 8.1 成功 - -1. 接口加载成功。 -2. 更新当前接口。 -3. 触发现有配置成功事件。 -4. 触发现有首页、站点、解析器、规则等消费方刷新。 -5. 若来自容灾,通知可带出“已切换到 xxx”。 - -### 8.2 失败 - -1. 所有候选接口都已失败。 -2. 只回调一次最终错误。 -3. 不发中间配置事件。 -4. 不触发首页、详情、播放器中间刷新。 -5. 保持当前失败前的界面状态。 - -### 8.3 中间失败不发事件 - -自动容灾过程中,每次中间失败都不应触发 `ConfigEvent.vod()`。只有最终成功或最终失败后才发一次事件,避免首页或播放页反复刷新。 - -## 9. 实现位置 - -### 9.1 建议集中点 - -1. `VodConfig` -2. `BaseConfig` - -理由: - -1. 接口加载集中在这两层。 -2. 成功/失败回调集中。 -3. UI 只需要在最终回调中处理提示。 -4. 不需要每个入口各自实现 fallback。 - -### 9.2 不建议的位置 - -1. `VodFragment` -2. `HomeActivity` -3. `SettingFragment` -4. 各 UI 回调 - -这些位置只能处理最终提示,不应承载容灾状态机。否则手机端、TV 端、远程接口、配置仓库等入口会出现重复实现和行为不一致。 - -## 10. 设置存储 - -### 10.1 设置键 - -建议新增: - -```text -interface_failover_mode -``` - -值: - -```text -0 = 关闭 -1 = 自动容灾 -2 = 手动确认 -``` - -### 10.2 默认值 - -默认值应为 `1`,即自动容灾。 - -### 10.3 设置入口 - -建议放在增强设置页,归类为“接口容灾”或“接口韧性”。 - -### 10.4 备份与同步 - -设置键和接口排序键应纳入备份与一键同步,保证用户换机后容灾顺序和模式不丢失。 - -## 11. 回退路径 - -### 11.1 运行时回退 - -1. 用户把模式切回“关闭”。 -2. 立即停止当前容灾轮次。 -3. 不发起新的候选接口请求。 - -### 11.2 实现回退 - -如果上线后发现严重副作用,应优先回退到关闭模式默认值,而不是直接移除排序或设置项。 - -### 11.3 代码回退 - -如果必须回退代码,应保留排序存储结构和设置键,避免用户数据丢失。 - -## 12. 验证 - -### 12.1 单元测试 - -建议至少覆盖: - -1. 接口排序保存、恢复、去重。 -2. 新增接口追加到末尾。 -3. 已删除接口不参与排序。 -4. 关闭模式不触发容灾。 -5. 自动容灾失败后尝试下一个未尝试接口。 -6. 自动容灾全部失败后只回调一次最终错误。 -7. 自动容灾同轮内不重复尝试同一接口。 -8. 自动容灾同轮内尝试次数不超过上限。 -9. 手动确认用户取消后不继续请求。 -10. 手动确认用户切换后只尝试一次。 -11. 中间失败不发 `ConfigEvent.vod()`。 -12. 成功后走正常成功链路。 -13. 设置键纳入备份。 -14. 排序键纳入备份。 - -### 12.2 手动验证 - -1. 设置页切换三种模式。 -2. 关闭模式:接口失败后仅提示,无切换。 -3. 自动容灾:接口失败后自动切换到下一个接口。 -4. 自动容灾:所有接口失败后显示最终错误。 -5. 手动确认:接口失败后弹出确认框。 -6. 手动确认:用户取消后不切换。 -7. 手动确认:用户确认后切换到指定或下一个接口。 -8. 接口排序调整后,容灾顺序随之变化。 -9. 容灾成功后首页、站点、解析器、规则正常刷新。 -10. 容灾过程中不出现首页或播放页重复刷新。 - -### 12.3 回归边界 - -以下能力不应受本设计影响: - -1. 站点健康排序 -2. 换源 -3. 播放器内核自动切换 -4. 解码自动切换 -5. 直播接口 -6. 壁纸接口 -7. 配置仓库导入 - -## 13. 边界与不做事项 - -1. 不合并所有接口的全局配置。 -2. 不把多个接口的 `sites/parses/rules/headers/proxy/webHomeExtensions` 合并成一个虚拟配置。 -3. 不对站点首页、详情、搜索、播放失败做接口级切换。 -4. 不修改 `Config` 数据库实体。 -5. 不引入数据库 migration。 -6. 不将接口容灾与“聚合全部接口”混为同一开关。 -7. 默认开启自动容灾;用户可随时切换为关闭或手动确认。 -8. 不在关闭模式下发起额外请求。 -9. 不在中间失败后触发全局事件。 diff --git a/docs/interface-failover-v1.md b/docs/interface-failover-v1.md deleted file mode 100644 index 165e6eb4e7..0000000000 --- a/docs/interface-failover-v1.md +++ /dev/null @@ -1,61 +0,0 @@ -# interface-failover-v1 - -## Recovery anchor - -- Objective: implement VOD configuration failover from `docs/interface-failover-design.md`. -- Acceptance: off/auto/confirm modes, ordered URL candidates, bounded single-round retries, one final callback/event, manual cancel and selection, drag reorder, backup keys, focused verification. -- Lane/scope: standard; `app/**`, this document only. Initial dirty paths: none. -- Base: detached `449b94a118b5d1f5e55745dba193da735febe711`. -- Completed: policy/store/settings/resources; centralized `VodConfig` failover; candidate list and cancel; mobile/TV history drag reorder; backup whitelist. -- Current files: `VodConfig`, `BaseConfig`, `InterfaceOrderStore`, both config adapters and history dialogs, `Backup`, settings/resources, tests. -- Verification: prior compile exposed missing JSON read and `R` import; both fixed. Focused policy/order/state/backup tests and Mobile/Leanback Arm64 Java compilation now pass. -- Risks: no APK/device validation; current machine has about 2.5 GiB available memory but APK packaging is intentionally not attempted. -- Next action: none for the source implementation; the current task is committed and tagged. Device/APK validation remains an explicitly separate follow-up. - -## Decision - -Use the narrow project-adapted design: default `AUTO`, URL order in preferences, maximum three candidates, automatic mode retries remaining candidates, confirm mode shows the remaining list once and ends the round after a selected candidate fails. Manual selection starts a fresh round. Intermediate failures do not emit VOD events; only terminal success/failure does. - -## Verification log - -- `git diff --check`: passed. -- `InterfaceFailoverPolicyTest` and `InterfaceOrderStoreTest`: passed via `:app:testMobileArm64_v8aDebugUnitTest`. -- `:app:compileMobileArm64_v8aDebugJavaWithJavac`: passed. -- `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`: passed. -- APK packaging: not run; available memory must be checked first and must be at least 1.5 GiB. -- Follow-up: history selection now uses the caller's existing VOD callback chain; the original failed interface plus at most two fallback candidates make three total attempts, and fallback errors are not double-wrapped. - -## Follow-up verification - -- `git diff --check`: passed. -- `:app:compileMobileArm64_v8aDebugJavaWithJavac`: passed. -- `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`: passed. -- Scope review: history selection delegates to `ConfigListener`; VOD history keeps the current interface visible; automatic failover uses at most two fallback candidates; confirm mode ends after the selected candidate fails. -- Runtime safety: switching the mode to `Off` cancels queued failover work and prevents a pending round from starting another candidate request. -- Runtime safety: cancellation during an active candidate request leaves the terminal event to that request's `finally` block, avoiding duplicate refresh events. -- Runtime safety: cancellation now invalidates and aborts an active BaseConfig task, so a late candidate success cannot mutate the active VOD state or emit a refresh event. -- Failure classification: a successfully parsed VOD payload with an empty `sites` list is treated as a configuration-load failure and enters the same failover path. -- Invalidation: ordinary direct `VodConfig.load()` calls now invalidate the superseded task without delivering a stale error, then start a fresh round; only the internal candidate loader preserves the current round. -- Policy/order evidence: invalid modes fall back to the default `AUTO` mode; the fallback limit reserves the origin attempt, and URL sorting is tested for saved-order precedence, new-URL append, duplicate removal, and ignored deleted URLs. -- State evidence: `InterfaceFailoverStateTest` drives automatic unique candidate progression with the three-attempt cap, confirm-mode single selection, and cancellation blocking all later attempts; `VodConfig` now uses this state for candidate selection and rejects stale attempt callbacks. - -## Default AUTO review - -- Reviewed commits: `c89b166e6e39c01fe1ea8446346316983adac726` and `ca9febe024002e55945a55caa6f66023da405699`. -- Compared with the previously reviewed implementation commit `3562be1ecf0a32ce4b7b0222ffbbff4d06254fe7`, the only behavior change is `InterfaceFailoverPolicy.DEFAULT_MODE` from `OFF` to `AUTO`; invalid stored or supplied modes now clamp to `AUTO`, and the policy tests assert that behavior. -- `git diff --check`: passed. -- `:app:testMobileArm64_v8aDebugUnitTest` focused on `InterfaceFailoverPolicyTest`, `InterfaceFailoverStateTest`, `InterfaceOrderStoreTest`, and `BackupPreferenceFilterTest`: passed. -- `:app:compileMobileArm64_v8aDebugJavaWithJavac` and `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`: passed. - -## Runtime-off verification - -- `git diff --check`: passed. -- `:app:compileMobileArm64_v8aDebugJavaWithJavac`: passed. -- `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`: passed. -- Source review: switching to `Off` clears the active round, closes the pending confirmation dialog, delivers the original failure once, and prevents any queued next candidate from starting. -- Sync evidence: the one-key sync settings checkbox is selected by default, and `BackupPreferenceFilterTest` confirms both `interface_failover_mode` and `interface_order_vod` are included when settings are selected; duplicate legacy whitelist entries were removed without dropping the current settings keys. -- Final source closure: commit `3562be1ecf0a32ce4b7b0222ffbbff4d06254fe7`, recovery tag `recovery/interface-failover-v1-state-tests/20260913203608-3562be1ecf0a`; worktree was clean after closure. No APK was built because APK packaging requires a fresh check for at least 1.5 GiB available memory, and no device interaction was requested or run. - -## Closure - -The source implementation is complete and committed. Focused state, policy, order, and backup tests plus Mobile/Leanback Arm64 Java compilation pass. Device interaction and APK artifact validation remain outside this source-focused change; no APK was produced. diff --git a/docs/leanback-pan-switch-layout.md b/docs/leanback-pan-switch-layout.md deleted file mode 100644 index c7ff3cfa56..0000000000 --- a/docs/leanback-pan-switch-layout.md +++ /dev/null @@ -1,45 +0,0 @@ -# TV 网盘线路切换布局冲突修复 - -## 目标与范围 - -- 任务:`leanback-pan-switch-layout`,`quick-fix`。 -- 目标:线路选择回调发生在 Leanback 布局期间时,完整切换操作在布局结束后执行;快速切换只处理最新选择,页面退出或列表替换后不执行失效选择。 -- 基线:`feature/mpv-dv7-fel` / `f836d419518d1a93d4ff77414a88558f3854c7e3`。 -- 保护:开始前已有的 `app/.cxx/`,共 104 个文件;测试构建的 CMake staging 放在临时目录。 -- 范围:Leanback `VideoActivity`、`FlagSelectionListener`、对应 Robolectric 测试、`app/build.gradle` 的测试配置及本文档。无播放器内核或依赖升级。 - -## 证据与修复 - -用户提供的 5.6.0 崩溃截图来自 XGIMI / Android 9(SDK 28): -`Cannot call this method while RecyclerView is computing a layout or scrolling`,目标为 `VideoActivity` 的 `HorizontalGridView`(`id/flag`),由 `notifyDataSetChanged()` 触发。 - -本地调用链:`onChildViewHolderSelected` 同步进入 `onItemClick(Flag)`,随后可通过 `updateFocus` → `FlagAdapter.setNextFocusDown`,或 `seamless` → `selectEpisode` → `FlagAdapter.toggle`,立即刷新仍在布局的线路列表。已有 `notifyItemsChanged` 的延迟处理只覆盖直接的选中样式通知,未覆盖这两条间接路径。截图缺少对应 R8 mapping,无法把混淆方法精确还原到某一条路径。 - -使用与现有 `BaseActivity.postRecyclerUpdate` 一致的主线程延迟及布局重试方式,将完整线路切换移出选择回调。合并待处理选择,执行前核对当前位置、对象身份、adapter 和窗口附着状态;播放页同时拒绝退出期间的线路点击。不捕获并吞掉异常,不按系统版本分支。 - -这项 RecyclerView 约束适用于所有系统版本。不同设备的布局、滚动时序及不同网盘的剧集/清晰度结构决定是否触发间接刷新;当前证据不能证明 Android 9 独有。 - -## 验证 - -2026-09-20 验证通过: - -- Leanback armeabi-v7a Debug Java 编译通过。 -- Robolectric 4.16 / Android 9(API 28),`FlagSelectionListenerTest` 共 8 项,0 失败、0 错误、0 跳过,测试耗时 20.536 秒;最终 Gradle 运行 `BUILD SUCCESSFUL`,共 46 秒。 -- 真实 RecyclerView 观察者确认 `setNextFocusDown` 在布局临界区会抛出截图中的异常。`toggle` 在本地 `cb21feb42287e2299b3cbc9a6c911254b58060aa` 已改为布局期不通知列表(上游 `fa09d226f9` 的 `notifyDataSetChanged()` 是不同基线),因此合并到 `dev2` 后该测试只断言焦点刷新异常,不再要求 `toggle` 抛出。 -- 真实 Leanback 布局确实触发选择回调,修复后在布局结束才切换线路并更新焦点与剧集。 -- 快速连续切换只执行最后一次;无效位置、同名线路对象替换、adapter 替换及视图离开窗口均不执行旧选择。 - -定向任务:`:app:testLeanbackArmeabi_v7aDebugUnitTest --tests com.fongmi.android.tv.ui.custom.FlagSelectionListenerTest`。本次临时 init 脚本只保留 Leanback 测试源集、把 CMake staging 放在临时目录,并使用已缓存的 Android SDK 测试包。测试显式推进 16ms 虚拟帧,避免在模拟持续布局时无限执行同一时刻的下一帧回调;进入测试前先推进首帧完成窗口附着。 - -结果文件:`app/build/test-results/testLeanbackArmeabi_v7aDebugUnitTest/TEST-com.fongmi.android.tv.ui.custom.FlagSelectionListenerTest.xml`。完整成功日志:`/private/tmp/webhtv-pan-switch-01j13nva/gradle-tests-2.log`。此前测试框架调用和虚拟帧初始化问题已在测试代码中修正。 - -没有受影响的 XGIMI 实机复测证据;Robolectric 不等同于实机网盘端到端播放。 - -## Recovery anchor - -- 已完成:截图/本地调用链分析、任务保护、最小修复、Java 编译及 8 项 Android 9 回归。 -- 2026-09-21 C4 第六轮合并复核:本地 `onVideoTouch`(含 `dispatchDiscMenuTouch` 前置)优先于上游整行替换,保留触摸优化与蓝光菜单触摸;`FlagSelectionListener` 按上游引入并在 `dev2` 上重跑 8 项测试通过。 -- 未验证:受影响 XGIMI 实机的网盘播放全流程。 -- 回滚:本任务单独提交,可整体 revert;上一个已验证状态为上述基线。 -- 本记录随修复原子提交,恢复标签使用 `recovery/leanback-pan-switch-layout/` 前缀。 -- 后续建议:在受影响 XGIMI 设备上复测原网盘线路切换场景。 diff --git a/docs/mobile-site-theme-20260906.md b/docs/mobile-site-theme-20260906.md deleted file mode 100644 index f107a0f737..0000000000 --- a/docs/mobile-site-theme-20260906.md +++ /dev/null @@ -1,53 +0,0 @@ -# 手机版站点选择主题配色修复 - -## 目标与边界 - -- 修复主题色彩在站点选择弹窗、搜索框、站点项及分组项不生效。 -- 手选色、跟随壁纸(由 `Setting.getDynamicColor()` 解析)、关闭自定义色均保留;兼容当前 Android 9 设备与明暗主题。 -- 仅修改 mobile 站点 UI 与针对性测试;不改全局主题、电视版、依赖、站点数据或播放逻辑。 -- `quick-fix`;任务 `mobile-site-theme-20260906`;基线 `dev1` / `f627d99e88078ccd8760986cbae65cc4a3eb5adc`。 -- 保护初始脏路径 `gradlew`。 - -## 原因与方案 - -1. `adapter_site.xml` 使用 main 中固定蓝白 `site_button_*`,`SiteGroupAdapter` 使用固定浅色 `dialog_outlined_button_*`;搜索框文字和图标也未接入配色。 -2. `BaseActivity.enableDynamicColor()` 调用的 Material 1.14.0 `DynamicColors.applyToActivityIfAvailable()` 有平台可用性门槛;当前设备 SDK 28 会直接返回。 -3. 旧 `SiteDialog.applyDynamicColor()` 只将原始色涂到底板,既不改变控件,也不保证背景/文字对比度。 - -不新增 EventBus 订阅:现有主题事件已重建宿主,弹窗创建时读取当前设置即可。 -新增 mobile 专用 `SiteDialogTheme`,每个弹窗生成一次调色板并共享给两个 Adapter。 -使用已有 Material `getColorRoles(context, seed)` 和 `getSurfaceContainerFromSeed(context, seed)`,不依赖系统动态色支持;无 seed 时读取当前主题属性。 -站点与分组统一 selected/focused/pressed/default 状态色,选中与交互前景/背景成对;保留禁用站点文字的不透明度。 -健康状态点继续使用原来的语义色,搜索/换源/屏蔽/列数/筛选/拖动排序逻辑不变。 - -## 依据(2026-09-06) - -- 项目实际依赖:`gradle/libs.versions.toml` 的 Material 1.14.0;本地 Maven 缓存对应官方 AAR。 -- 用 `javap` 核实上述三个 API 的实际字节码:动态色入口先检查平台支持;ColorRoles 依主题明暗生成配对色;surface 采用低色度、浅色 tone 94 / 深色 tone 12。 -- 本地源码:`Setting.getDynamicColor()`、mobile `BaseActivity.enableDynamicColor()`、`SiteDialog.initView()`、两个站点 Adapter 及对应布局。 -- 外部官方页面读取尝试因 TLS 失败未取得正文;实现以当前锁定版本实际依赖为据,不据此宣称做过全面设计调研。 - -## 验证与收尾 - -- **手机版编译通过**:`:app:compileMobileArm64_v8aDebugJavaWithJavac`、`:app:processMobileArm64_v8aDebugResources`,覆盖修改后的类、调用方、ViewBinding 与两个布局。首次编译发现 `colorPrimary` 属于 AppCompat 的非传递 R 命名空间,修正后编译通过。 -- **5 项针对性 JUnit 测试通过**:`SiteDialogThemeSourceTest`(4 项)与 `MobileSiteAdapterStyleTest`(1 项)。 - - 原 Gradle 单测任务在生产代码和资源编译后,被离线缺少 JUnit 4.13.2 / MockWebServer 5.4.0 缓存阻断。 - - 从 Maven Central 获取项目已指定版本 JUnit 4.13.2 与 Hamcrest 1.3,直接用 JDK 21 `javac` + `org.junit.runner.JUnitCore` 执行上述两类;没有替换断言、跳过测试或修改项目依赖。完整 Gradle 单测任务未宣称通过。 -- **Android 9 / SDK 28 控件运行验证通过**:独立测试包直接链接当前 `SiteDialogTheme.java` 源文件及相同 Material 1.14.0 依赖;不覆盖已安装的 WebHTV,不读写其设置。 - - 实际输出:`PASS sdk=28 dynamicAvailable=false palettes=28 checks=1168 minTextContrast=6.425314122550928`。 - - 覆盖明/暗主题 ×(关闭自定义色、12 个预设色、1 个壁纸颜色输入);真实 MaterialButton 的颜色状态列表、实际选中/禁用文字状态,以及搜索文字、提示和图标着色。 - - 每组检查站点和分组的 normal / selected / focused / pressed / disabled / selected+focused 配色;成对前景/背景对比度最低约 6.43:1(不含现有非选中分组的视图透明度效果)。 - - 示例:蓝色 `FF1E88E5` 的浅色底板/强调色为 `FFEDEDF4` / `FF0060A8`;绿色 `FF43A047` 为 `FFECEFE6` / `FF006E1C`,证明更换颜色会实际改变配色,而非只更新设置值。 - - 测试包 `com.silent.android.webhtv.sitethemeprobe` 已卸载;此次没有安装新的整包 WebHTV,也没有进行整页手动操作验收。 -- **静态差异审查**:搜索、分组筛选、站点及分组拖动排序、屏蔽、单/双列、搜索/换源开关与健康状态点逻辑未变;主题变化仍沿用现有宿主重建机制。 -- 日志:`/tmp/webhtv-site-theme/gradle-unit-fixed.log`、`junit-focused.log`、`probe-device-result.log`、`probe-cleanup.log`。独立测试包及构建材料仅在 `/tmp/webhtv-site-theme/probe/`,不纳入项目。 -- 本任务代码、测试和本文件由一次 `task_guard.sh finish` 原子提交并创建带注释本地恢复标签;不推送。 - -回滚:撤销本任务提交即可,不涉及数据迁移或设置格式变更。 - -## Recovery anchor - -- 状态:局部修复、手机版编译、5 项回归测试和 SDK 28 配色控件验证均已完成。 -- 当前文件:mobile SiteDialog / SiteAdapter / SiteGroupAdapter / SiteDialogTheme、两个站点布局、两个针对性测试、本文件。 -- 未验证范围:未替换手机现有整包,未进行整页手动操作验收;不影响本次已验证的编译与配色契约。 -- 唯一下一步:由任务守卫完成本地原子提交和恢复标签;完成后无剩余代码工作,整包安装或发布按后续授权处理。 diff --git a/docs/novel-pagination-history-restore-fix.md b/docs/novel-pagination-history-restore-fix.md deleted file mode 100644 index 55855ffe8d..0000000000 --- a/docs/novel-pagination-history-restore-fix.md +++ /dev/null @@ -1,28 +0,0 @@ -# 小说分页历史节点修复 - -## Recovery anchor -- 目标:修复小说分页模式重新进入时总是回到章节最后一页。 -- 验收:退出时保存当前章节的当前子分页,重新进入恢复到同一子分页;滚动模式和漫画模式不受影响。 -- 回滚:还原 `reader.html`、对应 source-test 与本任务文档。 - -## 诊断 -- 模拟器 `192.168.50.3:5561` 日志显示退出时记录为 `anchor=9/10`,重新进入也恢复 `anchor=9/10`。 -- 分页模式中未显示的子分页使用 `display:none`,但 `effectiveAnchorIndex()` 仍执行 `anchorsSettled() && atDocumentEnd()`。 -- 因此只要当前文档被判断为到底,就把当前页强制改写成 `total - 1`。 - -## 修复 -- `effectiveAnchorIndex()` 在分页模式直接返回当前 `currentPage`。 -- 仅滚动模式继续使用文档末尾判定来编码“读完”状态。 - -## 验证 -- `ReaderPlaybackRoutingSourceTest` 新增分页模式回归断言。 -- 专项测试通过: - `./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests 'com.fongmi.android.tv.ui.activity.ReaderPlaybackRoutingSourceTest' --console=plain` -- 构建前 `free -h` 显示 `available=2.1Gi`,满足至少 1.5 GiB 的打包要求; - `:app:assembleMobileArm64_v8aDebug` 构建成功。 -- APK 已安装到 `192.168.50.3:5561`。 -- 模拟器复测:从历史记录进入后,翻回一个子分页并退出,日志记录 - `saveProgress index=1 anchor=8/10 chapter=第二章 太荒吞天诀`;再次进入记录 - `restore index=1 anchor=8/10 kind=1 chapter=第二章 太荒吞天诀 reresolve=true`。 - 画面显示 `9 / 10`,对应保存接口的 0-based `anchor=8/10`,证明恢复的是退出时子分页而非末页。 -- 复测日志未出现 `Uncaught`、`AndroidRuntime` 或 WebView JavaScript 异常。 diff --git a/docs/personal-recommendation-cache-v1.md b/docs/personal-recommendation-cache-v1.md deleted file mode 100644 index 62fc6fa9d1..0000000000 --- a/docs/personal-recommendation-cache-v1.md +++ /dev/null @@ -1,116 +0,0 @@ -# 个性推荐结果页缓存设计 - -> **状态:✅ 已实现并验证** -> **日期:2026-09-14** -> **范围:** 个性推荐 TMDB / 豆瓣结果页。智能 AI 已有缓存优先路径,猜你喜欢继续沿用 TMDB 底层缓存 + 异步细粒度刷新。 - -## 1. 背景 - -当前个性推荐 TMDB / 豆瓣首屏通过 `PersonalRecommendationService.loadPage()` 后台生成,但结果页本身没有持久缓存。再次进入详情页时,即使观看历史、反馈、当前片名和配置未变,也会重新匹配种子、请求推荐列表并补齐豆瓣评分,造成等待、接口压力和重复关键词查询。 - -相关证据: - -- `PersonalRecommendationService.loadPage()` 首屏串行调用 `loadTmdbPage()` / `loadDoubanPage()`。 -- `loadFromTmdb()` 与 `loadFromDouban()` 均按当前片名、历史种子和反馈重新计算。 -- `RecommendationFeedbackStore.fingerprint()` 已能感知“不感兴趣”变化。 -- `RecommendationPage` 已包含 `offset`、`nextOffset`、`hasMore`、`historyFingerprint`。 -- 智能推荐 `AiRecommendationService.loadCached()/resolveCached()/refresh()` 已验证“缓存优先 + 后台刷新”范式。 - -## 2. 目标与非目标 - -### 目标 - -1. TMDB 和豆瓣个性推荐结果页支持先读取持久缓存。 -2. 缓存键包含推荐来源、当前片名、历史种子指纹、反馈指纹、TMDB API 地址和语言。 -3. 指纹一致时优先展示缓存;异步刷新得到等价或新结果后写回缓存并刷新 UI。 -4. 指纹变化、功能关闭、配置不就绪、空页或无效序列化数据不使用陈旧缓存。 -5. 失败时保留可用缓存兜底,但重新经过当前 blockedTitles 与反馈过滤。 -6. 分页 `nextOffset` / `hasMore` 随缓存恢复,保证滚动懒加载继续可用。 -7. TMDB / 豆瓣独立缓存,不互相阻塞。 -8. TMDB 豆瓣评分富集异步进行,富集后更新内存页和缓存。 - -### 非目标 - -1. 不缓存“猜你喜欢”结果页;继续复用 TMDB 底层缓存。 -2. 不改变现有推荐排序、去重、反馈、分页和焦点行为。 -3. 不增加数据库表或第三方依赖。 -4. 不在主线程执行磁盘 IO。 -5. 不缓存原始 HTML/API payload;只缓存过滤后的 `TmdbItem` 结果页。 - -## 3. 缓存设计 - -### 文件与格式 - -- 目录:`Path.cache()/personal_rec` -- 文件:`_.json` -- payload:JSON 对象,`version=1`、`items`、`offset`、`nextOffset`、`hasMore`、`fingerprint`。 -- TTL:7 天,TTL 内不因时间主动失效。 -- 上限:单页最多 128 个 item;每次写入执行 LRU 清理,保留最新 32 个结果页。 - -### 缓存键 - -`source|currentTitle|historyFingerprint|feedbackFingerprint|apiBase|language` - -- `currentTitle`:`currentTitle(currentVod, currentItem)`。 -- `historyFingerprint`:`historySeedFingerprint(...)`。 -- `feedbackFingerprint`:`RecommendationFeedbackStore.fingerprint()`。 -- TMDB 和豆瓣分别使用各自历史种子,豆瓣额外保留当前片名作为首个 seed。 -- fingerprint 存入 payload,读取时再校验 payload fingerprint 与当前 fingerprint 一致。 - -### 加载流程 - -1. `loadPage()` 进入 service 后台线程后读取独立缓存。 -2. 缓存命中且 fingerprint / TTL / 结构有效时直接返回。 -3. 缓存未命中、无效或指纹变化时调用现有 `loadFromTmdb()` / `loadFromDouban()`。 -4. 非空有效结果写入缓存后返回。 -5. 网络失败时回退现有网络失败行为;若已有旧缓存则在本次进入时仍可作为兜底。 -6. `enrichTmdbPageRatingsAsync()` 富集后由 UI 层把富集页写回缓存。 -7. “不感兴趣”立即从内存列表移除;下一次加载因 feedback fingerprint 变化重新生成,不返回已反馈作品。 - -### UI 流程 - -1. `TmdbUIAdapter.loadPersonalRecommendationsAsync()` 恢复现有异步调度。 -2. 缓存页到达时先绑定;若随后刷新出相同 display list,不重复 notify。 -3. 新结果到达后替换内存页,写回缓存。 -4. Leanback 原生路径继续通过现有 `loadNativePersonalRecommendations()` 消费同一 service API。 -5. Web/桌面通用路径通过现有 `VOD_PERSONAL` 事件细粒度刷新。 - -## 4. 方案比较 - -| 方案 | 结论 | 原因 | -|---|---|---| -| 不改动 | 不采用 | 每次重新匹配和请求,等待和接口压力没有解决 | -| 只缓存原始接口响应 | 不采用 | 会绕过当前 blockedTitles、历史种子合并和去重排序 | -| 缓存过滤后结果页(采用) | 采用 | 最小改动、可校验 fingerprint、保留分页与反馈行为 | - -## 5. 验收标准 - -1. 新增单元测试覆盖缓存命中、TTL 过期、结构损坏、fingerprint 变化、空页不写缓存和写回富集页。 -2. 现有 `PersonalRecommendationServiceTest` 全部通过。 -3. `:app:compileDebugJavaWithJavac` 通过。 -4. `task_guard.sh check` 通过。 -5. 不修改“猜你喜欢”路径和智能 AI 缓存路径。 - -## 6. 实施步骤 - -1. 新增 `PersonalRecommendationCache`:Gson 序列化、TTL、结构校验、fingerprint 校验、LRU cleanup。 -2. `PersonalRecommendationService` 新增 `loadCachedPage()` / `refreshPage()` / `writeCachedPage()`。 -3. `loadPage()` 改为 cache-first,只影响首屏。 -4. `TmdbUIAdapter.loadPersonalRecommendationsAsync()` 使用 cache-first service API。 -5. 富集回调写回对应来源缓存。 -6. 补充聚焦单元测试,并在同一测试任务内编译通过。 - -## 7. 风险与回滚 - -**风险:** - -1. 错误复用旧结果:由当前 fingerprint、TTL、结构校验和当前 blockedTitles/feedback 过滤降低。 -2. 磁盘失败:缓存操作失败不影响正常加载。 - -**回滚锚点:** 任务守卫 base HEAD `55006efe62961a0bdd6e62686611adc9c560b09b`。 - -## 8. 状态记录 - -- 2026-09-14:完成代码链路证据审查并建立设计文档。 -- 2026-09-14:完成 cache-first 实现。验证期间修复了 fingerprint 辅助方法、`RecommendationPage` 类型、multi-catch、refresh/load-more 回调参数与测试构造类型问题。 -- 2026-09-14:`:app:testMobileArm64_v8aDebugUnitTest --tests 'com.fongmi.android.tv.service.PersonalRecommendationServiceTest'` 与 `:app:compileMobileArm64_v8aDebugJavaWithJavac` 通过。 diff --git a/docs/playback-speed-choice-20260908.md b/docs/playback-speed-choice-20260908.md deleted file mode 100644 index bc3d66dfb3..0000000000 --- a/docs/playback-speed-choice-20260908.md +++ /dev/null @@ -1,29 +0,0 @@ -# 播放器倍速弹窗直选 - -## 目标与范围 - -- 将倍速短按的循环切换改为与播放核心一致的选项弹窗,保留倍速功能,不替换画质入口。 -- quick-fix:只改同一 app 模块的六处短按入口、共用弹窗及定向测试;不改播放核心、依赖、资源和二进制。 -- 起点:`dev3` / `4d42cc34c8a5ba74bed68455c5ab1c9761fd14ad`;初始工作区干净。 - -## 设计与验收 - -- 当前六处入口调用 `PlayerManager.addSpeed()`,用户只能循环选择;画质已有单选弹窗,播放核心复用 `ChoiceDialog.showSingleNoCancel`。 -- 复用项目既有设计,属于局部交互修复,不引入新框架或播放契约,不需要上游设计研究。 -- 共用 `PlaybackSpeedDialog` 使用与现有循环相同的十档:0.5、0.75、1、1.25、1.5、1.75、2、2.5、3、5。 -- 当前为滑块或遥控器微调值时,将该值按序补入并选中;不强行改变当前倍速。打开/返回不写倍速,点击某档立即应用并关闭。 -- 手机/电视点播、TMDB 内嵌播放器、直播页中的点播、电视投屏入口使用同一弹窗;保持各入口原有的剧集/默认倍速记忆规则。 -- 保留长按切换、长按临时加速、遥控器上下键微调及设置页滑块;不改变真正直播流的倍速限制。 -- 回调执行前重新检查服务与播放所有权,避免延迟选择触碰已释放/非当前播放器。 - -## 验证与回滚 - -- 待执行:`PlaybackSpeedDialogTest`、`PlaybackSpeedSyncTest` 定向单测,Mobile/Leanback arm64 Java 编译,共用弹窗设备点击验证。 -- 回滚:撤销本任务单个提交即可;最终提交及 annotated recovery tag 由 task guard 创建,不推送。 - -## Recovery anchor - -- 已完成:六个短按入口、共用单选弹窗及定向测试已完成;`PlaybackSpeedDialogTest`、`PlaybackSpeedSyncTest` 通过;Mobile arm64 APK 构建成功;Leanback arm64 Java 编译成功;`git diff --check` 通过。 -- 真机证据(2026-09-09,Asia/Shanghai,设备 `192.168.50.3:5557` / SM-N9700):WebHTV `VideoActivity` 前台播放真实视频流。控制栏短按 `1x` 打开“播放倍速”单选弹窗,截图确认十档(`0.50x` 至 `5.00x`)且当前 `1.00x` 高亮;按返回后诊断面板仍显示可支持/当前 `1.00x`,未改速;重新打开并直选 `1.25x` 后控制栏显示 `1.25x`,logcat 记录 `requested=1.250 actual=1.250` 及 `PlaybackParameters(speed=1.25)`。全程未见 WebHTV `FATAL EXCEPTION`。`uiautomator dump` 因视频 Surface 持续刷新无法进入 idle,但不影响截图、前台窗口、播放器诊断和日志证据。 -- 当前风险:未发现本任务范围内的代码回归;未改变长按、遥控器微调、设置滑块、倍速记忆或直播限制路径。 -- 唯一下一步:执行 `task_guard.sh finish` 原子提交并创建本地 recovery tag,不推送。 diff --git a/docs/pull-merge-beta-20260904.md b/docs/pull-merge-beta-20260904.md deleted file mode 100644 index 1341c89774..0000000000 --- a/docs/pull-merge-beta-20260904.md +++ /dev/null @@ -1,17 +0,0 @@ -# Pull merge beta 2026-09-04 - -## Recovery anchor - -Objective: merge `origin/beta` into `dev4` and verify the resulting merge. -Acceptance: worktree is clean, `dev4` contains `fbbefb75dbcbce1fcec259bb3a81a5e2e889362e`, targeted validation evidence is recorded. - -## Status - -- 2026-09-04: fast-forward merged `origin/beta` into `dev4` to `fbbefb75dbcbce1fcec259bb3a81a5e2e889362e`; no conflicts. -- 2026-09-04: `:app:testMobileArm64_v8aDebugUnitTest` found 4118 tests with 3 failures. - - `FfmpegVc1SupportTest` (2 failures): `video/wvc1` no longer maps to `vc1` and no longer returns initialization data. This is the known C10 upstream-binary alignment tradeoff recorded in `docs/C10-binary-upstream-alignment.md`; it is not introduced by this merge. - - `ReaderPlaybackRoutingSourceTest.readerProgressRefreshesHistoryAfterEverySave`: the literal contains an LF, while the checked-out source uses CRLF. -- 2026-09-04: made the Reader source assertion CRLF-tolerant; targeted `testMobileArm64_v8aDebugUnitTest` run completed 28 tests with the two known C10 `wvc1` failures only, so the 25 Reader tests passed. -- `git diff --check` passed. -- Rollback: merge state is inherently present in the fast-forward history; the Reader test repair can be reverted independently from this commit. -- Unresolved: the two C10 `FfmpegVc1SupportTest` failures are pre-existing and belong to the upstream binary alignment tradeoff, not this merge task. diff --git a/docs/subtitle-source-protocol-design.md b/docs/subtitle-source-protocol-design.md deleted file mode 100644 index 6a91b32716..0000000000 --- a/docs/subtitle-source-protocol-design.md +++ /dev/null @@ -1,930 +0,0 @@ -# WebHTV 字幕源订阅与 T3 脚本协议设计 - -## 1. 文档状态 - -- 状态:设计草案 -- 协议名称:WebHTV Subtitle Source Protocol -- 协议版本:1 -- 配置根节点:`subtitles` -- 主要实现目标:支持用户通过订阅自主维护字幕源,并以 Python、JavaScript、HTML 等实体文件执行字幕搜索与资源解析 -- 兼容方向:T3 本地脚本源为主,T4 远程接口源为辅 - -## 2. 背景 - -当前点播配置使用 `sites`,直播配置使用 `lives`。字幕源需要独立配置节点,确定使用 `subtitles`: - -```json -{ - "sites": [], - "lives": [], - "subtitles": [] -} -``` - -现有字幕模块已经具备统一的 `SubtitleProvider` 抽象,其核心能力分为: - -```java -List search(SubtitleQuery query, SubtitleContext context) throws Exception; - -SubtitleAsset resolve(SubtitleCandidate candidate, SubtitleContext context) throws Exception; -``` - -现有 `SubtitleProviderRegistry` 负责字幕提供者的注册、搜索调度、候选结果合并和资源解析。因此新增订阅字幕源时,不应重写自动匹配或手动搜索流程,而应把外部字幕源适配成标准 `SubtitleProvider`。 - -## 3. 目标 - -### 3.1 核心目标 - -1. 新增顶层配置节点 `subtitles`,与 `sites`、`lives` 并列。 -2. 支持远程订阅、本地文件和内容 URI 等配置入口。 -3. 优先支持 T3 字幕源,由 Python、JavaScript、HTML 实体文件自行完成网络请求、鉴权、页面解析和数据转换。 -4. 支持 T4 字幕源,由远程服务直接返回标准字幕协议。 -5. T3 与 T4 采用完全一致的搜索结果和资源解析结果协议。 -6. 复用现有 `SubtitleProvider`、`SubtitleProviderRegistry`、`SubtitleCandidate` 和 `SubtitleAsset`。 -7. 支持字幕源启用状态、优先级、超时、语言、格式及私有参数配置。 -8. 支持订阅更新、文件缓存、完整性校验和失败回滚。 -9. 保证单个字幕源失败不会阻塞其他字幕源。 - -### 3.2 非目标 - -1. 不把字幕源模拟成完整点播站点。 -2. 不要求字幕脚本实现首页、分类、详情、播放等点播 Spider 方法。 -3. 不在 App 配置中描述每个目标网站的字段映射和页面选择器。 -4. 不允许脚本直接访问 App 的高权限内部接口。 -5. 第一阶段不迁移或删除现有内置字幕源。 -6. 第一阶段不要求支持任意原生二进制插件。 - -## 4. 总体架构 - -```text -字幕订阅地址或本地配置文件 - -> SubtitleConfigLoader - -> 解析 subtitles 列表 - -> 校验、规范化实体文件地址 - -> SubtitleSourceCache 下载并缓存实体文件 - -> SubtitleProviderFactory - -> type=3: T3SubtitleProvider - -> PythonSubtitleRuntime - -> JavaScriptSubtitleRuntime - -> HtmlSubtitleRuntime - -> type=4: T4SubtitleProvider - -> HTTP 标准协议客户端 - -> SubtitleProviderRegistry - -> 现有自动匹配与手动搜索流程 - -> SubtitleCandidate - -> resolve - -> SubtitleAsset - -> 字幕下载、缓存、解压和播放器加载 -``` - -配置层只说明字幕源是谁、执行类型是什么、实体文件在哪里。目标字幕网站的请求、签名、Cookie、解析和结果转换由实体文件自行处理。 - -## 5. 配置协议 - -### 5.1 完整配置示例 - -```json -{ - "protocol": "webhtv-subtitle", - "version": 1, - "name": "我的字幕源订阅", - "subtitles": [ - { - "key": "assrt_py", - "name": "Assrt Python", - "type": 3, - "api": "py", - "ext": "./subtitle/assrt.py", - "enabled": 1, - "priority": 100, - "timeout": 15, - "queryIndependent": 0, - "languages": ["zh-Hans", "zh-Hant", "en"], - "formats": ["srt", "ass", "ssa", "vtt"], - "sha256": "", - "params": { - "token": "${ASSRT_TOKEN}" - } - }, - { - "key": "xunlei_js", - "name": "迅雷字幕 JavaScript", - "type": 3, - "api": "js", - "ext": "./subtitle/xunlei.js", - "enabled": 1, - "priority": 90, - "timeout": 12, - "queryIndependent": 1 - }, - { - "key": "browser_html", - "name": "网页字幕源", - "type": 3, - "api": "html", - "ext": "./subtitle/provider.html", - "enabled": 1, - "priority": 80, - "timeout": 20 - }, - { - "key": "private_t4", - "name": "私有字幕服务", - "type": 4, - "api": "https://subtitle.example.com/v1", - "enabled": 1, - "priority": 70, - "timeout": 10, - "headers": { - "Authorization": "Bearer ${SUBTITLE_SERVICE_TOKEN}" - } - } - ] -} -``` - -### 5.2 字段定义 - -#### 根对象 - -- `protocol`:可选,协议标识;推荐固定为 `webhtv-subtitle`。 -- `version`:必填,协议主版本;第一版为 `1`。 -- `name`:可选,订阅显示名称。 -- `subtitles`:必填,字幕源对象数组。 - -#### 字幕源对象 - -- `key`:必填,订阅内唯一、稳定的字幕源标识。 -- `name`:必填,用户界面显示名称。 -- `type`:必填;`3` 表示本地执行实体,`4` 表示远程标准接口。 -- `api`:必填;T3 为 `py`、`js` 或 `html`,T4 为服务基础地址。 -- `ext`:T3 必填,脚本或 HTML 实体文件地址。 -- `enabled`:可选,`1` 启用、`0` 禁用,默认启用。 -- `priority`:可选,数值越大优先级越高;相同分数保持订阅顺序。 -- `timeout`:可选,单次调用超时秒数;应设置全局上限。 -- `queryIndependent`:可选,是否只需对一组查询调用一次。 -- `languages`:可选,字幕源声明支持的语言标签。 -- `formats`:可选,字幕源声明支持的字幕格式。 -- `params`:可选,提供给脚本的私有配置。 -- `headers`:可选,T4 请求头或 T3 下载实体时需要的请求头。 -- `sha256`:可选,T3 实体文件 SHA-256 校验值。 -- `minAppVersion`:可选,最低兼容 App 版本。 -- `protocolVersion`:可选,单源协议版本;未设置时继承根对象版本。 - -### 5.3 地址形式 - -T3 的 `ext` 应支持以下形式: - -```text -./subtitle/provider.py -https://example.com/subtitle/provider.js -file:///storage/emulated/0/WebHTV/subtitle/provider.py -content://com.android.externalstorage.documents/document/... -asset://subtitle/provider.html -``` - -相对地址必须以订阅文件最终地址为基准解析,不以当前页面或进程工作目录为基准。 - -## 6. T3 与 T4 执行模型 - -### 6.1 T3 - -```text -App - -> 调用已缓存的本地 Python、JavaScript 或 HTML 实体 - -> 实体访问目标字幕网站 - -> 实体完成鉴权、抓取、解析和转换 - -> 返回标准字幕协议 JSON -``` - -T3 适合复杂网页解析、签名鉴权、多步跳转、Cookie 会话及社区自行维护的爬虫源。 - -### 6.2 T4 - -```text -App - -> 请求远程字幕服务 - -> 远程服务返回标准字幕协议 JSON -``` - -T4 适合已部署的字幕聚合服务、私有服务或局域网字幕服务器。 - -### 6.3 统一原则 - -T3 与 T4 只在执行位置和调用方式上不同。传入的业务请求以及返回的 `search`、`resolve` 数据结构必须一致。 - -## 7. 脚本生命周期与方法 - -每个 T3 实体使用以下生命周期: - -```text -init(config) -search(request) -resolve(request) -destroy() -``` - -- `init`:可选;加载源配置、初始化会话或检查运行环境。 -- `search`:必填;按统一查询协议返回候选字幕。 -- `resolve`:条件必填;把候选项解析成最终可下载资源。 -- `destroy`:可选;释放会话、WebView、脚本上下文及临时资源。 - -返回值统一为 JSON 对象或可解析为 JSON 对象的字符串。异步 JavaScript 或 HTML 实现允许返回 Promise。 - -## 8. 通用响应封装 - -所有方法建议使用统一响应结构: - -```json -{ - "code": 0, - "message": "", - "data": {} -} -``` - -约定: - -- `code = 0` 表示成功。 -- 非零 `code` 表示业务失败。 -- `message` 提供可记录的错误摘要,不应包含密钥。 -- `data` 保存对应方法的结果。 -- 运行时异常、超时和崩溃由 App 转换为统一 Provider 错误,不要求脚本捕获所有异常。 - -## 9. init 协议 - -### 9.1 请求 - -```json -{ - "protocol": "webhtv-subtitle", - "version": 1, - "source": { - "key": "assrt_py", - "name": "Assrt Python", - "type": 3, - "api": "py", - "params": { - "token": "运行时注入值" - } - }, - "environment": { - "appVersion": "1.0.0", - "platform": "android", - "locale": "zh-CN" - } -} -``` - -### 9.2 响应 - -```json -{ - "code": 0, - "message": "", - "data": { - "capabilities": { - "search": true, - "resolve": true, - "queryIndependent": false, - "languages": ["zh-Hans", "zh-Hant", "en"], - "formats": ["srt", "ass", "ssa", "vtt"] - } - } -} -``` - -脚本声明能力与配置冲突时,应采用更严格的能力交集。 - -## 10. search 协议 - -### 10.1 请求 - -```json -{ - "action": "search", - "requestId": "2fc5d947-9103-4c7f-a90b-63a83d60e19c", - "query": { - "text": "流浪地球", - "key": "流浪地球 2019", - "language": "zh-Hans", - "source": "title", - "strictness": "normal", - "year": 2019, - "season": -1, - "episode": -1 - }, - "context": { - "canonicalTitle": "流浪地球", - "originalTitle": "The Wandering Earth", - "year": 2019, - "season": -1, - "episode": -1, - "fileName": "The.Wandering.Earth.2019.1080p.mkv", - "mediaUrl": "", - "tmdbId": 535167, - "imdbId": "", - "preferredLanguage": "zh-Hans" - }, - "source": { - "key": "assrt_py", - "name": "Assrt Python", - "params": { - "token": "运行时注入值" - } - } -} -``` - -### 10.2 响应 - -```json -{ - "code": 0, - "message": "", - "data": { - "list": [ - { - "id": "subtitle-1001", - "name": "流浪地球.2019.简体中文.ass", - "language": "zh-Hans", - "format": "ass", - "url": "", - "score": 92, - "year": 2019, - "season": -1, - "episode": -1, - "matchType": "metadata_fuzzy", - "requiresResolve": true, - "payload": { - "fileId": "1001", - "downloadPage": "https://example.com/subtitle/1001" - } - } - ] - } -} -``` - -### 10.3 候选字段 - -- `id`:必填,在当前字幕源内稳定且唯一。 -- `name`:必填,用户可见名称或文件名。 -- `language`:可选,推荐使用 BCP 47 标签。 -- `format`:可选,推荐 `srt`、`ass`、`ssa`、`vtt`。 -- `url`:可选,候选已具有直接下载地址时填写。 -- `score`:可选,脚本估算匹配分;App 可以再次排序。 -- `year`:可选,影片年份。 -- `season`:可选,季度;未知使用 `-1`。 -- `episode`:可选,集数;未知使用 `-1`。 -- `matchType`:可选,匹配类型。 -- `requiresResolve`:必填,是否需要调用 `resolve`。 -- `payload`:可选,Provider 私有 JSON,App 只负责原样保存并在 `resolve` 时回传。 - -### 10.4 映射到现有模型 - -```text -source.key -> SubtitleCandidate.provider -id -> SubtitleCandidate.candidateId -name -> SubtitleCandidate.displayName -language -> SubtitleCandidate.language -format -> SubtitleCandidate.format -url -> SubtitleCandidate.downloadUrl -score -> SubtitleCandidate.score -year -> SubtitleCandidate.year -season -> SubtitleCandidate.season -episode -> SubtitleCandidate.episode -matchType -> SubtitleCandidate.matchType -requiresResolve -> SubtitleCandidate.requiresResolve -payload -> SubtitleCandidate.providerPayload -``` - -## 11. resolve 协议 - -### 11.1 请求 - -```json -{ - "action": "resolve", - "requestId": "4fc64ac6-f767-4eb7-b824-a2babc0f916a", - "candidate": { - "id": "subtitle-1001", - "name": "流浪地球.2019.简体中文.ass", - "language": "zh-Hans", - "format": "ass", - "url": "", - "payload": { - "fileId": "1001", - "downloadPage": "https://example.com/subtitle/1001" - } - }, - "context": { - "canonicalTitle": "流浪地球", - "originalTitle": "The Wandering Earth", - "year": 2019, - "season": -1, - "episode": -1 - }, - "source": { - "key": "assrt_py", - "params": { - "token": "运行时注入值" - } - } -} -``` - -### 11.2 响应 - -```json -{ - "code": 0, - "message": "", - "data": { - "url": "https://example.com/download/1001.ass", - "fileName": "流浪地球.2019.简体中文.ass", - "language": "zh-Hans", - "format": "ass", - "headers": { - "Referer": "https://example.com/", - "User-Agent": "Mozilla/5.0" - }, - "archive": { - "type": "none", - "entry": "" - } - } -} -``` - -### 11.3 资源字段 - -- `url`:与 `content` 至少提供一个;最终下载地址。 -- `content`:可选,Base64 编码的小体积字幕正文,不建议用于大文件。 -- `fileName`:可选,建议包含扩展名。 -- `language`:可选,字幕语言。 -- `format`:可选,字幕格式。 -- `headers`:可选,下载时附加的 HTTP 请求头。 -- `archive.type`:可选,`none`、`zip`、`gzip` 或后续扩展值。 -- `archive.entry`:可选,压缩包内优先文件路径或匹配提示。 -- `expiresAt`:可选,临时 URL 过期时间戳。 - -App 必须把响应转换为现有 `SubtitleAsset`,再进入现有字幕下载、缓存和播放器应用流程。 - -## 12. Python 实体约定 - -优先复用工程当前 Python Loader 及其 `Spider` 类加载习惯,字幕脚本增加字幕专用方法,不引入第二套 Python 运行环境。 - -建议形式: - -```python -class Spider: - - def init(self, config=""): - return { - "code": 0, - "data": {} - } - - def search(self, request): - return { - "code": 0, - "message": "", - "data": { - "list": [] - } - } - - def resolve(self, request): - return { - "code": 0, - "message": "", - "data": {} - } - - def destroy(self): - return { - "code": 0 - } -``` - -兼容实现可以允许模块级函数,但协议基线以现有 Loader 最容易复用的调用形式为准。 - -## 13. JavaScript 实体约定 - -JavaScript Runtime 应提供受控网络桥接,例如现有运行时的 `req` 能力。脚本不应创建绕过 App 安全策略的网络客户端。 - -```javascript -const subtitle = { - async init(config) { - return { code: 0, data: {} }; - }, - - async search(request) { - return { - code: 0, - message: "", - data: { list: [] } - }; - }, - - async resolve(request) { - return { - code: 0, - message: "", - data: {} - }; - }, - - async destroy() { - return { code: 0 }; - } -}; - -export default subtitle; -``` - -运行时应兼容同步返回值和 Promise,并统一序列化为标准 JSON。 - -## 14. HTML 实体约定 - -HTML 类型用于必须依赖 DOM、浏览器 Cookie、LocalStorage 或网页 JavaScript 的字幕源。 - -App 应在独立、隔离的 WebView 中加载实体文件,并调用: - -```javascript -window.WebHTVSubtitle.init(configJson) -window.WebHTVSubtitle.search(requestJson) -window.WebHTVSubtitle.resolve(requestJson) -window.WebHTVSubtitle.destroy() -``` - -页面返回 JSON 字符串或 Promise: - -```html - -``` - -HTML Runtime 不得复用 WebHome 等具有高权限原生桥接的 WebView。 - -## 15. App 侧接口设计 - -### 15.1 字幕脚本运行时 - -```java -public interface SubtitleScriptRuntime { - - String init(String sourceKey, String scriptPath, String config) throws Exception; - - String search(String sourceKey, String request) throws Exception; - - String resolve(String sourceKey, String request) throws Exception; - - void destroy(String sourceKey); -} -``` - -### 15.2 通用 T3 Provider - -```java -public final class T3SubtitleProvider implements SubtitleProvider { - - private final SubtitleSourceConfig source; - private final SubtitleScriptRuntime runtime; - - @Override - public String getName() { - return source.getKey(); - } - - @Override - public boolean isEnabled() { - return source.isEnabled(); - } - - @Override - public boolean isQueryIndependent() { - return source.isQueryIndependent(); - } - - @Override - public List search( - SubtitleQuery query, - SubtitleContext context) throws Exception { - String request = SubtitleProtocolMapper.createSearchRequest(source, query, context); - String response = runtime.search(source.getKey(), request); - return SubtitleProtocolMapper.parseCandidates(source, response); - } - - @Override - public SubtitleAsset resolve( - SubtitleCandidate candidate, - SubtitleContext context) throws Exception { - String request = SubtitleProtocolMapper.createResolveRequest(source, candidate, context); - String response = runtime.resolve(source.getKey(), request); - return SubtitleProtocolMapper.parseAsset(source, response); - } -} -``` - -### 15.3 Runtime Factory - -```java -public final class SubtitleScriptRuntimeFactory { - - public SubtitleScriptRuntime create(String api) { - switch (api) { - case "py": - return pythonRuntime; - case "js": - return javaScriptRuntime; - case "html": - return htmlRuntime; - default: - throw new IllegalArgumentException("Unsupported subtitle runtime: " + api); - } - } -} -``` - -### 15.4 T4 Provider - -`T4SubtitleProvider` 使用 HTTP 调用远程服务: - -```text -POST {api}/init -POST {api}/search -POST {api}/resolve -POST {api}/destroy -``` - -服务端返回与 T3 完全相同的标准响应。实现可以允许服务声明实际路径,但第一版宜保持固定路由,减少配置复杂度。 - -## 16. 配置加载和注册 - -建议新增独立的 `SubtitleConfig`,不要把字幕源混入 `VodConfig` 或 `LiveConfig`。 - -加载流程: - -1. 读取字幕订阅 JSON。 -2. 校验根对象和协议版本。 -3. 解析 `subtitles`。 -4. 规范化 `key`、`type`、`api`、`ext` 和超时。 -5. 忽略或记录不合法源,不因单项错误丢弃全部有效源。 -6. T3 实体下载到临时文件。 -7. 校验大小、扩展名和 SHA-256。 -8. 调用 `init` 做可加载性检查。 -9. 成功后原子替换缓存版本。 -10. 构造 `T3SubtitleProvider` 或 `T4SubtitleProvider`。 -11. 按优先级注册到 `SubtitleProviderRegistry`。 -12. 保留现有内置 Provider,并明确其与订阅源的顺序和冲突策略。 - -## 17. key、去重和冲突策略 - -- `key` 在单个订阅内必须唯一。 -- 多订阅场景下,运行时内部 ID 应使用 `subscriptionId:key` 命名空间。 -- 用户界面仍显示 `name`,不显示内部命名空间。 -- 同一订阅出现重复 `key` 时,默认保留第一项并记录错误。 -- 不同订阅出现相同 `key` 时不得静默覆盖。 -- 订阅源与内置源重名时不得替换内置实现,除非未来增加显式覆盖字段。 -- 候选去重必须至少包含 Provider 命名空间,不能只按候选 `id` 跨源去重。 - -## 18. 缓存与更新 - -建议缓存结构: - -```text -subtitle_sources/ - / - manifest.json - active/ - assrt.py - xunlei.js - provider.html - staging/ - metadata.json -``` - -更新规则: - -1. 下载到 `staging`。 -2. 限制单文件和订阅总大小。 -3. 校验扩展名、MIME 类型和哈希。 -4. 执行最小加载测试或 `init`。 -5. 所有关键文件成功后原子切换到 `active`。 -6. 失败时删除 `staging` 并继续使用旧版。 -7. 保留最近一个可用版本用于回滚。 -8. 不在搜索时重复下载脚本。 -9. 订阅刷新和搜索调用使用独立锁,避免切换过程中执行半更新文件。 - -## 19. 安全要求 - -### 19.1 通用要求 - -- 默认不信任订阅配置、脚本、HTML、远程响应和字幕文件。 -- 限制脚本文件大小、响应大小、字幕下载大小和解压后总大小。 -- 所有调用必须具有超时和取消能力。 -- 日志不得输出 Token、Cookie、Authorization 或完整私有参数。 -- 对下载 URL 和重定向继续应用 App 的网络安全策略。 -- 禁止路径穿越,压缩包解压必须校验目标路径。 -- 禁止脚本指定任意 App 私有文件作为输出位置。 - -### 19.2 Python 和 JavaScript - -- 优先使用工程现有受控运行时。 -- 仅开放字幕搜索所需的最小桥接能力。 -- 脚本不得获取 Android Context、任意 Java 反射或原生进程执行能力。 -- 每个字幕源使用隔离的逻辑上下文,销毁时释放资源。 - -### 19.3 HTML - -- 使用独立 WebView 和独立数据目录或可实现的最强隔离。 -- 禁止高权限 JS Bridge。 -- 默认关闭文件系统任意访问。 -- 限制混合内容、窗口创建、下载和外部 Intent。 -- 页面销毁时清理计时器、请求和回调。 - -### 19.4 敏感参数 - -配置中的 `${NAME}` 只表示引用本地安全设置,不应在公共订阅中存放真实密钥。App 在调用脚本前按白名单注入,导出配置和日志时继续保留占位符。 - -## 20. 超时、并发和故障隔离 - -- 每个字幕源单次 `search` 和 `resolve` 独立超时。 -- 全局并发数应受限,避免大量脚本同时占用 CPU、网络或 WebView。 -- HTML Runtime 的并发数应单独限制。 -- 单个 Provider 超时或异常只产生该源失败结果。 -- Registry 应继续等待其他源,直到全局搜索窗口结束。 -- 生命周期销毁不得在 UI 主线程阻塞。 -- 切换订阅、退出播放或清理配置时必须调用 `destroy`。 - -## 21. 错误分类 - -建议内部错误类型: - -```text -CONFIG_INVALID -PROTOCOL_UNSUPPORTED -SOURCE_DISABLED -ENTITY_DOWNLOAD_FAILED -ENTITY_HASH_MISMATCH -RUNTIME_UNSUPPORTED -RUNTIME_INIT_FAILED -RUNTIME_TIMEOUT -RUNTIME_CRASHED -RESPONSE_INVALID -SEARCH_FAILED -RESOLVE_FAILED -ASSET_DOWNLOAD_FAILED -ARCHIVE_INVALID -CANCELED -``` - -用户界面显示简短可理解提示,详细错误仅进入脱敏日志。 - -## 22. 向后兼容 - -1. 没有 `subtitles` 节点时,保持当前内置字幕源行为。 -2. `subtitles` 为空数组时,不代表强制禁用内置字幕源,除非未来增加明确配置。 -3. 未识别字段必须忽略,以支持协议向前扩展。 -4. 未支持的主版本应拒绝加载,并继续使用上一可用配置。 -5. 新增可选字段不得改变版本 1 已定义字段的语义。 -6. 现有内置 Provider 第一阶段继续注册,订阅源作为增量能力加入。 - -## 23. 实施阶段 - -### 阶段一:协议模型和解析 - -- 新增 `SubtitleSourceConfig` 和 `SubtitleSubscription`。 -- 解析根节点 `subtitles`。 -- 完成字段校验、地址解析和版本检查。 -- 建立协议请求与响应 DTO。 -- 建立 `SubtitleProtocolMapper`。 - -### 阶段二:T3 Python 和 JavaScript - -- 复用现有 Python、JavaScript Loader。 -- 增加字幕专用方法调用。 -- 实现 `T3SubtitleProvider`。 -- 接入 `SubtitleProviderRegistry`。 -- 完成搜索和解析单元测试。 - -### 阶段三:订阅缓存和管理 - -- 增加订阅地址管理。 -- 增加实体下载、缓存、哈希校验和原子更新。 -- 增加启用、禁用、优先级和刷新操作。 -- 增加失败回滚和脱敏日志。 - -### 阶段四:HTML Runtime - -- 实现隔离 WebView。 -- 实现 JSON Bridge、超时和销毁。 -- 建立 HTML 来源的权限边界和测试。 - -### 阶段五:T4 和内置源迁移评估 - -- 实现 `T4SubtitleProvider`。 -- 验证 T3/T4 协议一致性。 -- 评估将部分内置字幕源迁移成随 App 发布的 T3 实体文件。 -- 内置迁移必须保持可回滚,不应一次性删除当前 Java Provider。 - -## 24. 验收标准 - -### 配置 - -- 能正确解析同时包含 `sites`、`lives`、`subtitles` 的配置。 -- 不含 `subtitles` 时现有功能不退化。 -- 非法单项不会导致全部字幕源失效。 -- 相对实体地址能以订阅地址为基准正确解析。 - -### T3 - -- Python 和 JavaScript 实体可以完成 `init`、`search`、`resolve`、`destroy`。 -- HTML 类型在后续阶段可以完成相同协议调用。 -- 脚本返回结果能无损映射到 `SubtitleCandidate` 和 `SubtitleAsset`。 -- 单源超时、异常或非法 JSON 不影响其他源。 - -### 缓存 - -- 成功更新后使用新实体。 -- 下载、哈希或初始化失败时继续使用旧实体。 -- 搜索过程中不会执行半更新文件。 - -### 安全 - -- 日志不泄露密钥和 Cookie。 -- 路径穿越和压缩炸弹被拒绝。 -- HTML 页面无法访问高权限 App Bridge。 -- 运行时资源在销毁或配置切换后得到释放。 - -### 回归 - -- 现有自动匹配、手动搜索、候选去重和字幕应用流程保持可用。 -- 现有内置 Assrt、Shooter、Xunlei Provider 在第一阶段保持原行为。 - -## 25. 测试建议 - -### 单元测试 - -- 配置字段默认值和非法值。 -- 相对 URL、远程 URL、file URI、content URI 和 asset URI 解析。 -- `search` 响应映射和未知字段兼容。 -- `resolve` 响应映射、请求头和压缩信息。 -- 重复 key、跨订阅命名空间和排序。 -- 响应缺字段、非法 JSON、过大响应和非零错误码。 - -### 集成测试 - -- Python 源搜索和二次解析。 -- JavaScript Promise 搜索和二次解析。 -- T4 HTTP 搜索和二次解析。 -- 多 Provider 并发、单源超时和取消。 -- 订阅更新成功、失败回滚和 App 重启恢复。 - -### 安全测试 - -- 脚本路径穿越。 -- 压缩包路径穿越和异常压缩比。 -- 重定向到不允许的协议。 -- HTML Bridge 越权调用。 -- Token、Cookie 和 Authorization 日志脱敏。 - -## 26. 最终决策 - -- 字幕配置根节点确定为 `subtitles`。 -- T3 字幕源是主要扩展方式,`type` 固定为 `3`。 -- T3 的 `api` 表示运行时类型:`py`、`js`、`html`。 -- T3 的 `ext` 指向实体文件,可为相对远程地址、绝对远程地址或受支持的本地 URI。 -- T4 字幕源的 `type` 固定为 `4`,`api` 指向远程标准接口。 -- T3 和 T4 统一使用 `init`、`search`、`resolve`、`destroy` 语义及相同 JSON 结果协议。 -- App 通过通用 `T3SubtitleProvider` 和 `T4SubtitleProvider` 接入当前 `SubtitleProviderRegistry`。 -- 第一阶段保留所有现有内置字幕源,新增能力以增量、可回滚方式落地。 diff --git a/docs/theme-color-system-design-20260907.md b/docs/theme-color-system-design-20260907.md deleted file mode 100644 index dcc8187f1e..0000000000 --- a/docs/theme-color-system-design-20260907.md +++ /dev/null @@ -1,663 +0,0 @@ -# TweakCN 风格主题配色系统设计 - -> 状态:设计阶段,尚未实施 -> 设计日期:2026-09-07 -> 适用范围:Android mobile 优先;共享主题模型可供 leanback/TV 后续消费 -> 参考: 以及用户提供的五张界面截图 -> 关联现有能力:`Setting.theme_color`、Material 3 Dynamic Color、应用壁纸、WebHome 主题系统 - -## Recovery anchor - -- 目标:为 WebHTV 设计一个可持久化、可预览、可导入/导出的原生主题配色系统,达到截图所示的“主题配色”编辑体验,同时保持现有首页、详情页、播放页和站源功能可回退。 -- 验收标准:设计明确主题数据模型、用户流程、原生颜色消费边界、旧设置迁移、导入/导出安全规则、应用/回滚策略和分阶段验证;未获明确实施批准前不修改运行时代码。 -- 当前文件/符号:`app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java`、`AppearanceDialog.java`、`app/src/main/java/com/fongmi/android/tv/setting/Setting.java`、`BaseActivity`、`CustomWallView`、`SiteDialogTheme`。 -- 已完成证据:已查看五张参考截图;已核对当前主题色、动态壁纸、Material 3 和刷新事件实现;当前工作树另有预先存在的 `app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java` 脏改动,属于保护路径,不纳入本任务。 -- 未验证项:本设计尚未落地,未进行编译、设备交互或视觉验收。 -- 下一步唯一动作:用户批准设计后,以本文件为基线启动独立实现任务;实现前先锁定具体阶段和路径范围。 - ---- - -## 1. 背景与目标 - -### 1.1 用户希望解决的问题 - -参考截图展示的是一个独立的“主题配色”编辑器,主要能力包括: - -- 在同一个编辑页面实时预览主题效果; -- 选择壁纸、着色壁纸或纯色背景; -- 选择跟随系统、浅色或深色模式; -- 从预设色板快速选择强调色; -- 编辑高亮色、应用背景色、内容表面色等核心颜色,并直接看到十六进制值; -- 导入主题(浏览 TweakCN 社区、粘贴链接或 JSON、选择 JSON 文件); -- 导出主题; -- 点击“应用”后,设置页、首页、详情页等原生页面统一换色。 - -本项目需要的是**原生 Android 应用的设计变量主题系统**,而不是把 TweakCN 网页直接嵌入 WebView,也不是引入一套新的 CSS 运行时。 - -### 1.2 设计目标 - -1. 让用户可以在应用内编辑并应用一套可验证的主题配色。 -2. 保留现有 `theme_color`、壁纸和 Material 3 动态色行为,升级过程不丢设置。 -3. 在 Android 9 等无法依赖系统动态色的设备上,也能让关键原生界面使用自定义颜色。 -4. 主题编辑期间只改变临时草稿;点击“应用”后一次性持久化并刷新,点击“取消”不影响当前主题。 -5. 支持 JSON 导入/导出,允许后续接入 TweakCN 兼容映射,但不执行远程脚本、不加载任意 CSS。 -6. 对文字/背景对比度、导入大小、来源和回滚做确定性校验。 -7. 保持播放器内核、解析器、内容源数据和业务权限不受主题影响。 - -### 1.3 非目标 - -本阶段不做: - -- 不把首页、详情页或播放器整体迁移到 WebView; -- 不让远程主题执行 JavaScript、加载任意插件或修改原生布局; -- 不直接抓取 TweakCN 社区页面并自动执行其 CSS; -- 不在第一版建设带账号、点赞、评论、在线发布能力的主题市场; -- 不重新设计播放器解码、DRM、字幕、音轨和播放状态机; -- 不同步修改 leanback 的全部视觉资源;TV 只先共享数据模型和最低限度消费契约。 - ---- - -## 2. 当前实现盘点 - -### 2.1 已有主题色入口 - -| 位置 | 当前行为 | 对新设计的影响 | -| --- | --- | --- | -| `Setting.getThemeColor()` / `putThemeColor(int)` | 以整数保存主题色;`-1` 表示关闭,`0` 表示跟随壁纸,其他值为自定义种子色 | 保留作为旧设置兼容字段和快速回退字段 | -| `Setting.getDynamicColor()` | 将主题色解析为 `0`、自定义色或壁纸色 | 可作为新主题的 seed 默认值,但不能承担所有核心 token | -| `ThemeDialog` | 只有一组固定颜色圆点,点击后立即写入设置并触发主题刷新 | 由新的编辑器替代入口;保留旧行为作为迁移/回退 | -| `AppearanceDialog` | “主题色彩”行打开 `ThemeDialog` | 行点击改为打开主题编辑器,摘要显示主题名/背景模式 | -| `BaseActivity.enableDynamicColor()` | 使用 Material 3 `DynamicColors.applyToActivityIfAvailable()` | Android 12+ 可继续使用;低版本需增加应用侧 token 绑定 | -| `SiteDialogTheme` | 使用 Material 1.14.0 `ColorRoles`,不依赖系统动态色支持 | 可复用同一套 token/对比度规则 | -| `CustomWallView` | 读取现有壁纸设置并绘制壁纸/纯色/动效壁纸 | 新系统不直接破坏壁纸设置,使用主题背景层和内容 surface 控制可读性 | -| `RefreshEvent.theme()` | 当前主题改变后重建 Activity | 作为应用主题应用后的统一刷新路径 | -| `Backup.APP_PREFS` | 已备份 `theme_color`、`wall_color` 等字段 | 新增主题配置字段时必须加入备份白名单并兼容旧备份 | - -### 2.2 参考截图拆解 - -| 截图 | 观察到的交互/视觉 | 本设计对应方案 | -| --- | --- | --- | -| `QQ20260907-215135.png` | 主题编辑器、实时预览、背景/明暗分段控件、预设色板、核心颜色列表、取消/应用 | `ThemeEditorDialog` 草稿状态 + `ThemePreviewView` | -| `QQ20260907-215204.png` | 导入弹窗包含浏览社区、粘贴链接或 JSON、选择 JSON 文件 | `ThemeImportDialog`;第一版浏览社区使用系统浏览器,导入走 JSON | -| `QQ20260907-215232.png` | 设置页显示主题摘要和已应用提示 | `AppearanceDialog` 摘要 + 应用后统一刷新提示 | -| `QQ20260907-215313.png` | 首页卡片、分类和底部面板使用同一强调色 | 首页的语义 token 消费和选中态颜色统一 | -| `QQ20260907-215333.png` | 详情页工具栏、线路、选集和正文表面共享强调色/背景色 | 详情动作、标签、选集和正文 surface 接入 token | - ---- - -## 3. 总体方案 - -### 3.1 核心原则 - -采用“**主题配置 → 解析为语义 token → 原生页面消费 token**”三层结构: - -```text -ThemeProfile JSON / 旧设置 - | - v -ThemeProfileStore + ThemeProfileValidator - | - v -ThemeResolver(模式、seed、显式覆盖、对比度修正) - | - v -ThemeTokens(canvas / surface / text / accent / focus ...) - | - +--> BaseActivity / ThemeContextBinder - +--> Home / Detail / Setting semantic resources - +--> SiteDialogTheme - +--> WebHome chrome / WebTheme token bridge(只读) -``` - -主题只描述外观,不描述页面业务、数据源、播放地址或权限。页面继续使用现有原生数据和事件链路。 - -### 3.2 为什么不直接把 `theme_color` 扩展成多个整数 - -单独增加多个 `theme_*_color` 偏好值会造成: - -- 明色/暗色没有成对数据,系统模式切换时颜色可能不可读; -- 不能表达主题名称、来源、版本和导入信息; -- 导入/导出缺少稳定 schema; -- 新增 token 时需要不断增加独立偏好键; -- 回滚和校验难以做到原子化。 - -因此使用一个带版本的主题 JSON 作为新数据源,同时保留 `theme_color` 等旧键作为兼容镜像和故障回退。 - -### 3.3 应用边界 - -- **设置编辑器**:完整支持主题草稿、预览、导入和导出。 -- **原生页面**:第一阶段覆盖设置、首页、站源弹窗、详情页关键表面和动作;播放器仅覆盖控制层可着色区域,不改变视频画面和播放内核。 -- **WebHome**:向内置/受信主题提供只读 token 快照;远程主题不能调用写入接口改变原生主题。 -- **leanback**:读取同一 `ThemeProfile`,先映射 seed、背景和选中/焦点色;完整 TV 版资源替换作为后续任务。 - ---- - -## 4. 主题数据模型 - -### 4.1 顶层 schema - -建议文件格式为 `webhtv-theme/v1`,颜色统一采用不带透明度的 `#RRGGBB`;解析时接受 `#RGB`、`#RRGGBB` 和 `#AARRGGBB`,但应用 token 拒绝非 `FF` alpha,避免透明色污染文字和 surface。 - -```json -{ - "schemaVersion": 1, - "format": "webhtv-theme", - "id": "webhtv.claude-plus", - "name": "Claude +", - "author": "optional", - "source": { - "type": "local|tweakcn|url", - "url": "https://example.com/theme.json" - }, - "mode": "system|light|dark", - "background": { - "type": "wallpaper|tinted-wallpaper|solid", - "color": "#F8FAFC", - "scrimAlpha": 0.0 - }, - "seedColor": "#155DFC", - "colors": { - "light": { - "primary": "#155DFC", - "appBackground": "#F8FAFC", - "surface": "#FFFFFF", - "surfaceElevated": "#FFFFFF", - "onSurface": "#182230", - "onSurfaceVariant": "#526071", - "outline": "#CBD5E1", - "focus": "#155DFC", - "error": "#BA1A1A" - }, - "dark": { - "primary": "#A9C7FF", - "appBackground": "#111827", - "surface": "#1F2937", - "surfaceElevated": "#273449", - "onSurface": "#E5EDF8", - "onSurfaceVariant": "#B7C4D6", - "outline": "#455468", - "focus": "#A9C7FF", - "error": "#FFB4AB" - } - }, - "metadata": { - "tags": ["blue", "minimal"], - "previewVersion": 1 - } -} -``` - -### 4.2 字段语义 - -- `schemaVersion`:只用于文件解析兼容,不等同于应用版本。 -- `id`:稳定主题 ID;导入相同 ID 时通过版本/来源确认是否覆盖。 -- `name`:设置摘要和主题编辑器标题使用的可见名称。 -- `mode`:`system`、`light`、`dark`,对应截图中的明暗模式分段。 -- `background.type`: - - `wallpaper`:保留当前壁纸,内容表面按 token 叠加; - - `tinted-wallpaper`:保留壁纸并叠加主题色 scrim; - - `solid`:使用 `background.color` 的不透明 canvas 覆盖壁纸视觉。 -- `seedColor`:Material 角色生成和旧版兼容使用的强调色种子。 -- `colors.light/dark`:最终消费的语义色。缺少某个角色时由 `ThemeResolver` 从 `seedColor` 和背景推导。 -- `metadata`:只保存展示和兼容信息,不允许嵌入脚本、资源路径或任意 Android 属性。 - -### 4.3 可编辑核心颜色 - -第一版编辑器只暴露三个核心色,避免把 Material 角色表变成难以理解的专业配置: - -| UI 名称 | token | 用途 | -| --- | --- | --- | -| 高亮色 | `primary` | 选中态、主操作、导航选中、线路/选集当前项、焦点 ring | -| App 背景色 | `appBackground` | 页面 canvas、设置页和列表页底层背景 | -| 内容表面色 | `surface` | 卡片、面板、内容区域和弹窗主体 | - -以下颜色自动派生或在高级导入中接受: - -- `onSurface`、`onSurfaceVariant`; -- `surfaceElevated`; -- `primaryContainer`、`onPrimaryContainer`; -- `outline`、`focus`、`error`。 - -如果用户导入完整 token 集,则保留已验证的显式值;如果只提供 seed/三种核心颜色,则补齐缺失角色。 - -### 4.4 TweakCN/shadcn token 映射 - -第一版不依赖 TweakCN 的内部 API,只提供通用 CSS token 到 WebHTV token 的离线映射: - -| 常见 TweakCN token | WebHTV token | -| --- | --- | -| `--primary` | `primary` | -| `--primary-foreground` | `onPrimary` | -| `--background` | `appBackground` | -| `--card` / `--popover` | `surface` / `surfaceElevated` | -| `--foreground` | `onSurface` | -| `--muted-foreground` | `onSurfaceVariant` | -| `--border` / `--input` | `outline` | -| `--ring` | `focus` | -| `--destructive` | `error` | - -未识别 token 忽略并记录导入警告,不影响当前主题。若未来需要直接支持社区主题页面,增加独立适配器,不把网页 CSS 直接当成 Android 主题输入。 - ---- - -## 5. 颜色解析与可读性规则 - -### 5.1 解析顺序 - -`ThemeResolver` 按以下顺序生成当前运行态 `ThemeTokens`: - -1. 读取并校验本地 `ThemeProfile`; -2. 根据 `mode` 确定 light/dark 分支; -3. 以显式 token 为最高优先级; -4. 用 `seedColor` 调用 Material 1.14.0 的 `ColorRoles` 补齐强调色相关角色; -5. 缺失背景/表面时读取当前壁纸色或稳定默认值; -6. 计算 `on*`、outline 和 focus 的对比度; -7. 对不满足最低对比度的自动派生值进行修正; -8. 生成不可变的 `ThemeTokens`,供 Activity 生命周期内使用。 - -### 5.2 可读性约束 - -- 正文文字与其背景目标对比度至少 4.5:1; -- 大字号、图标、焦点边框目标对比度至少 3:1; -- 主按钮前景和背景至少 4.5:1; -- 禁止透明核心色; -- 用户显式输入对比度不足时,编辑器显示明确警告并禁止“应用”,而不是静默改变用户颜色; -- 自动派生的 `onSurface` 等角色可以在计算时调整,但预览必须反映最终值; -- TV 焦点态不能仅依赖颜色变化,还应保留边框/形状/亮度差异。 - -### 5.3 壁纸可读性 - -壁纸不是 token 的替代品: - -- `wallpaper` 模式必须加一层根据当前 token 透明度计算的内容 surface; -- `tinted-wallpaper` 的 scrimAlpha 限制在 `0.0..0.85`; -- `solid` 模式的 canvas 必须完全不透明,确保文字和卡片不会受底层动态图影响; -- 动态壁纸、视频壁纸和 GIF 壁纸不参与主题 JSON 导入,仍由现有 `CustomWallView` 管理。 - ---- - -## 6. 用户体验设计 - -### 6.1 设置入口 - -保留现有设置层级: - -```text -设置 → 外观与语言 → 主题配色 -``` - -点击后打开 `ThemeEditorDialog`(mobile 可使用全屏/大尺寸 Material Dialog;不新建独立 Activity),避免用户离开设置上下文。 - -设置行摘要建议: - -```text -Claude + · 纯色 -Claude + · 着色壁纸 -默认 · 跟随壁纸 -``` - -应用成功后显示一次轻量提示,并触发现有 `RefreshEvent.theme()`。刷新/重建期间不修改用户当前导航、历史、收藏和播放数据。 - -### 6.2 编辑器布局 - -按截图顺序设计: - -1. 顶部标题“主题配色”;右侧操作:重置、导入、导出; -2. “实时预览”卡片:应用名、搜索图标、收藏图标、主按钮、次按钮、集数标签; -3. “背景模式”:壁纸 / 着色壁纸 / 纯色; -4. “明暗模式”:跟随系统 / 浅色 / 深色; -5. “高亮色预设”:横向可滚动圆点,保留现有预设色并增加主题 profile 的预设; -6. “核心颜色”:三行可点击的色块、名称和 HEX; -7. 底部“取消 / 应用”。 - -所有控件最小触摸目标 48dp;预设色横向滚动不抢占核心颜色列表滚动;HEX 文本应支持复制但不要求用户直接编辑文本。 - -### 6.3 实时预览 - -预览只使用内存中的 draft `ThemeProfile`: - -- 改变模式、背景或颜色时,只重新解析 `ThemeTokens` 并刷新预览; -- 不写 `SharedPreferences`; -- 不重建宿主 Activity; -- 预览覆盖至少:页面背景、卡片、主操作、次操作、焦点/选中态、正文和弱化文字; -- 若 draft 对比度不足,预览顶部显示“不可应用”的原因。 - -### 6.4 颜色编辑器 - -颜色编辑器支持: - -- 预设色选择; -- HEX 输入(规范化为大写 `#RRGGBB`); -- Android 原生可访问的色相/饱和度/亮度选择控件(若项目现有组件不足,第一版以 HEX + 预设为主); -- 显示当前颜色与建议前景色; -- 显示对比度结果; -- 取消时回到编辑器 draft,不影响已应用主题。 - -### 6.5 导入弹窗 - -对应截图的三个入口: - -1. **浏览 TweakCN 社区**:使用系统浏览器打开社区页面;第一版不在应用内抓取社区页面。 -2. **粘贴链接或 JSON**:支持本项目 schema JSON、TweakCN 常见 token JSON;HTTPS 链接下载后经过大小、内容和 schema 校验。 -3. **选择 JSON 文件**:使用 Storage Access Framework `ACTION_OPEN_DOCUMENT`,不申请整盘存储权限。 - -导入流程: - -```text -读取 -> 限制大小 -> JSON 解析 -> schema/颜色校验 - -> token 映射 -> 对比度检查 -> 预览导入结果 - -> 用户确认 -> 替换编辑器 draft -``` - -导入不会直接应用;用户仍需在编辑器点击“应用”。 - -### 6.6 导出 - -导出内容为脱敏后的 `webhtv-theme/v1` JSON: - -- 通过 `ACTION_CREATE_DOCUMENT` 保存,或调用系统分享; -- 不包含站点 URL、Cookie、播放记录、用户配置和设备信息; -- 保留主题名、颜色、模式、背景模式和来源元数据; -- 导出失败不改变当前主题。 - ---- - -## 7. 原生架构设计 - -### 7.1 建议新增组件 - -| 组件 | 职责 | -| --- | --- | -| `ThemeProfile` | 不可变主题配置模型;负责 schema 字段,不负责 UI | -| `ThemeProfileCodec` | Gson JSON 读写、版本兼容、旧格式映射 | -| `ThemeProfileValidator` | 字段、颜色、范围、大小和来源校验 | -| `ThemeProfileStore` | 读写当前 profile、last-known-good 和迁移状态 | -| `ThemeResolver` | 根据系统模式、壁纸 seed 和显式 token 生成 `ThemeTokens` | -| `ThemeTokens` | 运行时不可变语义颜色集合 | -| `ThemeController` | 启动时读取主题、应用模式、发出刷新事件 | -| `ThemeEditorDialog` | 编辑器 UI、draft、应用/取消 | -| `ThemeImportDialog` | 社区、链接、文件导入入口 | -| `ThemeColorPickerDialog` | 单个核心颜色编辑和对比度提示 | -| `ThemePreviewView` | 与实际应用无业务耦合的预览组件 | -| `ThemeExport` | SAF 创建文件与系统分享 | -| `ThemeAware`(可选) | 需要在 Android 低版本运行时接收 token 的原生 View/容器契约 | - -首版不新增第三方依赖:项目已经使用 Gson 和 Material 1.14.0,优先复用现有实现。 - -### 7.2 Activity 应用时机 - -```text -Application.onCreate - -> 读取并验证 profile - -> 设置 AppCompat night mode(system/light/dark) - -BaseActivity.onCreate - -> 解析当前 ThemeTokens - -> API 31+ 继续尝试 Material DynamicColors - -> setContentView - -> 对需要低版本运行时换色的语义容器绑定 ThemeTokens - -> 创建 CustomWallView / 其他内容 -``` - -现有 `RefreshEvent.THEME` 继续作为应用后的统一刷新信号。应用配置时先写入 profile 和兼容字段,再发事件;若写入失败则不发事件。 - -### 7.3 低版本兼容策略 - -Material Dynamic Color 的系统入口不能作为 Android 9 等设备的唯一方案。建议分两层: - -1. **资源层**:把新增/维护中的原生布局从固定白/黑色迁移到语义属性或语义 color selector,例如 `colorPrimary`、`colorSurface`、`colorOnSurface`、`colorOutline`。 -2. **运行时层**:对无法通过主题属性解析、但必须支持自定义色的页面容器实现 `ThemeAware`,从 `ThemeTokens` 设置背景、文字、按钮、图标 tint 和状态列表。 - -以下区域继续保持独立,不强行套用普通页面主题: - -- 视频内容本身; -- 播放器上需要保证可见性的黑色渐变和控制层; -- 健康状态点、错误/警告等语义色; -- 站点内容图片和源数据中的颜色。 - -### 7.4 页面消费优先级 - -第一阶段按以下顺序替换: - -1. 主题编辑器预览和设置页; -2. 首页导航、分类、选中卡片、底部面板和站源弹窗; -3. 详情页顶部操作、线路/选集、正文 surface; -4. 播放器控制层的强调色和焦点色; -5. 其他低风险原生弹窗和列表。 - -每个页面都必须保留旧资源回退,不允许主题失败导致页面不可进入。 - ---- - -## 8. 持久化、迁移与回滚 - -### 8.1 新旧设置关系 - -新增建议键: - -- `theme_profile_json`:当前完整 profile; -- `theme_profile_last_good`:最近一次验证通过且成功应用的 profile; -- `theme_profile_schema`:可选,仅用于快速判断迁移版本。 - -现有键继续保留: - -- `theme_color`:写入 profile 的 `seedColor` 镜像; -- `wall_color`、`wall`、`wall_type`:继续由现有壁纸系统管理;主题 profile 只记录编辑器选择的背景模式和必要覆盖。 - -如果主题使用 `solid` 背景,不应直接删除用户现有壁纸;切回 `wallpaper` 时恢复原壁纸设置。 - -### 8.2 首次迁移 - -当 `theme_profile_json` 不存在时: - -- `theme_color == -1`:生成默认 profile,`seedColor` 为空,模式跟随系统; -- `theme_color == 0`:生成 seed 来源为壁纸的 profile; -- `theme_color > 0`:生成对应 seed 的 profile; -- 当前 `wall/wall_type` 只用于解析预览背景,不自动改变已有壁纸; -- 不在启动时立即触发第二次重建,只在用户打开/应用编辑器或下一次正常 Activity 创建时完成迁移。 - -### 8.3 应用事务 - -应用按钮按以下顺序执行: - -```text -validate(draft) - -> serialize(draft) - -> write theme_profile_json atomically - -> update legacy mirrors - -> write last_good - -> publish RefreshEvent.theme() -``` - -任何一步失败: - -- 保留旧 profile; -- 不更新兼容镜像; -- 不刷新页面; -- 向用户显示“应用失败,当前主题未改变”。 - -### 8.4 回滚 - -- 编辑器“重置”:只将 draft 恢复为内置默认 profile,等待用户点击应用; -- 设置中的“恢复默认”:删除当前 profile,恢复 `theme_color=-1`,模式跟随系统,保留用户壁纸; -- profile JSON 损坏:读取 `theme_profile_last_good`;若也损坏,回退旧 `theme_color` + 系统默认色; -- 任何导入主题都必须先进入 draft,不允许远程输入直接覆盖 last-good; -- 回滚不删除导出文件,不修改备份中的其他偏好。 - -### 8.5 备份兼容 - -新增 profile 键加入 `Backup.APP_PREFS`。恢复旧备份时缺少 profile 字段则走迁移逻辑;恢复新备份到旧版本时,旧版本忽略未知键并继续使用 `theme_color` 镜像。 - ---- - -## 9. 安全设计 - -### 9.1 本地 JSON - -- 最大大小:建议 256 KiB; -- 最大嵌套深度:建议 8; -- 只接受对象,不执行字符串中的代码; -- 颜色、枚举、数值范围严格校验; -- 忽略未知字段,拒绝危险/模糊字段(脚本、资源路径、Intent、类名、任意 CSS); -- `source.url` 只作为展示元数据保存,不在离线导入时自动访问。 - -### 9.2 HTTPS 链接 - -- 只接受 `https://`;拒绝 HTTP、文件 URI、私有 IP、环回地址和本地路径; -- 首次访问显示域名并要求用户确认; -- 使用现有网络栈,设置连接/读取超时和响应上限; -- 不发送 Cookie、Authorization、站点请求头或用户配置; -- 不允许重定向到非 HTTPS、私网或不同来源而不重新确认; -- 下载完成后按本地 JSON 相同规则校验; -- 网络失败只影响导入,不影响现有主题。 - -### 9.3 TweakCN 社区 - -“浏览 TweakCN 社区”第一版只是打开用户可见的系统浏览器页面。应用不保存登录态、不注入脚本、不自动抓取页面、不把社区 CSS 当作 Android 样式执行。 - -如果后续提供内置主题索引,必须采用静态 JSON + 预览图 + schema 校验,增加签名/哈希、版本、来源和回滚机制后才能进入应用内主题列表。 - ---- - -## 10. 分阶段实施计划 - -### 阶段 A:模型与迁移(建议先做) - -范围: - -- `ThemeProfile`、`ThemeTokens`、codec、validator、store、resolver; -- 旧 `theme_color` 迁移; -- `Backup.APP_PREFS` 新字段; -- 无 UI 大改,现有 `ThemeDialog` 仍可工作; -- 单元测试覆盖 JSON、颜色规范化、迁移、对比度和回滚。 - -完成标志:任何旧安装启动后都能得到有效 profile;无 profile/corrupt profile 不阻塞启动。 - -### 阶段 B:主题编辑器与实时预览 - -范围: - -- `AppearanceDialog` 入口替换; -- `ThemeEditorDialog`、预览、背景/明暗分段、预设色和三个核心颜色; -- 取消/应用/重置; -- 颜色编辑器和对比度提示。 - -完成标志:不点击应用不会改变当前界面;应用后主题摘要和关键页面发生预期变化。 - -### 阶段 C:原生页面 token 接入 - -范围: - -- BaseActivity / ThemeController 应用模式; -- 设置、首页、站源弹窗、详情关键 surface、播放器控制层逐步迁移到 semantic tokens; -- 低版本 `ThemeAware` 绑定; -- 保留现有 `SiteDialogTheme` 的对比度行为并统一 token 来源。 - -完成标志:Android 9+ 在不依赖平台 Dynamic Color 的情况下,至少设置/首页/详情关键控件能切换明暗和强调色;失败页面仍能回退。 - -### 阶段 D:导入/导出与 TweakCN 映射 - -范围: - -- 浏览社区、粘贴 JSON/HTTPS 链接、SAF 文件导入; -- TweakCN/shadcn 常见 token 映射; -- SAF 导出/系统分享; -- 导入预览、警告和失败回滚。 - -完成标志:导出的文件可在另一台安装同版本应用中导入;不支持的 token 不会导致崩溃或执行代码。 - -### 阶段 E:TV 与社区索引(后续) - -范围: - -- leanback 焦点和遥控交互; -- TV 语义资源完整迁移; -- 受签名/哈希保护的静态主题索引和预览图; -- 主题版本回滚和缓存。 - -不与阶段 A-D 混合实施。 - ---- - -## 11. 验证计划 - -### 11.1 单元/源代码测试 - -- `ThemeProfileCodecTest`:合法、缺字段、未知字段、版本号、颜色格式; -- `ThemeProfileValidatorTest`:透明色、范围、枚举、大小和对比度; -- `ThemeProfileMigrationTest`:`theme_color=-1/0/自定义`、旧备份和损坏 profile; -- `ThemeResolverTest`:system/light/dark、显式覆盖、seed 补全和壁纸模式; -- `ThemeTweakCnAdapterTest`:常见 CSS token 映射及未知 token; -- `ThemeStoreRollbackTest`:写入失败和 last-good 回滚; -- `Backup` 源码契约测试:新偏好键包含在备份集合中。 - -### 11.2 Android 编译验证 - -最小验证顺序: - -1. `:app:compileMobileArm64_v8aDebugJavaWithJavac`; -2. `:app:processMobileArm64_v8aDebugResources`; -3. `:app:assembleMobileArm64_v8aDebug`; -4. 若修改 TV 共享模型,再运行对应 leanback Java 编译/assemble。 - -不因主题 UI 任务运行全 ABI 矩阵,除非共享代码或资源触及所有变体。 - -### 11.3 设备场景 - -至少验证: - -- Android 9:默认主题、蓝色主题、暗色主题、纯色/壁纸主题; -- Android 12+:系统动态色与应用显式 profile 的优先级; -- 打开编辑器修改但取消,当前页面颜色不变; -- 应用主题后设置、首页、站源弹窗、详情页能进入且颜色一致; -- 应用重启后 profile 不丢失; -- 导入无效 JSON、超大 JSON、低对比度颜色不改变当前主题; -- 导出后删除当前 profile,再导入导出文件恢复; -- 播放页进入、暂停、切换线路、返回均不受主题系统影响; -- 动态壁纸刷新和主题切换不泄漏播放器/壁纸资源; -- 断网时打开主题编辑器仍可编辑本地主题,只有链接导入不可用。 - -### 11.4 视觉验收 - -以用户提供的截图为验收参考,不要求像素级复制,但应满足: - -- 编辑器信息层级、分段控件、色板、颜色行和底部操作可辨识; -- 预览中的主色、surface、正文和选中态同步变化; -- 主题应用后设置摘要、首页和详情页的强调色一致; -- 深浅模式下正文、按钮、焦点态均满足对比度规则; -- 窄屏手机滚动、输入法、返回键和系统字体放大不裁切关键操作。 - ---- - -## 12. 风险、取舍与回滚策略 - -| 风险 | 影响 | 缓解 | -| --- | --- | --- | -| 当前大量布局使用固定白/黑色 | 低版本无法全局换色 | 先迁移关键页面到语义 token;播放器和特殊遮罩保留专用资源 | -| Material Dynamic Color 在低版本不可用 | Android 9 主题色不生效 | 使用 `ThemeTokens` + `ThemeAware`,不把系统动态色当唯一实现 | -| 自定义颜色对比度不足 | 文字不可读 | 应用前验证;自动角色派生;编辑器显示对比度 | -| 壁纸动效与文字冲突 | 闪烁/不可读 | scrim 和 opaque solid canvas;保留现有壁纸生命周期 | -| 旧设置与新 profile 不一致 | 升级后主题变化 | 单向迁移、兼容镜像、last-good 和原子写入 | -| 导入远程 JSON 不可信 | 资源消耗或恶意输入 | HTTPS、来源确认、大小/范围/schema 校验,不执行脚本 | -| 全量 Activity 重建造成闪白 | 用户体验下降 | 草稿只更新预览;应用一次重建;启动读取已验证缓存 | -| TV 资源未同步 | 电视端显示不一致 | 第一阶段 TV 只消费共享模型和最低 token,完整迁移单独排期 | -| 主题改动影响播放层 | 播放回归 | 明确播放器内核非目标;播放场景单独回归 | - -回滚方式:撤销本任务对应实现提交即可;数据层保留 `theme_color` 和旧壁纸键,不要求数据库迁移。若实现阶段发现全局 token 改造范围超出批准边界,应先回滚到模型/编辑器独立阶段,不把半套全局颜色接入留在主分支。 - ---- - -## 13. 建议与审批点 - -### 建议 - -建议采用 **A → B → C → D → E** 的顺序,但将 A/B 作为第一期可独立验收单元:先建立可迁移、可验证的 profile 和编辑器,再逐步扩大原生页面消费范围。第一期不直接建设在线主题市场,也不直接抓取 TweakCN 页面。 - -### 需要用户确认的范围 - -1. 是否先只实施 mobile,还是 A/B 阶段同时制作 leanback 编辑器? -2. 是否接受“第一版只支持 TweakCN 常见 JSON/CSS token 映射,社区浏览通过系统浏览器完成”,而不是在应用内直接展示社区列表? -3. 是否接受现有播放器画面/特殊黑色遮罩不参与普通页面主题换色? -4. 是否批准先实施阶段 A+B,完成后再单独审批阶段 C 的全局原生页面接入? - -在以上边界未确认前,本文件只作为设计基线,不修改生产代码、资源、锁文件或运行时行为。 diff --git a/docs/tmdb-source-config-test.md b/docs/tmdb-source-config-test.md deleted file mode 100644 index 3f8f77bc5e..0000000000 --- a/docs/tmdb-source-config-test.md +++ /dev/null @@ -1,3 +0,0 @@ -# TMDB 数据源配置测试 - -配置弹窗使用当前未保存的 TMDB API 凭据、API 域名、图片域名和 OMDb API Key 执行只读测试。输入会先经过与保存配置相同的域名、路径和凭据类型规范化;TMDB API 检查请求 `3/configuration`,只有成功返回且包含 TMDB `images` 配置对象时通过;图片检查请求实际配置尺寸下的固定 TMDB 图片,只有 HTTP 状态、内容类型、非空响应和图片文件签名均有效时通过;OMDb 检查使用固定 IMDb ID 请求 `www.omdbapi.com`,只有返回有效 `imdbID` 时通过。测试在后台线程执行,不保存或改变配置,并分别显示 TMDB API、图片域名和 OMDb API 的结果及请求延迟。 diff --git a/docs/touch-optimization-mode-design.md b/docs/touch-optimization-mode-design.md deleted file mode 100644 index d2c7a8a698..0000000000 --- a/docs/touch-optimization-mode-design.md +++ /dev/null @@ -1,398 +0,0 @@ -# WebHTV 触屏优化模式设计 - -状态:实现完成,设备补验受限 -日期:2026-09-04 -范围:TV 版 leanback flavor - -## 1. 背景 - -WebHTV 的 TV 版当前以遥控器/焦点导航为主。部分车机和触屏电视用户会安装 leanback 版本,因为它的横屏布局、投屏、推送和播放界面更合适,但触屏体验仍按遥控器逻辑设计。典型现象是:第一次点击只把焦点移动到目标控件,第二次点击才触发实际动作。 - -代码层面的直接原因是 leanback 布局中有大量控件使用了 `android:focusableInTouchMode="true"`。当前统计共有 252 处,分布在 73 个 layout 文件中。该属性在遥控器场景下有意义,但在触摸场景下会让第一次触点先进入焦点选择流程。 - -本次只设计“触屏优化模式”,不处理“某些页面无法下滑”的问题。滚动问题需要单独复现和定位,不应和本项改动混在一起。 - -## 2. 目标 - -1. 在 TV 版中提供可开关的触屏优化模式。 -2. 开启后,普通可点击控件第一次触点即可触发动作,不再先抢焦点。 -3. 保持遥控器、D-pad、OK 键和返回键的现有导航行为不变。 -4. 不引入新依赖,不修改应用包名、播放器引擎或数据存储结构。 -5. 改动集中在设置和视图应用层,避免直接大范围改 73 个布局文件。 -6. 设置可随用户偏好备份,卸载重装后可按项目现有备份能力恢复。 - -## 3. 非目标 - -1. 不修复嵌套滚动、无法下滑、焦点与滚动冲突问题。 -2. 不复制 mobile 版整套界面。 -3. 不在第一版引入完整平板布局。 -4. 不改变播放器手势、双击、长按、音量/亮度/进度手势的语义。 -5. 不改变 WebView 的加载、渲染、输入和脚本行为。 -6. 不做触屏设备自动识别的静默默认值,避免现有用户行为被意外改变。 - -## 4. 方案选择 - -### 方案 A:直接修改 leanback layout - -把 73 个 layout 里的 `android:focusableInTouchMode="true"` 改为 `false` 或删除。 - -优点是实现简单。缺点是影响范围大,所有现有用户都会立即改变行为,不利于灰度和回滚;部分控件可能依赖当前焦点行为,需要逐页确认。 - -结论:不作为第一版方案。 - -### 方案 B:运行时触屏优化模式 - -新增一个用户可见设置项,只在 leanback flavor 生效。开启后,通过统一的 helper 在 Activity、Dialog 和动态视图上调整可点击控件的触摸模式。 - -优点是范围可控、可回滚、可灰度,默认不影响现有遥控器用户。缺点是实现上需要覆盖动态视图和弹窗,不能只靠静态布局修改。 - -结论:采用该方案。 - -### 方案 C:完整电视模式/平板模式 - -参考影视仓,引入电视模式和平板模式两套布局。 - -优点是最终体验更完整。缺点是涉及页面结构、尺寸、导航和交互重设计,范围过大,不应与触屏点击修复绑定。 - -结论:延后,不进入本期范围。 - -## 5. 产品行为 - -### 5.1 默认状态 - -第一版默认关闭。 - -理由是当前产品以遥控器交互为主,直接默认开启会改变所有 leanback 用户的第一触点行为。车机用户可在设置中手动开启。后续收集反馈后,可以再评估“检测到触屏设备时默认开启”的策略。 - -### 5.2 设置入口 - -设置页新增“触屏优化”开关。 - -建议放在界面/显示相关配置附近。该入口只在 leanback 版本显示;mobile 版本本身已经是触屏优先,不需要显示该设置。 - -### 5.3 开启后的行为 - -普通可点击控件: - -- 第一次触点直接触发点击动作。 -- 不再先移动焦点。 -- 长按行为保持系统默认语义,不额外扩展。 - -输入框: - -- 保持系统默认行为,允许第一次触点聚焦、弹出软键盘或进入编辑状态。 - -WebView: - -- 保持现有 WebView 焦点和交互行为,不强制关闭其触摸能力。 - -列表和网格: - -- item 的点击行为遵循“第一次触点直接触发”。 -- item 内部如果还有独立按钮,则优先命中按钮。 - -播放器: - -- 不调整 `PlayerGesture` 和播放控制栏的业务逻辑。 -- 只确保触屏优化不额外改变手势判定顺序。 - -### 5.4 关闭后的行为 - -完全回到当前 leanback 行为:第一次触点可先聚焦,第二次触点触发动作。遥控器导航不变。 - -## 6. 技术设计 - -### 6.1 配置存储 - -新增一个布尔型偏好: - -```text -key: touch_optimized -default: false -effective flavor: leanback -``` - -在 `Setting` 中提供读写入口: - -```java -public static boolean isTouchOptimized() -public static void putTouchOptimized(boolean enabled) -``` - -读取时建议至少叠加 leanback 判断,避免 mobile 版本误用: - -```java -return Util.isLeanback() && Prefers.getBoolean("touch_optimized"); -``` - -同时把 `touch_optimized` 加入 `Backup.APP_PREFS`,保证用户偏好可以随现有备份/恢复机制迁移。 - -### 6.2 统一 Helper - -新增一个轻量工具类,例如: - -```text -app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java -``` - -职责: - -1. 判断当前是否开启触屏优化。 -2. 遍历传入的 View 树。 -3. 对 `focusableInTouchMode == true` 且不是输入类控件的 View 调用 `setFocusableInTouchMode(false)`。 -4. 不修改 `focusable`,避免破坏 D-pad 导航。 -5. 不修改 `clickable`、`longClickable`、`OnClickListener`。 - -处理规则: - -```text -View.focusable = 保持不变 -View.focusableInTouchMode = false -View.clickable = 保持不变 -View.longClickable = 保持不变 -View.onClickListener = 保持不变 -``` - -例外控件: - -- `WebView` -- `EditText` -- 其他文本输入控件 - -这些控件保持系统默认的触摸焦点行为,否则可能影响输入、光标和选择。 - -### 6.3 Activity 接入 - -在 leanback 的 `BaseActivity.setContentView()` 后统一应用: - -```text -setContentView(view) - -> super.setContentView(view) - -> addCustomWall() - -> TouchOptimizationHelper.sync(decorView) -``` - -这样 leanback 侧大多数页面可以在一个入口接入,不需要每个 Activity 单独调用。 - -对动态添加的子 View,可以使用 `ViewTreeObserver.OnGlobalLayoutListener` 或 `OnAttachStateChangeListener` 补应用。第一版建议先使用布局后的重扫方式,减少对业务页面的侵入。 - -### 6.4 Dialog 接入 - -DialogFragment / AlertDialog 的 root view 通常不在 Activity content view 树的生命周期内,需要单独接入。 - -helper 提供 Dialog 重载: - -```java -TouchOptimizationHelper.sync(dialog); -``` - -`BaseActivity` 使用递归 `FragmentLifecycleCallbacks` 覆盖 `DialogFragment`,同时在 `LightDialog`、`SiteDialog.showDirect()` 和播放器集中式面板入口覆盖直接创建的窗口。第一版覆盖的高频弹窗包括: - -- 设置弹窗 -- 站点/配置选择 -- 选集 -- 轨道/音轨/字幕 -- 搜索 -- 备份/恢复 - -低频弹窗可以后续逐步覆盖,不影响功能开关本身。 - -### 6.5 RecyclerView 接入 - -RecyclerView 的 item view 会在滚动、复用和数据刷新时重建。helper 不应只处理初始布局。 - -建议两种方式结合: - -1. Activity 布局完成后对当前 view 树做一次遍历。 -2. 对 `RecyclerView` 添加 `OnChildAttachStateChangeListener`,在 child attach 时继续应用规则。 - -这样可以在不做全量 layout 修改的情况下,覆盖大部分动态列表。 - -### 6.6 设置页接入 - -在 leanback `SettingActivity` 中新增开关入口,逻辑为: - -```text -click -> 取反 Setting.isTouchOptimized() - -> Setting.putTouchOptimized(next) - -> updateSettingSummary() -``` - -切换后不需要重启应用。当前 Activity 可以立即重应用;其他已打开 Activity 可以在下一个 `onResume` 或 `onContentChanged` 时重应用。第一版如果存在个别页面状态复杂,允许提示用户重新进入页面,但不建议要求杀进程。 - -## 7. 兼容性 - -### 遥控器 - -helper 只关闭 `focusableInTouchMode`,不关闭 `focusable`。D-pad 焦点导航仍可移动到目标控件,OK 键仍然触发点击。 - -### 触屏电视 - -关闭默认开启后,普通触屏电视用户行为不变。需要触屏直达的用户可以手动打开。 - -### 车机 - -车机开启设置后,主要页面可从“点两下”变为“点一下”。投屏、推送和播放控制页保持 TV 版布局能力。 - -### mobile flavor - -不注册设置入口,不执行 helper。mobile 版本保持现有触屏行为。 - -## 8. 实施顺序 - -1. 新增 `touch_optimized` 偏好和 `Setting` 读写方法。 -2. 新增 `TouchOptimizationHelper`。 -3. 在 leanback `BaseActivity` 接入。 -4. 覆盖高频 Dialog。 -5. 处理 RecyclerView 动态 child。 -6. 设置页增加开关。 -7. 补备份 key。 -8. 手工回归。 - -## 9. 验收标准 - -开启触屏优化后: - -1. TV 版首页、分类、历史、搜索、设置页中,普通可点击 item 第一次触点直接进入或执行动作。 -2. 投屏、推送、播放器控制相关入口第一次触点可用。 -3. 弹窗中的主要按钮和列表项第一次触点可用。 -4. 输入框仍可正常聚焦和输入。 -5. WebView 页面不因该开关失去正常交互能力。 -6. 遥控器 D-pad 和 OK 键导航行为不变。 -7. 关闭开关后,行为恢复到当前版本。 -8. 备份/恢复可以包含该设置。 - -## 10. 验证计划 - -建议手工验证设备组合: - -1. 车机触屏:验证车机用户最关心的单击直达和投屏/推送入口。 -2. 普通电视 + 遥控器:验证焦点导航和 OK 键不回归。 -3. 触屏电视或带触摸的调试设备:验证设置开关、列表、弹窗和播放器入口。 -4. Android TV 模拟器:作为基础回归环境,覆盖设置切换和主要页面。 - -重点页面: - -- 首页 -- 分类 -- 搜索 -- 历史/收藏 -- 详情/选集 -- 直播 -- 投屏 -- 推送 -- 设置 - -重点场景: - -- 开启触屏优化后的首次点击。 -- 关闭触屏优化后的双击旧行为。 -- 遥控器导航和触摸操作交替使用。 -- 弹窗打开后的触摸点击。 -- 输入框聚焦和编辑。 -- WebView 页面加载和返回。 -- 播放器显示/隐藏控制栏、单双击和长按。 - -## 11. 风险 - -### 风险:输入类控件无法聚焦 - -处理:helper 遇到 `EditText`、`WebView` 等控件直接跳过,保持系统默认行为。 - -### 风险:某些页面依赖先聚焦展示状态 - -处理:第一版采用显式开关,默认关闭。发现问题可以立即关闭回滚,不需要改动布局或数据。 - -### 风险:动态弹窗遗漏 - -处理:先覆盖高频弹窗;遗漏弹窗继续表现为当前“先聚焦再点击”,不会造成崩溃。 - -### 风险:列表复用导致规则失效 - -处理:helper 除了扫描根布局外,还给 RecyclerView 增加 child attach 监听,覆盖滚动复用场景。 - -### 风险:与播放器手势冲突 - -处理:本期不修改播放器手势。触屏优化只作用于焦点属性,不新增或拦截手势。 - -## 12. 回滚策略 - -该功能由单个布尔偏好控制。 - -出现问题时: - -1. 用户可在设置中关闭“触屏优化”。 -2. 如果需要服务端止血,可在后续版本中把读取逻辑临时收敛为 `return false`。 -3. 代码回滚时只回退设置项、helper、Activity/Dialog 接入和设置页入口,不涉及数据迁移。 - -## 13. 后续可选项 - -1. 触屏设备检测默认开启。 -2. 触摸反馈样式统一。 -3. 针对车机横屏的更高对比度或更大触控目标。 -4. 独立平板布局模式。 -5. 单独排查嵌套滚动和无法下滑问题。 - -## 14. 决策依据 - -### 14.1 平台源码 - -- 来源:本机 Android SDK 37 官方源码 `sources/android-37.0/android/view/View.java`,访问日期 2026-09-04,证据等级 A。 -- `View` 的 touch mode 文档明确说明:触摸模式下只有 `isFocusableInTouchMode()` 为 `true` 的 View 才会取得焦点;普通可触摸按钮不会取得焦点,而是直接触发点击。 -- `View.onTouchEvent()` 的 ACTION_UP 路径会在 View 可聚焦、触摸模式可聚焦且尚未聚焦时先调用 `requestFocus()`;只有 `focusTaken == false` 才调用 `performClickInternal()`。这直接解释了“第一次只聚焦、第二次才点击”。 -- `setFocusableInTouchMode(false)` 只清除 `FOCUSABLE_IN_TOUCH_MODE`;本实现不调用 `setFocusable(false)`,因此 D-pad 所需的普通焦点能力保留。 -- 对 WebView、EditText 和 `onCheckIsTextEditor()` 为真的自定义编辑控件跳过整个子树,避免改变输入、光标和软键盘行为。 - -### 14.2 项目实际依赖 - -- Gradle `dependencyInsight` 确认 leanback debug 运行时解析到 `androidx.fragment:fragment:1.6.1`,证据等级 A。 -- Gradle `dependencyInsight` 确认 leanback debug 运行时解析到 `androidx.recyclerview:recyclerview:1.4.0`,证据等级 A。 -- Fragment 视图创建与 Dialog 启动通过递归 `FragmentLifecycleCallbacks` 接入;RecyclerView 通过 `OnChildAttachStateChangeListener` 在复用 child 重新挂载时同步。 -- 外部 Android 文档网页在 2026-09-04 连续发生 TLS 握手失败,因此没有以搜索摘要替代原文;平台结论改由同版本本地 SDK 官方源码和实际解析依赖证明。 - -### 14.3 其他证据类别 - -- 上游 PR、Issue、revert 与维护者讨论:本变更不移植上游补丁,且 AOSP 源码直接给出确定行为,因此不作为设计判定条件。 -- 成熟相关项目代码:本项目 73 个 leanback layout、252 处 `focusableInTouchMode="true"` 是当前产品行为的直接样本;未引入另一套产品架构作为依赖。 -- 论文、博客与基准:本问题是 Android View 确定性事件分支,不涉及算法、性能模型或经验性参数,不适用。 - -### 14.4 方案比较结论 - -- 不改:保留车机/触屏电视的双击问题,不满足目标。 -- 原样批量改 73 个布局:简单但不可灰度,默认改变全部 TV 用户行为,回滚面过大。 -- WebHTV 适配方案:默认关闭、leanback-only、运行时遍历、保留原值并可恢复,集中覆盖 Activity、Fragment/Dialog 和 RecyclerView;这是当前风险与回滚成本最低的方案。 - -## 15. 实现记录 - -1. `Setting` 新增 `touch_optimized` 读写,读取叠加 leanback flavor 判断,默认值为 false。 -2. `Backup.APP_PREFS` 纳入该键,并补设置同步筛选测试。 -3. `TouchOptimizationHelper` 使用弱引用表只记录首次看到的原始 `focusableInTouchMode=true` 状态;重复扫描不覆盖原值,关闭后恢复并移除记录。 -4. helper 在 mobile flavor 直接返回;WebView、EditText 和文本编辑器子树不参与修改。 -5. leanback `BaseActivity` 在内容树建立、`onResume`、Fragment view 创建和 DialogFragment 启动时同步。 -6. RecyclerView 仅在优化开启时安装一次弱引用跟踪的 child attach 监听,动态 item 继续同步。 -7. `LightDialog`、`SiteDialog.showDirect()` 和播放器直接面板补充 Dialog 同步入口。 -8. 设置页新增三语言“触屏优化”开关;开关本身保持 `focusable=true`、`focusableInTouchMode=false`,确保功能关闭时也能单击启用,同时仍支持 D-pad/OK。 - -## 16. 当前验证记录 - -- RED:原 helper 因 BOM、非法常量和未定义 tag key 无法通过 leanback Java 编译。 -- RED→GREEN:原始状态只记录一次、RecyclerView child 同步、输入子树跳过、mobile no-op、Activity/Fragment 接入、直接 Dialog/播放器面板接入等契约测试已完成。 -- RED→GREEN:新增开关最初需要两次触点;新增 XML 行为测试后将该行 `focusableInTouchMode` 改为 false,7 项 helper 契约测试通过。 -- `BackupPreferenceFilterTest` 9 项通过,其中包含 `touch_optimized` 只随 settings 备份的断言。 -- 最终组合构建 `compileMobileArm64_v8aDebugJavaWithJavac assembleLeanbackArmeabi_v7aDebug` 通过,耗时 3 分 26 秒;日志保存在 `build/touch-optimization-final-build.log`。 -- 最终 APK 大小为 177,375,055 bytes,已明确安装到 `emulator-5554`;从设备当前 code path 拉回的 APK 与本地产物长度一致,且设备侧 Manifest 包含 `SettingActivity`、`HomeActivityHistory` 和 `HomeActivityCurrent`。 -- API 28 模拟器已完成同一 leanback 调试安装链的交互检查:默认关闭时首页设置入口第一次触点只聚焦;开启后重启应用,普通设置入口第一次触点可直达;D-pad Right 可从 `vod` 移动到 `vodHome`;站点选择直接窗口可单击打开。 -- `touch_optimized` 已恢复为 false,`home_menu_key` 已恢复为原值 1。最终安装后,shell 直接启动未导出的 `SettingActivity` 被系统权限拒绝,因此无法再独立重复“最终 APK 开关单击启用/关闭恢复”的完整设备闭环;该行为由 7 项 helper/XML 契约测试和源码路径覆盖。 -- 定向结果:`TouchOptimizationHelperSourceTest` 7/7、`BackupPreferenceFilterTest` 9/9,均为 0 failures、0 errors。 - -## 17. Recovery anchor - -- 目标:完成 leanback 触屏优化模式,保持 mobile、输入控件、WebView、D-pad 和播放器手势语义不变。 -- 验收:开关默认关闭且可单击启用;开启后普通控件单击直达;关闭后恢复旧行为;动态列表/高频弹窗覆盖;偏好可备份。 -- 当前分支/基线:`dev2` / `fbbefb75dbcbce1fcec259bb3a81a5e2e889362e`。 -- 当前文件:见 `.codex/task-state/current/scope`;任务外 dirty 路径为空。 -- 已完成:实现、RED→GREEN 契约测试、最终 mobile 编译、最终 leanback APK 构建、APK 安装、主要 API 28 设备场景和模拟器状态恢复。 -- 设备补验限制:最终安装后无法由 shell 启动未导出的 `SettingActivity`,所以最终 APK 的开关单击/关闭设备闭环未独立重放;代码契约已验证入口属性、模式切换路径和恢复逻辑。 -- 未决风险:低频直接 Dialog 可能仍保持旧双击行为;第一版按设计只保证高频路径,遗漏不会导致崩溃。 -- 回滚:用户关闭 `touch_optimized`;代码回滚设置项/helper/入口/生命周期接入即可,无数据迁移。 -- 下一步:执行 `task_guard finish`,原子提交本任务路径并创建本地恢复 tag。 diff --git a/docs/tv-short-display-dead-20260914.md b/docs/tv-short-display-dead-20260914.md deleted file mode 100644 index 377e8f8284..0000000000 --- a/docs/tv-short-display-dead-20260914.md +++ /dev/null @@ -1,12 +0,0 @@ -# TV 短显按钮无响应修复 - -## Recovery anchor - -- 目标:修复 TV 版(leanback flavor)「短显」按钮获得焦点后按确认键无响应的问题。 -- 验收:Leanback `VideoActivity.initEvent()` 包含 `mBinding.shortDisplay.setOnClickListener(view -> onShortDisplay())`,控制栏确认键可沿 Android View 点击链执行既有动作;回归测试覆盖关键按钮,Leanback Java 编译通过。 -- 根因证据:commit `7dc58af1b0bb28818b23f43748c3ac67f76e0449`(2026-09-13 05:57,C4 合并)将 Leanback `VideoActivity.initEvent()` 中原有控制栏监听块删除(包括 `mBinding.shortDisplay.setOnClickListener`),导致按钮虽有布局和方法但没有点击事件绑定。 -- 已知修复:commit `015be8734cf561cf549a9998744809b7c88160fa`(2026-09-14 10:48,fix(tv): restore player control confirm actions)已经修复了此问题,但该提交不在当前 HEAD(`0b43e10040`)的祖先链上,因此电视版最新测试版仍受影响。 -- 当前修改:将该修复 cherry-pick 到当前 HEAD(`0b43e10040`),恢复丢失的 TV 控制栏监听(包括短显按钮的点击绑定),并恢复焦点滚动、选集/片头跳过初始化接线;新增 `PlayerControlFocusIntegrationTest.leanbackPlaybackControlButtonsKeepConfirmActionsWired` 防止再次回归。 -- 下一步:提交并创建恢复标签。 -- 已完成证据:定向测试 `:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ui.activity.PlayerControlFocusIntegrationTest` 通过;`:app:compileLeanbackArm64_v8aDebugJavaWithJavac` 通过;`git diff --cached --check` 通过。 -- 完成状态:修复提交 `cf1a3eef83` 已创建,恢复标签 `recovery/tv-short-display-dead-20260914/20260914143520-cf1a3eef83` 已创建,任务完成。 diff --git a/docs/upstream-player-dependency-merge-assessment-2026-08-20.md b/docs/upstream-player-dependency-merge-assessment-2026-08-20.md index d7172ec9c9..ea91f264f0 100644 --- a/docs/upstream-player-dependency-merge-assessment-2026-08-20.md +++ b/docs/upstream-player-dependency-merge-assessment-2026-08-20.md @@ -5,11 +5,6 @@ 文档状态:进行中。本文按检查点持续落盘;未标记“已完成逐提交审阅”的仓库,不应据此直接升级依赖。 当前恢复入口:以“稳定任务 ID 与唯一文档索引”及各任务文档顶部状态为准。完整逐提交审计已完成至检查点 43;`E1`、`E2-2`、`E-SP1`、`E2-1`、`E3-1a`、`E7-1`、`E7-2 + C3`、`E-SP3`、`E9-3`、`P1`、`P2-1`、`P2-5`、`P2-2`、`P3`、`P4-1` 和 `C0-M` 已完成验证,`E-SP2` 候选已接入但仍待实机性能/seek 验收。C0-M 已在 `9b7cf9cfbbeac00b0e5a342d4c6071c2c2d7a223` 把 MPV FFmpeg 从 `04482c8d13ac27b2a9fe93f5d388929eef8af5f4` 切换到 Exo 已验证的 `177f090e0503b7e013922ca903bde14b1c375f18`;`C2` 默认暂缓。 -- 当前分支:`dev2`。 -- 当前修复基线:`154e003520a751a19187057f103e1496c5197457`(2026-09-13)。 -- 历史完整评估:仓库历史提交 `3b346c85d0a3fb8e6078e4dbe4511f3aa15795a0` 中的同名文件;主线提交 `784b90420d646eb6c7ddcc63ad622a92c65b02b4` 删除了根目录本地任务文档,因此本分支保留稳定任务索引与当前实施需要的记录。 -- 源码合并:第三轮目标为 `fish2018/webhtv:main@fc62397591701b2232ae7de4f50a032bd7742064`;2026-09-19 第五轮目标为 `2623cb812ea842b676bc7d8db699c1a7e70b8e1e`,唯一文档冲突已按本地索引结构解决,详见 [C4-main-upstream-merge.md](C4-main-upstream-merge.md)。 -- 下一步:完成定向验证后由当前 guard 原子提交本轮已验证改动并创建本地恢复 tag,不推送,详见 [E9-3-exo-dv5-vulkan-renderer.md](E9-3-exo-dv5-vulkan-renderer.md) 与 [P9-MPV-BLURAY-MENU.md](P9-MPV-BLURAY-MENU.md)。 ## 稳定任务 ID 与唯一文档索引 @@ -27,26 +22,35 @@ | 顺序 | 任务 ID | 类别 | 功能/能力 | 状态 | 唯一文档 | | ---: | --- | --- | --- | --- | --- | -| 既有任务续修 | `E11` | Exo/App | 压缩音频输出、跳转释放状态、隧道一致性与起播恢复 | A 为 `123d871c027eb686702766bca995f616e8d2bd1e`,首次起播研究为 `e9fef2b90ee8aa634327c2c179eb3dc5d22cb1be`,直通开关修复为 `24fa078d2dc8a404fad23fc30972e5fc4a8a1b5b`。首次快速恢复现已实现:完整 AU 供数及启动门槛读回后,以 800 ms 窗口和原始播放头/路由复核判错,使用 Media3 内部恢复复用当前媒体与缓存;保留正常直出与关闭开关的 PCM 行为。C4 第七轮合并保留本地厂商直出初始化失败立即 PCM 回退,并接入按媒体/路由失败记忆、直通开关一致性和已缓冲起播恢复;合并冲突组合的最终验证记录见唯一任务文档。 | [E11-exo-compressed-audio-direct.md](E11-exo-compressed-audio-direct.md) | -| 插入修复 | `P11` | MPV/App | AV3A 直播使用 `.m3u8?ts=…` 媒体端点时保持分片语义,修复代理误报 HTTP 400 | 已修复;19 项定向测试、Debug/快速 Release 构建和手机同源实播通过,持续超过 6 分钟、AV3A 音频输出无写入错误;Release 已安装,视频手动解码合同保持;源内迅雷插件 Debug JNI 问题独立记录 | [P11-mpv-live-av3a.md](P11-mpv-live-av3a.md) | -| 插入需求 | `P10` | MPV/App | 全局智能去广开关接入,复用Exo识别并保持HLS时间轴/跳转 | 已实现并续修误跳正文、广告闪帧及Surface复用;原生输出边界fixture零广告帧,原链接正常跨广告;25项广告测试与4项Surface测试通过,Mobile64已安装且用户确认正常;原生库保持 | [P10-mpv-smart-adblock.md](P10-mpv-smart-adblock.md) | -| 插入需求 | `C-AVS3` | 通用,Exo → MPV | AVS3 视频解码,基准档次与 High profile 分阶段验证 | baseline/0x32软件后端及MPV MediaCodec接入已交付;手机不具备AVS3硬件,硬解实际出帧与性能待目标设备验证 | [C-AVS3-video-decoding.md](C-AVS3-video-decoding.md) | -| 插入需求 | `AV-DIAG-01` | 通用/App,Exo → MPV → IJK | 无 ADB 音视频分层诊断、脱敏和可判读的日志导出 | D0–D5实现/产物补齐,覆盖与验证见14.13–14.14;设备/性能待用户实测 | [AV-DIAG-01-playback-diagnostics.md](AV-DIAG-01-playback-diagnostics.md) | -| 22 | `E9-3` | Exo/App | 普通 HEVC 硬解 + Vulkan/libplacebo 的 DV5 色彩映射默认准入 | 2026-09-12默认准入已实现,三个定向测试类及 Mobile/Leanback arm64 Java 编译通过;保留原生杜比和设备能力门控,待新版包原场景复测 | [E9-3-exo-dv5-vulkan-renderer.md](E9-3-exo-dv5-vulkan-renderer.md) | -| P2 子阶段 | `P2-4` | MPV/native/App | Android BL 硬解 + EL 软解 + GPU FEL 重建,新增手动选择项 | 日志33否决9.20的整体性能收益;9.22完成跨项目/二进制复核,推荐先修正重复建链,再做绑定类型/外部图像对照;新方案未实施,电视实时性能未验收 | [P2-4-mpv-android-fel.md](P2-4-mpv-android-fel.md) | -| 38 | `P9-MPV-BLURAY-MENU` | MPV/native/App | HDMV Blu-ray 菜单画面、按钮高亮、方向/确认/返回/Popup、菜单跳转与 still frame;BD-J 无提示回退现状 | 2026-09-11父菜单未命中修复已实现,定向验证及构建通过,用户测试确认并要求tag | [P9-MPV-BLURAY-MENU.md](P9-MPV-BLURAY-MENU.md) | - -新增产品需求(不改变既有上游提交的实施顺序): - -| 任务 ID | 类别 | 功能/能力 | 状态 | 唯一文档 | -| --- | --- | --- | --- | --- | -| `E-SP9` | Exo 性能/播放行为 | 硬解模式无该 MIME 硬件候选时的处理策略 | **已回滚自动软解**:复评确认 `4a9f0fd14d04aa5f45c8601d9f77334666b435de` 违反“视频只能人工手动切换硬解/软解”的既有合同;恢复硬解模式不注册 FFmpeg 视频回退,音频独立回退保持不变 | [E-SP9-exo-unsupported-video-soft-fallback.md](E-SP9-exo-unsupported-video-soft-fallback.md) | -| `E4-LIBASS` | Exo/字幕 | ASS 特效字幕及对齐 MPV 默认行为的主/副字幕 | **上游 HDR/DV 修复已合并到 dev3 并通过修复相关验证**:独立 SDR RGB 字幕层放行 HDR/DV/BT.2020,同时保留 SDR 旧矩阵与 DRM/rotation/tunneling 边界;Mobile ARM64 Debug/测试 APK 构建成功,4 项视频策略、1 项 native 原始 RGB/切换和 1 项 blur/transform 共 6/6 通过,产物哈希一致。既有 `AssPlaybackTest` 因本地 `applicationId=com.silent.android.webhtv` 与硬编码 `com.fongmi.android.tv` 不符而未启动 Activity;该测试和构建配置本轮均未改动,作为独立本地夹具问题保留 | [E4-LIBASS-exo-ass-rendering.md](E4-LIBASS-exo-ass-rendering.md) | -| `C16` | common / 详情数据协议 | 参考 OmniBox,让 T3/T4 详情直接携带 TMDB 数据,APP 优先采用并仅补齐缺省部分;第 19 节实现**订阅配置接口响应根对象**的 `tmdb_api_key`,Key 只属于当前订阅接口,切换接口先清空,新接口配置有 Key 才加载、没有则为空,且不持久化;详情中的同名字段只剥离不作为凭据来源 | **客户端第 1-19 节阶段 1-5 已实现并验证,订阅配置根级修订及认证失败 epoch 隔离已落地**:既有阶段 A-F 提交为 `19128b7d18107b5c614078ebdadc863faa2476b5`、`1c4213db0baf24e9da9495edfdbaa9dcab43f527`、`6a994fa1acd85cd307bf111bc077f6e5a1385da0`、`b2343d1d1de476226b52bef11b80248e1d5033d9`、`6e2cde565ae34c1a0da392c7ca93f34cb97aaa99`、`d52fdfc5b2027872779026bad278c1a5c8e9f5d8`;第 19 节提交为 `a50de802a1c2c3491088fa0307c0484bc95e7f1e`、`7cc55beae44c891e01c0cbc5c4ea2ffee844cb3f`、`73abd0350b9808c5da727763deff3764e590d970`、`d2136e463391a6c34a4bf042d85cc001982cd001`、`fd593f3d3c3972811c7572ca92342b78f16b9fb3`、`b51a3795e9c88d2f370271f64513e5928d6e067f`、`9cd5c6399dac79411354c1be104d28a826e37127`。分配设备 `SM-N9700/Android 9 @ 192.168.50.3:5557` 上 Mobile/Leanback 凭据矩阵各 5/5、无 Key 回归各 3/3;私有目录与 logcat 无测试 Key。未提供真实第三方 Key,真实补齐、真实 401/403 和发布级源端门控/轮换仍未验证;T4 服务端待独立接入 | [C16-tmdb-source-detail-contract.md](C16-tmdb-source-detail-contract.md) | -| `C17` | common / beta 同步复评 | 将 beta 最新播放器与 Leanback 修复合入 dev4,复评全部未推送 C16 改动并完成交付 | **已完成**:合并提交 `5acd3f084155e7016b3798528eb8343f2f076177`;PR [#317](https://github.com/Silent1566/webhtv/pull/317) 目标 `beta` | [C17-beta-sync-review-dev4-20260919.md](C17-beta-sync-review-dev4-20260919.md) | -| `C18` | common / beta 同步复评 | 将 beta 最新代码合入 dev4,复评 FOLLOW-1 与 C16 等全部未推送改动并完成交付 | **已完成**:合并提交 `9f998d6808f3e8144d2d4d68f23d92308e49b599`;PR [#331](https://github.com/Silent1566/webhtv/pull/331) 目标 `beta` | [C18-beta-sync-review-dev4-20260920.md](C18-beta-sync-review-dev4-20260920.md) | -| `C18` | common / beta 同步复评 | 将 beta 最新代码合入 dev4,复评 FOLLOW-1 与 C16 等全部未推送改动并完成交付 | **进行中**:合并树双端编译和移动端 JVM 全量测试已通过,待最终复评、提交、推送和 PR 到 `beta` | [C18-beta-sync-review-dev4-20260920.md](C18-beta-sync-review-dev4-20260920.md) | -| `E-SP8` | Exo 性能/播放行为 | 基于现有短剧源设置的单实例队列连播、下一集预解析与受控预加载 | **代码实施及 beta 合并后复评通过**:`2b22c5240d52a8c2054299326f44fee6743ab26f` / `recovery/E-SP8/20260911201514-2b22c5240d52`;实验默认策略不变,连续切集双端设备验收与正式放量尚未完成;不变更依赖 | [E-SP8-exo-short-drama-queue.md](E-SP8-exo-short-drama-queue.md) | +| 1 | `E1` | Exo | nextlib 内置 FFmpeg 升级至 9.0.1 | 已完成 | [E1-exo-ffmpeg-9.0.1.md](E1-exo-ffmpeg-9.0.1.md) | +| 2 | `E2-2` | Exo | DV7→P8.1 codec/CSD 一致性 | 已实施 | [E2-2-exo-dv7-p81-csd.md](E2-2-exo-dv7-p81-csd.md) | +| 3 | `E-SP1` | Exo 性能 | 首帧已渲染时立即解除遮罩 | 已完成 | [E-SP1-exo-first-frame-visible.md](E-SP1-exo-first-frame-visible.md) | +| 4 | `E-SP2` | Exo 性能 | 远程大 MKV 延后 Cues、首次 seek 按需建索引 | **已完成**:`2156c74749a575b747d2f043419a5d47b485c0cf` / `recovery/E-SP2-CHAINED-SEEKHEAD/20260829004352-2156c74749a5`;双产品 arm64、连续 seek 和实机验证通过 | [E-SP2-exo-remote-mkv-deferred-cues.md](E-SP2-exo-remote-mkv-deferred-cues.md) | +| 5 | `E-SP3` | Exo 性能/生命周期 | seek 预载隔离、HLS 预缓存崩溃防护和 seek 恢复门槛 | **已合入 `fongmi-sync`**:App `17f1a4cfe2547b8f3ddc61fab34212e77ae719ff`,Media3 `c9d3bd912b90ec0ca440c28455f3e6d9bba019ea` | [E-SP3-exo-seek-preload-isolation.md](E-SP3-exo-seek-preload-isolation.md) | +| 6 | `E2-1` | Exo | HDR/Dolby Vision parser safety | **已完成** | [E2-1-exo-hdr-parser-safety.md](E2-1-exo-hdr-parser-safety.md) | +| 6 | `E3-1a` | Exo | Pixel E-AC3 JOC capability guard | 已实施:`cda1ac8cf2f5d4d9c3beec68b0b520d6f7c218ec` / `recovery/E3-1a/20260826175658-cda1ac8cf2f5` | [E3-1a-exo-pixel-eac3-joc-guard.md](E3-1a-exo-pixel-eac3-joc-guard.md) | +| 7 | `E3-1b` | Exo | DTS 14-bit 解析 | 已实施:`27b85eeeed5ceb55e56a67ae3b5cf8ff64b8da40` / `recovery/E3-1b/20260826201735-27b85eeeed5c` | [E3-1b-exo-dts-14bit.md](E3-1b-exo-dts-14bit.md) | +| 8 | `E4-1` | Exo | 字幕字节与边界安全 | **已完成:A4-1a/A4-1b** | [E4-1-exo-subtitle-byte-safety.md](E4-1-exo-subtitle-byte-safety.md) | +| 9 | `E4-J1` | Exo | Cue 数据契约 | **已实施并验证**:`af78e3b7656d6a0f210d7344b3852f301690c417` / `recovery/E4-J1/20260827133106-af78e3b7656d`;默认不启用碰撞或新渲染行为 | [E4-J1-exo-cue-data-contract.md](E4-J1-exo-cue-data-contract.md) | +| 10 | `E6-1` | Exo | 有界缓存写入 correctness | **已实施并验证**:`0a8ed3b910679a08a7e41c735338c3804a2eb938` / `recovery/E6-1/20260827145043-0a8ed3b91067`;不引入并行预加载 | [E6-1-exo-smb-proxy-cache-correctness.md](E6-1-exo-smb-proxy-cache-correctness.md) | +| 11 | `E7-1` | Exo | ISO reader safety | **已实施并验证**:`491a7def30484b0936426bbc57b09f5b6435ae80` / `recovery/E7-1/20260827160011-491a7def3048`;仅移植 IsoDataReader 安全修复 | [E7-1-exo-iso-reader-safety.md](E7-1-exo-iso-reader-safety.md) | +| 12 | `E7-2` | Exo | ISO multi-extent reader/API | **已实施并编译验证**:`5f7d834bfdd00f215609df7b41c2ea7cadc2cd4f` / `recovery/E7-2-C3/20260827193629-5f7d834bfdd0`;按用户要求未跑测试/真实 split ISO | [E7-2-exo-iso-multi-extent-api.md](E7-2-exo-iso-multi-extent-api.md) | +| 13 | `P0` | MPV | native 基线、等价提交与运行验收 | **评估已完成:基线/ELF/资产校验通过;无代码,待 P1/P2 明确批准** | [P0-mpv-native-baseline.md](P0-mpv-native-baseline.md) | +| 14 | `P1` | MPV | 格式与 shader correctness | **已实施并验证**:`a5971e3814d3b0826a5702d607dd6d1675b2ce53` / `recovery/P1-MPV-FORMAT-SHADER-CORRECTNESS/20260828184107-a5971e3814d3`;用户多原盘回归通过 | [P1-mpv-format-shader-correctness.md](P1-mpv-format-shader-correctness.md) | +| 15 | `P2-1` | MPV | Vulkan generic UV | **已实施并验证**:`fe4184933fbb3a02bd1ff2ff794a277123c35bdc` / `recovery/P2-1-MPV-VULKAN-GENERIC-UV/20260829003632-fe4184933fbb`;双 ABI、ELF、APK 资产身份及 compute/fragment/legacy/stable/auto 真机路径通过 | [P2-1-mpv-vulkan-generic-uv.md](P2-1-mpv-vulkan-generic-uv.md) | +| 16 | `P2-5` | MPV | 非原生 DV5 自动选择 Vulkan/gpu-next | **已实施并验证**:V2453A 上自动切换至 `vulkan/gpu-next` + `hevc_mediacodec`,普通 HDR 新媒体项恢复 `opengl/gpu`;用户确认正常 | [P2-5-mpv-dv5-auto-vulkan.md](P2-5-mpv-dv5-auto-vulkan.md) | +| 17 | `P2-2` | MPV | DV7 metadata/codecpar/error 完整性 | **已实施并验证**:`ba47756d7e463abeb9377088b819a2520e150935` / `recovery/P2-2-MPV-DV7-METADATA-CODECPAR/20260829065811-ba47756d7e46`;仅吸收 metadata-missing、`par_out`、错误传播和 `INT_MAX`,保留本地 packet/Surface/EL 安全契约 | [P2-2-mpv-dv7-metadata-codecpar.md](P2-2-mpv-dv7-metadata-codecpar.md) | +| 18 | `P3` | MPV | AudioTrack 能力与直通 | **已完成**:`d82336bde585b62af43771284075a0a94a3d999e` / `recovery/P3/20260829094014-d82336bde585`;双 ABI、ELF、APK、API 35 手机端多声道 PCM fallback、pause/resume/seek 通过,HDMI/eARC/USB 原码直通留作硬件补验 | [P3-mpv-audiotrack.md](P3-mpv-audiotrack.md) | +| 19 | `P4-1` | MPV | JNI shutdown/lifecycle | **已完成**:`907bfca982a4b1d4d9ee0eeddd05d02226b8f9bb` / `recovery/P4-1-MPV-JNI-SHUTDOWN/20260829103212-907bfca982a4` | `docs/P4-1-mpv-jni-shutdown.md` | +| 20 | `C0-M` | 通用/MPV 搭载 | MPV 使用 FFmpeg 9.0.1 同源 revision 独立重建 | **已完成并关闭**:`9b7cf9cfbbeac00b0e5a342d4c6071c2c2d7a223` / `recovery/C0-M-MPV-FFMPEG-9.0.1/20260829122948-9b7cf9cfbbea`;双 ABI、ELF/资产、arm64 APK、多格式播放、快进、画中画和退出通过;退出期无 Surface 重初始化为升级前已存在的独立生命周期 bug | [C0-M-mpv-ffmpeg-9.0.1.md](C0-M-mpv-ffmpeg-9.0.1.md) | +| 21 | `C2` | 通用 | FFmpeg DV7→P8.1 BSF | **实施中(显式 MPV P8.1,默认行为不变)** | [C2-dv7-p81-bsf.md](C2-dv7-p81-bsf.md) | +| 22 | `C3` | 通用 | ISO multi-extent App resolver | **已随 `E7-2` 联合实施并通过 App 编译**:`5f7d834bfdd00f215609df7b41c2ea7cadc2cd4f` / `recovery/E7-2-C3/20260827193629-5f7d834bfdd0`;真实 split metadata 未验收 | [C3-iso-multi-extent-resolver.md](C3-iso-multi-extent-resolver.md) | +| 23 | `C4` | 通用/上游应用合并 | 合并 `fish2018/webhtv:main` 的 APK 更新、局域网推送、MPV 字幕与阅读器修复 | **已实施并验证**:`d0809f804f812b818bcb22f36cae8634022db673` / `recovery/C4/20260901032617-d0809f804f81`;保留当前 `dev2` 文档、评估台账和本地播放器契约;OCI 发布失败即阻断并固定 setup-oras 完整 commit | [C4-main-upstream-merge.md](C4-main-upstream-merge.md) | +| 24 | `E9-3` | Exo | DV5 MediaCodec + Vulkan/libplacebo GPU 映射 | **已实现并通过目标设备验收**:DV5 色彩映射稳定,DV5 -> DV7/HDR10 Surface 生命周期切换正常;最终提交 `6a3ddd266a94a6b984099876631cc6260e77b776` | [E9-3-exo-dv5-vulkan-renderer.md](E9-3-exo-dv5-vulkan-renderer.md) | +| 25 | `P4-3` | MPV | 终止退出时抑制无 Surface 的 MediaCodec 重初始化 | **已实施并通过定向测试/真机验收**:`8250e2204f4054601202a3a3f2fe04f8766744ee` / `recovery/P4-3-MPV-SURFACE-TEARDOWN/20260829132806-8250e2204f40`;终止退出后不再创建一次性 decoder,PiP 返回和快速重开正常,不改 native/FFmpeg | [P4-3-mpv-surface-teardown.md](P4-3-mpv-surface-teardown.md) | +| 26 | `P4-4` | MPV | 自动播放意图与延迟 pause 回调隔离 | **已完成**:`e8a1582d74844df0292cb27c6c8259a3d5eb5dfa` / `recovery/P4-4-MPV-AUTOPLAY-PAUSE-RACE/20260829135715-e8a1582d7484`;V2453A/API 35 冷启动和两次快速媒体替换均保持自动播放,两个暖切换样本约 3 秒推进约 2.8 秒;不改 native/FFmpeg/渲染链 | [P4-4-mpv-autoplay-pause-race.md](P4-4-mpv-autoplay-pause-race.md) | +| 27 | `C5` | 通用/上游应用同步 | 合并 `origin/beta` 最新历史投影、播放 ownership、沉浸融合标题和 armv7 C2 资产修复 | **已完成并推送**:`fc5b6ba029348c2c06214a80e4c080d6b210269a` / `recovery/C5-beta-sync/20260901135541-fc5b6ba02934`;83 项定向测试、双端 Arm64 Java 编译及双 ABI MPV 资产门禁通过 | [C5-beta-sync.md](C5-beta-sync.md) | +| 28 | `C6` | 通用/上游应用同步 | 合并 `origin/beta` 最新原生增强详情页 loading/backdrop 修复,并复审未推送的实时字幕原声识别语言快捷切换 | **已完成并推送**:提交 `a33ff92b8e65e11330ab17270b5f86a4c0b08183` / 恢复 tag `recovery/C6-beta-sync/20260902090623-a33ff92b8e65`;beta `c975ae1ed482a4bf47f106f5931bd2392e8ecce3`;四个目标测试类共 171 项通过,Mobile/Leanback Arm64 Java 编译通过;评审发现的 2 项 Important/1 项 Medium 已修复;设备播放回归待补验 | [C6-beta-sync.md](C6-beta-sync.md) | `C1` 是跨播放器真实输入验收维度,不单独形成代码任务或文档;它写入对应的 E/P 任务文档。`E-SP3` 已在 `fongmi-sync` 完成 App/Media3 合并,保留既有 `E4-J1`/`E6-1`/`E7-1`/`E7-2 + C3` 能力;`E9-3` 与已完成的 `P1` 现已共同进入集成树,后续按既定顺序处理 P2 阶段。 @@ -68,12 +72,6 @@ 多个仓库共同实现的同一功能不会按仓库拆散,而会归并为一个可实施阶段;每个阶段记录完整 commit ID、依赖关系、当前项目已有实现、收益、风险、冲突点、建议动作和验证项。 -## 检查点 59:2026-09-03 C10 二进制依赖对齐完成 - -- `C10` 已完成:提交 `79597d2c688a804f2f6f4f3b27815f5c60595da8`,恢复标签 `recovery/C10-binary-upstream-align/20260903111337-79597d2c688a`。 -- 正式 Media3/Nextlib AAR、sidecar、lock、Media3/Nextlib patch 和 MPV stable override 已按 `fish2018/webhtv:main@ec478b0b697422a7785171c7b51a35b7a526564e` 对齐;未改 Java 播放策略或已打包播放器 `.so`。 -- 双 ABI MPV ELF 门禁及 Mobile/Leanback Arm64 Java 编译通过;目标设备播放 A/B 和 native 重建仍是后续独立证据,不改变本阶段关闭状态。 - ## 审计口径 - 当前项目基线以 `README.md`、`third_party/fongmi-repositories-lock.json`、`third_party/media-lock.json` 和 `third_party/mpv-native-lock.json` 为准。 @@ -4929,97 +4927,3 @@ C3 的触发来源主要是 media `990abc2368fd74779f525ee345734470659f3d53`(` - 回滚锚点:`backup/dev2-beta-baseline-20260830`、`backup/dev2-before-beta-overwrite-20260830`;合并源 `4489ca9ecc91c2c30fd23610cb0342aa1224717b` 保持不变。 - 未决:尚未创建合并提交。 - 下一动作:创建 dev2 两父合并提交并创建 `recovery/dev2-beta-fongmi-sync-20260830/` 本地 annotated tag,不推送。 - -## 检查点 55:2026-09-03 E-SP7 Exo H.264 自适应选轨实施启动 - -- 用户已批准实施 E-SP7;唯一任务文档为 [E-SP7-exo-avc-adaptive-selection.md](E-SP7-exo-avc-adaptive-selection.md)。 -- 当前基线:分支 `dev4`,HEAD `59fd2688f79d4e6ef46da23a162c8236920629e6`,实施前工作区无脏文件。 -- 根因:本地 `1536c1bcc8d409d6f2479764a8fee20c45fd1fc8` 在受约束 `applyVideoLimit()` 中启用 `setForceHighestSupportedBitrate(true)`;上游 `fish2018/webhtv@ec478b0b697422a7785171c7b51a35b7a526564e` 和 AndroidX Media3 `release@2bc207851df311340767e913931ca7b28cab1794` 均支持恢复自适应选轨。 -- 范围:仅 `ExoUtil.java`、`ExoUtilTest.java`、E-SP7 任务文档和本索引;不改 AAR、lock、FFmpeg、MPV、native 或其他播放器。 -- 当前状态:task guard `E-SP7/upstream` 已启动;下一动作是先运行修改后的 `ExoUtilTest` 证明旧源码不满足新回归断言,再修改生产代码。 - -## 检查点 56:2026-09-03 E-SP7 Exo H.264 自适应选轨修复完成 - -- 实现:`app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java` 的受约束 `applyVideoLimit()` 已将 `setForceHighestSupportedBitrate(true)` 改为 `false`;无轨道限制分支未改变。 -- 测试:先红后绿的 `:app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.player.exo.ExoUtilTest --no-daemon` 通过,绿灯执行记录为 `BUILD SUCCESSFUL in 58s`;共 16 项 ExoUtilTest,无失败。 -- 编译:`:app:compileMobileArm64_v8aDebugJavaWithJavac --no-daemon` 通过,执行记录为 `BUILD SUCCESSFUL in 57s`。 -- 范围:仅 E-SP7 源码、测试、任务文档和本索引;没有修改 Media3 AAR、lock、FFmpeg、MPV、native、JNI 或其他播放器。 -- 当前基线:分支 `dev4`,实施前 HEAD `59fd2688f79d4e6ef46da23a162c8236920629e6`;真实 Dangbei X7 Ultra 同资源 A/B 尚未执行,设备端掉帧改善仍待用户/设备证据确认。 -- 下一动作:完成限定范围安全检查后,由 task guard 创建 E-SP7 原子提交和 annotated recovery tag。 - -## 检查点 57:2026-09-03 播放差异只读审计收口 - -- 审计基线:`dev4@a228e988d488f178890b64592f9dd89761f8e011` 对比 `fish2018/webhtv:main@ec478b0b697422a7785171c7b51a35b7a526564e`;工作区干净。 -- 结论:在当前源码证据下,没有发现第二个与 E-SP7 同级别、可直接判定的单点回归;本地差异主要为有意叠加的播放能力、稳定性保护和 native 补丁链。 -- 需要继续观察的分叉:Exo FFmpeg 兜底与降载、Exo PreCache worker 生命周期、Exo 音频采集管线、MPV 自动直出保护、IJK 首帧 watchdog 和依赖/锁文件链。 -- 验证边界:本轮只做静态 diff、符号调用路径和默认值核对,未执行实机 A/B 或 native 行为复测。 -- 下一动作:按用户指示先合并最新 beta,再复核 E-SP7 合并树,随后输出其他播放差异的同步意见表。 - -## 检查点 58:2026-09-03 beta 合并与 E-SP7 复核 - -- 合并基线:`dev4@ff438637b89587cf4f378843338a4122ba07e9d3` 合并 `origin/beta@bcfe7b22a05e32913448a228f9513c690bc8233f`,共同基线为 `59fd2688f79d4e6ef46da23a162c8236920629e6`;仅评估索引发生内容冲突,已组合保留 E-SP7 与 C9 两行。 -- E-SP7:`a228e988d488f178890b64592f9dd89761f8e011` 的源码与测试未被 beta 覆盖;合并后定向 ExoUtilTest 通过,Mobile/Leanback Arm64 Java 编译通过。 -- beta:`IntroSkipServiceTest` 22 项与 `VideoActivityLayoutTest` 153 项共 175 项通过,失败/错误/跳过均为 0;片段身份、速度键释放和移动详情页外层滚动改动未发现需阻断提交的问题。 -- 结论:允许提交并推送当前合并树;C9 仍需保留真实设备播放和 OEM 行为作为后续补验,不把 Java/单测结果扩大为设备端完全验收。 -- 下一动作:task guard finish 创建两父合并提交和本地 annotated recovery tag,然后推送当前 `dev4` 与该新 tag。 - -## 检查点 59:2026-09-12 C4 dev1 增量同步 fish2018/main - -### 59.1 冻结源与关系 - -- 仓库:`https://github.com/fish2018/webhtv.git`;GitHub API 核对 `main` 为 `fc62397591701b2232ae7de4f50a032bd7742064`。 -- 本地 `upstream/main` 已指向同一完整 commit;一次 `git fetch upstream --prune` 因 GnuTLS 握手异常失败,故本轮不把失败的网络传输当作源内容,使用 API 与本地 ref 双重一致的冻结源。 -- C4 上一轮上游目标:`784b90420d646eb6c7ddcc63ad622a92c65b02b4`;本地实施基线:`dev1@da34bfc400ccff4c07287ef1e3cfa61327aaee39`。两者没有可用共同祖先(上游历史为 grafted 独立根),因此不能用普通 merge 直接生成有效三方索引;本轮按冻结基线对上游净变更做等价文件级三方合并,并在收口时保留上游目标作为 provenance parent。 - -### 59.2 本轮完整上游 commit ledger 与处置 - -| # | 完整 commit | 功能区域 | 处置 | -| ---: | --- | --- | --- | -| 1 | `2b36396c0d76b312154d560c0c94e55909b951a2` | MPV HDMV Blu-ray 菜单导航基础能力 | 纳入;新增 App 菜单策略、ISO/菜单接线与 native patch/test 资产,保留本地 MPV 生命周期和输出策略 | -| 2 | `0c16bd37c05828014d382aa3ccfcd1e08b30b3fe` | MPV Blu-ray patch 可应用性修复 | 纳入;直接采用上游 patch 内容 | -| 3 | `831b70433e3dbdfd6f119c6036a3c8cf22d85ae4` | MPV HDMV native 资产打包 | 纳入;18 个 `.so` 中属于本提交链的上游版本按用户规则直接覆盖,后续提交版本继续覆盖到最终目标 | -| 4 | `55a6365a9ff8c1a096ceb4275d50b95c1ed5f488` | Blu-ray 缓冲/音频 underrun 与双 ABI 资产 | 纳入;Java 及脚本三方处理,native 资产直接采用最终上游文件 | -| 5 | `f4e4f9e16fb6b99bf72f8a19a5cb184a488e0580` | ISO visit/饥饿跟踪 | 纳入;保留本地缓存、取消和生命周期保护 | -| 6 | `5b774430f155039d843f2e6a79aefc7df9f918cc` | HDMV authored return 路由 | 纳入;patch 与 App 逻辑按三方结果合并 | -| 7 | `4ded105fa7ea0e7aacd19a256919bd44d643ce96` | 任务文档正常跟踪规则 | 纳入;不删除本地任务文档 | -| 8 | `310f8feef5c0a05e5dae6c0a063453113214ea2c` | 合并 Blu-ray 菜单背景的 authored return | 纳入;保留本地播放器改动 | -| 9 | `1ec569658157d1a9323b5c2ef00cb3468b876fca` | ISO cache 分页渐进加载 | 纳入;ISO Java/ native 相关路径按三方结果合并 | -| 10 | `d4657ae879c32f2f93f30dd832735a02d0999487` | 光盘菜单响应性与 TV 输出回退 | 纳入;保留本地输出失败回退状态机 | -| 11 | `881c8bca2e6831d6a7d32f67c22c64ce37541e0b` | 历史卡片显示观看时间 | 纳入;在本地 playback/progress/marquee 合同上补入 `historyProgress` | -| 12 | `55612b3571222e7c5f91eb9713f5e98bc0dc173c` | 观看时间位置调整 | 纳入;布局与本地 playback 标签组合 | -| 13 | `c4b042bc5441b9e168293f7cadae5798c4608da5` | 观看时间位于文件名上方 | 纳入;布局按现有资源结构适配 | -| 14 | `48dfa4d67a433390e9959b934b87331a78f11436` | 观看时间/文件名标签顺序 | 纳入;保留本地名称/备注跑马灯行为 | -| 15 | `aa676a941ee101cbb40c9641840e3b638b81c3d4` | 子菜单触摸路由到父菜单 | 纳入;App/native patch/test 全部保留 | -| 16 | `1ce8df96075f703a16cecfa077fd715c66b90279` | MPV script 按钮执行时机可见性 | 纳入;保留现有脚本配置兼容和测试 | -| 17 | `f27805e30212e7f34410f20bb6f8979f44203c79` | MPV 独立 script enable 开关 | 纳入;配置模型、UI、资源和测试三方合并 | -| 18 | `969261479167bca3f8f16f11551de7dcc9290112` | Mobile 播放器 source switch 接线 | 纳入;去重现有 `change2` binding,避免 Data Binding 重复 ID | -| 19 | `d546c11ca875f31970e2389114020189ad032c08` | Exo DV5 GPU 映射默认准入 | 纳入;只合并既有 Exo renderer 默认策略,不改 ABI/native | -| 20 | `fc62397591701b2232ae7de4f50a032bd7742064` | 115 过期分享状态优先于 metadata | 纳入;服务逻辑、缓存命名空间和测试保留 | - -### 59.3 用户覆盖策略实施结果 - -- 二进制:最终 18 个 MPV `.so` 路径逐文件使用 `upstream/main` 内容,hash 校验通过;没有上游新增 `.aar`、`.jar` 或 `.apk` 需要覆盖,本轮未重新编译 native。 -- Java/Python:上游净变更包含 Java 文件,不包含 Python 文件;Java 采用基于旧目标的三方合并,冲突处保留 WebHTV 本地播放器、TMDB、生命周期、诊断和 UI 行为,并补入 Blu-ray 菜单、历史观看时间、脚本开关、DV5 默认映射和 115 状态修复。 -- XML/资源/patch/脚本:按代码契约组合;修复上游与本地已有 `change2` 的重复 Data Binding ID,保留全部上游菜单资源和本地既有控制入口。 -- 任务文档:保留 `docs/` 及任务文档,不接受上游删除本地评估资料的行为。 - -### 59.4 验证与边界 - -- 通过:18 路二进制 hash 对齐 `upstream/main`;`bash scripts/verify_mpv_native_assets.sh --require-elf`;`git diff --check`;无 `<<<<<<<`/`|||||||`/`>>>>>>>` 冲突标记。 -- 通过:Mobile/Leanback Arm64 Java 编译和受影响 JVM 单测,Gradle `BUILD SUCCESSFUL`。 -- 未执行:APK 打包、安装、连接设备播放、native 重建和 Blu-ray 实盘回归;因此不把 Java/ELF 校验扩大为设备播放完全验收。APK 打包前已观察可用内存达到约 1.7 GiB,但本轮没有执行 APK 打包。 -- 远端:不 push 分支或 tag。 - -### 59.5 当前恢复锚点 - -- 当前 HEAD 为 merge commit `65facf4bcbed78e702a1ec0fd86c50778fcf639f`,第一父提交 `da34bfc400ccff4c07287ef1e3cfa61327aaee39`,第二父提交 `fc62397591701b2232ae7de4f50a032bd7742064`;上游目标已是 HEAD 祖先。 -- Recovery tag:`recovery/merge-upstream-binary-override-java-merge/20260913015933-65facf4bcbed`。 -- 当前状态:完成(本地未推送);工作树收口后不再重复构建、测试或扩展研究。 - -## 检查点 60:2026-09-18 C4 dev4 同步 fish2018/main - -- 基线:`dev4@ac39115dd99c43b861e0a255c7b7a407af2855b2`;共同祖先:`fc62397591701b2232ae7de4f50a032bd7742064`;目标:`fish2018/webhtv:main@88aceb110959ff50afc23b10d9b9abe3e0f53255`。 -- 范围:52 个上游提交,10 个冲突文件全部按“本地行为契约 + 上游功能并集”解决;非代码二进制、测试资产、AAR/POM、lock/patch 随上游目标纳入。 -- 验证:Mobile/Leanback arm64 Debug Java 编译通过;Mobile arm64 Debug 单测 4587 项、0 failure、0 error、1 skipped;双 ABI MPV ELF/资产门禁通过;无冲突标记与 whitespace 错误。 -- 未执行:APK 打包、设备安装、实机播放矩阵、native 重建;不将当前证据扩大为实机机型验收。 -- 下一动作:由当前 C4 guard 生成双亲 merge commit 和 annotated recovery tag,不推送。 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b29b9296e4..fb522ead9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,6 @@ startup = "1.2.0" swiperefreshlayout = "1.2.0" viewpager2 = "1.1.0" webkit = "1.16.0" -work = "2.11.2" androidGifDrawable = "1.2.31" androidautosize = "1.2.1" @@ -36,10 +35,6 @@ guava = "33.6.0-android" jna = "5.18.1" jupnp = "3.0.4" junit = "4.13.2" -androidxTestRunner = "1.7.0" -androidxTestRules = "1.6.1" -androidxTestCore = "1.6.1" -androidxTestExtJunit = "1.2.1" juniversalchardet = "1.0.3" jgit = "6.10.0.202406032230-r" jsoup = "1.22.2" @@ -51,7 +46,7 @@ media3 = "1.11.0-alpha01-fongmi" exoplayerHdrUtils = "0.4.0" nanohttpd = "2.3.1" newpipeextractor = "v0.26.3" -nextlib = "1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5" +nextlib = "1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r1" okhttp = "5.4.0" permissionx = "1.8.1" quickjs = "3.2.3" @@ -84,8 +79,6 @@ startup = { group = "androidx.startup", name = "startup-runtime", version.ref = swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" } webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } -work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "work" } -work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" } android-gif-drawable = { group = "pl.droidsonroids.gif", name = "android-gif-drawable", version.ref = "androidGifDrawable" } androidautosize = { group = "com.github.JessYanCoding", name = "AndroidAutoSize", version.ref = "androidautosize" } @@ -107,10 +100,6 @@ juniversalchardet = { group = "com.googlecode.juniversalchardet", name = "junive jgit = { group = "org.eclipse.jgit", name = "org.eclipse.jgit", version.ref = "jgit" } jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } -androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } -androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } -androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" } rtmp-client = { group = "com.github.mcxinyu", name = "LibRtmp-Client-for-Android", version.ref = "rtmpClient" } logger = { group = "com.orhanobut", name = "logger", version.ref = "logger" } lottie = { group = "com.airbnb.android", name = "lottie", version.ref = "lottie" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8562eb981f..e1694c3719 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -2,6 +2,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f -distributionUrl=https://mirrors.huaweicloud.com/gradle/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 diff --git a/quickjs/src/main/java/com/fongmi/quickjs/method/Console.java b/quickjs/src/main/java/com/fongmi/quickjs/method/Console.java index a1f7b597d8..fc1cc63aef 100644 --- a/quickjs/src/main/java/com/fongmi/quickjs/method/Console.java +++ b/quickjs/src/main/java/com/fongmi/quickjs/method/Console.java @@ -1,6 +1,5 @@ package com.fongmi.quickjs.method; -import com.github.catvod.crawler.SpiderDebug; import com.orhanobut.logger.Logger; import com.whl.quickjs.wrapper.QuickJSContext; @@ -11,24 +10,20 @@ public class Console implements QuickJSContext.Console { @Override public void log(String info) { Logger.t(TAG).d(info); - SpiderDebug.console(TAG, "DEBUG", info); } @Override public void info(String info) { Logger.t(TAG).i(info); - SpiderDebug.console(TAG, "INFO", info); } @Override public void warn(String info) { Logger.t(TAG).w(info); - SpiderDebug.console(TAG, "WARN", info); } @Override public void error(String info) { Logger.t(TAG).e(info); - SpiderDebug.console(TAG, "ERROR", info); } -} +} \ No newline at end of file diff --git a/scripts/build_arm64_debug_install.sh b/scripts/build_arm64_debug_install.sh deleted file mode 100755 index ba2bcb8036..0000000000 --- a/scripts/build_arm64_debug_install.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env bash -# 打包指定 flavor (mobile/leanback) 的 Debug APK 并推送到 Android 模拟器/设备。 -# -# 用法: -# bash scripts/build_arm64_debug_install.sh [--flavor mobile|leanback] -# [--abi arm64-v8a|armeabi-v7a] -# [--serial 192.168.50.3:5555] -# [--adb /path/to/adb] -# [--skip-check] -# 示例: -# bash scripts/build_arm64_debug_install.sh # 手机版 arm64 -# bash scripts/build_arm64_debug_install.sh --flavor leanback # TV/电脑版 arm64 -# bash scripts/build_arm64_debug_install.sh --flavor leanback --serial 192.168.50.3:5559 -# -# 流程: 检查 Gradle 守护进程是否空闲 -> gradlew assembleDebug -# -> adb 连接/安装 -> 校验模拟器上包存在。 -# 手机版 package 为 com.silent.android.webhtv,TV/电脑版(leanback) 同用该包名。 - -set -euo pipefail - -FLAVOR="mobile" -ABI="arm64-v8a" -SERIAL="192.168.50.3:5555" -ADB="" -SKIP_IDLE_CHECK=0 - -usage() { - sed -n '3,14p' "$0" | sed 's/^# //; s/^#$//' - exit 0 -} - -while [[ $# -gt 0 ]]; do - case "$1" in - -f|--flavor) FLAVOR="${2:?missing flavor}"; shift 2 ;; - -a|--abi) ABI="${2:?missing abi}"; shift 2 ;; - -s|--serial) SERIAL="${2:?missing serial}"; shift 2 ;; - --adb) ADB="${2:?missing adb path}"; shift 2 ;; - --skip-check) SKIP_IDLE_CHECK=1; shift ;; - -h|--help) usage ;; - *) echo "未知参数: $1" >&2; usage ;; - esac -done - -case "$FLAVOR" in - mobile|leanback) ;; - *) echo "❌ --flavor 只能是 mobile 或 leanback" >&2; usage ;; -esac - -case "$ABI" in - arm64-v8a|armeabi-v7a) ;; - *) echo "❌ --abi 只能是 arm64-v8a 或 armeabi-v7a" >&2; usage ;; -esac - -# 组装 Gradle flavor/ABI 名 -case "$FLAVOR" in - mobile) FLAVOR_GRADLE="Mobile" ;; - leanback) FLAVOR_GRADLE="Leanback" ;; -esac -case "$ABI" in - arm64-v8a) ABI_GRADLE="Arm64_v8a"; ABISUFFIX="arm64_v8a" ;; - armeabi-v7a) ABI_GRADLE="Armeabi_v7a"; ABISUFFIX="armeabi_v7a" ;; -esac -TASK=":app:assemble${FLAVOR_GRADLE}${ABI_GRADLE}Debug" -APK="app/build/outputs/apk/${FLAVOR}${ABI_GRADLE}/debug/app-${FLAVOR}-${ABISUFFIX}-debug.apk" - - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -cd "$ROOT_DIR" - -if [[ -z "$ADB" ]]; then - for candidate in \ - "$HOME/android-sdk/platform-tools/adb" \ - "$HOME/Android/Sdk/platform-tools/adb" \ - "/usr/local/android-sdk/platform-tools/adb" \ - "adb"; do - if command -v "$candidate" >/dev/null 2>&1 || [[ -x "$candidate" ]]; then - ADB="$candidate" - break - fi - done -fi -if [[ -z "$ADB" ]]; then - echo "❌ 未找到 adb,请用 --adb 指定路径" >&2 - exit 1 -fi - -echo "==> 构建类型: ${FLAVOR}/${ABI} Debug" -echo "==> adb: $ADB" -echo "==> 设备: $SERIAL" - -if [[ "$SKIP_IDLE_CHECK" -eq 0 ]]; then - echo "==> 检查 Gradle 守护进程是否空闲(避免与其他打包任务冲突)..." - STATUS_OUTPUT="$(./gradlew --status 2>&1 || true)" - BUSY_PIDS="$(echo "$STATUS_OUTPUT" | awk '$2 == "BUSY" {print $1}')" - if [[ -n "$BUSY_PIDS" ]]; then - echo "❌ 检测到 Gradle 守护进程正在忙(PID: $BUSY_PIDS),可能有其他打包任务在进行。" >&2 - echo " 请等待其结束后再运行,或用 --skip-check 跳过此检查。" >&2 - exit 1 - fi - echo "==> Gradle 守护进程空闲,可以开始打包" -fi - -echo "==> 开始打包 $TASK ..." -./gradlew "$TASK" - -if [[ ! -f "$APK" ]]; then - echo "❌ 打包结束但未找到 APK: $APK" >&2 - exit 1 -fi -echo "==> APK: $APK ($(du -h "$APK" | cut -f1))" - -echo "==> 连接设备 $SERIAL ..." -"$ADB" connect "$SERIAL" >/dev/null 2>&1 || true - -echo "==> 安装 APK 到 $SERIAL ..." -"$ADB" -s "$SERIAL" install -r "$APK" - -echo "==> 校验安装 ..." -PKG="com.silent.android.webhtv" -if "$ADB" -s "$SERIAL" shell "pm list packages | grep -q '$PKG'"; then - echo "✅ 安装成功: $PKG 已存在于 $SERIAL" -else - echo "❌ 安装后未在设备上找到 $PKG" >&2 - exit 1 -fi - -echo "✅ 全部完成: ${FLAVOR}/${ABI} Debug 打包并安装到 $SERIAL" diff --git a/scripts/build_avs3_native.sh b/scripts/build_avs3_native.sh deleted file mode 100644 index 951aa9bd4b..0000000000 --- a/scripts/build_avs3_native.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env bash -# Shared, pinned uavs3d build. Exo and MPV call this with their own NDK/prefix. -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -ABI="" -NDK="" -PREFIX="" -WORK="" -JOBS="${AVS3_BUILD_JOBS:-4}" -while [[ $# -gt 0 ]]; do - case "$1" in - --abi) ABI="$2"; shift 2 ;; - --ndk) NDK="$2"; shift 2 ;; - --prefix) PREFIX="$2"; shift 2 ;; - --work-dir) WORK="$2"; shift 2 ;; - --jobs) JOBS="$2"; shift 2 ;; - --help|-h) - echo 'Usage: bash scripts/build_avs3_native.sh --abi arm64-v8a|armeabi-v7a|host --prefix DIR [--ndk DIR] [--work-dir DIR] [--jobs N]' - exit 0 ;; - *) echo "Unknown argument: $1" >&2; exit 2 ;; - esac -done -case "$ABI" in - host) ;; - arm64-v8a|armeabi-v7a) - [[ -f "$NDK/build/cmake/android.toolchain.cmake" ]] || { - echo 'The Android build requires --ndk with an installed NDK.' >&2; exit 2; - } ;; - *) echo 'A supported --abi is required.' >&2; exit 2 ;; -esac -[[ -n "$PREFIX" ]] || { echo '--prefix is required.' >&2; exit 2; } -[[ "$JOBS" =~ ^[1-9][0-9]*$ ]] || { echo 'Invalid --jobs.' >&2; exit 2; } -WORK="${WORK:-$ROOT/build/avs3-native/uavs3d-$ABI}" -mkdir -p "$WORK" "$PREFIX" -WORK="$(cd "$WORK" && pwd -P)" -PREFIX="$(cd "$PREFIX" && pwd -P)" - -eval "$(python3 - "$ROOT/third_party/media-lock.json" <<'PY' -import json, shlex, sys -decoder = json.load(open(sys.argv[1]))['avs3_video']['decoder'] -for key in ('commit', 'url', 'sha256'): - print('UAVS3D_' + key.upper() + '=' + shlex.quote(decoder[key])) -PY -)" -CACHE="$ROOT/build/avs3-native/cache" -ARCHIVE="$CACHE/uavs3d-$UAVS3D_COMMIT.tar.gz" -mkdir -p "$CACHE" -if [[ ! -f "$ARCHIVE" ]]; then - curl --fail --location --retry 2 "$UAVS3D_URL" -o "$ARCHIVE.part" - mv "$ARCHIVE.part" "$ARCHIVE" -fi -python3 - "$ARCHIVE" "$UAVS3D_SHA256" <<'PY' -import hashlib, pathlib, sys -actual = hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest() -if actual != sys.argv[2]: - raise SystemExit('uavs3d archive SHA-256 mismatch: ' + actual) -PY - -SOURCE="$WORK/uavs3d-$UAVS3D_COMMIT" -if [[ ! -d "$SOURCE" ]]; then - tar -xzf "$ARCHIVE" -C "$WORK" -fi -# Do not run upstream's version.sh inside the WebHTV worktree: it would pick -# WebHTV's Git revision. Each build owns its source copy and pkg-config file. -python3 - "$SOURCE" "$UAVS3D_COMMIT" <<'PY' -from pathlib import Path -import sys -source, revision = Path(sys.argv[1]), sys.argv[2] -cmake = source / 'source/CMakeLists.txt' -text = cmake.read_text() -text = text.replace('execute_process(COMMAND sh ${CONFIG_DIR}/version.sh ${CONFIG_DIR})', - '# Version header is generated from the pinned source revision by WebHTV.') -old = 'set(ARMV7_FLAGS "-mcpu=cortex-a9 -mfpu=neon -mfloat-abi=hard")' -new = '''if(ANDROID) - set(ARMV7_FLAGS "-mfpu=neon -mfloat-abi=softfp") - else() - set(ARMV7_FLAGS "-mcpu=cortex-a9 -mfpu=neon -mfloat-abi=hard") - endif()''' -if 'if(ANDROID)' not in text: - if text.count(old) != 1: - raise SystemExit('Unexpected uavs3d ARMv7 build configuration') - text = text.replace(old, new) -text = text.replace('if(CMAKE_USE_PTHREADS_INIT)', - 'if(CMAKE_USE_PTHREADS_INIT AND NOT ANDROID)') -cmake.write_text(text) -(source / 'version.h').write_text( - '#ifndef __VERSION_H__\n#define __VERSION_H__\n' - '#define VER_MAJOR 1\n#define VER_MINOR 2\n#define VER_BUILD 0\n' - '#define VERSION_TYPE "release"\n#define VERSION_STR "1.2.0"\n' - f'#define VERSION_SHA1 "{revision}"\n#endif\n') -PY - -args=( - -S "$SOURCE" -B "$WORK/build" - -DCMAKE_POLICY_VERSION_MINIMUM=3.5 - -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON - -DCOMPILE_10BIT=ON -DBUILD_SHARED_LIBS=OFF - -DCMAKE_INSTALL_PREFIX="$PREFIX" -) -if [[ "$ABI" != host ]]; then - args+=( - -DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" - -DANDROID_ABI="$ABI" -DANDROID_PLATFORM=android-21 - -DANDROID_STL=c++_shared - ) -fi -cmake "${args[@]}" -cmake --build "$WORK/build" --target uavs3d uavs3dec --parallel "$JOBS" -cmake --install "$WORK/build" -mkdir -p "$PREFIX/share/licenses/uavs3d" -cp "$SOURCE/COPYING" "$PREFIX/share/licenses/uavs3d/COPYING" -printf '%s\n' "$UAVS3D_COMMIT" > "$PREFIX/share/licenses/uavs3d/SOURCE" - -# The same registered AVS3 decoder dispatches High 10-bit to HPM. Both static -# backends are mandatory inputs; ordinary debug/release builds cannot omit it. -bash "$ROOT/scripts/build_hpm_native.sh" --abi "$ABI" --ndk "$NDK" \ - --prefix "$PREFIX" --work-dir "$WORK/hpm15" --jobs "$JOBS" -python3 - "$PREFIX/lib/pkgconfig/uavs3d.pc" <<'PY' -from pathlib import Path -import sys -path = Path(sys.argv[1]) -text = path.read_text() -if '-lwebhtvhpm' not in text: - text = text.replace('-luavs3d', '-luavs3d -lwebhtvhpm') -path.write_text(text) -PY diff --git a/scripts/build_exo_ass_native.py b/scripts/build_exo_ass_native.py deleted file mode 100644 index 54d838cc8c..0000000000 --- a/scripts/build_exo_ass_native.py +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env python3 -"""Build the standalone, API-24 Exo ASS prototype from the pinned source graph. - -Normal App builds never invoke this. No MPV/FFmpeg objects or prefixes are used. ---source-cache may supply clean Git objects/downloads; each identity is verified. -""" -import argparse -import hashlib -import io -import json -import os -from pathlib import Path -import platform -import shutil -import subprocess -import tarfile - -ROOT = Path(__file__).resolve().parents[1] - - -def run(args, **kwargs): - print("+", " ".join(map(str, args)), flush=True) - subprocess.run(list(map(str, args)), check=True, **kwargs) - - -def extract(archive, target): - # Source archives are pinned, but reject escaping paths and links as well. - target.mkdir(parents=True, exist_ok=True) - with tarfile.open(fileobj=io.BytesIO(archive)) as tar: - for member in tar.getmembers(): - path = Path(member.name) - if path.is_absolute() or ".." in path.parts: - raise ValueError("unsafe source archive path") - if member.issym() or member.islnk(): - link = Path(member.linkname) - if link.is_absolute() or ".." in link.parts: - raise ValueError("unsafe source archive link") - tar.extractall(target) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--work-dir", type=Path, default=ROOT / "build/exo-ass-native") - parser.add_argument("--source-cache", type=Path) - parser.add_argument("--download-cache", type=Path) - parser.add_argument("--jobs", type=int, default=min(os.cpu_count() or 4, 8)) - parser.add_argument("--install", action="store_true") - parser.add_argument("--dependencies-only", action="store_true") - parser.add_argument("--jni-only", action="store_true", - help="Reuse this independent build's verified source stamps and static archives") - args = parser.parse_args() - lock = json.loads((ROOT / "third_party/exo-ass-lock.json").read_text()) - work = args.work_dir.resolve() - work.mkdir(parents=True, exist_ok=True) - sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") - if not sdk: - for line in (ROOT / "local.properties").read_text().splitlines(): - if line.startswith("sdk.dir="): - sdk = line.split("=", 1)[1] - if not sdk: - raise ValueError("Set ANDROID_HOME to the installed Android SDK") - ndk = Path(sdk) / "ndk" / lock["android"]["ndk"] - if lock["android"]["ndk"] not in (ndk / "source.properties").read_text(): - raise ValueError("NDK identity mismatch") - host = "darwin-x86_64" if platform.system() == "Darwin" else "linux-x86_64" - toolchain = ndk / "toolchains/llvm/prebuilt" / host - prefix = work / "arm64-v8a/prefix" - prefix.mkdir(parents=True, exist_ok=True) - cross = work / "android-arm64.ini" - cross.write_text("\n".join([ - "[binaries]", - "c = '" + str(toolchain / "bin/aarch64-linux-android24-clang") + "'", - "cpp = '" + str(toolchain / "bin/aarch64-linux-android24-clang++") + "'", - "ar = '" + str(toolchain / "bin/llvm-ar") + "'", - "strip = '" + str(toolchain / "bin/llvm-strip") + "'", - "pkg-config = 'pkg-config'", - "[host_machine]", "system = 'android'", "cpu_family = 'aarch64'", - "cpu = 'aarch64'", "endian = 'little'", - "[properties]", "needs_exe_wrapper = true", - "pkg_config_libdir = ['" + str(prefix / "lib/pkgconfig") + "']", - "[built-in options]", "default_library = 'static'", "b_staticpic = true", - "buildtype = 'release'", "wrap_mode = 'nodownload'", - "c_args = ['-fvisibility=hidden', '-ffunction-sections', '-fdata-sections']", - "cpp_args = ['-fvisibility=hidden', '-ffunction-sections', '-fdata-sections']", - "", - ])) - env = os.environ.copy() - env.pop("CC", None) - env.pop("CXX", None) - env["PKG_CONFIG_LIBDIR"] = str(prefix / "lib/pkgconfig") - env["PKG_CONFIG_PATH"] = "" - sources = {} - for name, source in lock["sources"].items(): - dest = work / "sources" / name - stamp = work / "sources" / (name + ".identity") - identity = source.get("commit") or source["sha256"] - if not stamp.exists() or stamp.read_text() != identity: - if dest.exists(): - raise ValueError("Refusing to overwrite an unverified source directory: " + str(dest)) - if "commit" in source: - cache = args.source_cache / name if args.source_cache else None - if cache and cache.is_dir(): - actual = subprocess.check_output([ - "git", "-C", str(cache), "rev-parse", source["commit"] + "^{commit}" - ], text=True).strip() - if actual != source["commit"]: - raise ValueError("Source cache identity mismatch") - else: - cache = work / "git" / name - if not cache.exists(): - run(["git", "clone", "--no-checkout", source["repo"], cache]) - run(["git", "-C", cache, "cat-file", "-e", source["commit"] + "^{commit}"]) - archive = subprocess.check_output([ - "git", "-C", str(cache), "archive", "--format=tar", source["commit"] - ]) - extract(archive, dest) - else: - archive_path = args.download_cache / source["filename"] if args.download_cache else None - if not archive_path or not archive_path.exists(): - archive_path = work / source["filename"] - if not archive_path.exists(): - run(["curl", "-L", "--fail", "--retry", "2", source["url"], "-o", archive_path]) - archive = archive_path.read_bytes() - if hashlib.sha256(archive).hexdigest() != source["sha256"]: - raise ValueError("Source archive checksum mismatch: " + name) - unpacked = work / "sources" / (name + "-unpack") - extract(archive, unpacked) - roots = list(unpacked.iterdir()) - if len(roots) != 1 or not roots[0].is_dir(): - raise ValueError("Unexpected source archive layout") - roots[0].rename(dest) - stamp.write_text(identity) - sources[name] = dest - if args.jni_only: - if not (prefix / "lib" / ("lib" + {"freetype2": "freetype"}.get(name, name).removeprefix("lib") + ".a")).is_file(): - raise ValueError("Missing independently built static archive: " + name) - continue - if name == "libass": - # The exported source has no .git. Meson's vcs_tag would otherwise describe - # the enclosing WebHTV worktree, including its unrelated recovery tag. - meson_file = dest / "meson.build" - content = meson_file.read_text() - original_stamp = "conf.set('CONFIG_SOURCEVERSION', '\"meson, commit: @VCS_TAG@\"')" - locked_stamp = "conf.set('CONFIG_SOURCEVERSION', '\"meson, locked commit: " + identity + "\"')" - if original_stamp in content: - meson_file.write_text(content.replace(original_stamp, locked_stamp, 1)) - elif locked_stamp not in content: - raise ValueError("Unexpected libass source-version configuration") - build = work / "arm64-v8a" / name - opts = source["meson_options"] - command = ["meson", "setup", build, dest, "--cross-file", cross, - "--prefix", prefix, "--libdir", "lib"] + opts - if (build / "build.ninja").exists(): - command.append("--reconfigure") - run(command, env=env) - run(["ninja", "-C", build, "-j", args.jobs], env=env) - run(["ninja", "-C", build, "install"], env=env) - if args.dependencies_only: - return - native = ROOT / "third_party/exo-ass-native" - build = work / "arm64-v8a/jni" - run(["cmake", "-S", native, "-B", build, "-G", "Ninja", - "-DCMAKE_TOOLCHAIN_FILE=" + str(ndk / "build/cmake/android.toolchain.cmake"), - "-DANDROID_ABI=arm64-v8a", "-DANDROID_PLATFORM=android-24", - "-DANDROID_STL=c++_static", "-DCMAKE_BUILD_TYPE=Release", - "-DASS_PREFIX=" + str(prefix)]) - run(["cmake", "--build", build, "-j", args.jobs]) - lib = build / "libexo_ass.so" - run([toolchain / "bin/llvm-strip", "--strip-unneeded", lib]) - digest = hashlib.sha256(lib.read_bytes()).hexdigest() - print("arm64-v8a libexo_ass.so SHA-256", digest) - if args.install: - output = native / "prebuilt/arm64-v8a" - output.mkdir(parents=True, exist_ok=True) - shutil.copy2(lib, output / lib.name) - for name, source in lock["sources"].items(): - for license_path in source["licenses"]: - target = native / "licenses" / name / license_path - target.parent.mkdir(parents=True, exist_ok=True) - # Preserve notice text while making generated copies pass repository whitespace - # checks. Hash the installed form below so source/build provenance stays coherent. - notice = (sources[name] / license_path).read_bytes() - notice = b"\n".join(line.rstrip(b" \t\r") for line in notice.split(b"\n")) - target.write_bytes(notice.rstrip(b"\n") + b"\n") - manifest = native / "MANIFEST.sha256" - manifest.write_text(digest + " prebuilt/arm64-v8a/libexo_ass.so\n") - def sha(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - inputs = [ROOT / "third_party/exo-ass-lock.json", Path(__file__).resolve(), - native / "CMakeLists.txt", native / "exo_ass.cpp", native / "mask_copy.h"] - provenance = { - "schema": 1, - "artifact": {"path": "prebuilt/arm64-v8a/libexo_ass.so", "sha256": digest, - "bytes": lib.stat().st_size}, - "android": lock["android"], - "inputs": {str(path.relative_to(ROOT)): sha(path) for path in inputs}, - "sources": {name: source.get("commit") or source["sha256"] - for name, source in lock["sources"].items()}, - "static_archives": {path.name: sha(path) for path in sorted((prefix / "lib").glob("*.a"))}, - "licenses": {str(path.relative_to(native)): sha(path) - for path in sorted((native / "licenses").rglob("*")) if path.is_file()}, - "compiler": subprocess.check_output([str(toolchain / "bin/clang"), "--version"], text=True).strip(), - "tools": {name: subprocess.check_output([name, "--version"], text=True).splitlines()[0] - for name in ["cmake", "meson", "ninja"]}, - "rebuild": "python3 scripts/build_exo_ass_native.py --jobs 6 --install", - "cache_note": "Source caches supply fixed Git objects only; all static archives belong to this independent build.", - } - (native / "build-provenance.json").write_text(json.dumps(provenance, indent=2) + "\n") - - -if __name__ == "__main__": - main() diff --git a/scripts/build_hpm_native.sh b/scripts/build_hpm_native.sh deleted file mode 100644 index 6c73a3255d..0000000000 --- a/scripts/build_hpm_native.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -# HPM is built separately for each player/ABI. No runtime feature switch. -set -euo pipefail -HPM_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -HPM_ABI="" -HPM_NDK="" -HPM_PREFIX="" -HPM_WORK="" -HPM_JOBS=4 -HPM_ASAN=0 -while [[ $# -gt 0 ]]; do - case "$1" in - --abi) HPM_ABI="$2"; shift 2 ;; - --ndk) HPM_NDK="$2"; shift 2 ;; - --prefix) HPM_PREFIX="$2"; shift 2 ;; - --work-dir) HPM_WORK="$2"; shift 2 ;; - --jobs) HPM_JOBS="$2"; shift 2 ;; - --asan) HPM_ASAN=1; shift ;; - *) echo "Unknown HPM argument: $1" >&2; exit 2 ;; - esac -done -case "$HPM_ABI" in - host) ;; - arm64-v8a|armeabi-v7a) - [[ -f "$HPM_NDK/build/cmake/android.toolchain.cmake" ]] || { - echo 'HPM requires an installed Android NDK.' >&2; exit 2; - } ;; - *) echo 'HPM requires --abi host|arm64-v8a|armeabi-v7a.' >&2; exit 2 ;; -esac -[[ -n "$HPM_PREFIX" && "$HPM_JOBS" =~ ^[1-9][0-9]*$ ]] || exit 2 -HPM_WORK="${HPM_WORK:-$HPM_ROOT/build/avs3-native/hpm-$HPM_ABI}" -mkdir -p "$HPM_WORK" "$HPM_PREFIX" -HPM_WORK="$(cd "$HPM_WORK" && pwd -P)" -HPM_PREFIX="$(cd "$HPM_PREFIX" && pwd -P)" - -python3 - "$HPM_ROOT" "$HPM_WORK" <<'PY' -from pathlib import Path -import hashlib, json, shutil, subprocess, sys, tarfile -root, work = map(Path, sys.argv[1:]) -source = json.loads((root / 'third_party/media-lock.json').read_text())['avs3_video']['high_decoder'] -archive = root / source['archive'] -if hashlib.sha256(archive.read_bytes()).hexdigest() != source['sha256']: - raise SystemExit('HPM archive identity mismatch') -simd = source['simd_adapter'] -if hashlib.sha256((root / simd['header']).read_bytes()).hexdigest() != simd['sha256']: - raise SystemExit('sse2neon header identity mismatch') -adapter = root / 'third_party/avs3-hpm/prepare_source.py' -stamp = source['sha256'] + ':' + hashlib.sha256(adapter.read_bytes()).hexdigest() -if not (work / 'source.stamp').exists() or (work / 'source.stamp').read_text() != stamp: - tree = work / 'hpm-15.0' - staging = work / 'prepare' - if staging.exists(): - shutil.rmtree(staging) - staging.mkdir() - with tarfile.open(archive) as tar: - for member in tar.getmembers(): - parts = Path(member.name).parts - if not parts or parts[0] != 'hpm-15.0' or '..' in parts or member.issym() or member.islnk(): - raise SystemExit('Unexpected HPM archive entry: ' + member.name) - tar.extractall(staging) - prepared = staging / 'hpm-15.0' - subprocess.run([sys.executable, str(adapter), str(prepared)], check=True) - # Preserve mtimes for unchanged source: a one-file safety correction must - # not force a complete native rebuild of all reference-code translation units. - for path in prepared.rglob('*'): - if path.is_file(): - target = tree / path.relative_to(prepared) - target.parent.mkdir(parents=True, exist_ok=True) - if not target.exists() or target.read_bytes() != path.read_bytes(): - shutil.copyfile(path, target) - shutil.rmtree(staging) - (work / 'source.stamp').write_text(stamp) -PY - -hpm_args=(-S "$HPM_ROOT/third_party/avs3-hpm" -B "$HPM_WORK/build" - -DHPM_SOURCE="$HPM_WORK/hpm-15.0" -DCMAKE_BUILD_TYPE=Release - -DCMAKE_INSTALL_PREFIX="$HPM_PREFIX") -if [[ "$HPM_ABI" != host ]]; then - hpm_args+=(-DCMAKE_TOOLCHAIN_FILE="$HPM_NDK/build/cmake/android.toolchain.cmake" - -DANDROID_ABI="$HPM_ABI" -DANDROID_PLATFORM=android-21 -DANDROID_STL=none) -fi -if [[ "$HPM_ASAN" == 1 ]]; then - hpm_args+=(-DCMAKE_C_FLAGS=-fsanitize=address -DCMAKE_EXE_LINKER_FLAGS=-fsanitize=address) -fi -cmake "${hpm_args[@]}" -cmake --build "$HPM_WORK/build" --parallel "$HPM_JOBS" -cmake --install "$HPM_WORK/build" -mkdir -p "$HPM_PREFIX/share/licenses/hpm-avs3" -cp "$HPM_ROOT/third_party/avs3-hpm/LICENSE.HPM" "$HPM_PREFIX/share/licenses/hpm-avs3/LICENSE" -python3 - "$HPM_ROOT/third_party/avs3-hpm/vendor/sse2neon.h" "$HPM_PREFIX/share/licenses/hpm-avs3/SSE2NEON" <<'PY' -from pathlib import Path -import sys -text = Path(sys.argv[1]).read_text() -notice = text[text.index('/*'):text.index('*/') + 2] -Path(sys.argv[2]).write_text('DLTcollab/sse2neon@3cf69760cc6fdc45a5d70f0d42a8f079489f9867\n' + notice + '\n') -PY -printf '%s\n' 'zzZ2001a/avs@0c7ac42edfac6d18b92b58a5ef43bca58526ca7a dependencies/hpm-HPM-15.0' \ - > "$HPM_PREFIX/share/licenses/hpm-avs3/SOURCE" diff --git a/scripts/build_media3_ass_observer.py b/scripts/build_media3_ass_observer.py deleted file mode 100644 index e63ea4485c..0000000000 --- a/scripts/build_media3_ass_observer.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -"""Rebuild only TextRenderer classes, preserving every unrelated shipped class. - -The input AAR/source JAR is the committed, already patched WebHTV artifact. The -same source patch is also listed in build_media_deps.sh for full source builds. -Use --install to update the coupled AAR, sources, metadata, checksums and lock. -""" -import argparse -import hashlib -import io -import json -import os -from pathlib import Path -import subprocess -import zipfile - -ROOT = Path(__file__).resolve().parents[1] -BASE = "845ce82c64b16c94a1db7a61ed6bd38d2d1d35ac" -VERSION = "1.11.0-alpha01-fongmi" -NAME = "media3-exoplayer-" + VERSION -REL = Path("third_party/maven/androidx/media3/media3-exoplayer") / VERSION -JAVA = "androidx/media3/exoplayer/text/TextRenderer.java" -PATCH = "third_party/patches/media3-exo-ass-observer.patch" - - -def baseline(path): - return subprocess.check_output(["git", "show", BASE + ":" + str(path)], cwd=ROOT) - - -def digest(data, algorithm="sha256"): - return hashlib.new(algorithm, data).hexdigest() - - -def repack(data, updates): - out = io.BytesIO() - with zipfile.ZipFile(io.BytesIO(data)) as src, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst: - for info in src.infolist(): - dst.writestr(info, updates.pop(info.filename, src.read(info.filename))) - for name, value in sorted(updates.items()): - info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) - info.compress_type = zipfile.ZIP_DEFLATED - info.external_attr = 0o644 << 16 - dst.writestr(info, value) - return out.getvalue() - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--install", action="store_true") - parser.add_argument("--work-dir", type=Path, default=ROOT / "build/exo-ass-media3") - parser.add_argument("--gradle-cache", type=Path, default=Path.home() / ".gradle/caches/modules-2/files-2.1") - args = parser.parse_args() - work = args.work_dir.resolve() - work.mkdir(parents=True, exist_ok=True) - original_aar = baseline(REL / (NAME + ".aar")) - original_sources = baseline(REL / (NAME + "-sources.jar")) - if digest(original_aar) != "cfea29681799509923174cac7ce31b7e944e542db9df58fd56b7a86499a5c07e": - raise ValueError("Unexpected input AAR") - if digest(original_sources) != "83f4f83b4f44e621d52002c161f63fbcb77be6856af4b1e4a4cb0982b04549e1": - raise ValueError("Unexpected input sources") - source_root = work / "source" - target = source_root / "libraries/exoplayer/src/main/java" / JAVA - target.parent.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(io.BytesIO(original_sources)) as archive: - target.write_bytes(archive.read(JAVA)) - # patch operates on this extracted tree even inside the parent Git worktree. - subprocess.run(["patch", "-p1", "-i", str(ROOT / PATCH)], cwd=source_root, check=True) - if "public interface Observer" not in target.read_text(): - raise ValueError("Observer patch was not applied to the compiler input") - classpath = [] - for module in ["common", "container", "datasource", "database", "decoder", "extractor", "exoplayer"]: - artifact = "media3-" + module - aar = (ROOT / "third_party/maven/androidx/media3" / artifact / VERSION / (artifact + "-" + VERSION + ".aar")).read_bytes() - if module == "exoplayer": - aar = original_aar - with zipfile.ZipFile(io.BytesIO(aar)) as archive: - jar = work / (artifact + ".jar") - jar.write_bytes(archive.read("classes.jar")) - classpath.append(jar) - compile_jars = ["guava-33.6.0-android.jar", "checker-qual-3.43.0.jar", "annotation-jvm-1.9.1.jar"] - for name in compile_jars: - matches = list(args.gradle_cache.glob("**/" + name)) - if len(matches) != 1: - raise ValueError("Resolve compile dependency first: " + name) - classpath.append(matches[0]) - sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") - if not sdk: - for line in (ROOT / "local.properties").read_text().splitlines(): - if line.startswith("sdk.dir="): - sdk = line.split("=", 1)[1] - classpath.append(Path(sdk) / "platforms/android-36/android.jar") - javac = Path(os.environ["JAVA_HOME"]) / "bin/javac" if "JAVA_HOME" in os.environ else Path("javac") - classes = work / "classes" - classes.mkdir(exist_ok=True) - subprocess.run([str(javac), "-J-Duser.language=en", "-encoding", "UTF-8", "-g", "--release", "8", "-proc:none", "-Xlint:-classfile,-options", - "-classpath", os.pathsep.join(map(str, classpath)), "-d", str(classes), str(target)], check=True) - updates = {str(p.relative_to(classes)): p.read_bytes() for p in classes.rglob("*.class")} - if any(not n.startswith("androidx/media3/exoplayer/text/TextRenderer") for n in updates): - raise ValueError("Compilation escaped the declared class boundary") - if not {JAVA[:-5] + "$Observer.class", JAVA[:-5] + "$Stream.class"}.issubset(updates): - raise ValueError("Observer API classes missing from compiler output") - with zipfile.ZipFile(io.BytesIO(original_aar)) as archive: - old_classes = archive.read("classes.jar") - new_classes = repack(old_classes, updates.copy()) - with zipfile.ZipFile(io.BytesIO(old_classes)) as old, zipfile.ZipFile(io.BytesIO(new_classes)) as new: - preserved = 0 - for name in old.namelist(): - if name not in updates: - if old.read(name) != new.read(name): - raise ValueError("Unrelated class changed: " + name) - preserved += 1 - aar = repack(original_aar, {"classes.jar": new_classes}) - sources = repack(original_sources, {JAVA: target.read_bytes()}) - outputs = {NAME + ".aar": aar, NAME + "-sources.jar": sources} - module = json.loads(baseline(REL / (NAME + ".module"))) - for variant in module["variants"]: - for item in variant.get("files", []): - if item["name"] in outputs: - data = outputs[item["name"]] - item["size"] = len(data) - for algorithm in ["md5", "sha1", "sha256", "sha512"]: - item[algorithm] = digest(data, algorithm) - outputs[NAME + ".module"] = (json.dumps(module, indent=2) + "\n").encode() - for name, data in list(outputs.items()): - for algorithm in ["md5", "sha1", "sha256", "sha512"]: - outputs[name + "." + algorithm] = digest(data, algorithm).encode() - dest = ROOT / REL if args.install else work / "publication" - dest.mkdir(parents=True, exist_ok=True) - for name, data in outputs.items(): - (dest / name).write_bytes(data) - if args.install: - path = ROOT / "third_party/media-lock.json" - lock = json.loads(path.read_text()) - patches = lock["fongmi_media"]["patches"] - patches[:] = [p for p in patches if p["path"] != PATCH] - patches.append({"path": PATCH, "sha256": digest((ROOT / PATCH).read_bytes())}) - for artifact in lock["fongmi_media"]["artifact_overrides"]: - if artifact["coordinate"] == "androidx.media3:media3-exoplayer:" + VERSION: - artifact["aar_sha256"] = digest(aar) - artifact["sources_sha256"] = digest(sources) - artifact["reason"] = "E3-1a/E4-1/E7-2/E-SP3-B baseline; E4-LIBASS optional TextRenderer observer (all unrelated class bytes preserved)" - path.write_text(json.dumps(lock, indent=2) + "\n") - print(json.dumps({"aar_sha256": digest(aar), "sources_sha256": digest(sources), - "preserved_class_entries": preserved, "compiled_classes": sorted(updates), - "compile_inputs": {str(p): digest(p.read_bytes()) for p in classpath}, - "javac": str(javac)}, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/scripts/build_media3_diagnostics.py b/scripts/build_media3_diagnostics.py deleted file mode 100644 index 8aba3691b2..0000000000 --- a/scripts/build_media3_diagnostics.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -"""Compile AV-DIAG hooks against the committed patched AARs; preserve unrelated bytes.""" -import argparse -import hashlib -import io -import json -import os -from pathlib import Path -import re -import subprocess -import zipfile - -ROOT = Path(__file__).resolve().parents[1] -BASE = "180811f16073271ba1cb6a4f2f889008966facd3" -VERSION = "1.11.0-alpha01-fongmi" -PATCH = "third_party/patches/media3-playback-diagnostics.patch" - - -def digest(data, algorithm="sha256"): - return hashlib.new(algorithm, data).hexdigest() - - -def baseline(path): - return subprocess.check_output(["git", "show", BASE + ":" + str(path)], cwd=ROOT) - - -def repack(data, updates): - output = io.BytesIO() - with zipfile.ZipFile(io.BytesIO(data)) as source, zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as target: - for info in source.infolist(): - target.writestr(info, updates.pop(info.filename, source.read(info.filename))) - for name, content in sorted(updates.items()): - info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) - info.compress_type = zipfile.ZIP_DEFLATED - info.external_attr = 0o644 << 16 - target.writestr(info, content) - return output.getvalue() - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--install", action="store_true") - parser.add_argument("--work-dir", type=Path, default=ROOT / "build/media3-diagnostics") - args = parser.parse_args() - work = args.work_dir.resolve() - work.mkdir(parents=True, exist_ok=True) - source_root = work / "source" - sources = re.findall(r"^\+\+\+ b/(libraries/(?:common|exoplayer)/src/main/java/[^\n]+\.java)$", (ROOT / PATCH).read_text(), re.M) - if not sources: - raise ValueError("No declared hook sources") - original = {} - classpath = [] - for module in ["common", "container", "datasource", "database", "decoder", "extractor", "exoplayer"]: - name = "media3-" + module + "-" + VERSION - relative = Path("third_party/maven/androidx/media3/media3-" + module) / VERSION - aar = baseline(relative / (name + ".aar")) - with zipfile.ZipFile(io.BytesIO(aar)) as archive: - jar = work / (module + ".jar") - jar.write_bytes(archive.read("classes.jar")) - classpath.append(jar) - if module not in ["common", "exoplayer"]: - continue - src = baseline(relative / (name + "-sources.jar")) - original[module] = (relative, name, aar, src) - with zipfile.ZipFile(io.BytesIO(src)) as archive: - for path in sources: - prefix = "libraries/" + module + "/src/main/java/" - if not path.startswith(prefix): - continue - target = source_root / path - target.parent.mkdir(parents=True, exist_ok=True) - inner = path[len(prefix):] - if inner in archive.namelist(): - target.write_bytes(archive.read(inner)) - elif target.exists(): - target.unlink() # Only the task-owned generated new source, for repeatable builds. - subprocess.run(["patch", "--batch", "-p1", "-i", str(ROOT / PATCH)], cwd=source_root, check=True) - cache = Path.home() / ".gradle/caches/modules-2/files-2.1" - for name in ["guava-33.6.0-android.jar", "checker-qual-3.43.0.jar", "annotation-jvm-1.9.1.jar", - "error_prone_annotations-2.48.0.jar", "kotlin-stdlib-2.2.10.jar", "j2objc-annotations-3.0.0.jar"]: - matches = list(cache.glob("**/" + name)) - if len(matches) != 1: - raise ValueError("Resolve exact compiler dependency: " + name) - classpath.append(matches[0]) - sdk = os.environ.get("ANDROID_HOME", "/Users/macbookpro/Downloads/bizhi/android-sdk") - classpath.append(Path(sdk) / "platforms/android-36/android.jar") - javac = Path(os.environ["JAVA_HOME"]) / "bin/javac" - classes = work / "classes" - classes.mkdir(exist_ok=True) - compiler_sources = [] - for path in sources: - content = (source_root / path).read_text() - # This package-private @Retention(SOURCE) annotation is absent from the shipped AAR. - # Strip it in the javac-only copy; preserve the canonical source JAR and runtime code. - if path.endswith("/ExoPlayerImplInternal.java"): - if content.count("@MediaPeriodQueue.UpdatePeriodQueueResult") != 4: - raise ValueError("Unexpected source-only annotation count") - content = content.replace("@MediaPeriodQueue.UpdatePeriodQueueResult", "") - compiler_source = work / "compiler" / path - compiler_source.parent.mkdir(parents=True, exist_ok=True) - compiler_source.write_text(content) - compiler_sources.append(str(compiler_source)) - subprocess.run([str(javac), "-J-Duser.language=en", "-encoding", "UTF-8", "-g", "--release", "8", "-proc:none", "-Xlint:-classfile,-options", - "-classpath", os.pathsep.join(map(str, classpath)), "-d", str(classes)] + compiler_sources, check=True) - all_updates = {str(p.relative_to(classes)): p.read_bytes() for p in classes.rglob("*.class")} - prefixes = [p.split("/src/main/java/", 1)[1][:-5] for p in sources] - if any(not any(name == prefix + ".class" or name.startswith(prefix + "$") for prefix in prefixes) for name in all_updates): - raise ValueError("Compiler escaped declared class boundary") - reports = {} - lock_path = ROOT / "third_party/media-lock.json" - lock = json.loads(lock_path.read_text()) - for module, (relative, name, old_aar, old_sources) in original.items(): - updates = {n: v for n, v in all_updates.items() if n.startswith("androidx/media3/" + module + "/")} - with zipfile.ZipFile(io.BytesIO(old_aar)) as archive: - old_classes = archive.read("classes.jar") - new_classes = repack(old_classes, updates.copy()) - with zipfile.ZipFile(io.BytesIO(old_classes)) as old, zipfile.ZipFile(io.BytesIO(new_classes)) as new: - preserved = 0 - for entry in old.namelist(): - if entry not in updates: - if old.read(entry) != new.read(entry): - raise ValueError("Unrelated class changed: " + entry) - preserved += 1 - aar = repack(old_aar, {"classes.jar": new_classes}) - changed_sources = {p.split("/src/main/java/", 1)[1]: (source_root / p).read_bytes() for p in sources if p.startswith("libraries/" + module + "/")} - source_jar = repack(old_sources, changed_sources) - outputs = {name + ".aar": aar, name + "-sources.jar": source_jar} - metadata = json.loads(baseline(relative / (name + ".module"))) - for variant in metadata["variants"]: - for entry in variant.get("files", []): - if entry["name"] in outputs: - data = outputs[entry["name"]] - entry["size"] = len(data) - for algorithm in ["md5", "sha1", "sha256", "sha512"]: - entry[algorithm] = digest(data, algorithm) - outputs[name + ".module"] = (json.dumps(metadata, indent=2) + "\n").encode() - for file, data in list(outputs.items()): - for algorithm in ["md5", "sha1", "sha256", "sha512"]: - outputs[file + "." + algorithm] = digest(data, algorithm).encode() - destination = ROOT / relative if args.install else work / "publication" / module - destination.mkdir(parents=True, exist_ok=True) - for file, data in outputs.items(): - (destination / file).write_bytes(data) - coordinate = "androidx.media3:media3-" + module + ":" + VERSION - overrides = lock["fongmi_media"]["artifact_overrides"] - override = next((item for item in overrides if item["coordinate"] == coordinate), None) - if override is None: - override = {"coordinate": coordinate}; overrides.append(override) - override.update(aar_sha256=digest(aar), sources_sha256=digest(source_jar), - reason="Committed local baseline including ASS; AV-DIAG-01 optional owner hooks, all unrelated class bytes preserved") - reports[module] = {"baseline_aar": digest(old_aar), "aar_sha256": digest(aar), "sources_sha256": digest(source_jar), - "preserved_entries": preserved, "compiled_classes": sorted(updates)} - if args.install: - patches = lock["fongmi_media"]["patches"] - patches[:] = [item for item in patches if item["path"] != PATCH] - patches.append({"path": PATCH, "sha256": digest((ROOT / PATCH).read_bytes())}) - lock_path.write_text(json.dumps(lock, indent=2) + "\n") - print(json.dumps({"baseline": BASE, "modules": reports, "javac": str(javac), - "compile_inputs": {str(p): digest(p.read_bytes()) for p in classpath}}, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/scripts/build_media_deps.sh b/scripts/build_media_deps.sh index 15307e56eb..5cb9face1e 100755 --- a/scripts/build_media_deps.sh +++ b/scripts/build_media_deps.sh @@ -238,7 +238,7 @@ apply_media_patches() { "$patch_dir/media3-upstream-playback-fixes-2026-08.patch" "$patch_dir/media3-exo-hdr-parser-safety.patch" "$patch_dir/media3-deferred-cues.patch" - "$patch_dir/media3-exo-pixel-eac3-joc-guard.patch" + "$patch_dir/media3-exo-eac3-joc-pixel-guard.patch" "$patch_dir/media3-exo-dts-14bit-frame-size.patch" "$patch_dir/media3-exo-subtitle-byte-safety.patch" "$patch_dir/media3-exo-cue-data-contract.patch" @@ -246,12 +246,6 @@ apply_media_patches() { "$patch_dir/media3-exo-iso-reader-safety.patch" "$patch_dir/media3-exo-iso-multi-extent.patch" "$patch_dir/media3-precache-hls-safety.patch" - "$patch_dir/media3-exo-av3a-mp4.patch" - "$patch_dir/media3-exo-av3a-dash-channel-config.patch" - "$patch_dir/media3-exo-alac-wave.patch" - "$patch_dir/media3-exo-ass-observer.patch" - "$patch_dir/media3-playback-diagnostics.patch" - "$patch_dir/media3-exo-avs3.patch" ) for patch_file in "${patches[@]}"; do [[ -f "$patch_file" ]] || continue @@ -280,8 +274,6 @@ apply_nextlib_patches() { local patches=( "$THIRD_PARTY_DIR/patches/nextlib-ffmpeg-soft-load-shedding.patch" "$THIRD_PARTY_DIR/patches/nextlib-av3a.patch" - "$THIRD_PARTY_DIR/patches/nextlib-ape-support.patch" - "$THIRD_PARTY_DIR/patches/nextlib-avs3.patch" ) for patch_file in "${patches[@]}"; do if [[ ! -f "$patch_file" ]]; then @@ -442,23 +434,7 @@ verify_nextlib_aar() { echo "Missing $abi AV3A Audio Vivid marker in $aar" >&2 return 1 fi - if ! grep -aFq "libuavs3d" "$temp_dir/jni/$abi/libavcodec.so"; then - echo "Missing $abi AVS3 video decoder in $aar" >&2 - return 1 - fi - if ! grep -aFq "WebHTV HPM 15.0" "$temp_dir/jni/$abi/libavcodec.so"; then - echo "Missing $abi AVS3 High 10-bit decoder in $aar" >&2 - return 1 - fi done - [[ -s "$temp_dir/assets/licenses/uavs3d.txt" ]] || { - echo "Missing uavs3d license in $aar" >&2 - return 1 - } - [[ -s "$temp_dir/assets/licenses/hpm-avs3.txt" && -s "$temp_dir/assets/licenses/sse2neon.txt" ]] || { - echo "Missing HPM/sse2neon notices in $aar" >&2 - return 1 - } rm -rf "$temp_dir" } diff --git a/scripts/build_mpv_native.sh b/scripts/build_mpv_native.sh index b007fdf19c..8a669b2e90 100755 --- a/scripts/build_mpv_native.sh +++ b/scripts/build_mpv_native.sh @@ -2,26 +2,16 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -export WEBHTV_ROOT="$ROOT" LOCK_FILE="$ROOT/third_party/mpv-native-lock.json" OVERRIDE_DIR="$ROOT/third_party/mpv-native-overrides" MPV_DISC_PATCH="$ROOT/third_party/patches/mpv-stream-cb-disc-controls.patch" -MPV_DISC_MENU_PATCH="$ROOT/third_party/patches/mpv-discnav.patch" -MPV_DISC_INPUT_PATCH="$ROOT/third_party/mpv-player-jni/patches/mpv-discnav-input.patch" -MPV_DISC_POLL_PATCH="$ROOT/third_party/mpv-player-jni/patches/mpv-discnav-poll.patch" -LIBBLURAY_HDMV_INPUT_PATCH="$ROOT/third_party/mpv-player-jni/patches/libbluray-hdmv-input.patch" MPV_DOVI_SURFACE_PATCH="$ROOT/third_party/patches/mpv-android-dovi-el-surface.patch" -MPV_ANDROID_FEL_PATCH="$ROOT/third_party/patches/mpv-android-fel.patch" MPV_DOVI_HDR10_BL_PATCH="$ROOT/third_party/patches/mpv-dovi-profile7-hdr10-base-layer.patch" MPV_DOVI_P81_PATCH="$ROOT/third_party/patches/mpv-dovi-profile7-p81.patch" -MPV_DOVI_P8_HDR10_PATCH="$ROOT/third_party/patches/mpv-dovi-profile8-hdr10-base-layer.patch" MPV_AUDIO_TRUEHD_PATCH="$ROOT/third_party/patches/mpv-audiotrack-truehd-channel-mask.patch" -MPV_AUDIO_COMPRESSED_PATCH="$ROOT/third_party/patches/mpv-audiotrack-compressed-audio.patch" -MPV_AUDIO_UNDERRUN_PATCH="$ROOT/third_party/mpv-player-jni/patches/mpv-audiotrack-underrun.patch" MPV_OPTIONAL_OSD_PATCH="$ROOT/third_party/patches/mpv-mediacodec-embed-optional-osd.patch" MPV_MEDIACODEC_TIMED_RELEASE_PATCH="$ROOT/third_party/patches/mpv-mediacodec-embed-timed-release.patch" MPV_MEDIACODEC_TIMING_DIAGNOSTICS_PATCH="$ROOT/third_party/patches/mpv-mediacodec-output-timing-diagnostics.patch" -MPV_MEDIACODEC_RESET_PATCH="$ROOT/third_party/patches/mpv-mediacodec-embed-reset.patch" MPV_VULKAN_CONVERSION_PATCH="$ROOT/third_party/patches/mpv-android-vulkan-conversion-default.patch" MPV_VULKAN_SMART_PATCH="$ROOT/third_party/patches/mpv-android-vulkan-smart-backend.patch" MPV_VULKAN_LEGACY_PATCH="$ROOT/third_party/patches/mpv-android-vulkan-legacy-backend.patch" @@ -36,11 +26,6 @@ MPV_P1_HLS_EDITION_PATCH="$ROOT/third_party/patches/mpv-p1-hls-edition.patch" LIBPLACEBO_P1_ALPHA_PATCH="$ROOT/third_party/patches/libplacebo-p1-alpha.patch" FFMPEG_PROXY_RANGE_PATCH="$ROOT/third_party/patches/ffmpeg-webhtv-proxy-range.patch" FFMPEG_MEDIACODEC_STARVATION_PATCH="$ROOT/third_party/patches/ffmpeg-mediacodec-port-starvation.patch" -FFMPEG_AUDIO_MEDIACODEC_HARDWARE_PATCH="$ROOT/third_party/patches/ffmpeg-audio-mediacodec-hardware-first.patch" -FFMPEG_MEDIACODEC_OUTPUT_PATCH="$ROOT/third_party/patches/ffmpeg-mediacodec-output-serialization.patch" -FFMPEG_DIAGNOSTICS_PATCH="$ROOT/third_party/patches/ffmpeg-mediacodec-diagnostics.patch" -FFMPEG_AVS3_MEDIACODEC_PATCH="$ROOT/third_party/patches/ffmpeg-avs3-mediacodec.patch" -MPV_DIAGNOSTICS_PATCH="$ROOT/third_party/patches/mpv-playback-diagnostics.patch" WORK_DIR="${MPV_NATIVE_WORK_DIR:-$ROOT/build/mpv-native}" ABI="arm64-v8a" JOBS="${MPV_NATIVE_JOBS:-}" @@ -347,8 +332,6 @@ prepare_framework() { cp "$OVERRIDE_DIR/nghttp2.sh" "$BUILDSCRIPTS/scripts/nghttp2.sh" cp "$OVERRIDE_DIR/curl.sh" "$BUILDSCRIPTS/scripts/curl.sh" cp "$OVERRIDE_DIR/mpv.sh" "$BUILDSCRIPTS/scripts/mpv.sh" - cp "$OVERRIDE_DIR/ffmpeg.sh" "$BUILDSCRIPTS/scripts/ffmpeg.sh" - cp "$OVERRIDE_DIR/uavs3d.sh" "$BUILDSCRIPTS/scripts/uavs3d.sh" local lock_hash lock_hash="$(sha256_file "$LOCK_FILE")" printf '\n# WebHTV wrapper cache identity: exact selected lock file.\nci_tarball="prefix-webhtv-%s.tgz"\n' \ @@ -451,27 +434,12 @@ prepare_sources() { -r "$deps/mbedtls/scripts/basic.requirements.txt" checkout_repo dav1d "$DAV1D_REPO" "$DAV1D_COMMIT" "$deps/dav1d" checkout_repo FFmpeg "$FFMPEG_REPO" "$FFMPEG_COMMIT" "$deps/ffmpeg" - mkdir -p "$deps/uavs3d" - git -C "$deps/ffmpeg" apply --check "$ROOT/third_party/patches/ffmpeg-avs3.patch" - git -C "$deps/ffmpeg" apply "$ROOT/third_party/patches/ffmpeg-avs3.patch" [ -f "$FFMPEG_PROXY_RANGE_PATCH" ] || die "missing FFmpeg proxy range patch: $FFMPEG_PROXY_RANGE_PATCH" git -C "$deps/ffmpeg" apply --check "$FFMPEG_PROXY_RANGE_PATCH" git -C "$deps/ffmpeg" apply "$FFMPEG_PROXY_RANGE_PATCH" [ -f "$FFMPEG_MEDIACODEC_STARVATION_PATCH" ] || die "missing FFmpeg MediaCodec starvation patch: $FFMPEG_MEDIACODEC_STARVATION_PATCH" git -C "$deps/ffmpeg" apply --check "$FFMPEG_MEDIACODEC_STARVATION_PATCH" git -C "$deps/ffmpeg" apply "$FFMPEG_MEDIACODEC_STARVATION_PATCH" - [ -f "$FFMPEG_AUDIO_MEDIACODEC_HARDWARE_PATCH" ] || die "missing FFmpeg hardware audio MediaCodec patch: $FFMPEG_AUDIO_MEDIACODEC_HARDWARE_PATCH" - git -C "$deps/ffmpeg" apply --check "$FFMPEG_AUDIO_MEDIACODEC_HARDWARE_PATCH" - git -C "$deps/ffmpeg" apply "$FFMPEG_AUDIO_MEDIACODEC_HARDWARE_PATCH" - [ -f "$FFMPEG_MEDIACODEC_OUTPUT_PATCH" ] || die "missing FFmpeg MediaCodec output serialization patch: $FFMPEG_MEDIACODEC_OUTPUT_PATCH" - git -C "$deps/ffmpeg" apply --check "$FFMPEG_MEDIACODEC_OUTPUT_PATCH" - git -C "$deps/ffmpeg" apply "$FFMPEG_MEDIACODEC_OUTPUT_PATCH" - git -C "$deps/ffmpeg" apply --check "$FFMPEG_DIAGNOSTICS_PATCH" - git -C "$deps/ffmpeg" apply "$FFMPEG_DIAGNOSTICS_PATCH" - git -C "$deps/ffmpeg" apply --check "$FFMPEG_AVS3_MEDIACODEC_PATCH" - git -C "$deps/ffmpeg" apply "$FFMPEG_AVS3_MEDIACODEC_PATCH" - grep -Fq 'WebHTV hardware audio MediaCodec decoder:' "$deps/ffmpeg/libavcodec/mediacodecdec_common.c" || \ - die "FFmpeg hardware audio MediaCodec marker is absent" checkout_repo FreeType "$FREETYPE2_REPO" "$FREETYPE2_COMMIT" "$deps/freetype2" "$FREETYPE2_SUBMODULES" extract_archive libxml2 "$LIBXML2_URL" "$LIBXML2_SHA256" "$deps/libxml2" extract_archive libaribcaption "$LIBARIBCAPTION_URL" "$LIBARIBCAPTION_SHA256" "$deps/libaribcaption" @@ -501,10 +469,6 @@ prepare_sources() { extract_archive curl "$CURL_URL" "$CURL_SHA256" "$deps/curl" fi extract_archive libbluray "$LIBBLURAY_URL" "$LIBBLURAY_SHA256" "$deps/libbluray" - [ -f "$LIBBLURAY_HDMV_INPUT_PATCH" ] || die "missing libbluray HDMV input patch: $LIBBLURAY_HDMV_INPUT_PATCH" - if ! patch -d "$deps/libbluray" -p1 -R --dry-run < "$LIBBLURAY_HDMV_INPUT_PATCH" >/dev/null 2>&1; then - patch -d "$deps/libbluray" -p1 --forward < "$LIBBLURAY_HDMV_INPUT_PATCH" - fi extract_archive libarchive "$LIBARCHIVE_URL" "$LIBARCHIVE_SHA256" "$deps/libarchive" extract_archive libdvdread "$LIBDVDREAD_URL" "$LIBDVDREAD_SHA256" "$deps/libdvdread" extract_archive libdvdnav "$LIBDVDNAV_URL" "$LIBDVDNAV_SHA256" "$deps/libdvdnav" @@ -548,15 +512,6 @@ prepare_sources() { [ -f "$MPV_DISC_PATCH" ] || die "missing MPV disc controls patch: $MPV_DISC_PATCH" git -C "$deps/mpv" apply --check "$MPV_DISC_PATCH" git -C "$deps/mpv" apply "$MPV_DISC_PATCH" - [ -f "$MPV_DISC_MENU_PATCH" ] || die "missing MPV disc navigation patch: $MPV_DISC_MENU_PATCH" - git -C "$deps/mpv" apply --check "$MPV_DISC_MENU_PATCH" - git -C "$deps/mpv" apply "$MPV_DISC_MENU_PATCH" - [ -f "$MPV_DISC_INPUT_PATCH" ] || die "missing MPV disc input patch: $MPV_DISC_INPUT_PATCH" - git -C "$deps/mpv" apply --check "$MPV_DISC_INPUT_PATCH" - git -C "$deps/mpv" apply "$MPV_DISC_INPUT_PATCH" - [ -f "$MPV_DISC_POLL_PATCH" ] || die "missing MPV event-only navigation patch: $MPV_DISC_POLL_PATCH" - git -C "$deps/mpv" apply --check "$MPV_DISC_POLL_PATCH" - git -C "$deps/mpv" apply "$MPV_DISC_POLL_PATCH" [ -f "$MPV_DOVI_SURFACE_PATCH" ] || die "missing MPV Android Dolby Vision Surface patch: $MPV_DOVI_SURFACE_PATCH" git -C "$deps/mpv" apply --check "$MPV_DOVI_SURFACE_PATCH" git -C "$deps/mpv" apply "$MPV_DOVI_SURFACE_PATCH" @@ -566,9 +521,6 @@ prepare_sources() { [ -f "$MPV_DOVI_P81_PATCH" ] || die "missing MPV Dolby Vision Profile 7 P8.1 patch: $MPV_DOVI_P81_PATCH" git -C "$deps/mpv" apply --check --recount "$MPV_DOVI_P81_PATCH" git -C "$deps/mpv" apply --recount "$MPV_DOVI_P81_PATCH" - [ -f "$MPV_DOVI_P8_HDR10_PATCH" ] || die "missing MPV Dolby Vision Profile 8.1 HDR10 base-layer patch: $MPV_DOVI_P8_HDR10_PATCH" - git -C "$deps/mpv" apply --check --recount "$MPV_DOVI_P8_HDR10_PATCH" - git -C "$deps/mpv" apply --recount "$MPV_DOVI_P8_HDR10_PATCH" grep -Fq 'av_bsf_get_by_name(filter_name)' "$deps/mpv/demux/dovi_split.c" || \ die "MPV Dolby Vision Profile 7 P8.1 BSF selection is absent" grep -Fq 'DV7 P8.1 conversion: using FFmpeg dovi_rpu BSF.' "$deps/mpv/demux/dovi_split.c" || \ @@ -585,14 +537,6 @@ prepare_sources() { die "MPV Dolby Vision Profile 7 enhancement-layer metadata is not cleared" grep -Fq 'bl_dp->len > INT_MAX' "$deps/mpv/demux/dovi_split.c" || \ die "MPV Dolby Vision Profile 7 packet-size guard is absent" - grep -Fq 'demuxer-dovi-profile8' "$deps/mpv/demux/demux.c" || \ - die "MPV Dolby Vision Profile 8.1 HDR10 option is absent" - grep -Fq 'P8.1 HDR10 fallback: stripping RPU before decoder.' "$deps/mpv/demux/dovi_split.c" || \ - die "MPV Dolby Vision Profile 8.1 RPU stripping marker is absent" - grep -Fq 's->bsf->par_out->profile = AV_PROFILE_HEVC_MAIN_10' "$deps/mpv/demux/dovi_split.c" || \ - die "MPV Dolby Vision Profile 8.1 decoder profile reset is absent" - grep -Fq 'P8.1 HDR10 fallback: using MediaCodec base-layer decoder' "$deps/mpv/video/decode/vd_lavc.c" || \ - die "MPV Dolby Vision Profile 8.1 decoder fallback guard is absent" [ -f "$MPV_AUDIO_TRUEHD_PATCH" ] || die "missing MPV AudioTrack codec-aware channel-mask patch: $MPV_AUDIO_TRUEHD_PATCH" git -C "$deps/mpv" apply --check "$MPV_AUDIO_TRUEHD_PATCH" git -C "$deps/mpv" apply "$MPV_AUDIO_TRUEHD_PATCH" @@ -602,18 +546,6 @@ prepare_sources() { die "MPV AudioTrack Android 12 carrier gate is absent" grep -Fq 'ao->channels.num == 8' "$deps/mpv/audio/out/ao_audiotrack.c" || \ die "MPV AudioTrack 8-channel carrier gate is absent" - [ -f "$MPV_AUDIO_COMPRESSED_PATCH" ] || die "missing MPV compressed AudioTrack patch: $MPV_AUDIO_COMPRESSED_PATCH" - git -C "$deps/mpv" apply --check --recount "$MPV_AUDIO_COMPRESSED_PATCH" - git -C "$deps/mpv" apply --recount "$MPV_AUDIO_COMPRESSED_PATCH" - grep -Fq 'spdif_ctx->raw_compressed = true' "$deps/mpv/audio/decode/ad_spdif.c" || \ - die "MPV raw compressed decoder flag is absent" - grep -Fq 'AudioTrack compressed access-unit decoder' "$deps/mpv/audio/decode/ad_spdif.c" || \ - die "MPV compressed AudioTrack decoder registration is absent" - [ -f "$MPV_AUDIO_UNDERRUN_PATCH" ] || die "missing MPV AudioTrack underrun patch: $MPV_AUDIO_UNDERRUN_PATCH" - git -C "$deps/mpv" apply --check "$MPV_AUDIO_UNDERRUN_PATCH" - git -C "$deps/mpv" apply "$MPV_AUDIO_UNDERRUN_PATCH" - git -C "$deps/mpv" apply --check "$MPV_DIAGNOSTICS_PATCH" - git -C "$deps/mpv" apply "$MPV_DIAGNOSTICS_PATCH" [ -f "$MPV_MEDIACODEC_TIMED_RELEASE_PATCH" ] || die "missing MPV MediaCodec timed-release patch: $MPV_MEDIACODEC_TIMED_RELEASE_PATCH" git -C "$deps/mpv" apply --check --recount "$MPV_MEDIACODEC_TIMED_RELEASE_PATCH" git -C "$deps/mpv" apply --recount "$MPV_MEDIACODEC_TIMED_RELEASE_PATCH" @@ -623,9 +555,6 @@ prepare_sources() { [ -f "$MPV_MEDIACODEC_TIMING_DIAGNOSTICS_PATCH" ] || die "missing MPV MediaCodec output timing diagnostics patch: $MPV_MEDIACODEC_TIMING_DIAGNOSTICS_PATCH" git -C "$deps/mpv" apply --check "$MPV_MEDIACODEC_TIMING_DIAGNOSTICS_PATCH" git -C "$deps/mpv" apply "$MPV_MEDIACODEC_TIMING_DIAGNOSTICS_PATCH" - [ -f "$MPV_MEDIACODEC_RESET_PATCH" ] || die "missing MPV direct-output reset patch: $MPV_MEDIACODEC_RESET_PATCH" - git -C "$deps/mpv" apply --check "$MPV_MEDIACODEC_RESET_PATCH" - git -C "$deps/mpv" apply "$MPV_MEDIACODEC_RESET_PATCH" [ -f "$MPV_VULKAN_CONVERSION_PATCH" ] || die "missing MPV Android Vulkan conversion-default patch: $MPV_VULKAN_CONVERSION_PATCH" git -C "$deps/mpv" apply --check "$MPV_VULKAN_CONVERSION_PATCH" git -C "$deps/mpv" apply "$MPV_VULKAN_CONVERSION_PATCH" @@ -663,10 +592,6 @@ prepare_sources() { [ -f "$MPV_P1_HLS_EDITION_PATCH" ] || die "missing MPV HLS edition patch: $MPV_P1_HLS_EDITION_PATCH" git -C "$deps/mpv" apply --check "$MPV_P1_HLS_EDITION_PATCH" git -C "$deps/mpv" apply "$MPV_P1_HLS_EDITION_PATCH" - [ -f "$MPV_ANDROID_FEL_PATCH" ] || die "missing MPV Android FEL patch: $MPV_ANDROID_FEL_PATCH" - git -C "$deps/mpv" apply --check --recount "$MPV_ANDROID_FEL_PATCH" - git -C "$deps/mpv" apply --recount "$MPV_ANDROID_FEL_PATCH" - python3 "$ROOT/scripts/verify_mpv_fel_contract.py" --mpv-source "$deps/mpv" } patch_dynamic_names() { @@ -738,11 +663,6 @@ verify_directory() { grep -Fq "mpv v$MPV_VERSION" <<<"$version_strings" || die "unexpected MPV version in $directory/libmpv.so" grep -Fq "v$LIBPLACEBO_VERSION" <<<"$version_strings" || die "unexpected libplacebo version in $directory/libmpv.so" grep -Fq "WebHTV stream_cb controls enabled" <<<"$version_strings" || die "MPV stream_cb disc controls patch missing from $directory/libmpv.so" - grep -Fq "disc-menu-active" <<<"$version_strings" || die "MPV HDMV disc navigation patch missing from $directory/libmpv.so" - grep -Fq "android-dovi-fel" <<<"$version_strings" || die "MPV opt-in FEL option missing from $directory/libmpv.so" - grep -Fq "WebHTV Android FEL: software enhancement-layer decoder" <<<"$version_strings" || die "MPV software EL decoder patch missing from $directory/libmpv.so" - grep -Fq "WebHTV FEL GPU input: matched EL uploaded with active NLQ." <<<"$version_strings" || die "MPV FEL GPU input diagnostics missing from $directory/libmpv.so" - grep -Fq "discnav" <<<"$version_strings" || die "MPV discnav command missing from $directory/libmpv.so" grep -Fq "Vulkan AImageReader backend:" <<<"$version_strings" || die "MPV Vulkan AImageReader backend missing from $directory/libmpv.so" grep -Fq "Using Vulkan YCbCr AHardwareBuffer sampling" <<<"$version_strings" || die "MPV direct Vulkan AHardwareBuffer sampling missing from $directory/libmpv.so" grep -Fq "Vulkan AImageReader sync-fd:" <<<"$version_strings" || die "MPV AImageReader sync-fd support missing from $directory/libmpv.so" @@ -755,9 +675,6 @@ verify_directory() { grep -Fq "DV7 HDR10 fallback: failed to produce base-layer packet." <<<"$version_strings" || die "MPV Dolby Vision Profile 7 filter failure guard missing from $directory/libmpv.so" grep -Fq "DV7 P8.1 conversion: using FFmpeg dovi_rpu BSF." <<<"$version_strings" || die "MPV Dolby Vision Profile 7 P8.1 conversion missing from $directory/libmpv.so" grep -Fq "DV7 P8.1 conversion: removed stale enhancement-layer configuration." <<<"$version_strings" || die "MPV Dolby Vision Profile 7 P8.1 stale enhancement-layer configuration guard missing from $directory/libmpv.so" - grep -Fq "P8.1 HDR10 fallback: stripping RPU before decoder." <<<"$version_strings" || die "MPV Dolby Vision Profile 8.1 RPU stripping marker missing from $directory/libmpv.so" - grep -Fq "P8.1 HDR10 fallback: synchronized decoder parameters to the HDR10 base layer." <<<"$version_strings" || die "MPV Dolby Vision Profile 8.1 codec-parameter sync missing from $directory/libmpv.so" - grep -Fq "P8.1 HDR10 fallback: using MediaCodec base-layer decoder" <<<"$version_strings" || die "MPV Dolby Vision Profile 8.1 direct decoder fallback missing from $directory/libmpv.so" if grep -Fq "Using device native output sample rate for passthrough compatibility" <<<"$version_strings"; then die "obsolete MPV AudioTrack passthrough native-rate patch present in $directory/libmpv.so" fi @@ -766,8 +683,6 @@ verify_directory() { grep -Fq "WebHTV direct output accepts an optional Android OSD Surface" <<<"$version_strings" || die "MPV optional direct-output OSD patch missing from $directory/libmpv.so" grep -Fq "WebHTV timestamped MediaCodec output enabled" <<<"$version_strings" || die "MPV MediaCodec timestamped-release patch missing from $directory/libmpv.so" grep -Fq "MediaCodec VO drop timing" <<<"$version_strings" || die "MPV MediaCodec output timing diagnostics missing from $directory/libmpv.so" - grep -Fq "WebHTV MediaCodec reset discards pending output" <<<"$version_strings" || die "MPV direct-output reset missing from $directory/libmpv.so" - grep -Fq "WebHTV event-only disc navigation polling enabled" <<<"$version_strings" || die "MPV event-only navigation pump missing from $directory/libmpv.so" grep -Fq "WebHTV Vulkan auto backend prefers direct AHardwareBuffer sampling" <<<"$version_strings" || die "MPV Android Vulkan smart backend patch missing from $directory/libmpv.so" grep -Fq "WebHTV Vulkan auto uses a queue-safe four-output bounded-fence pool" <<<"$version_strings" || die "MPV Android Vulkan queue-safe conversion pool missing from $directory/libmpv.so" grep -Fq "CPU-precomputed UV transform" <<<"$version_strings" || die "MPV Android Vulkan low-power coordinate transform missing from $directory/libmpv.so" @@ -777,16 +692,7 @@ verify_directory() { grep -Fq "WebHTV AImageReader uses stable release/acquire flow" <<<"$version_strings" || die "MPV Android stable AImageReader release/acquire patch missing from $directory/libmpv.so" grep -Fq "Using declared Matroska segment end for seek metadata." <<<"$version_strings" || die "MPV Matroska segment seek patch missing from $directory/libmpv.so" grep -Fq "libarcdav3a AV3A" <<<"$codec_strings" || die "FFmpeg AV3A decoder missing from $directory/libmvcodec.so" - grep -Fq "libuavs3d" <<<"$codec_strings" || die "FFmpeg AVS3 decoder missing from $directory/libmvcodec.so" - grep -Fq "avs3_mediacodec" <<<"$codec_strings" || die "FFmpeg AVS3 MediaCodec decoder missing from $directory/libmvcodec.so" - grep -Fq "No hardware AVS3 MediaCodec decoder" <<<"$codec_strings" || die "FFmpeg AVS3 hardware-only selection missing from $directory/libmvcodec.so" - grep -Fq "AVS3 MediaCodec requires an explicit hardware context" <<<"$codec_strings" || die "FFmpeg AVS3 manual decode selection missing from $directory/libmvcodec.so" - grep -Fq "WebHTV HPM 15.0" <<<"$codec_strings" || die "FFmpeg AVS3 High 10-bit decoder missing from $directory/libmvcodec.so" grep -Fq "failing hardware decode so the player can fall back" <<<"$codec_strings" || die "FFmpeg MediaCodec fallback patch missing from $directory/libmvcodec.so" - grep -Fq "WebHTV hardware audio MediaCodec decoder:" <<<"$codec_strings" || die "FFmpeg hardware audio MediaCodec patch missing from $directory/libmvcodec.so" - grep -Fq "WebHTV MediaCodec output release/flush serialization enabled" <<<"$codec_strings" || die "FFmpeg MediaCodec output serialization missing from $directory/libmvcodec.so" - grep -Fq "WebHTV AV-DIAG codec-lookup v=1" <<<"$codec_strings" || die "FFmpeg diagnostic lookup hook missing from $directory/libmvcodec.so" - grep -Fq "WebHTV AV-DIAG audio-write v=1" <<<"$version_strings" || die "MPV diagnostic AudioTrack hook missing from $directory/libmpv.so" grep -Fq "libaribcaption" <<<"$codec_strings" || die "FFmpeg ARIB caption decoder missing from $directory/libmvcodec.so" grep -Fq "Timed Text Markup Language subtitle" <<<"$codec_strings" || die "FFmpeg TTML decoder missing from $directory/libmvcodec.so" grep -Fq "MMT protocol over TLV packets" <<<"$format_strings" || die "FFmpeg MMT/TLV demuxer missing from $directory/libmvformat.so" @@ -897,7 +803,7 @@ build_abi() { export WEBHTV_ANDROID_API_LEVEL="$ANDROID_API_LEVEL" local targets=( libiconv uchardet bzip2 xz zstd mbedtls dav1d libxml2 freetype2 - libaribcaption uavs3d ffmpeg fontconfig fribidi harfbuzz unibreak libass lua + libaribcaption ffmpeg fontconfig fribidi harfbuzz unibreak libass lua shaderc libplacebo ) if [ "$ENABLE_LIBCURL" -eq 1 ]; then @@ -926,8 +832,6 @@ build_abi() { libxml2) [ -f "$BUILDSCRIPTS/prefix/$prefix_name/lib/libxml2.a" ] ;; freetype2) [ -f "$BUILDSCRIPTS/prefix/$prefix_name/lib/libfreetype.a" ] ;; libaribcaption) [ -f "$BUILDSCRIPTS/prefix/$prefix_name/lib/libaribcaption.a" ] ;; - uavs3d) [ -f "$BUILDSCRIPTS/prefix/$prefix_name/lib/libuavs3d.a" ] && - [ -f "$BUILDSCRIPTS/prefix/$prefix_name/lib/libwebhtvhpm.a" ] ;; ffmpeg) [ -f "$BUILDSCRIPTS/prefix/$prefix_name/lib/libavcodec.so" ] ;; fontconfig) [ -f "$BUILDSCRIPTS/prefix/$prefix_name/lib/libfontconfig.a" ] ;; fribidi) [ -f "$BUILDSCRIPTS/prefix/$prefix_name/lib/libfribidi.a" ] ;; diff --git a/scripts/test_avs3_contract.sh b/scripts/test_avs3_contract.sh deleted file mode 100644 index 26bfd5bcf7..0000000000 --- a/scripts/test_avs3_contract.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -task_root="$(cd "$(dirname "$0")/.." && pwd)" -exec python3 "$task_root/scripts/verify_avs3_contract.py" --jvm "$@" diff --git a/scripts/test_avs3_mediacodec.sh b/scripts/test_avs3_mediacodec.sh deleted file mode 100644 index 282c50eb4a..0000000000 --- a/scripts/test_avs3_mediacodec.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SOURCE="${1:-$ROOT/build/mpv-native/mpv-android/buildscripts/deps/ffmpeg}" -WORK="$(mktemp -d "${TMPDIR:-/tmp}/webhtv-avs3-mediacodec.XXXXXX")" -trap 'rm -rf "$WORK"' EXIT - -# Execute the production functions; only the Android platform calls are faked. -python3 - "$SOURCE" "$WORK/production.inc" <<'PY' -from pathlib import Path -import sys -source = Path(sys.argv[1]) / 'libavcodec' -parts = [] -for file, name in [('mediacodecdec.c', 'avs3_set_extradata'), - ('mediacodecdec_common.c', 'mediacodec_dec_get_video_codec'), - ('mediacodecdec_common.c', 'mediacodec_dec_get_audio_codec')]: - text = (source / file).read_text() - start = text.index('static int ' + name + '(') - end = text.index('\n}\n', start) + 3 - parts.append(text[start:end]) -Path(sys.argv[2]).write_text('\n'.join(parts)) -PY - -"${CC:-clang}" -std=c11 -O1 -g -Wall -Wextra -Werror \ - -fsanitize=address,undefined -fno-omit-frame-pointer -I"$WORK" \ - "$ROOT/scripts/tests/avs3_mediacodec/contract.c" -o "$WORK/contract" -"$WORK/contract" diff --git a/scripts/test_hpm_decoder.sh b/scripts/test_hpm_decoder.sh deleted file mode 100644 index 714de557c7..0000000000 --- a/scripts/test_hpm_decoder.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -# Compare the player boundary with an independently built HPM reference binary. -# Stream raw pictures through a FIFO: the 4K128 oracle needs no 3 GB YUV file. -set -euo pipefail -if [[ $# != 4 ]]; then - echo 'Usage: bash scripts/test_hpm_decoder.sh INPUT_AVS3 REFERENCE_DECODER HPM_CONTRACT OUTPUT_DIR' >&2 - exit 2 -fi -python3 - "$@" <<'PY' -from pathlib import Path -import hashlib, json, os, re, subprocess, sys, time - -sample, reference, decoder, output = map(lambda p: Path(p).resolve(), sys.argv[1:]) -output.mkdir(parents=True, exist_ok=True) -fifo = output / 'reference.yuv.fifo' -if fifo.exists(): - raise SystemExit('Refusing to replace an existing oracle FIFO: ' + str(fifo)) -os.mkfifo(fifo) -keep = os.open(fifo, os.O_RDWR | os.O_NONBLOCK) -try: - # This bounded contract sample is 3840x2160 planar YUV420, 16-bit storage. - with fifo.open('rb') as pipe, (output / 'reference-hashes.log').open('w') as hashes: - consumer = subprocess.Popen([str(decoder), '--raw-hash', str(3840 * 2160 * 3)], - stdin=pipe, stdout=hashes) - started = time.monotonic() - with (output / 'reference-decode.log').open('w') as log: - result = subprocess.run([str(reference), '-i', str(sample), '-o', str(fifo), '-v', '0'], - stdout=log, stderr=subprocess.STDOUT) - os.close(keep) - keep = None - consumer_result = consumer.wait() - if result.returncode or consumer_result: - raise SystemExit('Reference decode/hash failed; inspect retained logs') - oracle_seconds = time.monotonic() - started -finally: - if keep is not None: - os.close(keep) - fifo.unlink() - -with (output / 'adapter-contract.log').open('w') as log: - subprocess.run([str(decoder), str(sample), '--interleave', '--flush'], - stdout=log, stderr=subprocess.STDOUT, check=True) -oracle = re.findall(r'reference frame=\d+ hash=([0-9a-f]+)', (output / 'reference-hashes.log').read_text()) -actual = re.findall(r'(?:^|\s)frame=\d+ poc=\S+ .* hash=([0-9a-f]+)', (output / 'adapter-contract.log').read_text(), re.M) -if len(oracle) != 128 or actual != oracle + oracle: - raise SystemExit('Pixel/frame count mismatch against original HPM (including flush replay)') -record = dict(sample_sha256=hashlib.sha256(sample.read_bytes()).hexdigest(), - reference_sha256=hashlib.sha256(reference.read_bytes()).hexdigest(), - adapter_sha256=hashlib.sha256(decoder.read_bytes()).hexdigest(), - frames=128, pixel_match=True, interleaved_instances=True, - close_peer_then_flush_replay=True, oracle_seconds=oracle_seconds) -(output / 'result.json').write_text(json.dumps(record, indent=2) + '\n') -print(json.dumps(record, indent=2)) -PY diff --git a/scripts/test_mpv_fel_context_options.py b/scripts/test_mpv_fel_context_options.py deleted file mode 100644 index c706b8e131..0000000000 --- a/scripts/test_mpv_fel_context_options.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -"""Focused regression for FEL context option ownership, using production C.""" -import argparse -import os -from pathlib import Path -import re -import subprocess -import tempfile - -ROOT = Path(__file__).resolve().parent.parent -parser = argparse.ArgumentParser(description=__doc__) -parser.add_argument("--mpv-source", type=Path, default=ROOT / "build/mpv-native/mpv-android/buildscripts/deps/mpv") -parser.add_argument("--expect-invalid-free", action="store_true") -args = parser.parse_args() -source = args.mpv_source.resolve() - - -def function(text, name): - match = re.search(r"^(?:static (?:inline )?)?[^\n]*\b" + re.escape(name) - + r"\([^;]*?\n\{.*?^\}", text, re.M | re.S) - if not match: - raise ValueError("Missing production function: " + name) - return match.group(0) + "\n" - - -with tempfile.TemporaryDirectory(prefix="webhtv-fel-options-") as directory: - output = Path(directory) - option_c = (source / "options/m_option.c").read_text() - option_h = (source / "options/m_option.h").read_text() - functions = "#define VAL(p) (*(char ***)(p))\n" - functions += function(option_c, "free_str_list") + function(option_c, "copy_str_list") - functions += "#undef VAL\n#define VAL(p) (*(m_obj_settings_t **)(p))\n" - for name in ("obj_setting_free", "free_obj_settings_list", "copy_obj_settings_list"): - functions += function(option_c, name) - functions += "#undef VAL\nstatic const m_option_type_t m_option_type_obj_settings_list = { .copy=copy_obj_settings_list, .free=free_obj_settings_list };\n" - functions += function(option_h, "m_option_copy") + function(option_h, "m_option_free") - (output / "option_functions.inc").write_text(functions) - vo = (source / "video/out/vo_gpu_next.c").read_text() - preinit = function(vo, "preinit") - start = preinit.index(" struct ra_ctx_opts *ctx_opts =") - end = preinit.index(" talloc_free(ctx_opts);", start) + len(" talloc_free(ctx_opts);") - helper = function(vo, "set_android_fel_context_options") if "static void set_android_fel_context_options(" in vo else "" - (output / "fel_preinit.inc").write_text(helper + "\nstatic int run_preinit(struct vo *vo)\n{\n struct priv *p = vo->priv;\n" - + preinit[start:end] + "\n return p->context ? 0 : -1;\n}\n") - # Use the host's pthread mutexes for ta's debug registry; no allocator stub. - (output / "osdep").mkdir() - (output / "osdep/threads.h").write_text("#include \ntypedef pthread_mutex_t mp_static_mutex;\n#define MP_STATIC_MUTEX_INITIALIZER PTHREAD_MUTEX_INITIALIZER\n#define mp_mutex_lock pthread_mutex_lock\n#define mp_mutex_unlock pthread_mutex_unlock\n") - (output / "config.h").write_text("#define HAVE_ANDROID 0\n") - binary = output / "test" - # Upstream ta's debug leak printer compares int with sizeof; retain its code. - command = [os.environ.get("CC", "cc"), "-std=gnu11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-Wno-sign-compare", - "-fsanitize=address,undefined", "-g", "-pthread", "-DTA_MEMORY_DEBUGGING=1", - "-I" + str(output), "-I" + str(source), - str(ROOT / "third_party/mpv-player-jni/tests/fel_context_options_test.c"), - *[str(source / "ta" / name) for name in ("ta.c", "ta_utils.c", "ta_talloc.c")], - "-o", str(binary)] - subprocess.run(command, check=True) - result = subprocess.run([str(binary)], capture_output=True, text=True) - print(result.stdout, end="") - print(result.stderr, end="") - if args.expect_invalid_free: - if result.returncode == 0 or not re.search("AddressSanitizer|canary.*CANARY", result.stderr): - raise SystemExit("Expected the production static-option invalid free") - print("PASS baseline reproduces invalid free with the real mpv allocator") - elif result.returncode: - raise SystemExit(result.returncode) diff --git a/scripts/test_mpv_fel_contract.sh b/scripts/test_mpv_fel_contract.sh deleted file mode 100644 index 7e8569e65b..0000000000 --- a/scripts/test_mpv_fel_contract.sh +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -task_root="$(cd "$(dirname "$0")/.." && pwd)" -mpv_source="${1:-$task_root/build/mpv-native/mpv-android/buildscripts/deps/mpv}" -test_output="$(mktemp -d /private/tmp/webhtv-fel-contract.XXXXXX)" -python3 "$task_root/scripts/test_mpv_fel_context_options.py" --mpv-source "$mpv_source" -awk ' - /^static bool should_use_android_fel_output\(/ || /^void reinit_video_chain_src\(/ || - /^static int mp_property_android_fel_active\(/ || /^static int mp_property_video_frame_submitted\(/ { copying = 1 } - copying { print } - copying && /^}/ { copying = 0 } -' "$mpv_source/player/video.c" "$mpv_source/player/command.c" | \ - "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -Wno-unused-parameter \ - -fsanitize=address,undefined \ - -include "$task_root/third_party/mpv-player-jni/tests/fel_startup_selection_test.c" \ - -x c - -o "$test_output/fel-startup-selection-test" -"$test_output/fel-startup-selection-test" -# Use Vulkan declarations without using Android's libc headers on the host. -vulkan_headers="${VULKAN_HEADERS_INCLUDE:-}" -if [[ -z "$vulkan_headers" && -n "${ANDROID_NDK_HOME:-}" ]]; then - for candidate in "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/sysroot/usr/include; do - if [[ -f "$candidate/vulkan/vulkan.h" ]]; then vulkan_headers="$candidate"; break; fi - done -fi -if [[ ! -f "$vulkan_headers/vulkan/vulkan.h" ]]; then - printf '%s\n' 'Set VULKAN_HEADERS_INCLUDE or ANDROID_NDK_HOME for the FEL Vulkan host contract.' >&2 - exit 1 -fi -awk ' - /^bool get_internal_paused\(/ || /^static void handle_fel_bind_probe\(/ || - /^static int mp_property_android_fel_bind_probe\(/ || - /^static void fel_latency_add\(/ || /^static int fel_latency_compare\(/ || - /^static void fel_latency_summary\(/ { copying = 1 } - copying { print } - copying && /^}/ { copying = 0 } -' "$mpv_source/player/playloop.c" "$mpv_source/player/command.c" \ - "$mpv_source/video/out/hwdec/hwdec_aimagereader_vk_stable.c" | \ - "${CC:-cc}" -std=gnu11 -Wall -Wextra -Werror -Wno-unused-parameter \ - -fsanitize=address,undefined -I"$mpv_source" -idirafter "$vulkan_headers" \ - -include "$task_root/third_party/mpv-player-jni/tests/fel_bind_probe_test.c" \ - -x c - -o "$test_output/fel-bind-probe-test" -"$test_output/fel-bind-probe-test" -awk ' - /^static struct fel_api_clock fel_api_begin\(/ || - /^static int fel_parse_schedstat\(/ || /^static int fel_read_schedstat\(/ || - /^static int fel_probe_acquire_fence\(/ || /^static struct fel_wait_snapshot fel_wait_snapshot\(/ || - /^static int64_t fel_wait_delta\(/ || /^static struct fel_wait_sample fel_wait_probe_begin\(/ || - /^static void fel_wait_probe_end\(/ || /^static bool has_extension\(/ || - /^static void log_fel_descriptor_capabilities\(/ { copying = 1 } - copying { print } - copying && /^}/ { copying = 0 } -' "$mpv_source/video/out/hwdec/hwdec_aimagereader_vk_stable.c" | \ - "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -Wno-unused-parameter \ - -fsanitize=address,undefined -idirafter "$vulkan_headers" \ - -include "$task_root/third_party/mpv-player-jni/tests/fel_wait_probe_test.c" \ - -x c - -o "$test_output/fel-wait-probe-test" -"$test_output/fel-wait-probe-test" -awk ' - /^static const char \*const fel_api_names\[/ || - /^static bool has_extension\(/ || /^static bool create_conversion_descriptor_layout\(/ || - /^static void trace_fel_frame_order\(/ || /^static void format_fel_frame_order\(/ || - /^static struct fel_api_clock fel_api_begin\(/ || /^static void fel_api_end\(/ || - /^static void fel_latency_add\(/ || /^static int fel_latency_compare\(/ || - /^static void fel_latency_summary\(/ || /^static void fel_perf_checkpoint\(/ || - /^static void fel_perf_finish_map\(/ || - /^static void invalidate_input_recordings\(/ || /^static void destroy_input\(/ || - /^static void destroy_recording_cache\(/ || /^static struct vk_input \*find_input\(/ || - /^static void purge_removed_inputs\(/ || /^static struct vk_input \*select_input_slot\(/ || - /^static bool recording_pending\(/ || /^static bool recording_matches\(/ || - /^static struct vk_recording \*select_recording\(/ || /^static bool allocate_recording\(/ || - /^static void update_conversion_descriptor\(/ || /^static bool record_conversion\(/ || - /^static bool prepare_conversion\(/ || /^void aimagereader_vk_stable_buffer_removed\(/ { copying = 1 } - copying { print } - copying && /^}/ { copying = 0 } -' "$mpv_source/video/out/hwdec/hwdec_aimagereader_vk_stable.c" | \ - "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -Wno-unused-parameter \ - -fsanitize=address,undefined -I"$mpv_source" -idirafter "$vulkan_headers" \ - -include "$task_root/third_party/mpv-player-jni/tests/fel_vk_cache_test.c" \ - -x c - -o "$test_output/fel-vk-cache-test" -"$test_output/fel-vk-cache-test" -"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -fsanitize=address,undefined \ - -I"$task_root/third_party/mpv-player-jni/src" \ - -I"$task_root/third_party/mpv-player-jni/include" -I"$mpv_source" \ - "$task_root/third_party/mpv-player-jni/tests/fel_contract_test.cpp" \ - -o "$test_output/fel-contract-test" -"$test_output/fel-contract-test" -# Compile the real wrapper bodies; a blocking decoder-dispatch stub makes any -# accidental synchronous read fail deterministically, without timing sleeps. -awk ' - /^static int update_cached_values\(/ || /^int mp_decoder_wrapper_control\(/ || - /^double mp_decoder_wrapper_get_container_fps\(/ { copying = 1 } - copying { print } - copying && /^}/ { copying = 0 } -' "$mpv_source/filters/f_decoder_wrapper.c" | \ - "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -fsanitize=address,undefined \ - -I"$mpv_source" \ - -include "$task_root/third_party/mpv-player-jni/tests/fel_decoder_control_test.cpp" \ - -x c++ - -o "$test_output/fel-decoder-control-test" -"$test_output/fel-decoder-control-test" -awk ' - /^static int preload_dropped_fel_frame\(/ || /^static bool render_frame\(/ || - /^static bool begin_fel_render_init\(/ || /^static void end_fel_render_init\(/ { copying = 1 } - copying { print } - copying && /^}/ { copying = 0 } -' "$mpv_source/video/out/vo_gpu_next.c" "$mpv_source/video/out/vo.c" | \ - "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -Wno-unused-function \ - -fsanitize=address,undefined -I"$mpv_source" \ - -include "$task_root/third_party/mpv-player-jni/tests/fel_vo_drop_test.c" \ - -x c - -o "$test_output/fel-vo-drop-test" -"$test_output/fel-vo-drop-test" -awk ' - /^static void cancel_fel_prepare\(/ || /^void vo_cancel_fel_frame\(/ || - /^static bool begin_fel_render_init\(/ || /^static void end_fel_render_init\(/ || - /^static bool fel_prepare_timed_out\(/ || /^int vo_prepare_fel_frame\(/ || - /^static int64_t process_fel_prepare\(/ || /^static bool use_video_lookahead\(/ || - /^static int get_req_frames\(/ || /^static bool needs_new_frame\(/ || - /^static void add_new_frame\(/ || /^static bool have_new_frame\(/ || - /^static void trace_fel_core\(/ || /^static int video_output_image\(/ || - /^static bool output_has_live_fel_frame\(/ || /^static int select_reusable_output\(/ || - /^static void restore_fel_display_params\(/ || /^static bool hwdec_reconfig\(/ { copying = 1 } - copying { print } - copying && /^}/ { copying = 0 } -' "$mpv_source/video/out/vo.c" "$mpv_source/player/video.c" \ - "$mpv_source/video/out/hwdec/hwdec_aimagereader_vk_stable.c" \ - "$mpv_source/video/out/vo_gpu_next.c" | \ - "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -Wno-unused-function \ - -fsanitize=address,undefined -I"$mpv_source" \ - $(pkg-config --cflags libavutil) \ - -include "$task_root/third_party/mpv-player-jni/tests/fel_core_preload_test.c" \ - -x c - $(pkg-config --libs libavutil) -o "$test_output/fel-core-preload-test" -"$test_output/fel-core-preload-test" -awk ' - /^static int stage_fel_before_publish\(/ || /^static bool finish_output\(/ || - /^static void fel_perf_checkpoint\(/ || /^static void fel_perf_finish_map\(/ || - /^static void collect_fel_gpu_time\(/ || /^static void init_fel_gpu_timer\(/ || - /^static VkSemaphore create_fel_release_semaphore\(/ || - /^static bool release_fel_source_async\(/ || /^static bool submit_conversion\(/ || - /^bool aimagereader_vk_stable_reuse\(/ { copying = 1 } - copying { print } - copying && /^}/ { copying = 0 } -' "$mpv_source/filters/f_decoder_wrapper.c" \ - "$mpv_source/video/out/hwdec/hwdec_aimagereader_vk_stable.c" | \ - "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -Wno-unused-function \ - -fsanitize=address,undefined -I"$mpv_source" \ - $(pkg-config --cflags libavutil) \ - -include "$task_root/third_party/mpv-player-jni/tests/fel_producer_handoff_test.c" \ - -x c - $(pkg-config --libs libavutil) -o "$test_output/fel-producer-handoff-test" -"$test_output/fel-producer-handoff-test" -awk ' - /^static int stage_fel_before_publish\(/ || /^static void reset_fel_staging\(/ || - /^static void read_frame\(/ || /^static void decf_process\(/ || - /^static MP_THREAD_VOID dec_thread\(/ { copying = 1 } - copying { print } - copying && /^}/ { copying = 0 } -' "$mpv_source/filters/f_decoder_wrapper.c" | \ - "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -Wno-unused-function \ - -fsanitize=address,undefined -I"$mpv_source" \ - $(pkg-config --cflags libavutil) \ - -include "$task_root/third_party/mpv-player-jni/tests/fel_async_producer_test.c" \ - -x c - $(pkg-config --libs libavutil) -o "$test_output/fel-async-producer-test" -"$test_output/fel-async-producer-test" -awk ' - /^static void mp_image_destructor\(/ || /^void mp_image_unref_data\(/ || - /^static void ref_buffer\(/ || /^struct mp_image \*mp_image_new_ref\(/ || - /^struct mp_image \*mp_image_new_dummy_ref\(/ { copying = 1 } - copying { print } - copying && /^}/ { copying = 0 } -' "$mpv_source/video/mp_image.c" | \ - "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined \ - $(pkg-config --cflags libavutil) \ - -include "$task_root/third_party/mpv-player-jni/tests/fel_image_lease_test.c" \ - -x c - $(pkg-config --libs libavutil) -o "$test_output/fel-image-lease-test" -"$test_output/fel-image-lease-test" -"${CC:-cc}" -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined \ - -pthread -I"$mpv_source" \ - "$task_root/third_party/mpv-player-jni/tests/fel_progress_test.c" \ - -o "$test_output/fel-progress-test" -"$test_output/fel-progress-test" -# Exercise the production bitstream/ownership helper with a real FFmpeg BSF. -# Optional argument 2 is a length-prefixed Profile 7 sample (not a fixture copy). -"${CC:-cc}" -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined \ - -I"$mpv_source" $(pkg-config --cflags libavformat libavcodec libavutil) \ - "$task_root/third_party/mpv-player-jni/tests/fel_packet_test.c" \ - $(pkg-config --libs libavformat libavcodec libavutil) \ - -o "$test_output/fel-packet-test" -"$test_output/fel-packet-test" ${2:+"$2"} -"${CC:-cc}" -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined \ - $(pkg-config --cflags libavformat libavcodec libavutil) \ - "$task_root/third_party/mpv-player-jni/tests/fel_el_rpu_test.c" \ - $(pkg-config --libs libavformat libavcodec libavutil) \ - -o "$test_output/fel-el-rpu-test" -"$test_output/fel-el-rpu-test" ${2:+"$2"} -awk ' - /^static void inherit_dovi_from_el\(/ { copying = 1 } - copying { print } - copying && /^}/ { copying = 0 } -' "$mpv_source/filters/f_enhancement_pair.c" | \ - "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined \ - $(pkg-config --cflags libavutil) \ - -include "$task_root/third_party/mpv-player-jni/tests/fel_rpu_inherit_test.c" \ - -x c - $(pkg-config --libs libavutil) -o "$test_output/fel-rpu-inherit-test" -"$test_output/fel-rpu-inherit-test" -python3 "$task_root/scripts/verify_mpv_fel_contract.py" --mpv-source "$mpv_source" -rg -q 'mp_android_fel_stage_output\(' "$mpv_source/video/out/hwdec/hwdec_aimagereader.c" -rg -q 'mp_android_fel_preserve_depth\(' "$mpv_source/video/out/hwdec/hwdec_aimagereader_vk_stable.c" -rg -q 'finish_output\(p, output, MP_ANDROID_FEL_COPY_WAIT_NS\)' "$mpv_source/video/out/hwdec/hwdec_aimagereader_vk_stable.c" diff --git a/scripts/tests/avs3_mediacodec/contract.c b/scripts/tests/avs3_mediacodec/contract.c deleted file mode 100644 index 6fedbaec41..0000000000 --- a/scripts/tests/avs3_mediacodec/contract.c +++ /dev/null @@ -1,302 +0,0 @@ -/* MediaCodec platform fakes for the unmodified production AVS3 entry points. */ -#include -#include -#include -#include -#include -#include - -#define AVERROR(e) (-(e)) -#define AVERROR_INVALIDDATA (-1094995529) -#define AVERROR_EXTERNAL (-542398533) -#define AVERROR_DECODER_NOT_FOUND (-1128613112) -#define AV_HWACCEL_FLAG_ALLOW_PROFILE_MISMATCH 1 -#define AV_HWDEVICE_TYPE_MEDIACODEC 1 -#define AV_CODEC_ID_AVS3 1 -#define AV_CODEC_ID_HEVC 2 -#define AV_CODEC_ID_AAC 3 -#define AV_LOG_ERROR 0 -#define AV_LOG_WARNING 1 -#define AV_LOG_INFO 2 -#define AV_LOG_DEBUG 3 -#define av_log(...) ((void)0) -#define AV_RB16(p) ((uint16_t)(((uint16_t)(p)[0] << 8) | (p)[1])) -#define AV_RB32(p) (((uint32_t)AV_RB16(p) << 16) | AV_RB16((p) + 2)) - -enum AVPixelFormat { AV_PIX_FMT_NONE = -1, AV_PIX_FMT_MEDIACODEC = 1 }; -typedef struct { void *data; } AVBufferRef; -typedef struct { void *surface, *native_window; } AVMediaCodecDeviceContext; -typedef struct { int type; void *hwctx; } AVHWDeviceContext; -typedef struct { void *surface; } AVMediaCodecContext; -typedef struct { - uint8_t *extradata; - int extradata_size, codec_id, hwaccel_flags; - void *hwaccel_context; - AVBufferRef *hw_device_ctx; -} AVCodecContext; -typedef struct { int unused; } FFAMediaCodec; -typedef struct { - void *surface; - int use_ndk_codec; - char *codec_name; - FFAMediaCodec *codec; -} MediaCodecDecContext; -typedef struct { - const uint8_t *csd; - size_t size; - int buffers, profile_present, profile; - const char *mime; -} FFAMediaFormat; - -static int have_hardware, have_software, missing_jvm, create_fails; -static int expected_hardware_only, lookups, by_name, by_type, profile_mismatch; -static int surface_refs; -static FFAMediaCodec component; - -static char *av_strdup(const char *s) -{ - size_t size = strlen(s) + 1; - char *copy = malloc(size); - assert(copy); - memcpy(copy, s, size); - return copy; -} - -static void ff_AMediaFormat_setBuffer(FFAMediaFormat *f, const char *key, void *p, size_t size) -{ - assert(!strcmp(key, "csd-0")); - f->csd = p; - f->size = size; - f->buffers++; -} - -static int ff_AMediaFormat_getInt32(FFAMediaFormat *f, const char *key, int32_t *value) -{ - assert(!strcmp(key, "profile")); - if (f->profile_present) - *value = f->profile; - return f->profile_present; -} - -static void ff_AMediaFormat_setString(FFAMediaFormat *f, const char *key, const char *value) -{ - assert(!strcmp(key, "mime")); - f->mime = value; -} - -static enum AVPixelFormat ff_get_format(AVCodecContext *avctx, const enum AVPixelFormat *formats) -{ - (void)avctx; - assert(formats[0] == AV_PIX_FMT_MEDIACODEC); - assert(formats[1] == AV_PIX_FMT_NONE); - return formats[0]; -} - -static void *ff_mediacodec_surface_ref(void *surface, void *window, void *log_ctx) -{ - (void)log_ctx; - surface_refs++; - return surface ? surface : window; -} - -static int ff_AMediaCodecProfile_getProfileFromAVCodecContext(AVCodecContext *avctx) -{ - return avctx->codec_id == AV_CODEC_ID_AVS3 ? -1 : 2; -} - -static char *ff_AMediaCodecList_getCodecNameByType(const char *mime, int profile, - int encoder, int hardware_only, const char **codec_mime, void *log_ctx) -{ - (void)log_ctx; - assert(!encoder); - assert(hardware_only == expected_hardware_only); - lookups++; - *codec_mime = mime; - if (missing_jvm || (profile_mismatch && profile >= 0)) - return NULL; - if (have_hardware) - return av_strdup("c2.vendor.avs3.decoder"); - if (have_software && !hardware_only) - return av_strdup("c2.android.software.decoder"); - return NULL; -} - -static FFAMediaCodec *ff_AMediaCodec_createCodecByName(const char *name, int ndk) -{ - (void)ndk; - assert(name); - by_name++; - return create_fails ? NULL : &component; -} - -static FFAMediaCodec *ff_AMediaCodec_createDecoderByType(const char *mime, int ndk) -{ - assert(mime && ndk); - by_type++; - return &component; -} - -static char *ff_AMediaCodec_getName(FFAMediaCodec *codec) -{ - assert(codec); - return av_strdup("c2.mime-selected.decoder"); -} - -#include "production.inc" - -static void reset_platform(int hardware_only) -{ - have_hardware = have_software = missing_jvm = create_fails = 0; - lookups = by_name = by_type = profile_mismatch = surface_refs = 0; - expected_hardware_only = hardware_only; -} - -static void check_csd(void) -{ - uint8_t raw[] = {0, 0, 1, 0xb0, 0x32, 0x55, 0x8f, 0, 0x43, 0x86, 0xa0, 0x42}; - uint8_t saved[sizeof(raw)]; - uint8_t record[sizeof(raw) + 4] = {1, 0, sizeof(raw)}; - memcpy(saved, raw, sizeof(raw)); - memcpy(record + 3, raw, sizeof(raw)); - record[sizeof(record) - 1] = 0xfc; - AVCodecContext ctx = { .extradata = raw, .extradata_size = sizeof(raw) }; - FFAMediaFormat fmt = {0}; - assert(!avs3_set_extradata(&ctx, &fmt)); - assert(fmt.buffers == 1 && fmt.csd == raw && fmt.size == sizeof(raw)); - assert(!fmt.profile_present && !memcmp(raw, saved, sizeof(raw))); - - ctx.extradata = record; - ctx.extradata_size = sizeof(record); - fmt = (FFAMediaFormat){0}; - assert(!avs3_set_extradata(&ctx, &fmt)); - assert(fmt.buffers == 1 && fmt.csd == record + 3 && fmt.size == sizeof(raw)); - assert(!memcmp(fmt.csd, saved, sizeof(raw)) && !fmt.profile_present); - - /* Hardware capability is not restricted to the software backend profiles. */ - for (int profile = 0x20; profile <= 0x32; profile += 2) { - raw[4] = (uint8_t)profile; - ctx = (AVCodecContext){ .extradata = raw, .extradata_size = sizeof(raw) }; - fmt = (FFAMediaFormat){0}; - assert(!avs3_set_extradata(&ctx, &fmt)); - assert(fmt.csd[4] == profile); - } - for (size_t size = 1; size < sizeof(record); size++) { - uint8_t *short_record = malloc(size); - assert(short_record); - memcpy(short_record, record, size); - ctx = (AVCodecContext){ .extradata = short_record, .extradata_size = (int)size }; - fmt = (FFAMediaFormat){0}; - assert(avs3_set_extradata(&ctx, &fmt) == AVERROR_INVALIDDATA); - assert(!fmt.buffers); - free(short_record); - } - ctx = (AVCodecContext){0}; - fmt = (FFAMediaFormat){0}; - assert(!avs3_set_extradata(&ctx, &fmt) && !fmt.buffers); - ctx.extradata_size = 6; - assert(avs3_set_extradata(&ctx, &fmt) == AVERROR_INVALIDDATA); - ctx = (AVCodecContext){ .extradata = record, .extradata_size = sizeof(record) }; - record[1] = 0xff; - assert(avs3_set_extradata(&ctx, &fmt) == AVERROR_INVALIDDATA); - record[1] = record[2] = 0; - assert(avs3_set_extradata(&ctx, &fmt) == AVERROR_INVALIDDATA); - ctx.extradata = raw; - ctx.extradata_size = sizeof(raw); - raw[3] = 0xb3; - assert(avs3_set_extradata(&ctx, &fmt) == AVERROR_INVALIDDATA); - ctx.extradata_size = -1; - assert(avs3_set_extradata(&ctx, &fmt) == AVERROR_INVALIDDATA); -} - -static void check_hardware_selection(void) -{ - for (int ndk = 0; ndk <= 1; ndk++) { - reset_platform(1); - have_hardware = 1; - AVCodecContext software_retry = { .codec_id = AV_CODEC_ID_AVS3 }; - MediaCodecDecContext unrequested = { .use_ndk_codec = ndk }; - FFAMediaFormat no_format = {0}; - assert(mediacodec_dec_get_video_codec(&software_retry, &unrequested, - "video/avs3", &no_format) == AVERROR(EINVAL)); - assert(!lookups && !by_name && !by_type); - AVHWDeviceContext wrong_device = { .type = 99 }; - AVBufferRef wrong_ref = { .data = &wrong_device }; - software_retry.hw_device_ctx = &wrong_ref; - assert(mediacodec_dec_get_video_codec(&software_retry, &unrequested, - "video/avs3", &no_format) == AVERROR(EINVAL)); - assert(!lookups && !by_name && !by_type); - for (int no_jvm = 0; no_jvm <= 1; no_jvm++) { - reset_platform(1); - missing_jvm = no_jvm; - have_software = 1; - AVMediaCodecContext requested = {0}; - AVCodecContext ctx = { .codec_id = AV_CODEC_ID_AVS3, .hwaccel_context = &requested }; - MediaCodecDecContext dec = { .use_ndk_codec = ndk }; - FFAMediaFormat fmt = {0}; - assert(mediacodec_dec_get_video_codec(&ctx, &dec, "video/avs3", &fmt) - == AVERROR_DECODER_NOT_FOUND); - assert(lookups == 1 && !by_name && !by_type && !dec.codec); - } - reset_platform(1); - have_hardware = have_software = 1; - int surface; - AVMediaCodecDeviceContext mc = { .surface = &surface }; - AVHWDeviceContext hw = { .type = AV_HWDEVICE_TYPE_MEDIACODEC, .hwctx = &mc }; - AVBufferRef ref = { .data = &hw }; - AVCodecContext ctx = { .codec_id = AV_CODEC_ID_AVS3, .hw_device_ctx = &ref }; - MediaCodecDecContext dec = { .use_ndk_codec = ndk }; - FFAMediaFormat fmt = {0}; - assert(!mediacodec_dec_get_video_codec(&ctx, &dec, "video/avs3", &fmt)); - assert(lookups == 1 && by_name == 1 && !by_type && dec.codec == &component); - assert(dec.surface == &surface && surface_refs == 1 && !strcmp(fmt.mime, "video/avs3")); - free(dec.codec_name); - - reset_platform(1); - have_hardware = create_fails = 1; - dec = (MediaCodecDecContext){ .use_ndk_codec = ndk }; - assert(mediacodec_dec_get_video_codec(&ctx, &dec, "video/avs3", &fmt) == AVERROR_EXTERNAL); - assert(by_name == 1 && !by_type); - free(dec.codec_name); - - reset_platform(1); - have_hardware = profile_mismatch = 1; - ctx.hwaccel_flags = AV_HWACCEL_FLAG_ALLOW_PROFILE_MISMATCH; - fmt = (FFAMediaFormat){ .profile_present = 1, .profile = 123 }; - dec = (MediaCodecDecContext){ .use_ndk_codec = ndk }; - assert(!mediacodec_dec_get_video_codec(&ctx, &dec, "video/avs3", &fmt)); - assert(lookups == 2 && by_name == 1 && !by_type); - free(dec.codec_name); - } -} - -static void check_existing_codecs(void) -{ - /* Preserve the pre-existing HEVC NDK/JVM behavior and audio policy. */ - reset_platform(0); - missing_jvm = 1; - AVCodecContext ctx = { .codec_id = AV_CODEC_ID_HEVC }; - MediaCodecDecContext dec = { .use_ndk_codec = 1 }; - FFAMediaFormat fmt = {0}; - assert(!mediacodec_dec_get_video_codec(&ctx, &dec, "video/hevc", &fmt)); - assert(by_type == 1 && !by_name); - free(dec.codec_name); - reset_platform(1); - have_software = 1; - ctx.codec_id = AV_CODEC_ID_AAC; - dec = (MediaCodecDecContext){ .use_ndk_codec = 1 }; - assert(mediacodec_dec_get_audio_codec(&ctx, &dec, "audio/mp4a-latm", &fmt) == AVERROR_EXTERNAL); - assert(!by_type && !by_name); - have_hardware = 1; - assert(!mediacodec_dec_get_audio_codec(&ctx, &dec, "audio/mp4a-latm", &fmt)); - assert(by_name == 1 && !by_type); - free(dec.codec_name); -} - -int main(void) -{ - check_csd(); - check_hardware_selection(); - check_existing_codecs(); - puts("PASS AVS3 CSD bounds/profile preservation, explicit JNI/NDK hardware selection, Surface forwarding, HEVC/audio neighbors (ASan/UBSan)"); - return 0; -} diff --git a/scripts/verify_avs3_contract.py b/scripts/verify_avs3_contract.py deleted file mode 100644 index 967c386d7c..0000000000 --- a/scripts/verify_avs3_contract.py +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env python3 -"""Verify shipped AVS3 artifacts; optionally run extraction tests and compare a baseline.""" -import argparse -import hashlib -import io -import json -import os -from pathlib import Path -import re -import shutil -import subprocess -import tempfile -import zipfile - -ROOT = Path(__file__).resolve().parents[1] -MAVEN = ROOT / "third_party/maven" -FIXTURES = ROOT / "app/src/test/java/androidx/media3/extractor/fixtures" - - -def require(value, message): - if not value: - raise RuntimeError(message) - - -def sha(data): - return hashlib.sha256(data).hexdigest() - - -def archive(data): - with zipfile.ZipFile(io.BytesIO(data)) as z: - return {n: z.read(n) for n in z.namelist() if not n.endswith("/")} - - -def artifact(group, name, version, suffix): - return MAVEN / group.replace(".", "/") / name / version / (name + "-" + version + suffix) - - -def git_bytes(revision, path): - return subprocess.check_output(["git", "show", revision + ":" + str(path.relative_to(ROOT))], cwd=ROOT) - - -def preserved(old, new, allowed, label): - changed = {n for n in old.keys() | new.keys() if old.get(n) != new.get(n)} - unexpected = changed - allowed - require(not unexpected, f"Unrelated {label} entries changed: {sorted(unexpected)}") - - -def checksums(path): - for item in path.parent.iterdir(): - if item.suffix[1:] in ("md5", "sha1", "sha256", "sha512"): - source = item.with_suffix("") - require(source.is_file(), f"Missing checksum source: {source}") - require(hashlib.new(item.suffix[1:], source.read_bytes()).hexdigest() == item.read_text().strip(), - f"Checksum mismatch: {item}") - metadata = json.loads(path.with_suffix(".module").read_text()) - for variant in metadata["variants"]: - for entry in variant.get("files", []): - data = (path.parent / entry["name"]).read_bytes() - require(len(data) == entry["size"], f"Module size mismatch: {entry['name']}") - for algo in ("md5", "sha1", "sha256", "sha512"): - if algo in entry: - require(hashlib.new(algo, data).hexdigest() == entry[algo], f"Module hash mismatch: {entry['name']}") - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--jvm", action="store_true") - parser.add_argument("--baseline", help="Full pre-AVS3 commit for unrelated-byte preservation checks") - parser.add_argument("--apk", type=Path, help="Check ARM64 APK native entries against the committed candidates") - args = parser.parse_args() - media = json.loads((ROOT / "third_party/media-lock.json").read_text()) - mpv = json.loads((ROOT / "third_party/mpv-native-lock.json").read_text()) - decoder = media["avs3_video"]["decoder"] - for key in ("commit", "sha256"): - require(decoder[key] == mpv["sources"]["uavs3d"][key], "Exo/MPV uavs3d input mismatch") - require(decoder["profiles"] == ["0x20", "0x22"] and decoder["bit_depths"] == [8, 10], - "AVS3 capability declaration needs a new verified contract") - high = media["avs3_video"]["high_decoder"] - require(high == mpv["avs3_high_decoder"], "Exo/MPV HPM inputs differ") - require(high["profiles"] == ["0x32"] and high["bit_depths"] == [10], - "HPM capability declaration needs a new verified contract") - require(sha((ROOT / high["archive"]).read_bytes()) == high["sha256"], "HPM archive hash mismatch") - simd = high["simd_adapter"] - require(sha((ROOT / simd["header"]).read_bytes()) == simd["sha256"], "SIMD source hash mismatch") - for patch in media["fongmi_media"]["patches"] + [media["avs3_video"]["ffmpeg_patch"]]: - require(sha((ROOT / patch["path"]).read_bytes()) == patch["sha256"], f"Patch hash mismatch: {patch['path']}") - fixture_manifest = json.loads((FIXTURES / "manifest.json").read_text()) - for depth, stream in fixture_manifest["streams"].items(): - require(sha((FIXTURES / f"baseline-{depth}.avs3").read_bytes()) == stream["sha256"], "Fixture hash mismatch") - - version = media["nextlib"]["version"] - require(f'nextlib = "{version}"' in (ROOT / "gradle/libs.versions.toml").read_text(), "Gradle nextlib version mismatch") - next_path = artifact("io.github.anilbeesetti", "nextlib-media3ext", version, ".aar") - next_entries = archive(next_path.read_bytes()) - checksums(next_path) - require(sha(next_path.read_bytes()) == media["nextlib"]["avs3_artifact"]["aar_sha256"], "nextlib AAR hash mismatch") - require(sha(next_path.with_name(next_path.stem + "-sources.jar").read_bytes()) == - media["nextlib"]["avs3_artifact"]["sources_sha256"], "nextlib sources hash mismatch") - license_data = next_entries.get("assets/licenses/uavs3d.txt", b"") - require(b"Redistribution and use in source and binary forms" in license_data, "Missing shipped uavs3d license") - require(next_entries.get("assets/licenses/hpm-avs3.txt") == - (ROOT / "third_party/avs3-hpm/LICENSE.HPM").read_bytes(), "Missing original HPM license") - require(b"Permission is hereby granted" in next_entries.get("assets/licenses/sse2neon.txt", b""), - "Missing SSE2NEON license") - next_classes = archive(next_entries["classes.jar"]) - require(b"libuavs3d" in next_classes["io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.class"], - "Shipped nextlib has no AVS3 decoder mapping") - jars = {} - for item in media["fongmi_media"]["artifact_overrides"]: - group, name, ver = item["coordinate"].split(":") - if name not in ("media3-common", "media3-container", "media3-extractor"): - continue - path = artifact(group, name, ver, ".aar") - require(sha(path.read_bytes()) == item["aar_sha256"], f"AAR hash mismatch: {name}") - require(sha(path.with_name(path.stem + "-sources.jar").read_bytes()) == item["sources_sha256"], - f"Sources hash mismatch: {name}") - checksums(path) - jars[name] = (path, archive(path.read_bytes())["classes.jar"]) - extractor = archive(jars["media3-extractor"][1]) - require("androidx/media3/extractor/Avs3Config.class" in extractor and - "androidx/media3/extractor/ts/Avs3Reader.class" in extractor, "Missing shipped AVS3 extraction classes") - - sdk = Path(os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") or "") - readelf = shutil.which("llvm-readelf") or shutil.which("readelf") - if not readelf: - candidates = sorted(sdk.glob("ndk/*/toolchains/llvm/prebuilt/*/bin/llvm-readelf")) - require(candidates, "Set ANDROID_HOME or provide llvm-readelf") - readelf = str(candidates[-1]) - with tempfile.TemporaryDirectory(prefix="webhtv-avs3-") as temporary: - work = Path(temporary) - for abi, flavor, machine in (("arm64-v8a", "arm64_v8a", b"AArch64"), ("armeabi-v7a", "armeabi_v7a", b"ARM")): - exo = next_entries[f"jni/{abi}/libavcodec.so"] - mpv_path = ROOT / f"app/src/{flavor}/assets/mpv-libs/{abi}/libmvcodec.so" - for player, name, data in (("Exo", "libavcodec.so", exo), ("MPV", "libmvcodec.so", mpv_path.read_bytes())): - require(b"libuavs3d" in data and decoder["commit"].encode() in data, f"{player}/{abi} decoder not embedded") - require(b"Unsupported AVS3 profile" in data, f"{player}/{abi} lacks the raw profile guard") - require(b"WebHTV HPM 15.0" in data and high["commit"].encode() in data, - f"{player}/{abi} has no pinned High 10-bit backend") - path = work / name - path.write_bytes(data) - elf = subprocess.check_output([readelf, "-h", "-l", "-d", "--wide", str(path)]) - require(machine in elf and ("Library soname: [" + name + "]").encode() in elf, f"{player}/{abi} ABI or SONAME mismatch") - load_alignment = re.findall(rb"^\s+LOAD\s+.*\s+(0x[0-9a-f]+)\s*$", elf, re.M) - require(load_alignment and all(int(value, 16) >= 16384 for value in load_alignment), - f"{player}/{abi} lost Android 16 KB page alignment") - needed = re.findall(rb"Shared library: \[([^]]+)\]", elf) - require(not any(b"uavs3d" in n or b"webhtvhpm" in n or b"pthread" in n for n in needed), - "AVS3 must be statically linked") - exports = subprocess.check_output([readelf, "--dyn-syms", "--wide", str(path)]) - require(not re.search(rb"GLOBAL\s+DEFAULT\s+(?!UND)\S+\s+(?:webhtv_hpm|com_mc|dec_cnk)", exports), - f"{player}/{abi} leaks internal reference-decoder symbols") - forbidden = (b"libmv", b"libmw") if player == "Exo" else (b"libav", b"libsw") - require(not any(n.startswith(forbidden) for n in needed), f"Cross-player dependency: {player}/{abi}") - print(f"{player}/{abi}: {sha(data)} ({len(data)} bytes)") - - if args.baseline: - require(re.fullmatch(r"[0-9a-f]{40}", args.baseline), "Use the full baseline commit") - old_lock = json.loads(git_bytes(args.baseline, ROOT / "third_party/media-lock.json")) - old_version = old_lock["nextlib"]["version"] - old_next = archive(git_bytes(args.baseline, artifact("io.github.anilbeesetti", "nextlib-media3ext", old_version, ".aar"))) - allowed_entries = {"jni/arm64-v8a/libavcodec.so", "jni/armeabi-v7a/libavcodec.so", - "assets/licenses/hpm-avs3.txt", "assets/licenses/sse2neon.txt"} - if "high_decoder" not in old_lock.get("avs3_video", {}) and "avs3_artifact" not in old_lock["nextlib"]: - allowed_entries.update({"classes.jar", "assets/licenses/uavs3d.txt"}) - preserved(old_next, next_entries, allowed_entries, "nextlib") - old_classes = archive(old_next["classes.jar"]) - allowed = {n for n in old_classes.keys() | next_classes.keys() if re.search(r"/Ffmpeg(?:Library|VideoDecoder|VideoRenderer)(?:\$[^/]*)?\.class$", n)} - preserved(old_classes, next_classes, allowed, "nextlib class") - patch = (ROOT / "third_party/patches/media3-exo-avs3.patch").read_text() - prefixes = re.findall(r"^\+\+\+ b/libraries/[^/]+/src/main/java/(.+)\.java$", patch, re.M) - for name, (path, jar) in jars.items(): - old_aar = archive(git_bytes(args.baseline, path)) - preserved(old_aar, archive(path.read_bytes()), {"classes.jar"}, name) - old_classes, new_classes = archive(old_aar["classes.jar"]), archive(jar) - allowed = {n for n in old_classes.keys() | new_classes.keys() if any(n == p + ".class" or n.startswith(p + "$") for p in prefixes)} - preserved(old_classes, new_classes, allowed, name + " class") - for flavor in ("arm64_v8a", "armeabi_v7a"): - for path in (ROOT / f"app/src/{flavor}/assets/mpv-libs").rglob("*.so"): - if path.name != "libmvcodec.so": - require(git_bytes(args.baseline, path) == path.read_bytes(), f"Unrelated MPV library changed: {path}") - print("All unrelated AAR classes/entries and MPV libraries match the baseline.") - - if args.jvm: - cp = [] - media_version = next(iter(jars.values()))[0].parent.name - for name in ("common", "container", "datasource", "database", "decoder", "extractor", "exoplayer"): - path = artifact("androidx.media3", "media3-" + name, media_version, ".aar") - jar = work / (name + ".jar") - jar.write_bytes(archive(path.read_bytes())["classes.jar"]) - cp.append(str(jar)) - cache = Path(os.environ.get("GRADLE_USER_HOME", str(Path.home() / ".gradle"))) / "caches/modules-2/files-2.1" - for group, name in (("com.google.guava", "guava"), ("junit", "junit"), ("org.hamcrest", "hamcrest-core"), - ("androidx.annotation", "annotation-jvm"), ("org.checkerframework", "checker-qual"), - ("com.google.errorprone", "error_prone_annotations")): - candidates = sorted((cache / group / name).glob("*/*/*.jar")) - require(candidates, f"Resolve Gradle dependency first: {group}:{name}") - cp.append(str(candidates[-1])) - platforms = sorted(sdk.glob("platforms/android-*/android.jar")) - require(platforms, "Set ANDROID_HOME to an SDK with android.jar") - cp.append(str(platforms[-1])) - classes = work / "classes" - classes.mkdir() - java_bin = Path(os.environ["JAVA_HOME"]) / "bin" if os.environ.get("JAVA_HOME") else None - javac = str(java_bin / "javac") if java_bin else "javac" - java = str(java_bin / "java") if java_bin else "java" - subprocess.run([javac, "-encoding", "UTF-8", "-proc:none", "-classpath", os.pathsep.join(cp), "-d", str(classes), - str(ROOT / "app/src/test/java/androidx/media3/extractor/Avs3ExtractionTest.java")], check=True) - subprocess.run([java, "-Davs3.fixtures=" + str(FIXTURES), "-classpath", os.pathsep.join([str(classes)] + cp), - "org.junit.runner.JUnitCore", "androidx.media3.extractor.Avs3ExtractionTest"], check=True, cwd=ROOT) - if args.apk: - apk = archive(args.apk.read_bytes()) - for name, data in next_entries.items(): - if name.startswith("jni/arm64-v8a/"): - require(apk.get(name.replace("jni/", "lib/", 1)) == data, f"APK nextlib mismatch: {name}") - for path in (ROOT / "app/src/arm64_v8a/assets/mpv-libs/arm64-v8a").glob("*.so"): - require(apk.get("assets/mpv-libs/arm64-v8a/" + path.name) == path.read_bytes(), f"APK MPV mismatch: {path.name}") - require(apk.get("assets/licenses/uavs3d.txt") == license_data, "APK license missing") - for name in ("hpm-avs3.txt", "sse2neon.txt"): - entry = "assets/licenses/" + name - require(apk.get(entry) == next_entries[entry], "APK license mismatch: " + name) - print("APK native entries and license match the verified candidates.") - print("AVS3 artifact contract passed: baseline 0x20/0x22 and HPM 0x32; no 0x30 or real-time claim.") - - -if __name__ == "__main__": - main() diff --git a/scripts/verify_crawler_web_logs.py b/scripts/verify_crawler_web_logs.py deleted file mode 100644 index 3ba7003ae6..0000000000 --- a/scripts/verify_crawler_web_logs.py +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env python3 -"""Render the actual Java-generated log page and check it with an existing Playwright install.""" - -import argparse -import json -import os -from pathlib import Path -import subprocess - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--out", type=Path, required=True) - parser.add_argument("--java-home", default=os.environ.get("JAVA_HOME", "")) - parser.add_argument("--playwright", help="Path to an installed Playwright module; omit to generate only") - args = parser.parse_args() - root = Path(__file__).resolve().parents[1] - out = args.out.resolve() - out.mkdir(parents=True, exist_ok=True) - java = Path(args.java_home) / "bin" - source = (root / "app/src/main/java/com/fongmi/android/tv/server/process/DebugLogs.java").read_text() - methods = source[source.index(" private String html() {"):source.rindex("}")] - rows = [ - "quickjs: [DEBUG] initialized", - "quickjs: [INFO] no error occurred", - "quickjs: [WARN] careful", - "quickjs: [ERROR] explicit failure", - "python-spider: [INFO] module-import-output", - "python-spider: [ERROR] ERROR:broken", - "python-spider: [STDERR] ordinary stderr", - 'python-spider: [INFO] ', - "webview-console: original console remains", - 'av-diag: {"schemaVersion":1,"event":"audio.output","level":"info","trace":"fixture","attemptId":1}', - ] + [f"player: fixture row {i} " + "long-text-" * 10 for i in range(300)] - raw = "".join("2026-09-15 16:00:00 [fixture] " + row + "\n" for row in rows) - fixture = """ -public class PageFixture { - static class TextUtils { static boolean isEmpty(String s) { return s == null || s.isEmpty(); } } - static class DebugLogStore { - static boolean isEnabled() { return Boolean.parseBoolean(System.getProperty("fixture.enabled", "true")); } - static int version() { return 1; } - static int bytes() { return 12345; } - static String text() { return RAW; } - } - static class Server { - static Server get() { return new Server(); } - String getAddress(String path) { return "http://127.0.0.1:9978" + path; } - String getAddress(boolean local) { return "http://192.0.2.1:9978"; } - } - public static void main(String[] args) throws java.io.IOException { - System.out.write(new PageFixture().html().getBytes(java.nio.charset.StandardCharsets.UTF_8)); - } -""" - fixture += " static final String RAW = " + json.dumps(raw, ensure_ascii=True) + ";\n" - fixture += methods + "}\n" - (out / "PageFixture.java").write_text(fixture) - subprocess.run([str(java / "javac"), "-d", str(out), str(out / "PageFixture.java")], check=True) - with (out / "logs.html").open("w") as target: - subprocess.run([str(java / "java"), "-cp", str(out), "PageFixture"], stdout=target, check=True) - with (out / "logs-disabled.html").open("w") as target: - subprocess.run([str(java / "java"), "-Dfixture.enabled=false", "-cp", str(out), "PageFixture"], stdout=target, check=True) - assert "调试日志" in (out / "logs.html").read_text(encoding="utf-8"), "Fixture must preserve UTF-8 UI text" - (out / "browser.cjs").write_text(BROWSER) - print(f"Generated actual DebugLogs HTML: {out / 'logs.html'}", flush=True) - if args.playwright: - subprocess.run(["node", str(out / "browser.cjs"), args.playwright, str(out)], check=True) - - -BROWSER = r""" -const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'); -const {chromium}=require(process.argv[2]); -const out=process.argv[3],html=fs.readFileSync(path.join(out,'logs.html'),'utf8'),disabledHtml=fs.readFileSync(path.join(out,'logs-disabled.html'),'utf8'); -(async()=>{ - const browser=await chromium.launch({headless:true,executablePath:process.env.WEBHTV_CHROMIUM||undefined}); - const metrics=[]; - try { - for(const [width,height] of [[320,640],[390,844],[844,390],[1440,900]]) { - const page=await browser.newPage({viewport:{width,height}}); - page.setDefaultTimeout(5000); - const errors=[],requests=[]; - let captureEnabled=true; - page.on('pageerror',e=>errors.push(e.message)); - await page.route('**/*',route=>{ - const req=route.request(),pathname=new URL(req.url()).pathname; - requests.push({pathname,method:req.method(),authorization:req.headers().authorization,origin:req.headers().origin}); - if(pathname==='/debug/logs')return route.fulfill({contentType:'text/html',body:captureEnabled?html:disabledHtml}); - if(pathname==='/debug/stream')return route.fulfill({json:{enabled:true,runId:'fixture',generation:1,newestSeq:310,text:'',bytes:12345,health:{}}}); - if(pathname==='/debug/diag/status')return route.fulfill({json:{engine:'MPV',deepRemainingMs:0}}); - if(pathname==='/debug/logs.txt')return route.fulfill({contentType:'text/plain',headers:{'Content-Disposition':'attachment; filename=webhtv-debug-log.txt'},body:'fixture log'}); - if(pathname==='/debug/diag/export')return route.fulfill({contentType:'application/zip',body:Buffer.from('fixture archive')}); - if(pathname==='/debug/disable')captureEnabled=false; - if(pathname==='/debug/enable')captureEnabled=true; - return route.fulfill({json:{}}); - }); - await page.goto('http://webhtv.test/debug/logs'); - await page.waitForFunction(()=>document.querySelectorAll('#logs .entry').length===310); - const g=await page.evaluate(()=>{ - const rect=q=>{const r=document.querySelector(q).getBoundingClientRect();return{x:r.x,y:r.y,right:r.right,bottom:r.bottom,width:r.width,height:r.height}}; - const tabs=document.querySelector('.tabs'); - return{header:rect('.console-head'),actions:rect('.primary-actions'),brand:rect('.brand'),search:rect('#filter'),download:rect('#download'),clear:rect('#clear'),pause:rect('#pause'),tools:rect('#tools-open'), - pageWidth:document.documentElement.scrollWidth,tabYs:[...document.querySelectorAll('.chip')].map(e=>e.getBoundingClientRect().y), - tabWidth:tabs.clientWidth,tabScrollWidth:tabs.scrollWidth,searchHidden:document.getElementById('search-panel').hidden}; - }); - console.log('layout',width,JSON.stringify(g)); - assert(g.pageWidth<=width,'page overflow'); - assert(g.header.height<=(width<800?100:85),'default header is not two compact rows'); - assert(g.brand.right<=g.actions.x,'brand overlaps primary actions'); - assert(g.actions.right<=width&&g.pause.right<=g.tools.x,'primary actions overlap'); - assert(g.download.right<=g.clear.x&&g.clear.right<=g.pause.x,'download or clear overlaps'); - for(const action of [g.download,g.clear,g.pause,g.tools])assert(action.height>=(width<800?44:40)&&action.width>=44,'touch target too small'); - assert.equal(await page.locator('#diag-code,#diag-pair,#diag-compare,.compare-fields').count(),0,'obsolete pairing/comparison controls remain'); - assert.equal(new Set(g.tabYs).size,1,'category tabs wrap'); - if(width<800){assert(g.searchHidden);assert(g.tabScrollWidth>g.tabWidth)} - else {assert(!g.searchHidden);assert(g.search.width<=260,'desktop search too wide')} - await page.screenshot({path:path.join(out,'logs-'+width+'.png')}); - await page.evaluate(()=>scrollTo(0,1200)); - await page.waitForFunction(()=>Math.abs(document.querySelector('.console-head').getBoundingClientRect().y)<1); - for(const id of ['download','clear'])assert(await page.locator('#'+id).evaluate(e=>{const r=e.getBoundingClientRect();return r.top>=0&&r.bottom<=innerHeight&&e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2))}),'top action unavailable after scrolling'); - await page.locator('#tools-open').click(); - await page.waitForFunction(()=>document.getElementById('log-drawer').open); - const bounds=await page.locator('#log-drawer').boundingBox(); - assert(bounds.x>=0&&bounds.y>=0&&bounds.x+bounds.width<=width+1&&bounds.y+bounds.height<=height+1,'drawer outside viewport'); - assert.equal(await page.locator('[role=tabpanel]:visible').count(),1); - await page.locator('#tool-tab-filters').focus(); - await page.keyboard.press('End'); - assert.equal(await page.locator('#tool-tab-utilities').getAttribute('aria-selected'),'true'); - await page.locator('#info-toggle').click(); - assert(await page.locator('#address-panel').isVisible()); - assert(await page.locator('#info-toggle').evaluate(e=>e.previousElementSibling.dataset.debugAction==='/debug/disable')); - await page.evaluate(()=>{ - const nodes=[...document.querySelectorAll('#log-drawer button,#log-drawer a[href],#log-drawer input,#log-drawer select')].filter(e=>!e.disabled&&e.tabIndex>=0&&e.getClientRects().length); - nodes[nodes.length-1].focus(); - }); - await page.keyboard.press('Tab'); - assert(await page.evaluate(()=>document.getElementById('log-drawer').contains(document.activeElement)),'focus escaped modal'); - await page.keyboard.press('Escape'); - await page.waitForFunction(()=>!document.body.classList.contains('drawer-open')); - assert.equal(await page.evaluate(()=>document.activeElement.id),'tools-open'); - await page.locator('[data-mode=console]').click(); - assert.equal(await page.locator('#logs .entry').count(),9); - assert.equal(await page.locator('#logs .entry.err').count(),2); - assert.equal(await page.locator('#logs img').count(),0); - assert.equal(await page.evaluate(()=>!!window.injected),false); - await page.locator('[data-mode=error]').click(); - assert.equal(await page.locator('#logs .entry').count(),2); - await page.locator('[data-mode=console]').click(); - if(width<800)await page.locator('#search-toggle').click(); - await page.locator('#filter').fill('MODULE-IMPORT'); - assert.equal(await page.locator('#logs .entry').count(),1); - assert.equal(await page.locator('#filter-count').textContent(),'1'); - if(width<800){await page.locator('#search-hide').click();assert(await page.locator('#search-toggle').evaluate(e=>e.classList.contains('on')))} - await page.locator('#pause').click(); - const before=requests.filter(r=>r.pathname==='/debug/stream').length; - await page.evaluate(()=>poll()); - assert.equal(requests.filter(r=>r.pathname==='/debug/stream').length,before,'pause still polls logs'); - await page.locator('#tools-open').click(); - await page.locator('#tool-tab-filters').click(); - await page.locator('#simple').check(); - assert.equal(await page.locator('#logs .rawline').evaluate(e=>getComputedStyle(e).display),'none'); - await page.locator('#filter-reset').click(); - assert.equal(await page.locator('#logs .entry').count(),310); - await page.locator('#diag-domain').selectOption('audio'); - assert.equal(await page.locator('#logs .entry').count(),1); - await page.locator('#tool-tab-diagnostics').click(); - await page.screenshot({path:path.join(out,'drawer-'+width+'.png')}); - await page.locator('#diag-mark').click(); - await page.waitForFunction(()=>document.getElementById('diag-feedback').textContent.includes('已标记')); - page.once('dialog',dialog=>dialog.accept()); - await page.locator('#diag-deep').click(); - await page.waitForFunction(()=>document.getElementById('diag-feedback').textContent.includes('已开启')); - await page.locator('#diag-stop').click(); - await page.waitForFunction(()=>document.getElementById('diag-feedback').textContent.includes('已停止')); - await page.locator('#tool-tab-utilities').click(); - const [archive]=await Promise.all([page.waitForEvent('download'),page.locator('#diag-zip').click()]); - assert.equal(archive.suggestedFilename(),'webhtv-av-report.zip'); - await page.locator('#tools-close').click(); - await page.waitForFunction(()=>!document.getElementById('log-drawer').open); - if(width===1440){ - await page.evaluate(()=>document.getElementById('log-drawer').showModal=undefined); - await page.locator('#tools-open').click(); - assert.equal(await page.locator('main').getAttribute('aria-hidden'),'true'); - await page.keyboard.press('Escape'); - await page.waitForFunction(()=>!document.body.classList.contains('drawer-open')); - assert.equal(await page.locator('main').getAttribute('aria-hidden'),null); - } - const [download]=await Promise.all([page.waitForEvent('download'),page.locator('#download').click()]); - assert.equal(download.suggestedFilename(),'webhtv-debug-log.txt'); - assert.equal(await page.locator('#pause').getAttribute('aria-pressed'),'true'); - await Promise.all([page.waitForEvent('load'),page.locator('#clear').click()]); - if(width===1440){ - for(const action of ['disable','enable']){ - await page.locator('#tools-open').click(); - await page.locator('#tool-tab-utilities').click(); - await Promise.all([page.waitForEvent('load'),page.locator('[data-debug-action="/debug/'+action+'"]').click()]); - } - } - for(const endpoint of ['mark','deep','stop','export'])assert(requests.some(r=>r.pathname==='/debug/diag/'+endpoint&&r.method==='POST'),'missing unpaired '+endpoint); - assert(requests.some(r=>r.pathname==='/debug/clear'&&r.method==='POST'),'clear must POST'); - assert(!requests.some(r=>r.pathname==='/debug/diag/pair'||r.pathname==='/debug/diag/compare'),'obsolete endpoint used'); - for(const request of requests.filter(r=>r.method==='POST')){ - assert.equal(request.authorization,undefined,'pairing authorization still required'); - assert.equal(request.origin,'http://webhtv.test','control request must identify its origin'); - } - assert.deepEqual(errors,[],'JavaScript errors'); - metrics.push({width,height,headerHeight:g.header.height,searchHidden:g.searchHidden,drawer:bounds}); - await page.close(); - } - fs.writeFileSync(path.join(out,'browser-result.json'),JSON.stringify({passed:true,metrics},null,2)); - console.log('PASS: actual Java HTML, four viewports, fixed download/clear, two-row header, touch targets, horizontal categories, Console severity, search/pause, modal bounds/focus/Escape, tabs, unpaired same-origin POST controls, downloads, filters and legacy dialog fallback'); - } finally {await browser.close()} -})().catch(e=>{console.error(e);process.exitCode=1}); -""" - - -if __name__ == "__main__": - main() diff --git a/scripts/verify_exo_ass.py b/scripts/verify_exo_ass.py deleted file mode 100644 index 16a067ba76..0000000000 --- a/scripts/verify_exo_ass.py +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env python3 -"""Verify the scoped E4-LIBASS publication; optionally check APKs and sanitize mask copies.""" -import argparse -import hashlib -import io -import json -import os -from pathlib import Path -import platform -import re -import struct -import subprocess -import zipfile - -ROOT = Path(__file__).resolve().parents[1] -NATIVE = ROOT / "third_party/exo-ass-native" -BASE = "845ce82c64b16c94a1db7a61ed6bd38d2d1d35ac" -VERSION = "1.11.0-alpha01-fongmi" -MAVEN = Path("third_party/maven/androidx/media3/media3-exoplayer") / VERSION -NAME = "media3-exoplayer-" + VERSION -PREFIX = "androidx/media3/exoplayer/text/TextRenderer" - - -def require(condition, message): - if not condition: - raise ValueError(message) - - -def digest(data, algorithm="sha256"): - return hashlib.new(algorithm, data).hexdigest() - - -def archive(data): - with zipfile.ZipFile(io.BytesIO(data)) as z: - return {name: z.read(name) for name in z.namelist()} - - -def original(path): - return subprocess.check_output(["git", "show", BASE + ":" + str(path)], cwd=ROOT) - - -def sdk_path(): - sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") - if not sdk: - sdk = next(line.split("=", 1)[1] for line in (ROOT / "local.properties").read_text().splitlines() - if line.startswith("sdk.dir=")) - return Path(sdk) - - -def verify_native(work): - lock = json.loads((ROOT / "third_party/exo-ass-lock.json").read_text()) - provenance = json.loads((NATIVE / "build-provenance.json").read_text()) - require(lock["android"] == provenance["android"], "Android provenance mismatch") - require(lock["android"]["api"] == 24 and lock["android"]["abis"] == ["arm64-v8a"], "Prototype ABI/API drift") - for name, source in lock["sources"].items(): - identity = source.get("commit") or source["sha256"] - require(bool(re.fullmatch(r"[0-9a-f]{40}" if "commit" in source else r"[0-9a-f]{64}", identity)), - "Unpinned source: " + name) - require(provenance["sources"][name] == identity, "Source provenance mismatch: " + name) - for notice in source["licenses"]: - path = "licenses/" + name + "/" + notice - require(digest((NATIVE / path).read_bytes()) == provenance["licenses"][path], "License mismatch: " + path) - for name, sha in provenance["inputs"].items(): - require(digest((ROOT / name).read_bytes()) == sha, "Rebuild required after input change: " + name) - lib = NATIVE / provenance["artifact"]["path"] - data = lib.read_bytes() - require(("meson, locked commit: " + lock["sources"]["libass"]["commit"]).encode() in data, - "libass source-version stamp must match its locked commit") - sha = digest(data) - require(sha == provenance["artifact"]["sha256"], "Native provenance mismatch") - require(len(data) == provenance["artifact"]["bytes"], "Native size mismatch") - require((NATIVE / "MANIFEST.sha256").read_text().strip() == sha + " " + provenance["artifact"]["path"], - "Native manifest mismatch") - require(data[:6] == b"\x7fELF\x02\x01" and struct.unpack_from("= 16384 and offset % 16384 == vaddr % 16384, "Invalid ELF LOAD alignment") - require(flags & 3 != 3, "Writable executable LOAD") - alignment.append(align) - if kind == 4: - end = offset + filesz - while offset + 12 <= end: - namesz, descsz, note_type = struct.unpack_from("video_out->extra.android_dovi_fel && el", - "VO_CAP_GPU_DOVI_EL_SW", - "track->stream->codec->dv_profile == 7", - "info->force_swdec = true", - ".force_swdec = sinfo->force_swdec", - "AV_CODEC_CAP_HARDWARE | AV_CODEC_CAP_HYBRID", - "!ctx->force_swdec &&", - "ctx->hwdec_opts->software_fallback == INT_MAX", - ): - require(marker in additions, f"missing FEL safety contract: {marker}") - require(not re.search(r"android_dovi_fel\s*=\s*(?:true|1)\b", additions), - "FEL must never become a native default") - build_script = (ROOT / "scripts/build_mpv_native.sh").read_text() - require('apply --recount "$MPV_ANDROID_FEL_PATCH"' in build_script, - "the production native build must apply the FEL patch") - - if args.mpv_source: - source = args.mpv_source.resolve() - subprocess.run(["git", "-C", str(source), "apply", "--reverse", "--check", - "--recount", str(PATCH)], check=True) - pair = (source / "filters/f_enhancement_pair.c").read_text() - decoder = (source / "video/decode/vd_lavc.c").read_text() - renderer = (source / "video/out/vo_gpu_next.c").read_text() - wrapper = (source / "filters/f_decoder_wrapper.c").read_text() - vo = (source / "video/out/vo.c").read_text() - stable = (source / "video/out/hwdec/hwdec_aimagereader_vk_stable.c").read_text() - timing = (source / "filters/f_android_fel_perf.h").read_text() - require("CLOCK_THREAD_CPUTIME_ID" in timing and "bits < 64" in timing, - "FEL diagnostics must distinguish thread CPU and valid-bit GPU timestamps") - collect = stable[stable.index("static void collect_fel_gpu_time("): - stable.index("static void init_fel_gpu_timer(")] - finish = stable[stable.index("static bool finish_output("): - stable.index("static void destroy_output(")] - require("VK_QUERY_RESULT_WAIT_BIT" not in collect - and "VK_QUERY_RESULT_64_BIT" in collect - and "output->fel_query_recorded = false" in collect - and "p->fel_query_failed = true" in collect, - "optional GPU timing cannot wait, reuse old results, or fail playback") - require(finish.index('"waiting for AHardwareBuffer conversion"') - < finish.index("collect_fel_gpu_time(p, output)") - < finish.index("vkResetFences("), - "read GPU timestamps only after this copy fence succeeds and before reset") - require("p->fel_profile = p->android_fel && mp_msg_test(p->log, MSGL_INFO)" in stable - and "output->fel_query_recorded = p->fel_query_pool && !p->fel_query_failed" in stable, - "timing must be explicit-FEL/log gated and disable cleanly when unavailable") - require("WebHTV FEL decoder threads:" in decoder - and "avctx->active_thread_type" in decoder - and "GPU last-known pass averages, not frame wall time" in renderer, - "diagnostics must report actual thread configuration and label cached GPU samples") - prepare = stable[stable.index("static bool prepare_conversion("): - stable.index("static bool submit_conversion(")] - reuse_hit = prepare[prepare.index("if (hit)"): - prepare.index("p->recording_misses++")] - require("p->android_fel && input->initialized && !input->removed && output->written" in prepare - and "if (output->pending)" in prepare - and reuse_hit.index("record->valid = false") - < reuse_hit.index("record_conversion") - < reuse_hit.index("record->valid = true") - < reuse_hit.index("output->active_command = record->command") - and "update_conversion_descriptor" not in reuse_hit - and "if (!p->push_descriptors)" in reuse_hit - and "p->descriptor_content_hits++" in reuse_hit - and "output->fel_query_recorded = record->timestamps" in reuse_hit, - "completed FEL content hits skip writes but must bind and record before selection") - require("record->input_view == input->view" in stable - and "record->output_view == output->view" in stable - and "record->input_view = input->view" in prepare - and "record->output_view = output->view" in prepare, - "descriptor content identity must include both exact image views") - require("#define FEL_INPUT_CACHE_SIZE 32" in stable - and "#define FEL_RECORD_CACHE_SIZE 128" in stable - and "p->android_fel ? FEL_INPUT_CACHE_SIZE : INPUT_CACHE_SIZE" in stable - and ".flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT" in stable - and "command-mode=fresh-bind-record replay=0" in stable, - "FEL metadata reuse stays bounded; every frame uses a fresh one-shot recording") - descriptor_layout = stable[stable.index("static bool create_conversion_descriptor_layout("): - stable.index("static bool create_pipeline(")] - pipeline = stable[stable.index("static bool create_pipeline("): - stable.index("static int output_sample_depth(VkFormat format)\n{")] - require("if (p->android_fel)" in descriptor_layout - and "has_extension(p->vk, VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME)" in descriptor_layout - and "sampler_descriptors < max_push" in descriptor_layout - and "push-layout-failed" in descriptor_layout - and pipeline.index("if (p->push_descriptors)") < pipeline.index("vkCreateDescriptorPool("), - "push descriptors require enabled capabilities and a set-layout fallback without pool allocation") - require("p->CmdPushDescriptorSetKHR(command, VK_PIPELINE_BIND_POINT_COMPUTE" in stable - and "FEL_API_PUSH_DESCRIPTORS" in stable - and "if (!p->push_descriptors && !p->recording_pool)" in stable - and "if (!p->push_descriptors && !record->descriptor)" in stable - and "if (!p->push_descriptors)\n vkUpdateDescriptorSets" in stable, - "push layouts cannot allocate/update ordinary descriptor sets, including extra output slots") - destroy = stable[stable.index("static void destroy_conversion_resources("): - stable.index("static int ycbcr_format_depth(")] - require(destroy.index("finish_output(p, &p->outputs[n], UINT64_MAX)") - < destroy.index("destroy_recording_cache(p)") - < destroy.index("destroy_output(p, &p->outputs[n])"), - "cached commands must finish and be freed before referenced resources are destroyed") - for release in ("vkDestroyPipelineLayout(", "vkDestroyDescriptorSetLayout(", - "vkDestroySampler(", "vkDestroySamplerYcbcrConversion("): - require(destroy.index("destroy_recording_cache(p)") < destroy.index(release), - "descriptor pool lifetime must end before immutable layout/sampler/YCbCr objects") - destroy_input = stable[stable.index("static void destroy_input("): - stable.index("static void destroy_recording_cache(")] - require(destroy_input.index("invalidate_input_recordings(p, input)") - < destroy_input.index("vkDestroyImageView("), - "input generation must be invalidated before Vulkan can recycle a view handle") - require("#define FEL_LATENCY_SAMPLES 128" in stable - and "latency-window:%s" in stable - and "descriptor-content-cache=1" in stable, - "content reuse and actual bind/map latency must remain separately observable and bounded") - require("recording_cache_disabled = true" in stable - and "WebHTV FEL reuse:" in stable and "WebHTV FEL api perf:" in stable, - "optional reuse failure must stop allocation retries and remain diagnosable") - require("#define FEL_ORDER_CAPACITY 8" in stable - and "WebHTV FEL frame order:" in stable - and "WebHTV FEL api slow:" in stable - and "p->fel_slow_count < 4" in stable - and "FEL_API_BARRIER_IN" in stable and "FEL_API_DISPATCH" in stable, - "frame identity and slow-call evidence must be bounded and stage-specific") - app = (ROOT / "app/src/main/java/androidx/media3/mpvplayer/MpvPlayer.java").read_text() - callback = app[app.index("public void logMessage("):app.index("private void openCurrent(")] - fast_log = callback[callback.index("if (performanceKind >= 0)"): - callback.index("String line = MpvDiagnosticsPolicy.redactSensitive")] - collector = (ROOT / "app/src/main/java/androidx/media3/mpvplayer/MpvDiagnosticCollector.java").read_text() - native_log = collector[collector.index("synchronized void nativeLog("): - collector.index("static boolean isHook(")] - sink = (ROOT / "app/src/main/java/com/fongmi/android/tv/player/PlaybackDiagnosticCollector.java").read_text() - require(callback.index("diagnostics.nativeLog(prefix, level, text)") - < callback.index("if (performanceKind >= 0)") - and "log.emitNative(" in native_log and "DebugLogStore.event(event)" in sink - and "postToMain(" not in native_log and "PlaybackTrace.log(" not in native_log - and "return;" in fast_log - and "postToMain(" not in fast_log and "PlaybackTrace.log(" not in fast_log, - "pure FEL measurements must not enqueue UI work or duplicate pretty Logcat output") - require("WebHTV FEL wait sample:" in stable and "capability-v=1" in stable - and "MP_TIME_MS_TO_NS(3000)" in stable - and "poll(&state, 1, 0)" in stable and "RUSAGE_THREAD" in stable, - "FEL waiting diagnostics must remain bounded, per-thread and non-waiting") - output = wrapper[wrapper.index("output_frame:\n", wrapper.index("static void read_frame(")):] - require(output.index("stage_fel_before_publish(p, frame)") < output.index("mp_pin_in_write(pin, frame)"), - "FEL BL must return its source before publishing to the decoder queue") - require("mp_android_fel_staging_ready(image->android_fel_staging->data)" in wrapper, - "a texture handle alone is not safe source handoff") - handoff = wrapper[wrapper.index("static int stage_fel_before_publish("): - wrapper.index("static void read_frame(")] - require("mp_sleep" not in handoff and "mp_dispatch_queue_process" not in handoff, - "pending FEL must return to filter dispatch, not wait on a live stack frame") - require("p->fel_stage_frame = frame;" in output - and "p->fel_stage_frame.type ? 0.002 : INFINITY" in wrapper - and "mp_filter_mark_async_progress(p->decf)" in wrapper, - "pending frame ownership and interruptible decoder scheduling must be connected") - require(re.search(r"if \(p->fel_stage_failed\)\s*return;", wrapper) - and "if (!p->decoder || p->fel_stage_failed)" not in wrapper, - "FEL failure must not repeatedly enqueue zero-weight EOF signals") - require("reset_fel_staging(p);\n mp_filter_reset(p->dec_root_filter);" in wrapper - and "reset_fel_staging(p);\n p->request_terminate_dec_thread = 1;" in wrapper, - "reset/stop must cancel the pending lease before resetting/terminating decode") - stable_map = stable[stable.index("int aimagereader_vk_stable_map("):] - require("mp_android_fel_staging_begin_init(" in stable_map - and "mp_android_fel_staging_end_init(" in stable_map - and stable_map.index("mp_android_fel_staging_begin_init(") - < stable_map.index("input = create_input(") - < stable_map.index("mp_android_fel_staging_end_init(") - and "MP_ANDROID_FEL_INIT_TIMEOUT_NS" in vo - and "MP_ANDROID_FEL_FRAME_TIMEOUT_NS" in vo, - "mapper cold initialization must retain its separate bounded deadline") - draw = vo[vo.index("static bool render_frame("):] - require("in->fel_render_init_attempted" in vo - and "MPMAX(in->fel_prepare_phase_started, in->fel_render_init_started)" in vo - and draw.index("begin_fel_render_init(vo, frame)") - < draw.index("in->visible = vo->driver->draw_frame(vo, frame)") - < draw.index("end_fel_render_init(vo)"), - "first actual draw needs bounded renderer warmup independent of producer polling") - require(vo.index("if (same_request)") < vo.index("} else if (image->android_fel_prepared"), - "the prepared fast path must acknowledge its own single-slot request") - require(re.search(r"if \(result == VO_TRUE &&\s*" - r"!mp_android_fel_staging_ready\(image->android_fel_staging->data\)\)\s*" - r"result = VO_FALSE;", vo), - "mapped-but-unreturned sources must retain the pending request") - release = stable[stable.index("static bool release_fel_source_async("): - stable.index("static bool has_ycbcr_conversion(")] - require(release.index("AImage_deleteAsync(") < release.index("mp_android_fel_staging_release_fenced("), - "publish fence-backed completion only after Android owns the image/fd") - require("{output->ready, output->source_release}" in stable - and "VK_EXTERNAL_SEMAPHORE_FEATURE_EXPORTABLE_BIT" in stable - and "p->android_fel && p->release_sync_fd &&" in stable, - "FEL source-fence export must be capability gated and independent of rendering") - require("#define PTS_MATCH_TOLERANCE 1e-6" in pair and "#define QUEUE_MAX 16" in pair, - "keep the upstream bounded PTS matching contract") - require("p->el_eof" in pair and "static void pair_reset" in pair, - "pairing must still handle EOF and seek/reset") - require("frame->enhancement_layer = &fp->el_frame" in renderer - and "upload_planes_sw(vo, gpu, el" in renderer, - "gpu-next must compile the software EL upload path") - require(re.search(r"else if \(!ctx->force_swdec &&\s*" - r"ctx->hwdec_opts->software_fallback == INT_MAX\)", decoder), - "disabled BL fallback must not reject explicitly software EL") - require("while (!ctx->avctx);" not in decoder, - "decoder failure must honor forced EOF instead of retrying forever") - require("#if !HAVE_ANDROID\n VO_CAP_GPU_DOVI_EL |" in renderer, - "ordinary Android playback must not acquire hardware EL capability") - print("PASS: opt-in FEL gate, software-only EL, BL fallback isolation, and build reachability") - - -if __name__ == "__main__": - main() diff --git a/scripts/verify_mpv_native_assets.sh b/scripts/verify_mpv_native_assets.sh index 92fe31204e..972b4e6baf 100755 --- a/scripts/verify_mpv_native_assets.sh +++ b/scripts/verify_mpv_native_assets.sh @@ -186,10 +186,6 @@ verify_abi() { contains_string "$directory/libmpv.so" "mpv v$MPV_VERSION" contains_string "$directory/libmpv.so" "v$LIBPLACEBO_VERSION" contains_string "$directory/libmpv.so" "WebHTV stream_cb controls enabled" - contains_string "$directory/libmpv.so" "disc-menu-active" - contains_string "$directory/libmpv.so" "discnav" - contains_string "$directory/libmpv.so" "WebHTV event-only disc navigation polling enabled" - contains_string "$directory/libmpv.so" "WebHTV MediaCodec reset discards pending output" contains_string "$directory/libmpv.so" "Vulkan AImageReader backend:" contains_string "$directory/libmpv.so" "Using Vulkan YCbCr AHardwareBuffer sampling" contains_string "$directory/libmpv.so" "Vulkan AImageReader sync-fd:" @@ -201,41 +197,6 @@ verify_abi() { contains_string "$directory/libmpv.so" "DV7 HDR10 fallback: synchronized decoder parameters to the HDR10 base layer." contains_string "$directory/libmpv.so" "DV7 HDR10 fallback: failed to produce base-layer packet." contains_string "$directory/libmpv.so" "DV7 P8.1 conversion: removed stale enhancement-layer configuration." - contains_string "$directory/libmpv.so" "android-dovi-fel" - contains_string "$directory/libmpv.so" "WebHTV Android FEL: software enhancement-layer decoder" - contains_string "$directory/libmpv.so" "WebHTV FEL GPU input: matched EL uploaded with active NLQ." - contains_string "$directory/libmpv.so" "WebHTV FEL decoder queue:" - contains_string "$directory/libmpv.so" "WebHTV FEL stats:" - contains_string "$directory/libmpv.so" "WebHTV FEL fatal:" - contains_string "$directory/libmpv.so" "WebHTV FEL GPU staging:" - contains_string "$directory/libmpv.so" "WebHTV FEL GPU pool:" - contains_string "$directory/libmpv.so" "WebHTV FEL status snapshot:" - contains_string "$directory/libmpv.so" "WebHTV FEL pipeline diagnostics v=1 enabled;" - contains_string "$directory/libmpv.so" "WebHTV FEL pipeline:" - contains_string "$directory/libmpv.so" "WebHTV FEL dropped-frame staging:" - contains_string "$directory/libmpv.so" "WebHTV FEL BL input isolation: pure-bl;" - contains_string "$directory/libmpv.so" "WebHTV FEL producer handoff:" - contains_string "$directory/libmpv.so" "WebHTV FEL decoder cost:" - contains_string "$directory/libmpv.so" "WebHTV FEL source release:" - contains_string "$directory/libmpv.so" "WebHTV FEL map cost:" - contains_string "$directory/libmpv.so" "WebHTV FEL GPU init:" - contains_string "$directory/libmpv.so" "WebHTV FEL perf:" - contains_string "$directory/libmpv.so" "WebHTV FEL perf stages:" - contains_string "$directory/libmpv.so" "WebHTV FEL reuse:" - contains_string "$directory/libmpv.so" "descriptor-content-cache=1" - contains_string "$directory/libmpv.so" "latency-window:" - contains_string "$directory/libmpv.so" "WebHTV FEL api perf:" - contains_string "$directory/libmpv.so" "WebHTV FEL frame order:" - contains_string "$directory/libmpv.so" "command-mode=fresh-bind-record replay=0." - contains_string "$directory/libmpv.so" "WebHTV FEL descriptors:" - contains_string "$directory/libmpv.so" "WebHTV FEL renderer init:" - contains_string "$directory/libmpv.so" "WebHTV FEL render perf:" - contains_string "$directory/libmpv.so" "WebHTV FEL decoder threads:" - contains_string "$directory/libmpv.so" "WebHTV FEL handoff timeout: phase=" - contains_string "$directory/libmpv.so" "wait=async." - contains_string "$directory/libmpv.so" "BL-input={isolated=" - contains_string "$directory/libplayer.so" "eventPropertyNode" - contains_string "$directory/libplayer.so" "bounded NODE snapshot rejected" reject_string "$directory/libmpv.so" "DV7 HDR10 fallback: preserving decoder input and stripping Dolby Vision frame metadata." reject_string "$directory/libmpv.so" "Using device native output sample rate for passthrough compatibility" contains_string "$directory/libmpv.so" "Using 7.1 IEC61937 carrier mask for TrueHD" @@ -249,20 +210,11 @@ verify_abi() { contains_string "$directory/libmpv.so" "WebHTV AImageReader uses stable release/acquire flow" contains_string "$directory/libmpv.so" "Using declared Matroska segment end for seek metadata." contains_string "$directory/libmvcodec.so" "libarcdav3a AV3A" - contains_string "$directory/libmvcodec.so" "libuavs3d" - contains_string "$directory/libmvcodec.so" "avs3_mediacodec" - contains_string "$directory/libmvcodec.so" "WebHTV hardware AVS3 MediaCodec decoder:" - contains_string "$directory/libmvcodec.so" "No hardware AVS3 MediaCodec decoder" - contains_string "$directory/libmvcodec.so" "AVS3 MediaCodec requires an explicit hardware context" contains_string "$directory/libmvcodec.so" "libaribcaption" contains_string "$directory/libmvcodec.so" "Timed Text Markup Language subtitle" contains_string "$directory/libmvformat.so" "MMT protocol over TLV packets" contains_string "$directory/libmvformat.so" "WebHTV proxy range offset accepted" contains_string "$directory/libmvcodec.so" "failing hardware decode so the player can fall back" - contains_string "$directory/libmvcodec.so" "WebHTV MediaCodec output release/flush serialization enabled" - contains_string "$directory/libmvcodec.so" "WebHTV AV-DIAG codec-lookup v=1" - contains_string "$directory/libmpv.so" "WebHTV AV-DIAG audio-write v=1" - contains_string "$directory/libmvcodec.so" "WebHTV hardware audio MediaCodec decoder:" contains_string "$directory/libmpv.so" "No usable fontconfig configuration file found, using fallback." if [ -n "$CURL_VERSION" ]; then contains_string "$directory/libmpv.so" "libcurl/$CURL_VERSION" diff --git a/scripts/verify_mpv_v556_shader_contract.py b/scripts/verify_mpv_v556_shader_contract.py new file mode 100644 index 0000000000..50cb13928e --- /dev/null +++ b/scripts/verify_mpv_v556_shader_contract.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import re +import struct + + +ROOT = Path(__file__).resolve().parent.parent +OVERRIDE = ROOT / "third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec" +# Shader/source baseline: FongMi/mpv@fd679c812149fe1f3e246897b1015ae109da7c74, +# which is the Vulkan implementation bundled by v5.5.6-202608072014. +SOURCE = OVERRIDE / "hwdec_aimagereader_vk.c" +SHADER = OVERRIDE / "hwdec_aimagereader.comp" +HEADER = OVERRIDE / "hwdec_aimagereader_comp.h" + + +def require(pattern: str, text: str, description: str) -> re.Match[str]: + match = re.search(pattern, text, re.MULTILINE) + if not match: + raise SystemExit(f"missing {description}") + return match + + +source = SOURCE.read_text(encoding="utf-8") +shader = SHADER.read_text(encoding="utf-8") +header = HEADER.read_text(encoding="utf-8") + +shader_group = require( + r"layout\s*\(\s*local_size_x\s*=\s*(\d+)\s*,\s*local_size_y\s*=\s*(\d+)\s*\)\s*in\s*;", + shader, + "v5.5.6 Vulkan shader workgroup declaration", +) +shader_x, shader_y = map(int, shader_group.groups()) +dispatch = require( + r"vkCmdDispatch\([^;]*?MP_ALIGN_UP\(p->width,\s*(\d+)\)\s*/\s*(\d+)\s*,\s*" + r"MP_ALIGN_UP\(p->height,\s*(\d+)\)\s*/\s*(\d+)\s*,\s*1\s*\);", + source, + "v5.5.6 Vulkan dispatch dimensions", +) +dispatch_x_align, dispatch_x_divisor, dispatch_y_align, dispatch_y_divisor = map( + int, dispatch.groups() +) +if (shader_x, shader_y) != ( + dispatch_x_align, + dispatch_y_align, +) or (shader_x, shader_y) != (dispatch_x_divisor, dispatch_y_divisor): + raise SystemExit( + "v5.5.6 Vulkan dispatch/shader mismatch: " + f"shader={shader_x}x{shader_y}, " + f"dispatch=align({dispatch_x_align}x{dispatch_y_align})/" + f"divide({dispatch_x_divisor}x{dispatch_y_divisor})" + ) + +for token in ( + "layout(set = 0, binding = 0) uniform sampler2D source_image;", + "layout(set = 0, binding = 1) writeonly uniform image2D target_image;", + "ivec2 size = imageSize(target_image);", + "imageStore(target_image, position, texture(source_image, uv));", +): + if token not in shader: + raise SystemExit(f"v5.5.6 Vulkan shader is missing contract token: {token}") + +words = [int(value, 16) for value in re.findall(r"0x[0-9a-fA-F]+", header)] +if len(words) < 5 or words[0] != 0x07230203: + raise SystemExit("v5.5.6 Vulkan shader header is not valid SPIR-V words") +if words[1] > 0x00010300: + raise SystemExit( + "v5.5.6 Vulkan shader exceeds the documented Vulkan 1.1 target: " + f"SPIR-V word 0x{words[1]:08x}" + ) + +offset = 5 +local_size = None +descriptor_bindings: dict[int, int] = {} +while offset < len(words): + instruction = words[offset] + word_count = instruction >> 16 + opcode = instruction & 0xFFFF + if word_count == 0 or offset + word_count > len(words): + raise SystemExit(f"malformed SPIR-V instruction at word {offset}") + operands = words[offset + 1 : offset + word_count] + if opcode == 16 and len(operands) >= 5 and operands[1] == 17: + local_size = tuple(operands[2:5]) + elif opcode == 71 and len(operands) >= 3 and operands[1] == 33: + descriptor_bindings[operands[0]] = operands[2] + offset += word_count + +if local_size != (shader_x, shader_y, 1): + raise SystemExit( + "embedded v5.5.6 SPIR-V workgroup mismatch: " + f"header={local_size}, shader={shader_x}x{shader_y}x1" + ) +if sorted(descriptor_bindings.values()) != [0, 1]: + raise SystemExit( + "embedded v5.5.6 SPIR-V descriptor bindings mismatch: " + f"{sorted(descriptor_bindings.values())}" + ) + +# Pack once to ensure every literal fits a uint32_t exactly. +struct.pack(f"<{len(words)}I", *words) +print( + "Verified v5.5.6 Vulkan shader contract: " + f"{shader_x}x{shader_y}, bindings 0/1, {len(words)} SPIR-V words" +) diff --git a/serverless/webhtv-remote-cloudflare/src/relay.js b/serverless/webhtv-remote-cloudflare/src/relay.js index 0d6d76b756..6274c15b10 100644 --- a/serverless/webhtv-remote-cloudflare/src/relay.js +++ b/serverless/webhtv-remote-cloudflare/src/relay.js @@ -567,7 +567,6 @@ function normalizeSyncOptions(options) { search: options.search !== false, keep: options.keep !== false, history: options.history !== false, - follow: options.follow === true, settings: options.settings === true, paths: typeof options.paths === 'string' ? options.paths : undefined }; diff --git a/serverless/webhtv-remote-deno/relay.js b/serverless/webhtv-remote-deno/relay.js index 7c94fc406b..e1a4b8243e 100644 --- a/serverless/webhtv-remote-deno/relay.js +++ b/serverless/webhtv-remote-deno/relay.js @@ -535,7 +535,6 @@ function normalizeSyncOptions(options) { search: options.search !== false, keep: options.keep !== false, history: options.history !== false, - follow: options.follow === true, settings: options.settings === true, paths: typeof options.paths === 'string' ? options.paths : undefined }; diff --git a/serverless/webhtv-remote-go/main.go b/serverless/webhtv-remote-go/main.go index 89b8602602..0ab12fd94d 100644 --- a/serverless/webhtv-remote-go/main.go +++ b/serverless/webhtv-remote-go/main.go @@ -1361,7 +1361,6 @@ func normalizeSyncOptions(options map[string]any) map[string]any { "search": boolVal(options["search"], true), "keep": boolVal(options["keep"], true), "history": boolVal(options["history"], true), - "follow": boolVal(options["follow"], false), "settings": boolVal(options["settings"], false), "remoteRelay": boolVal(options["remoteRelay"], false), "paths": optionalString(options["paths"]), diff --git a/serverless/webhtv-remote-rust/src/main.rs b/serverless/webhtv-remote-rust/src/main.rs index d7b7d343fc..fa5114ed70 100644 --- a/serverless/webhtv-remote-rust/src/main.rs +++ b/serverless/webhtv-remote-rust/src/main.rs @@ -1618,7 +1618,6 @@ fn normalize_sync_options(options: Option<&Value>) -> Value { "search": bool_value(object.and_then(|m| m.get("search")), true), "keep": bool_value(object.and_then(|m| m.get("keep")), true), "history": bool_value(object.and_then(|m| m.get("history")), true), - "follow": bool_value(object.and_then(|m| m.get("follow")), false), "settings": bool_value(object.and_then(|m| m.get("settings")), false), "remoteRelay": bool_value(object.and_then(|m| m.get("remoteRelay")), false), "paths": object diff --git a/serverless/webhtv-remote-vercel/api/relay.js b/serverless/webhtv-remote-vercel/api/relay.js index 9a851a6b1a..8644376ee1 100644 --- a/serverless/webhtv-remote-vercel/api/relay.js +++ b/serverless/webhtv-remote-vercel/api/relay.js @@ -535,7 +535,6 @@ function normalizeSyncOptions(options) { search: options.search !== false, keep: options.keep !== false, history: options.history !== false, - follow: options.follow === true, settings: options.settings === true, paths: typeof options.paths === 'string' ? options.paths : undefined }; diff --git a/settings.gradle b/settings.gradle index 7a09a68057..4c291c8b7b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,10 +1,10 @@ pluginManagement { repositories { + maven { url = "https://maven.aliyun.com/repository/gradle-plugin" } + maven { url = "https://maven.aliyun.com/repository/public" } gradlePluginPortal() mavenCentral() google() - maven { url = "https://maven.aliyun.com/repository/gradle-plugin" } - maven { url = "https://maven.aliyun.com/repository/public" } } } @@ -16,10 +16,10 @@ dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { maven { url = uri("$rootDir/third_party/maven") } - mavenCentral() - google() maven { url = "https://maven.aliyun.com/repository/public" } maven { url = "https://maven.aliyun.com/repository/google" } + mavenCentral() + google() flatDir { dirs "$rootDir/app/libs" } maven { url = "https://jitpack.io" } } diff --git a/third_party/avs3-hpm/CMakeLists.txt b/third_party/avs3-hpm/CMakeLists.txt deleted file mode 100644 index e0acb85f51..0000000000 --- a/third_party/avs3-hpm/CMakeLists.txt +++ /dev/null @@ -1,36 +0,0 @@ -cmake_minimum_required(VERSION 3.16) -project(webhtv_hpm C) -if(NOT HPM_SOURCE) - message(FATAL_ERROR "HPM_SOURCE must be the verified, prepared HPM 15.0 tree") -endif() -file(GLOB HPM_COMMON CONFIGURE_DEPENDS "${HPM_SOURCE}/src/com*.c") -file(GLOB HPM_DECODER CONFIGURE_DEPENDS "${HPM_SOURCE}/src/dec*.c") -add_library(webhtvhpm STATIC webhtv_hpm.c ${HPM_COMMON} ${HPM_DECODER}) -set_target_properties(webhtvhpm PROPERTIES POSITION_INDEPENDENT_CODE ON C_STANDARD 11) -target_include_directories(webhtvhpm PRIVATE "${HPM_SOURCE}/inc" "${CMAKE_CURRENT_SOURCE_DIR}/vendor" - PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") -target_compile_definitions(webhtvhpm PRIVATE LINUX=1) -target_compile_options(webhtvhpm PRIVATE -O3 -fvisibility=hidden -fno-strict-aliasing - -Wno-shift-negative-value -Wno-unused-value -Wno-incompatible-pointer-types) -# Preserve assertions which consume syntax bits. Do not compile HPM with NDEBUG. -target_compile_options(webhtvhpm PRIVATE -UNDEBUG) -target_compile_options(webhtvhpm PRIVATE -include "${CMAKE_CURRENT_SOURCE_DIR}/hpm_namespace.h") -if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64") - target_compile_options(webhtvhpm PRIVATE -msse4.2) -elseif(ANDROID_ABI STREQUAL "armeabi-v7a") - target_compile_options(webhtvhpm PRIVATE -mfpu=neon) -endif() -target_compile_definitions(webhtvhpm PRIVATE SSE2NEON_PRECISE_MINMAX=1 SSE2NEON_PRECISE_DIV=1 - SSE2NEON_PRECISE_SQRT=1 SSE2NEON_PRECISE_DP=1) -if(CMAKE_C_COMPILER_ID MATCHES "Clang") - # Catch runtime indices into fixed-size syntax arrays, returning an ordinary - # decoder error through our private handler, without a UBSan runtime/SONAME. - set_source_files_properties(${HPM_COMMON} ${HPM_DECODER} PROPERTIES - COMPILE_OPTIONS "-fsanitize=array-bounds;-fno-sanitize-link-runtime") -endif() -find_package(Threads REQUIRED) -target_link_libraries(webhtvhpm PUBLIC Threads::Threads m) -add_executable(hpm_contract hpm_contract.c) -target_link_libraries(hpm_contract PRIVATE webhtvhpm) -install(TARGETS webhtvhpm ARCHIVE DESTINATION lib) -install(FILES webhtv_hpm.h webhtv_hpm_ffmpeg.h DESTINATION include) diff --git a/third_party/avs3-hpm/LICENSE.HPM b/third_party/avs3-hpm/LICENSE.HPM deleted file mode 100644 index cd7dc6f6ad..0000000000 --- a/third_party/avs3-hpm/LICENSE.HPM +++ /dev/null @@ -1,32 +0,0 @@ -/* ==================================================================================================================== - - The copyright in this software is being made available under the License included below. - This software may be subject to other third party and contributor rights, including patent rights, and no such - rights are granted under this license. - - Copyright (c) 2018, HUAWEI TECHNOLOGIES CO., LTD. All rights reserved. - Copyright (c) 2018, SAMSUNG ELECTRONICS CO., LTD. All rights reserved. - Copyright (c) 2018, PEKING UNIVERSITY SHENZHEN GRADUATE SCHOOL. All rights reserved. - Copyright (c) 2018, PENGCHENG LABORATORY. All rights reserved. - - Redistribution and use in source and binary forms, with or without modification, are permitted only for - the purpose of developing standards within Audio and Video Coding Standard Workgroup of China (AVS) and for testing and - promoting such standards. The following conditions are required to be met: - - * Redistributions of source code must retain the above copyright notice, this list of conditions and - the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and - the following disclaimer in the documentation and/or other materials provided with the distribution. - * The name of HUAWEI TECHNOLOGIES CO., LTD. or SAMSUNG ELECTRONICS CO., LTD. may not be used to endorse or promote products derived from - this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, - INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, - INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -* ==================================================================================================================== -*/ diff --git a/third_party/avs3-hpm/ffmpeg_contract.c b/third_party/avs3-hpm/ffmpeg_contract.c deleted file mode 100644 index ce3af9d259..0000000000 --- a/third_party/avs3-hpm/ffmpeg_contract.c +++ /dev/null @@ -1,200 +0,0 @@ -/* Exercise the actual demuxer -> AVPacket -> AVFrame boundary, including seek. - * This native command uses the player's codec libraries, never a helper APK. */ -#include -#include -#include -#include -#include -#include -#include -#include - -static int headers(void) -{ - const AVCodec *codec = avcodec_find_decoder_by_name("libuavs3d"); - if (!codec) return 1; - for (int container = 0; container < 3; container++) { - for (int sample = 0; sample < 3; sample++) { - uint8_t header[] = {0, 0, 1, 0xb0, 0x32, 0x55}; - header[4] = sample == 0 ? 0x30 : sample == 1 ? 0x7f : 0x32; - int expected = sample == 2 ? AVERROR_INVALIDDATA : AVERROR_PATCHWELCOME; - AVCodecContext *ctx = avcodec_alloc_context3(codec); - if (!ctx) return 1; - ctx->thread_count = 2; - if (container < 2) { - int offset = container ? 3 : 0; - ctx->extradata_size = sizeof(header) + (container ? 4 : 0); - ctx->extradata = av_mallocz(ctx->extradata_size + AV_INPUT_BUFFER_PADDING_SIZE); - if (!ctx->extradata) { avcodec_free_context(&ctx); return 1; } - if (container) { ctx->extradata[0] = 1; ctx->extradata[2] = sizeof(header); } - memcpy(ctx->extradata + offset, header, sizeof(header)); - } - int ret = avcodec_open2(ctx, codec, NULL); - if (container == 2 && ret >= 0) { - AVPacket *packet = av_packet_alloc(); - AVFrame *frame = av_frame_alloc(); - if (!packet || !frame || av_new_packet(packet, sizeof(header)) < 0) { - av_frame_free(&frame); - av_packet_free(&packet); - avcodec_free_context(&ctx); - return 1; - } - memcpy(packet->data, header, sizeof(header)); - ret = avcodec_send_packet(ctx, packet); - if (ret >= 0) ret = avcodec_receive_frame(ctx, frame); - av_frame_free(&frame); - av_packet_free(&packet); - } - avcodec_free_context(&ctx); - if (ret != expected) { - fprintf(stderr, "header error mismatch: container=%d profile=%x ret=%d expected=%d\n", - container, header[4], ret, expected); - return 1; - } - } - } - puts("PASS unknown 0x30/0x7f and truncated 0x32: raw/av3c/in-band errors and cleanup"); - return 0; -} - -static int metadata(const char *path) -{ - FILE *input = fopen(path, "rb"); - if (!input) return 1; - uint8_t data[65536]; - size_t size = fread(data, 1, sizeof(data), input); - fclose(input); - if (!size || size == sizeof(data)) return 1; - const AVCodec *codec = avcodec_find_decoder_by_name("libuavs3d"); - AVCodecContext *ctx = avcodec_alloc_context3(codec); - if (!codec || !ctx) return 1; - ctx->thread_count = 2; - ctx->extradata = av_mallocz(size + AV_INPUT_BUFFER_PADDING_SIZE); - if (!ctx->extradata) { avcodec_free_context(&ctx); return 1; } - memcpy(ctx->extradata, data, size); - ctx->extradata_size = size; - int result = avcodec_open2(ctx, codec, NULL); - if (result >= 0) - printf("metadata size=%dx%d sar=%d/%d range=%d depth=%d\n", ctx->width, ctx->height, - ctx->sample_aspect_ratio.num, ctx->sample_aspect_ratio.den, - ctx->color_range, ctx->bits_per_raw_sample); - avcodec_free_context(&ctx); - return result < 0; -} - -static int receive(AVCodecContext *ctx, AVFrame *frame, int pass, int *frames, - int limit, int check_pts, int64_t *last_pts, struct AVSHA *sha) -{ - int ret; - while (*frames < limit && (ret = avcodec_receive_frame(ctx, frame)) >= 0) { - const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(frame->format); - if (!desc || (desc->comp[0].depth != 8 && desc->comp[0].depth != 10)) - return AVERROR_INVALIDDATA; - if (check_pts && frame->pts != AV_NOPTS_VALUE) { - if (*last_pts != AV_NOPTS_VALUE && frame->pts <= *last_pts) - return AVERROR_INVALIDDATA; - *last_pts = frame->pts; - } - int bytes = desc->comp[0].depth > 8 ? 2 : 1; - uint64_t hash = UINT64_C(14695981039346656037); - for (int c = 0; c < 3; c++) { - int width = (frame->width + (c != 0)) >> (c != 0); - int height = (frame->height + (c != 0)) >> (c != 0); - for (int y = 0; y < height; y++) { - const uint8_t *p = frame->data[c] + y * frame->linesize[c]; - av_sha_update(sha, p, width * bytes); - for (int x = 0; x < width * bytes; x++) - hash = (hash ^ p[x]) * UINT64_C(1099511628211); - } - } - printf("pass=%d frame=%d pts=%" PRId64 " size=%dx%d depth=%d hash=%016" PRIx64 "\n", - pass, (*frames)++, frame->pts, frame->width, frame->height, - desc->comp[0].depth, hash); - av_frame_unref(frame); - } - return *frames == limit || ret == AVERROR(EAGAIN) || ret == AVERROR_EOF ? 0 : ret; -} - -int main(int argc, char **argv) -{ - if (argc == 2 && !strcmp(argv[1], "--headers")) return headers(); - if (argc == 3 && !strcmp(argv[1], "--metadata")) return metadata(argv[2]); - if (argc < 3 || argc > 5) { - fprintf(stderr, "usage: ffmpeg_contract INPUT FRAME_LIMIT [PASSES] [SEEK_US]\n"); - return 2; - } - int limit = atoi(argv[2]), passes = argc >= 4 ? atoi(argv[3]) : 2; - int64_t seek_us = argc == 5 ? strtoll(argv[4], NULL, 10) : 0; - if (limit < 1 || limit > 3000 || passes < 1 || passes > 2 || seek_us < 0) return 2; - AVFormatContext *format = NULL; - AVCodecContext *ctx = NULL; - AVPacket *packet = av_packet_alloc(); - AVFrame *frame = av_frame_alloc(); - struct AVSHA *sha = av_sha_alloc(); - int result = 1, ret = 0; - if (!packet || !frame || !sha || (ret = avformat_open_input(&format, argv[1], NULL, NULL)) < 0) - goto done; - /* Stream discovery must not decode dozens of 4K pictures merely to probe. - * The MP4 supplies complete codec parameters; raw streams need the parser. */ - if (!format->nb_streams && (ret = avformat_find_stream_info(format, NULL)) < 0) - goto done; - int stream = -1; - for (unsigned i = 0; i < format->nb_streams; i++) - if (format->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { stream = i; break; } - if (stream < 0) goto done; - /* Raw ES contains no container timestamps; its demuxer synthesizes packet - * times before frame reordering. Pixel/flush checks still apply to it. */ - int raw = !strcmp(format->iformat->name, "avs3"); - const AVCodec *codec = avcodec_find_decoder_by_name("libuavs3d"); - if (!codec || !(ctx = avcodec_alloc_context3(codec))) goto done; - if ((ret = avcodec_parameters_to_context(ctx, format->streams[stream]->codecpar)) < 0) goto done; - ctx->thread_count = 2; - ctx->pkt_timebase = format->streams[stream]->time_base; - if ((ret = avcodec_open2(ctx, codec, NULL)) < 0) goto done; - uint8_t first_hash[32] = {0}; - for (int pass = 0; pass < passes; pass++) { - int frames = 0; - int64_t last_pts = AV_NOPTS_VALUE; - if (pass || seek_us) { - if (raw) { - if (avio_seek(format->pb, 0, SEEK_SET) < 0) goto done; - avformat_flush(format); - } else if ((ret = av_seek_frame(format, stream, - av_rescale_q(seek_us, AV_TIME_BASE_Q, ctx->pkt_timebase), AVSEEK_FLAG_BACKWARD)) < 0) goto done; - avcodec_flush_buffers(ctx); - } - av_sha_init(sha, 256); - while (frames < limit && (ret = av_read_frame(format, packet)) >= 0) { - if (packet->stream_index == stream) { - if ((ret = avcodec_send_packet(ctx, packet)) < 0) goto done; - if ((ret = receive(ctx, frame, pass, &frames, limit, !raw, &last_pts, sha)) < 0) goto done; - } - av_packet_unref(packet); - } - if (frames < limit) { - if (ret != AVERROR_EOF || (ret = avcodec_send_packet(ctx, NULL)) < 0) goto done; - if ((ret = receive(ctx, frame, pass, &frames, limit, !raw, &last_pts, sha)) < 0) goto done; - } - if (frames != limit) goto done; - uint8_t digest[32]; - av_sha_final(sha, digest); - printf("pass=%d frames=%d sha256=", pass, frames); - for (int i = 0; i < 32; i++) printf("%02x", digest[i]); - printf(" time_base=%d/%d\n", ctx->pkt_timebase.num, ctx->pkt_timebase.den); - if (!pass) memcpy(first_hash, digest, 32); - else if (memcmp(first_hash, digest, 32)) goto done; - } - result = 0; -done: - if (result) { - char message[AV_ERROR_MAX_STRING_SIZE]; - av_strerror(ret, message, sizeof(message)); - fprintf(stderr, "FFmpeg contract failed: %s\n", message); - } - av_free(sha); - av_frame_free(&frame); - av_packet_free(&packet); - avcodec_free_context(&ctx); - avformat_close_input(&format); - return result; -} diff --git a/third_party/avs3-hpm/hpm_contract.c b/third_party/avs3-hpm/hpm_contract.c deleted file mode 100644 index b11a0d84e1..0000000000 --- a/third_party/avs3-hpm/hpm_contract.c +++ /dev/null @@ -1,156 +0,0 @@ -/* Standalone native contract runner; no Android helper APK is required. */ -#include "webhtv_hpm.h" -#include -#include -#include -#include -#include - -static uint64_t picture_hash(const WebHpmFrame *f) -{ - uint64_t value = UINT64_C(14695981039346656037); - for (int c = 0; c < 3; c++) { - int width = (f->info.width + (c != 0)) >> (c != 0); - int height = (f->info.height + (c != 0)) >> (c != 0); - for (int y = 0; y < height; y++) { - const uint8_t *p = f->data[c] + y * f->stride[c]; - for (int x = 0; x < width * 2; x++) - value = (value ^ p[x]) * UINT64_C(1099511628211); - } - } - return value; -} - -static int decode(WebHpm *a, WebHpm *b, const uint8_t *data, int size, uint64_t *sum) -{ - int offset = 0, frames = 0; - for (;;) { - WebHpmFrame fa, fb; - int used = 0, used_b = 0; - int result = webhtv_hpm_decode(a, data + offset, size - offset, - INT64_MIN, INT64_MIN, &used, &fa); - if (result < 0) { - fprintf(stderr, "decode failed at %d: %s\n", offset, webhtv_hpm_error(a)); - return -1; - } - if (b) { - int rb = webhtv_hpm_decode(b, data + offset, size - offset, - INT64_MIN, INT64_MIN, &used_b, &fb); - if (rb != result || used != used_b || - (result && (picture_hash(&fa) != picture_hash(&fb) || fa.poc != fb.poc))) { - fprintf(stderr, "interleaved instance mismatch at %d\n", offset); - return -1; - } - } - offset += used; - if (result) { - uint64_t hash = picture_hash(&fa); - *sum = (*sum ^ hash) * UINT64_C(1099511628211); - printf("frame=%d poc=%d pts=%" PRId64 " size=%dx%d depth=%d hash=%016" PRIx64 "\n", - frames++, fa.poc, fa.pts, fa.info.width, fa.info.height, fa.info.bit_depth, hash); - } else if (offset == size) { - /* A nonempty input can consume the last picture without output. - * Always make a separate empty-input EOS call before finishing. */ - if (used) - continue; - break; - } else if (!used) { - fprintf(stderr, "decoder made no progress\n"); - return -1; - } - } - return frames; -} - -static int malformed(const uint8_t *data, int size) -{ - WebHpm *h = webhtv_hpm_create(); - if (!h) - return -1; - for (int length = 1; length < 32 && length < size; length++) { - WebHpmFrame frame; - int consumed; - int result = webhtv_hpm_decode(h, data, length, 0, 0, &consumed, &frame); - if (result >= 0) { - fprintf(stderr, "truncated sequence accepted: %d\n", length); - webhtv_hpm_close(h); - return -1; - } - webhtv_hpm_flush(h); - if (webhtv_hpm_memory(h)) - return -1; - } - webhtv_hpm_close(h); - puts("PASS truncated sequence and error/flush ownership"); - return 0; -} - -int main(int argc, char **argv) -{ - if (argc == 3 && !strcmp(argv[1], "--raw-hash")) { - unsigned long bytes = strtoul(argv[2], NULL, 10); - if (!bytes || bytes > 256UL * 1024 * 1024) return 2; - uint8_t buffer[65536]; - unsigned long left = bytes; - uint64_t hash = UINT64_C(14695981039346656037); - int frames = 0; - for (;;) { - size_t n = fread(buffer, 1, left < sizeof(buffer) ? left : sizeof(buffer), stdin); - if (!n) break; - for (size_t i = 0; i < n; i++) - hash = (hash ^ buffer[i]) * UINT64_C(1099511628211); - left -= n; - if (!left) { - printf("reference frame=%d hash=%016" PRIx64 "\n", frames++, hash); - left = bytes; - hash = UINT64_C(14695981039346656037); - } - } - return ferror(stdin) || left != bytes || !frames ? 1 : 0; - } - if (argc < 2) { - fprintf(stderr, "usage: hpm_contract input.avs3 [--flush] [--interleave] [--malformed]\n"); - return 2; - } - int flush = 0, interleave = 0, invalid = 0; - for (int i = 2; i < argc; i++) { - if (!strcmp(argv[i], "--flush")) flush = 1; - else if (!strcmp(argv[i], "--interleave")) interleave = 1; - else if (!strcmp(argv[i], "--malformed")) invalid = 1; - else return 2; - } - FILE *input = fopen(argv[1], "rb"); - if (!input) return 2; - if (fseek(input, 0, SEEK_END)) return 2; - long size = ftell(input); - if (size <= 0 || size > 64 * 1024 * 1024) return 2; - rewind(input); - uint8_t *data = malloc(size); - if (!data || fread(data, 1, size, input) != (size_t)size) return 2; - fclose(input); - if (invalid && malformed(data, (int)size)) return 1; - WebHpm *a = webhtv_hpm_create(); - WebHpm *b = interleave ? webhtv_hpm_create() : NULL; - if (!a || (interleave && !b)) return 2; - clock_t started = clock(); - uint64_t checksum = 0; - int frames = decode(a, b, data, (int)size, &checksum); - printf("frames=%d checksum=%016" PRIx64 " memory=%zu cpu_seconds=%.3f\n", - frames, checksum, webhtv_hpm_memory(a), (double)(clock() - started) / CLOCKS_PER_SEC); - if (frames <= 0) return 1; - if (flush) { - webhtv_hpm_flush(a); - webhtv_hpm_close(b); - b = NULL; - uint64_t replay = 0; - if (decode(a, NULL, data, (int)size, &replay) != frames || replay != checksum) { - fprintf(stderr, "flush replay mismatch\n"); - return 1; - } - puts("PASS flush replay after closing peer instance"); - } - webhtv_hpm_close(a); - webhtv_hpm_close(b); - free(data); - return 0; -} diff --git a/third_party/avs3-hpm/hpm_guard.h b/third_party/avs3-hpm/hpm_guard.h deleted file mode 100644 index 9142977074..0000000000 --- a/third_party/avs3-hpm/hpm_guard.h +++ /dev/null @@ -1,15 +0,0 @@ -/* Internal to the pinned HPM build; never included by other FFmpeg codecs. */ -#ifndef WEBHTV_HPM_GUARD_H -#define WEBHTV_HPM_GUARD_H -#include -#include - -void *webhtv_hpm_alloc(size_t size); -void *webhtv_hpm_calloc(size_t count, size_t size); -void *webhtv_hpm_aligned_alloc(size_t alignment, size_t size); -void webhtv_hpm_free(void *ptr); -__attribute__((noreturn)) void webhtv_hpm_fail(int code, const char *reason, int line); -void webhtv_hpm_set_color(int range, int primaries, int trc, int matrix); -#define HPM_REQUIRE(x) do { if (!(x)) webhtv_hpm_fail(-1, #x, __LINE__); } while (0) -#define HPM_EXIT(x) webhtv_hpm_fail(-1, "upstream fatal error", __LINE__) -#endif diff --git a/third_party/avs3-hpm/hpm_namespace.h b/third_party/avs3-hpm/hpm_namespace.h deleted file mode 100644 index 78262107e3..0000000000 --- a/third_party/avs3-hpm/hpm_namespace.h +++ /dev/null @@ -1,551 +0,0 @@ -/* HPM 15.0 globals: isolate the reference codec from uavs3d/FFmpeg. - * Derived from the fixed HPM common/decoder translation units. */ -#ifndef WEBHTV_HPM_NAMESPACE_H -#define WEBHTV_HPM_NAMESPACE_H -#define ALPHA_TABLE webhtv_hpm_ref_ALPHA_TABLE -#define BETA_TABLE webhtv_hpm_ref_BETA_TABLE -#define CLIP_TAB_AVS webhtv_hpm_ref_CLIP_TAB_AVS -#define EO_OFFSET_MAP webhtv_hpm_ref_EO_OFFSET_MAP -#define SAO_on_block webhtv_hpm_ref_SAO_on_block -#define SAO_on_smb webhtv_hpm_ref_SAO_on_smb -#define air_parameters_extension webhtv_hpm_ref_air_parameters_extension -#define alf_ctu_padding webhtv_hpm_ref_alf_ctu_padding -#define alf_process_dec webhtv_hpm_ref_alf_process_dec -#define allocate_alf_param webhtv_hpm_ref_allocate_alf_param -#define asp_deltMV_calc webhtv_hpm_ref_asp_deltMV_calc -#define average_16b_no_clip_sse webhtv_hpm_ref_average_16b_no_clip_sse -#define bio_grad webhtv_hpm_ref_bio_grad -#define bio_opt webhtv_hpm_ref_bio_opt -#define bio_sigma webhtv_hpm_ref_bio_sigma -#define bio_vxvy webhtv_hpm_ref_bio_vxvy -#define calc_diff_range webhtv_hpm_ref_calc_diff_range -#define camera_parameters_extension webhtv_hpm_ref_camera_parameters_extension -#define ccsao_clip webhtv_hpm_ref_ccsao_clip -#define ccsao_edge_type_x webhtv_hpm_ref_ccsao_edge_type_x -#define ccsao_edge_type_y webhtv_hpm_ref_ccsao_edge_type_y -#define ccsao_on_block webhtv_hpm_ref_ccsao_on_block -#define ccsao_on_block_for_chroma webhtv_hpm_ref_ccsao_on_block_for_chroma -#define ccsao_on_block_for_chroma_edge webhtv_hpm_ref_ccsao_on_block_for_chroma_edge -#define ccsao_on_frame webhtv_hpm_ref_ccsao_on_frame -#define ccsao_quan_value webhtv_hpm_ref_ccsao_quan_value -#define ccsao_type_x webhtv_hpm_ref_ccsao_type_x -#define ccsao_type_y webhtv_hpm_ref_ccsao_type_y -#define check_boundary_available_for_esao webhtv_hpm_ref_check_boundary_available_for_esao -#define check_boundary_param webhtv_hpm_ref_check_boundary_param -#define check_boundary_proc webhtv_hpm_ref_check_boundary_proc -#define check_copy_refp webhtv_hpm_ref_check_copy_refp -#define check_filter_coeff_value webhtv_hpm_ref_check_filter_coeff_value -#define check_mvp_motion_availability webhtv_hpm_ref_check_mvp_motion_availability -#define check_set_tb_part webhtv_hpm_ref_check_set_tb_part -#define check_skip_filtering webhtv_hpm_ref_check_skip_filtering -#define check_sp_offset webhtv_hpm_ref_check_sp_offset -#define check_spatial_block_bsv_available webhtv_hpm_ref_check_spatial_block_bsv_available -#define check_tb_part webhtv_hpm_ref_check_tb_part -#define check_umve_base webhtv_hpm_ref_check_umve_base -#define check_umve_motion_availability webhtv_hpm_ref_check_umve_motion_availability -#define clear_edge_filter_avs2 webhtv_hpm_ref_clear_edge_filter_avs2 -#define clip_pred webhtv_hpm_ref_clip_pred -#define com_DMVR_cost webhtv_hpm_ref_com_DMVR_cost -#define com_IBC_mc webhtv_hpm_ref_com_IBC_mc -#define com_affine_mc webhtv_hpm_ref_com_affine_mc -#define com_affine_mc_l webhtv_hpm_ref_com_affine_mc_l -#define com_affine_mc_lc webhtv_hpm_ref_com_affine_mc_lc -#define com_atomic_dec webhtv_hpm_ref_com_atomic_dec -#define com_atomic_inc webhtv_hpm_ref_com_atomic_inc -#define com_awp_mc webhtv_hpm_ref_com_awp_mc -#define com_bsr_clz_in_code webhtv_hpm_ref_com_bsr_clz_in_code -#define com_bsr_flush webhtv_hpm_ref_com_bsr_flush -#define com_bsr_init webhtv_hpm_ref_com_bsr_init -#define com_bsr_next webhtv_hpm_ref_com_bsr_next -#define com_bsr_read webhtv_hpm_ref_com_bsr_read -#define com_bsr_read1 webhtv_hpm_ref_com_bsr_read1 -#define com_bsr_read_se webhtv_hpm_ref_com_bsr_read_se -#define com_bsr_read_ue webhtv_hpm_ref_com_bsr_read_ue -#define com_calculate_awp_para webhtv_hpm_ref_com_calculate_awp_para -#define com_calculate_awp_weight webhtv_hpm_ref_com_calculate_awp_weight -#define com_check_same_uni_motion webhtv_hpm_ref_com_check_same_uni_motion -#define com_check_split_mode webhtv_hpm_ref_com_check_split_mode -#define com_cleanup_useless_pic_buffer_in_pm webhtv_hpm_ref_com_cleanup_useless_pic_buffer_in_pm -#define com_cntmpm_copy webhtv_hpm_ref_com_cntmpm_copy -#define com_cntmpm_init webhtv_hpm_ref_com_cntmpm_init -#define com_cntmpm_map webhtv_hpm_ref_com_cntmpm_map -#define com_cntmpm_reset webhtv_hpm_ref_com_cntmpm_reset -#define com_cntmpm_update webhtv_hpm_ref_com_cntmpm_update -#define com_constrain_pred_mode webhtv_hpm_ref_com_constrain_pred_mode -#define com_constrcut_ref_list_doi webhtv_hpm_ref_com_constrcut_ref_list_doi -#define com_derive_affine_constructed_candidate webhtv_hpm_ref_com_derive_affine_constructed_candidate -#define com_derive_affine_model_mv webhtv_hpm_ref_com_derive_affine_model_mv -#define com_derive_awp_base_motions webhtv_hpm_ref_com_derive_awp_base_motions -#define com_derive_awp_pred webhtv_hpm_ref_com_derive_awp_pred -#define com_derive_awp_weight webhtv_hpm_ref_com_derive_awp_weight -#define com_derive_bvp_list webhtv_hpm_ref_com_derive_bvp_list -#define com_derive_mvp webhtv_hpm_ref_com_derive_mvp -#define com_derive_no_overlap_str_len webhtv_hpm_ref_com_derive_no_overlap_str_len -#define com_derive_sawp_pred webhtv_hpm_ref_com_derive_sawp_pred -#define com_derive_sp_offset webhtv_hpm_ref_com_derive_sp_offset -#define com_dmvr_refine webhtv_hpm_ref_com_dmvr_refine -#define com_dt_allow webhtv_hpm_ref_com_dt_allow -#define com_etmvp_mc webhtv_hpm_ref_com_etmvp_mc -#define com_free_2d_sao_param webhtv_hpm_ref_com_free_2d_sao_param -#define com_free_3d_esao_stat_data webhtv_hpm_ref_com_free_3d_esao_stat_data -#define com_free_3d_sao_stat_data webhtv_hpm_ref_com_free_3d_sao_stat_data -#define com_free_4d_ccsao_statdata webhtv_hpm_ref_com_free_4d_ccsao_statdata -#define com_get_affine_memory_access webhtv_hpm_ref_com_get_affine_memory_access -#define com_get_affine_merge_candidate webhtv_hpm_ref_com_get_affine_merge_candidate -#define com_get_affine_mvp_scaling webhtv_hpm_ref_com_get_affine_mvp_scaling -#define com_get_avail_intra webhtv_hpm_ref_com_get_avail_intra -#define com_get_cntmpm webhtv_hpm_ref_com_get_cntmpm -#define com_get_cons_pred_mode webhtv_hpm_ref_com_get_cons_pred_mode -#define com_get_ctx_gt0_inc webhtv_hpm_ref_com_get_ctx_gt0_inc -#define com_get_ctx_gt1_inc webhtv_hpm_ref_com_get_ctx_gt1_inc -#define com_get_ctx_gt2_inc webhtv_hpm_ref_com_get_ctx_gt2_inc -#define com_get_ctx_gtx_inc webhtv_hpm_ref_com_get_ctx_gtx_inc -#define com_get_ctx_offset webhtv_hpm_ref_com_get_ctx_offset -#define com_get_ctx_srxy_para webhtv_hpm_ref_com_get_ctx_srxy_para -#define com_get_inverse_trans_shift webhtv_hpm_ref_com_get_inverse_trans_shift -#define com_get_mpm webhtv_hpm_ref_com_get_mpm -#define com_get_mvp_default webhtv_hpm_ref_com_get_mvp_default -#define com_get_mvp_hmvp webhtv_hpm_ref_com_get_mvp_hmvp -#define com_get_nbr webhtv_hpm_ref_com_get_nbr -#define com_get_sawp_mpm webhtv_hpm_ref_com_get_sawp_mpm -#define com_get_split_mode webhtv_hpm_ref_com_get_split_mode -#define com_imgb_create webhtv_hpm_ref_com_imgb_create -#define com_init_pos_info webhtv_hpm_ref_com_init_pos_info -#define com_init_scan_sr webhtv_hpm_ref_com_init_scan_sr -#define com_inter_filter webhtv_hpm_ref_com_inter_filter -#define com_inter_pred_correction webhtv_hpm_ref_com_inter_pred_correction -#define com_ipred webhtv_hpm_ref_com_ipred -#define com_ipred_uv webhtv_hpm_ref_com_ipred_uv -#define com_is_cu_dqp webhtv_hpm_ref_com_is_cu_dqp -#define com_is_emcpm webhtv_hpm_ref_com_is_emcpm -#define com_is_mcpm webhtv_hpm_ref_com_is_mcpm -#define com_itdq webhtv_hpm_ref_com_itdq -#define com_itdq_plane webhtv_hpm_ref_com_itdq_plane -#define com_itdq_yuv webhtv_hpm_ref_com_itdq_yuv -#define com_itrans webhtv_hpm_ref_com_itrans -#define com_malloc_2d_sao_param webhtv_hpm_ref_com_malloc_2d_sao_param -#define com_malloc_3d_esao_stat_data webhtv_hpm_ref_com_malloc_3d_esao_stat_data -#define com_malloc_3d_sao_stat_data webhtv_hpm_ref_com_malloc_3d_sao_stat_data -#define com_malloc_4d_ccsao_statdata webhtv_hpm_ref_com_malloc_4d_ccsao_statdata -#define com_mc webhtv_hpm_ref_com_mc -#define com_mc_c_00 webhtv_hpm_ref_com_mc_c_00 -#define com_mc_c_0n webhtv_hpm_ref_com_mc_c_0n -#define com_mc_c_n0 webhtv_hpm_ref_com_mc_c_n0 -#define com_mc_c_nn webhtv_hpm_ref_com_mc_c_nn -#define com_mc_l_00 webhtv_hpm_ref_com_mc_l_00 -#define com_mc_l_0n webhtv_hpm_ref_com_mc_l_0n -#define com_mc_l_n0 webhtv_hpm_ref_com_mc_l_n0 -#define com_mc_l_nn webhtv_hpm_ref_com_mc_l_nn -#define com_md5_finish webhtv_hpm_ref_com_md5_finish -#define com_md5_imgb webhtv_hpm_ref_com_md5_imgb -#define com_md5_init webhtv_hpm_ref_com_md5_init -#define com_md5_update webhtv_hpm_ref_com_md5_update -#define com_md5_update_16 webhtv_hpm_ref_com_md5_update_16 -#define com_mv_rounding_s16 webhtv_hpm_ref_com_mv_rounding_s16 -#define com_mv_rounding_s32 webhtv_hpm_ref_com_mv_rounding_s32 -#define com_mvap_mc webhtv_hpm_ref_com_mvap_mc -#define com_obmc_blending webhtv_hpm_ref_com_obmc_blending -#define com_pic_alloc webhtv_hpm_ref_com_pic_alloc -#define com_pic_free webhtv_hpm_ref_com_pic_free -#define com_picbuf_alloc webhtv_hpm_ref_com_picbuf_alloc -#define com_picbuf_expand webhtv_hpm_ref_com_picbuf_expand -#define com_picbuf_free webhtv_hpm_ref_com_picbuf_free -#define com_picbuf_signature webhtv_hpm_ref_com_picbuf_signature -#define com_picman_check_repeat_doi webhtv_hpm_ref_com_picman_check_repeat_doi -#define com_picman_deinit webhtv_hpm_ref_com_picman_deinit -#define com_picman_dpbpic_doi_minus_cycle_length webhtv_hpm_ref_com_picman_dpbpic_doi_minus_cycle_length -#define com_picman_get_empty_pic webhtv_hpm_ref_com_picman_get_empty_pic -#define com_picman_init webhtv_hpm_ref_com_picman_init -#define com_picman_out_libpic webhtv_hpm_ref_com_picman_out_libpic -#define com_picman_out_pic webhtv_hpm_ref_com_picman_out_pic -#define com_picman_put_libpic webhtv_hpm_ref_com_picman_put_libpic -#define com_picman_put_pic webhtv_hpm_ref_com_picman_put_pic -#define com_picman_refp_rpl_based_init webhtv_hpm_ref_com_picman_refp_rpl_based_init -#define com_picman_refp_rpl_based_init_decoder webhtv_hpm_ref_com_picman_refp_rpl_based_init_decoder -#define com_picman_refpic_marking webhtv_hpm_ref_com_picman_refpic_marking -#define com_picman_refpic_marking_decoder webhtv_hpm_ref_com_picman_refpic_marking_decoder -#define com_recon webhtv_hpm_ref_com_recon -#define com_recon_yuv webhtv_hpm_ref_com_recon_yuv -#define com_sbTmvp_mc webhtv_hpm_ref_com_sbTmvp_mc -#define com_sbac_ctx_init webhtv_hpm_ref_com_sbac_ctx_init -#define com_sbt_allow webhtv_hpm_ref_com_sbt_allow -#define com_scan_sr webhtv_hpm_ref_com_scan_sr -#define com_scan_tbl webhtv_hpm_ref_com_scan_tbl -#define com_scan_tbl_delete webhtv_hpm_ref_com_scan_tbl_delete -#define com_scan_tbl_init webhtv_hpm_ref_com_scan_tbl_init -#define com_sec_pred webhtv_hpm_ref_com_sec_pred -#define com_set_affine_mvf webhtv_hpm_ref_com_set_affine_mvf -#define com_set_awp_mv_para webhtv_hpm_ref_com_set_awp_mv_para -#define com_set_awp_mvf webhtv_hpm_ref_com_set_awp_mvf -#define com_set_awp_mvr_mv_para webhtv_hpm_ref_com_set_awp_mvr_mv_para -#define com_set_cons_pred_mode webhtv_hpm_ref_com_set_cons_pred_mode -#define com_set_split_mode webhtv_hpm_ref_com_set_split_mode -#define com_set_uni_cand webhtv_hpm_ref_com_set_uni_cand -#define com_sp_delete webhtv_hpm_ref_com_sp_delete -#define com_sp_init webhtv_hpm_ref_com_sp_init -#define com_split_get_direction webhtv_hpm_ref_com_split_get_direction -#define com_split_get_part_size webhtv_hpm_ref_com_split_get_part_size -#define com_split_get_part_size_idx webhtv_hpm_ref_com_split_get_part_size_idx -#define com_split_get_part_structure webhtv_hpm_ref_com_split_get_part_structure -#define com_split_get_split_rdo_order webhtv_hpm_ref_com_split_get_split_rdo_order -#define com_split_is_BT webhtv_hpm_ref_com_split_is_BT -#define com_split_is_EQT webhtv_hpm_ref_com_split_is_EQT -#define com_split_is_horizontal webhtv_hpm_ref_com_split_is_horizontal -#define com_split_is_vertical webhtv_hpm_ref_com_split_is_vertical -#define com_split_part_count webhtv_hpm_ref_com_split_part_count -#define com_split_tbl webhtv_hpm_ref_com_split_tbl -#define com_st_chroma_allow webhtv_hpm_ref_com_st_chroma_allow -#define com_sub_pel_error_surface webhtv_hpm_ref_com_sub_pel_error_surface -#define com_subblk_obmc webhtv_hpm_ref_com_subblk_obmc -#define com_tbl_awp_angle_mode webhtv_hpm_ref_com_tbl_awp_angle_mode -#define com_tbl_bawp_mode webhtv_hpm_ref_com_tbl_bawp_mode -#define com_tbl_bawp_num webhtv_hpm_ref_com_tbl_bawp_num -#define com_tbl_df_st webhtv_hpm_ref_com_tbl_df_st -#define com_tbl_dq_scale webhtv_hpm_ref_com_tbl_dq_scale -#define com_tbl_dq_shift webhtv_hpm_ref_com_tbl_dq_shift -#define com_tbl_ipred_adi webhtv_hpm_ref_com_tbl_ipred_adi -#define com_tbl_ipred_adi_long webhtv_hpm_ref_com_tbl_ipred_adi_long -#define com_tbl_ipred_dxdy webhtv_hpm_ref_com_tbl_ipred_dxdy -#define com_tbl_log2 webhtv_hpm_ref_com_tbl_log2 -#define com_tbl_logmap webhtv_hpm_ref_com_tbl_logmap -#define com_tbl_mc_c webhtv_hpm_ref_com_tbl_mc_c -#define com_tbl_mc_l webhtv_hpm_ref_com_tbl_mc_l -#define com_tbl_qp_chroma_adjust webhtv_hpm_ref_com_tbl_qp_chroma_adjust -#define com_tbl_qp_chroma_adjust_enc webhtv_hpm_ref_com_tbl_qp_chroma_adjust_enc -#define com_tbl_raster2trav webhtv_hpm_ref_com_tbl_raster2trav -#define com_tbl_raster2trav_2d webhtv_hpm_ref_com_tbl_raster2trav_2d -#define com_tbl_subset_inter webhtv_hpm_ref_com_tbl_subset_inter -#define com_tbl_tm16 webhtv_hpm_ref_com_tbl_tm16 -#define com_tbl_tm2 webhtv_hpm_ref_com_tbl_tm2 -#define com_tbl_tm32 webhtv_hpm_ref_com_tbl_tm32 -#define com_tbl_tm4 webhtv_hpm_ref_com_tbl_tm4 -#define com_tbl_tm64 webhtv_hpm_ref_com_tbl_tm64 -#define com_tbl_tm8 webhtv_hpm_ref_com_tbl_tm8 -#define com_tbl_trav2raster webhtv_hpm_ref_com_tbl_trav2raster -#define com_tree_split webhtv_hpm_ref_com_tree_split -#define copy_alf_param webhtv_hpm_ref_copy_alf_param -#define copy_available_motions webhtv_hpm_ref_copy_available_motions -#define copy_block_motion webhtv_hpm_ref_copy_block_motion -#define copy_block_motion_table webhtv_hpm_ref_copy_block_motion_table -#define copy_buffer webhtv_hpm_ref_copy_buffer -#define copy_ctu_for_sao webhtv_hpm_ref_copy_ctu_for_sao -#define copy_esao_param_for_one_component webhtv_hpm_ref_copy_esao_param_for_one_component -#define copy_fap_unpred_pix_motion_table webhtv_hpm_ref_copy_fap_unpred_pix_motion_table -#define copy_frame_for_alf webhtv_hpm_ref_copy_frame_for_alf -#define copy_frame_for_ccsao webhtv_hpm_ref_copy_frame_for_ccsao -#define copy_frame_for_esao webhtv_hpm_ref_copy_frame_for_esao -#define copy_frame_for_sao webhtv_hpm_ref_copy_frame_for_sao -#define copy_motion webhtv_hpm_ref_copy_motion -#define copy_motion_table webhtv_hpm_ref_copy_motion_table -#define copy_mv webhtv_hpm_ref_copy_mv -#define copy_neighbor_motion webhtv_hpm_ref_copy_neighbor_motion -#define copy_rec_y_to_pic webhtv_hpm_ref_copy_rec_y_to_pic -#define copy_refp webhtv_hpm_ref_copy_refp -#define copy_sao_param_for_blk webhtv_hpm_ref_copy_sao_param_for_blk -#define copy_sao_param_for_blk_one_component webhtv_hpm_ref_copy_sao_param_for_blk_one_component -#define copyright_extension webhtv_hpm_ref_copyright_extension -#define counter_thr1 webhtv_hpm_ref_counter_thr1 -#define counter_thr2 webhtv_hpm_ref_counter_thr2 -#define create_alf_global_buffer webhtv_hpm_ref_create_alf_global_buffer -#define create_block_motion webhtv_hpm_ref_create_block_motion -#define create_edge_filter_avs2 webhtv_hpm_ref_create_edge_filter_avs2 -#define create_motion webhtv_hpm_ref_create_motion -#define cross_random_access_point_referencing_extension webhtv_hpm_ref_cross_random_access_point_referencing_extension -#define cu_nz_cln webhtv_hpm_ref_cu_nz_cln -#define cu_plane_nz_cln webhtv_hpm_ref_cu_plane_nz_cln -#define cu_plane_nz_cpy webhtv_hpm_ref_cu_plane_nz_cpy -#define cwr2LGS webhtv_hpm_ref_cwr2LGS -#define de_copy_lcu_scu webhtv_hpm_ref_de_copy_lcu_scu -#define deblock_block_avs2 webhtv_hpm_ref_deblock_block_avs2 -#define deblock_frame_avs2 webhtv_hpm_ref_deblock_frame_avs2 -#define deblock_mb_avs2 webhtv_hpm_ref_deblock_mb_avs2 -#define dec_alf_avs2 webhtv_hpm_ref_dec_alf_avs2 -#define dec_ccsao webhtv_hpm_ref_dec_ccsao -#define dec_cnk webhtv_hpm_ref_dec_cnk -#define dec_config webhtv_hpm_ref_dec_config -#define dec_create webhtv_hpm_ref_dec_create -#define dec_deblock_avs2 webhtv_hpm_ref_dec_deblock_avs2 -#define dec_decode webhtv_hpm_ref_dec_decode -#define dec_decode_cu webhtv_hpm_ref_dec_decode_cu -#define dec_decode_cu_chroma webhtv_hpm_ref_dec_decode_cu_chroma -#define dec_delete webhtv_hpm_ref_dec_delete -#define dec_derive_bvp_list webhtv_hpm_ref_dec_derive_bvp_list -#define dec_derive_ibc_bvp_info webhtv_hpm_ref_dec_derive_ibc_bvp_info -#define dec_derive_skip_direct_info webhtv_hpm_ref_dec_derive_skip_direct_info -#define dec_eco_DB_param webhtv_hpm_ref_dec_eco_DB_param -#define dec_eco_affine_motion_derive webhtv_hpm_ref_dec_eco_affine_motion_derive -#define dec_eco_alf_coeff webhtv_hpm_ref_dec_eco_alf_coeff -#define dec_eco_alf_lcu_ctrl webhtv_hpm_ref_dec_eco_alf_lcu_ctrl -#define dec_eco_alf_param webhtv_hpm_ref_dec_eco_alf_param -#define dec_eco_ccsao_lcu_flag webhtv_hpm_ref_dec_eco_ccsao_lcu_flag -#define dec_eco_ccsao_offset webhtv_hpm_ref_dec_eco_ccsao_offset -#define dec_eco_ccsao_pic_header webhtv_hpm_ref_dec_eco_ccsao_pic_header -#define dec_eco_cnkh webhtv_hpm_ref_dec_eco_cnkh -#define dec_eco_cons_pred_mode_child webhtv_hpm_ref_dec_eco_cons_pred_mode_child -#define dec_eco_esao_lcu_control_flag webhtv_hpm_ref_dec_eco_esao_lcu_control_flag -#define dec_eco_esao_offset webhtv_hpm_ref_dec_eco_esao_offset -#define dec_eco_esao_param webhtv_hpm_ref_dec_eco_esao_param -#define dec_eco_lcu_delta_qp webhtv_hpm_ref_dec_eco_lcu_delta_qp -#define dec_eco_nnlf_lcu_enable_flag webhtv_hpm_ref_dec_eco_nnlf_lcu_enable_flag -#define dec_eco_nnlf_lcu_set_index webhtv_hpm_ref_dec_eco_nnlf_lcu_set_index -#define dec_eco_patch_header webhtv_hpm_ref_dec_eco_patch_header -#define dec_eco_pic_header webhtv_hpm_ref_dec_eco_pic_header -#define dec_eco_rlp webhtv_hpm_ref_dec_eco_rlp -#define dec_eco_sao_BO_start webhtv_hpm_ref_dec_eco_sao_BO_start -#define dec_eco_sao_EO_type webhtv_hpm_ref_dec_eco_sao_EO_type -#define dec_eco_sao_merge_flag webhtv_hpm_ref_dec_eco_sao_merge_flag -#define dec_eco_sao_mode webhtv_hpm_ref_dec_eco_sao_mode -#define dec_eco_sao_offset webhtv_hpm_ref_dec_eco_sao_offset -#define dec_eco_send webhtv_hpm_ref_dec_eco_send -#define dec_eco_split_mode webhtv_hpm_ref_dec_eco_split_mode -#define dec_eco_sqh webhtv_hpm_ref_dec_eco_sqh -#define dec_eco_udata webhtv_hpm_ref_dec_eco_udata -#define dec_esao webhtv_hpm_ref_dec_esao -#define dec_esao_chroma_band_flag webhtv_hpm_ref_dec_esao_chroma_band_flag -#define dec_esao_chroma_len webhtv_hpm_ref_dec_esao_chroma_len -#define dec_flush webhtv_hpm_ref_dec_flush -#define dec_is_bv_valid webhtv_hpm_ref_dec_is_bv_valid -#define dec_pic webhtv_hpm_ref_dec_pic -#define dec_picbuf_check_signature webhtv_hpm_ref_dec_picbuf_check_signature -#define dec_picbuf_expand webhtv_hpm_ref_dec_picbuf_expand -#define dec_pull_frm webhtv_hpm_ref_dec_pull_frm -#define dec_ready webhtv_hpm_ref_dec_ready -#define dec_sao_avs2 webhtv_hpm_ref_dec_sao_avs2 -#define dec_sbac_decode_bin webhtv_hpm_ref_dec_sbac_decode_bin -#define dec_sbac_decode_binW webhtv_hpm_ref_dec_sbac_decode_binW -#define dec_sbac_decode_bin_trm webhtv_hpm_ref_dec_sbac_decode_bin_trm -#define dec_sbac_init webhtv_hpm_ref_dec_sbac_init -#define dec_set_dec_info webhtv_hpm_ref_dec_set_dec_info -#define dec_set_patch_idx webhtv_hpm_ref_dec_set_patch_idx -#define decide_ccsao_func_pointer webhtv_hpm_ref_decide_ccsao_func_pointer -#define decide_esao_filter_func_pointer webhtv_hpm_ref_decide_esao_filter_func_pointer -#define decode_coef webhtv_hpm_ref_decode_coef -#define decode_est_flag webhtv_hpm_ref_decode_est_flag -#define decode_ets_flag webhtv_hpm_ref_decode_ets_flag -#define decode_inter_dir webhtv_hpm_ref_decode_inter_dir -#define decode_intra_dir webhtv_hpm_ref_decode_intra_dir -#define decode_intra_dir_c webhtv_hpm_ref_decode_intra_dir_c -#define decode_ipcm webhtv_hpm_ref_decode_ipcm -#define decode_pvbuf webhtv_hpm_ref_decode_pvbuf -#define decode_sawp_dir webhtv_hpm_ref_decode_sawp_dir -#define decode_sawp_dir1 webhtv_hpm_ref_decode_sawp_dir1 -#define decode_st_chroma_flag webhtv_hpm_ref_decode_st_chroma_flag -#define decode_ts_flag webhtv_hpm_ref_decode_ts_flag -#define delete_edge_filter_avs2 webhtv_hpm_ref_delete_edge_filter_avs2 -#define delete_libvcdata webhtv_hpm_ref_delete_libvcdata -#define deltaband_cost webhtv_hpm_ref_deltaband_cost -#define derive_MHBskip_spatial_motions webhtv_hpm_ref_derive_MHBskip_spatial_motions -#define derive_affine_umve_final_motion webhtv_hpm_ref_derive_affine_umve_final_motion -#define derive_alf_boundary_availibility webhtv_hpm_ref_derive_alf_boundary_availibility -#define derive_awp_mvr_final_motion webhtv_hpm_ref_derive_awp_mvr_final_motion -#define derive_boundary_avail webhtv_hpm_ref_derive_boundary_avail -#define derive_first_stage_motion webhtv_hpm_ref_derive_first_stage_motion -#define derive_mvap_motions webhtv_hpm_ref_derive_mvap_motions -#define derive_ref_block_position webhtv_hpm_ref_derive_ref_block_position -#define derive_scaled_base_motion webhtv_hpm_ref_derive_scaled_base_motion -#define derive_umve_base_motions webhtv_hpm_ref_derive_umve_base_motions -#define derive_umve_final_motions webhtv_hpm_ref_derive_umve_final_motions -#define distortion_cal webhtv_hpm_ref_distortion_cal -#define div_for_maxq7 webhtv_hpm_ref_div_for_maxq7 -#define divide_tbl webhtv_hpm_ref_divide_tbl -#define downsample_pixel_for_tscpm webhtv_hpm_ref_downsample_pixel_for_tscpm -#define drive_scaled_tmporal_motion webhtv_hpm_ref_drive_scaled_tmporal_motion -#define edge_loop_x webhtv_hpm_ref_edge_loop_x -#define esao_clip webhtv_hpm_ref_esao_clip -#define esao_on_block webhtv_hpm_ref_esao_on_block -#define esao_on_block_for_chroma_without_simd webhtv_hpm_ref_esao_on_block_for_chroma_without_simd -#define esao_on_block_for_luma_without_simd webhtv_hpm_ref_esao_on_block_for_luma_without_simd -#define esao_on_frame webhtv_hpm_ref_esao_on_frame -#define esao_on_smb webhtv_hpm_ref_esao_on_smb -#define extend_pic_border webhtv_hpm_ref_extend_pic_border -#define extension_and_user_data webhtv_hpm_ref_extension_and_user_data -#define extension_data webhtv_hpm_ref_extension_data -#define fill_bvp_list webhtv_hpm_ref_fill_bvp_list -#define fill_neighbor_motions webhtv_hpm_ref_fill_neighbor_motions -#define fill_skip_candidates webhtv_hpm_ref_fill_skip_candidates -#define filter_one_comp_region webhtv_hpm_ref_filter_one_comp_region -#define filter_one_ctb webhtv_hpm_ref_filter_one_ctb -#define free_alf_param webhtv_hpm_ref_free_alf_param -#define free_mem_1D_int webhtv_hpm_ref_free_mem_1D_int -#define free_mem_2D_int webhtv_hpm_ref_free_mem_2D_int -#define g_CountDOICyCleTime webhtv_hpm_ref_g_CountDOICyCleTime -#define g_DOIPrev webhtv_hpm_ref_g_DOIPrev -#define g_aiTscpmDivTable64 webhtv_hpm_ref_g_aiTscpmDivTable64 -#define g_cntMpmInitTable webhtv_hpm_ref_g_cntMpmInitTable -#define g_compatible_back webhtv_hpm_ref_g_compatible_back -#define g_group_idx webhtv_hpm_ref_g_group_idx -#define g_min_in_group webhtv_hpm_ref_g_min_in_group -#define g_msb_p1_idx webhtv_hpm_ref_g_msb_p1_idx -#define g_sp_tb_tbl webhtv_hpm_ref_g_sp_tb_tbl -#define g_uc_run_top_lut webhtv_hpm_ref_g_uc_run_top_lut -#define getSameNeigMotion webhtv_hpm_ref_getSameNeigMotion -#define get_coef_offset_tb webhtv_hpm_ref_get_coef_offset_tb -#define get_col_mv webhtv_hpm_ref_get_col_mv -#define get_col_mv_ext webhtv_hpm_ref_get_col_mv_ext -#define get_col_mv_from_list0 webhtv_hpm_ref_get_col_mv_from_list0 -#define get_col_mv_from_list0_ext webhtv_hpm_ref_get_col_mv_from_list0_ext -#define get_colocal_scup webhtv_hpm_ref_get_colocal_scup -#define get_distortion webhtv_hpm_ref_get_distortion -#define get_hmvp_skip_cands webhtv_hpm_ref_get_hmvp_skip_cands -#define get_ipc_linear webhtv_hpm_ref_get_ipc_linear -#define get_lcu_ctrl_ctx_idx webhtv_hpm_ref_get_lcu_ctrl_ctx_idx -#define get_libidx webhtv_hpm_ref_get_libidx -#define get_linear_param_for_tscpm webhtv_hpm_ref_get_linear_param_for_tscpm -#define get_luma_border_pixel_for_tscpm webhtv_hpm_ref_get_luma_border_pixel_for_tscpm -#define get_mem_1D_int webhtv_hpm_ref_get_mem_1D_int -#define get_mem_2D_int webhtv_hpm_ref_get_mem_2D_int -#define get_msb_p1_idx webhtv_hpm_ref_get_msb_p1_idx -#define get_part_idx webhtv_hpm_ref_get_part_idx -#define get_part_info webhtv_hpm_ref_get_part_info -#define get_part_num webhtv_hpm_ref_get_part_num -#define get_part_num_tb_in_pb webhtv_hpm_ref_get_part_num_tb_in_pb -#define get_rb_scup webhtv_hpm_ref_get_rb_scup -#define get_sao_merge_neighbor webhtv_hpm_ref_get_sao_merge_neighbor -#define get_sbt_tb_pos_offset webhtv_hpm_ref_get_sbt_tb_pos_offset -#define get_sbt_tb_size webhtv_hpm_ref_get_sbt_tb_size -#define get_sbt_tr webhtv_hpm_ref_get_sbt_tr -#define get_sp_trav_index webhtv_hpm_ref_get_sp_trav_index -#define get_spatial_motion_adj_sv webhtv_hpm_ref_get_spatial_motion_adj_sv -#define get_spatial_motion_nonadj_bsv webhtv_hpm_ref_get_spatial_motion_nonadj_bsv -#define get_tb_idx_offset webhtv_hpm_ref_get_tb_idx_offset -#define get_tb_part_size_by_pb webhtv_hpm_ref_get_tb_part_size_by_pb -#define get_tb_pos_in_pb webhtv_hpm_ref_get_tb_pos_in_pb -#define get_tb_start_pos webhtv_hpm_ref_get_tb_start_pos -#define get_tb_width_height webhtv_hpm_ref_get_tb_width_height -#define get_tb_width_height_in_pb webhtv_hpm_ref_get_tb_width_height_in_pb -#define get_tb_width_height_log2 webhtv_hpm_ref_get_tb_width_height_log2 -#define get_valid_etmvp_motion webhtv_hpm_ref_get_valid_etmvp_motion -#define get_valid_mvap_mode webhtv_hpm_ref_get_valid_mvap_mode -#define grad_x webhtv_hpm_ref_grad_x -#define grad_y webhtv_hpm_ref_grad_y -#define have_diff_motion webhtv_hpm_ref_have_diff_motion -#define hdr_dynamic_metadata_extension webhtv_hpm_ref_hdr_dynamic_metadata_extension -#define iip_refine webhtv_hpm_ref_iip_refine -#define init_dct_coef webhtv_hpm_ref_init_dct_coef -#define init_libvcdata webhtv_hpm_ref_init_libvcdata -#define init_motion webhtv_hpm_ref_init_motion -#define init_pb_part webhtv_hpm_ref_init_pb_part -#define init_pic_wq_matrix webhtv_hpm_ref_init_pic_wq_matrix -#define init_scan webhtv_hpm_ref_init_scan -#define init_stat_data webhtv_hpm_ref_init_stat_data -#define init_tb_part webhtv_hpm_ref_init_tb_part -#define insert_bsv webhtv_hpm_ref_insert_bsv -#define intrinsic_mask_10bit webhtv_hpm_ref_intrinsic_mask_10bit -#define ipf_core webhtv_hpm_ref_ipf_core -#define ipred_ang webhtv_hpm_ref_ipred_ang -#define ipred_bi webhtv_hpm_ref_ipred_bi -#define ipred_dc webhtv_hpm_ref_ipred_dc -#define ipred_hor webhtv_hpm_ref_ipred_hor -#define ipred_mcpm webhtv_hpm_ref_ipred_mcpm -#define ipred_plane webhtv_hpm_ref_ipred_plane -#define ipred_tscpm webhtv_hpm_ref_ipred_tscpm -#define ipred_vert webhtv_hpm_ref_ipred_vert -#define is_cu_cbf webhtv_hpm_ref_is_cu_cbf -#define is_cu_nz webhtv_hpm_ref_is_cu_nz -#define is_cu_nz_equ webhtv_hpm_ref_is_cu_nz_equ -#define is_cu_plane_nz webhtv_hpm_ref_is_cu_plane_nz -#define is_pv_valid webhtv_hpm_ref_is_pv_valid -#define is_same_motion webhtv_hpm_ref_is_same_motion -#define is_same_patch webhtv_hpm_ref_is_same_patch -#define is_str_overlap webhtv_hpm_ref_is_str_overlap -#define is_tb_avaliable webhtv_hpm_ref_is_tb_avaliable -#define itx_dct8_pb16 webhtv_hpm_ref_itx_dct8_pb16 -#define itx_dct8_pb32 webhtv_hpm_ref_itx_dct8_pb32 -#define itx_dct8_pb4 webhtv_hpm_ref_itx_dct8_pb4 -#define itx_dct8_pb64 webhtv_hpm_ref_itx_dct8_pb64 -#define itx_dct8_pb8 webhtv_hpm_ref_itx_dct8_pb8 -#define itx_dst7_pb16 webhtv_hpm_ref_itx_dst7_pb16 -#define itx_dst7_pb32 webhtv_hpm_ref_itx_dst7_pb32 -#define itx_dst7_pb4 webhtv_hpm_ref_itx_dst7_pb4 -#define itx_dst7_pb64 webhtv_hpm_ref_itx_dst7_pb64 -#define itx_dst7_pb8 webhtv_hpm_ref_itx_dst7_pb8 -#define linear_transform_for_tscpm webhtv_hpm_ref_linear_transform_for_tscpm -#define mCabac_offset webhtv_hpm_ref_mCabac_offset -#define mCabac_ws webhtv_hpm_ref_mCabac_ws -#define mastering_display_and_content_metadata_extension webhtv_hpm_ref_mastering_display_and_content_metadata_extension -#define mc_filter_bilin_horz_sse webhtv_hpm_ref_mc_filter_bilin_horz_sse -#define mc_filter_bilin_vert_sse webhtv_hpm_ref_mc_filter_bilin_vert_sse -#define mv_clip webhtv_hpm_ref_mv_clip -#define off_sao webhtv_hpm_ref_off_sao -#define padding webhtv_hpm_ref_padding -#define picture_display_extension webhtv_hpm_ref_picture_display_extension -#define pred_inter_filter webhtv_hpm_ref_pred_inter_filter -#define pred_inter_pred_correction webhtv_hpm_ref_pred_inter_pred_correction -#define pred_obmc webhtv_hpm_ref_pred_obmc -#define pred_recon_intra_luma_pb webhtv_hpm_ref_pred_recon_intra_luma_pb -#define print_motion webhtv_hpm_ref_print_motion -#define printf_flag webhtv_hpm_ref_printf_flag -#define process_DMVR webhtv_hpm_ref_process_DMVR -#define read_byte webhtv_hpm_ref_read_byte -#define read_param_sao_one_lcu webhtv_hpm_ref_read_param_sao_one_lcu -#define read_sao_lcu webhtv_hpm_ref_read_sao_lcu -#define read_sp_lo_ref_maxlen webhtv_hpm_ref_read_sp_lo_ref_maxlen -#define read_sp_match_flag_run webhtv_hpm_ref_read_sp_match_flag_run -#define read_sp_scanmode_type webhtv_hpm_ref_read_sp_scanmode_type -#define read_wq_matrix webhtv_hpm_ref_read_wq_matrix -#define reconstruct_coef_info webhtv_hpm_ref_reconstruct_coef_info -#define reconstruct_coefficients webhtv_hpm_ref_reconstruct_coefficients -#define release_alf_global_buffer webhtv_hpm_ref_release_alf_global_buffer -#define roi_parameters_extension webhtv_hpm_ref_roi_parameters_extension -#define same_block_motion webhtv_hpm_ref_same_block_motion -#define same_motion webhtv_hpm_ref_same_motion -#define same_neighbor_motion webhtv_hpm_ref_same_neighbor_motion -#define sao_frame webhtv_hpm_ref_sao_frame -#define saoclip webhtv_hpm_ref_saoclip -#define save_prev_pvbuf webhtv_hpm_ref_save_prev_pvbuf -#define scaling_mv1 webhtv_hpm_ref_scaling_mv1 -#define sequence_display_extension webhtv_hpm_ref_sequence_display_extension -#define set_edge_filter_avs2 webhtv_hpm_ref_set_edge_filter_avs2 -#define set_edge_filter_one_scu_avs2 webhtv_hpm_ref_set_edge_filter_one_scu_avs2 -#define set_edge_filter_param_hor_avs2 webhtv_hpm_ref_set_edge_filter_param_hor_avs2 -#define set_edge_filter_param_ver_avs2 webhtv_hpm_ref_set_edge_filter_param_ver_avs2 -#define set_etmvp_mvfield webhtv_hpm_ref_set_etmvp_mvfield -#define set_filter_image webhtv_hpm_ref_set_filter_image -#define set_mvap_mvfield webhtv_hpm_ref_set_mvap_mvfield -#define set_pb_part webhtv_hpm_ref_set_pb_part -#define set_pic_wq_matrix_by_param webhtv_hpm_ref_set_pic_wq_matrix_by_param -#define set_refp webhtv_hpm_ref_set_refp -#define set_tb_part webhtv_hpm_ref_set_tb_part -#define sfcoeff_1D_h webhtv_hpm_ref_sfcoeff_1D_h -#define sfcoeff_1D_v webhtv_hpm_ref_sfcoeff_1D_v -#define sigma webhtv_hpm_ref_sigma -#define sm_beta_table webhtv_hpm_ref_sm_beta_table -#define sm_tc_table webhtv_hpm_ref_sm_tc_table -#define sp_cs2_recon_yuv webhtv_hpm_ref_sp_cs2_recon_yuv -#define sp_recon_yuv webhtv_hpm_ref_sp_recon_yuv -#define store_blk_mvfield webhtv_hpm_ref_store_blk_mvfield -#define store_sbtmvp_mvfield webhtv_hpm_ref_store_sbtmvp_mvfield -#define store_subblk_mvfield webhtv_hpm_ref_store_subblk_mvfield -#define subtract_sample_for_tscpm webhtv_hpm_ref_subtract_sample_for_tscpm -#define tab_WeightQuantModel webhtv_hpm_ref_tab_WeightQuantModel -#define tab_WeightQuantModel4x4 webhtv_hpm_ref_tab_WeightQuantModel4x4 -#define tab_WqMDefault4x4 webhtv_hpm_ref_tab_WqMDefault4x4 -#define tab_WqMDefault8x8 webhtv_hpm_ref_tab_WqMDefault8x8 -#define tab_c4_trans webhtv_hpm_ref_tab_c4_trans -#define tab_c8_trans webhtv_hpm_ref_tab_c8_trans -#define tab_cycno_lgpmps_mps webhtv_hpm_ref_tab_cycno_lgpmps_mps -#define tab_esao_chroma_class webhtv_hpm_ref_tab_esao_chroma_class -#define tab_esao_chroma_class_bit webhtv_hpm_ref_tab_esao_chroma_class_bit -#define tab_wq_param_default webhtv_hpm_ref_tab_wq_param_default -#define tbl_fimc webhtv_hpm_ref_tbl_fimc -#define tbl_mc_c_coeff webhtv_hpm_ref_tbl_mc_c_coeff -#define tbl_mc_c_coeff_hp webhtv_hpm_ref_tbl_mc_c_coeff_hp -#define tbl_mc_c_coeff_ibc webhtv_hpm_ref_tbl_mc_c_coeff_ibc -#define tbl_mc_l_coeff webhtv_hpm_ref_tbl_mc_l_coeff -#define tbl_mc_l_coeff_hp webhtv_hpm_ref_tbl_mc_l_coeff_hp -#define tbl_weights_shape1_sym webhtv_hpm_ref_tbl_weights_shape1_sym -#define tbl_weights_shape2_sym webhtv_hpm_ref_tbl_weights_shape2_sym -#define temporal_scalability_extension webhtv_hpm_ref_temporal_scalability_extension -#define update_ibc_skip_candidates webhtv_hpm_ref_update_ibc_skip_candidates -#define update_intra_info_map_scu webhtv_hpm_ref_update_intra_info_map_scu -#define update_sawp_info_map_scu webhtv_hpm_ref_update_sawp_info_map_scu -#define update_skip_candidates webhtv_hpm_ref_update_skip_candidates -#define update_sp_recent_cands webhtv_hpm_ref_update_sp_recent_cands -#define update_sp_recent_pvcands webhtv_hpm_ref_update_sp_recent_pvcands -#define user_data webhtv_hpm_ref_user_data -#define weight_average_16b_no_clip_sse webhtv_hpm_ref_weight_average_16b_no_clip_sse -#define x_decode_dup_count webhtv_hpm_ref_x_decode_dup_count -#define x_decode_pvbuf_pred_indicator webhtv_hpm_ref_x_decode_pvbuf_pred_indicator -#define x_read_CS2_pv_adr webhtv_hpm_ref_x_read_CS2_pv_adr -#define x_read_ep_ex_golomb webhtv_hpm_ref_x_read_ep_ex_golomb -#endif diff --git a/third_party/avs3-hpm/prepare_source.py b/third_party/avs3-hpm/prepare_source.py deleted file mode 100644 index 2aa372ec2e..0000000000 --- a/third_party/avs3-hpm/prepare_source.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 -"""Adapt a fresh, hash-verified HPM 15.0 copy without changing its codec tools.""" -from pathlib import Path -import re -import sys - -root = Path(sys.argv[1]) - - -def edit(name, before, after, count=1): - path = root / name - text = path.read_text(encoding="latin-1") - if text.count(before) != count: - raise SystemExit(f"HPM source drift: {name}: {before[:80]!r}") - path.write_text(text.replace(before, after), encoding="latin-1") - - -edit("src/com_recon.c", "__inline u8 dec_is_pix_ref_valid", "static __inline u8 dec_is_pix_ref_valid") - -# Four-tap chroma filters only use the low four s16 coefficients. The reference -# SSE code read eight, crossing the final row of the coefficient table. Keep -# the exact low lanes and load only the eight bytes the filter owns. -path = root / "src/com_mc.c" -text = path.read_text(encoding="latin-1") -for name in ("mc_filter_c_4pel_horz_sse", "mc_filter_c_4pel_vert_sse"): - start = text.index("static void " + name) - end = text.index("\n}", start) - body = text[start:end] - before = "coeff0_1_8x16b = _mm_loadu_si128((__m128i*)coeff);" - if body.count(before) != 1: - raise SystemExit("HPM chroma coefficient load drift: " + name) - text = text[:start] + body.replace(before, - "coeff0_1_8x16b = _mm_loadl_epi64((const __m128i*)coeff);") + text[end:] -path.write_text(text, encoding="latin-1") - -# CLI progress output has no place in a library or an Android playback loop. -edit("src/dec_eco.c", ' printf("%d ", patch->idx);', - " /* Per-patch CLI progress is intentionally omitted in the library. */") - -# Keep the scalar ESAO implementations, removing only x86 dispatch/functions. -path = root / "src/com_esao.c" -text = path.read_text(encoding="latin-1") -text = re.sub(r"^#include <(?:[a-z]*intrin|cpuid)\.h>\n", "", text, flags=re.M) -for name in ("esao_on_block_for_luma_avx", "esao_on_block_for_luma_sse", - "esao_on_block_for_chroma_avx", "esao_on_block_for_chroma_sse", - "is_support_sse_avx", "decide_esao_filter_func_pointer"): - match = re.search(r"^(?:void|int) " + name + r"\(", text, re.M) - if not match: - raise SystemExit("Missing HPM ESAO function: " + name) - start = text.index("{", match.end()) - end, depth = start + 1, 1 - while depth: - depth += (text[end] == "{") - (text[end] == "}") - end += 1 - replacement = "" - if name == "decide_esao_filter_func_pointer": - replacement = """void decide_esao_filter_func_pointer(ESAO_FUNC_POINTER *f) -{ - f->esao_on_block_for_luma = esao_on_block_for_luma_without_simd; - f->esao_on_block_for_chroma = esao_on_block_for_chroma_without_simd; -}""" - text = text[:match.start()] + replacement + text[end:] -path.write_text(text, encoding="latin-1") - -# A disabled ESAO LCU carries flag 0. HPM read parameter[-1] before testing -# that flag, even though the result was unused. Preserve the no-filter result -# while avoiding that reference-code out-of-bounds read. -edit("src/com_esao.c", " int lcu_flag = info->pic_header.esao_lcu_enable[comp_idx];", - " int lcu_flag = info->pic_header.esao_lcu_enable[comp_idx];\n if (lcu_flag && !lcu_open_flag) return;") - -# Route all allocations and fatal assertions through an instance-owned arena. -# Only C decoder/common sources are linked; no longjmp crosses C++ destructors. -for path in list((root / "inc").glob("*.h")) + list((root / "src").glob("*.c")): - text = path.read_text(encoding="latin-1") - text = text.replace("#ifdef X86_SSE", "#if X86_SSE") - text = text.replace("#include ", "#include ") - for old, new in (("malloc", "webhtv_hpm_alloc"), ("calloc", "webhtv_hpm_calloc"), - ("memalign", "webhtv_hpm_aligned_alloc"), ("free", "webhtv_hpm_free"), - ("assert", "HPM_REQUIRE"), ("exit", "HPM_EXIT")): - text = re.sub(r"\b" + old + r"\s*\(", new + "(", text) - path.write_text(text, encoding="latin-1") -edit("inc/com_port.h", "#include ", '#include \n#include "hpm_guard.h"') -edit("inc/com_port.h", "#if defined(WIN32) || defined(WIN64)\n#include ", - '#if defined(__arm__) || defined(__aarch64__)\n#include "sse2neon.h"\n#elif defined(WIN32) || defined(WIN64)\n#include ') - -# Existing com_assert variants evaluate x a second time on failure; a read in x -# must never run twice. The error boundary replaces both abort and goto cleanup. -path = root / "inc/com_port.h" -text = path.read_text(encoding="latin-1") -start = text.index("#define com_assert(x)") -end = text.index("#if X86_SSE", start) -text = text[:start] + """#define com_assert(x) HPM_REQUIRE(x) -#define com_assert_r(x) HPM_REQUIRE(x) -#define com_assert_rv(x,r) HPM_REQUIRE(x) -#define com_assert_g(x,g) HPM_REQUIRE(x) -#define com_assert_gv(x,r,v,g) HPM_REQUIRE(x) - -""" + text[end:] -path.write_text(text, encoding="latin-1") - -# Reject oversized RPLs before writing arrays (including pointers obtained from -# an array before a compiler bounds check could detect a subsequent overwrite). -edit("src/dec_eco.c", "sqh->rpls_l0_num = (u32)com_bsr_read_ue(bs);", - "sqh->rpls_l0_num = com_bsr_read_ue(bs);\n HPM_REQUIRE(sqh->rpls_l0_num <= MAX_NUM_RPLS);") -edit("src/dec_eco.c", "sqh->rpls_l1_num = (u32)com_bsr_read_ue(bs);", - "sqh->rpls_l1_num = com_bsr_read_ue(bs);\n HPM_REQUIRE(sqh->rpls_l1_num <= MAX_NUM_RPLS);") - -# Validate dimensions, sampling and external-reference requirements before the -# first allocation or indexing based on a decoded sequence header. -edit("src/dec.c", "ret = dec_eco_sqh(bs, sqh);", """ret = dec_eco_sqh(bs, sqh); - HPM_REQUIRE(sqh->profile_id == 0x32 && sqh->chroma_format == 1); - HPM_REQUIRE(!sqh->library_stream_flag && !sqh->library_picture_enable_flag); - HPM_REQUIRE(sqh->horizontal_size > 0 && sqh->vertical_size > 0); - HPM_REQUIRE(sqh->horizontal_size <= 8192 && sqh->vertical_size <= 8192); - HPM_REQUIRE((uint64_t)sqh->horizontal_size * sqh->vertical_size <= 33554432); - HPM_REQUIRE(sqh->sample_precision == 1 || sqh->sample_precision == 2); - HPM_REQUIRE(sqh->encoding_precision == 2);""") - -# The reader permits exactly three lookahead bytes for the appended next-start -# sentinel. Reaching the end is an error, never an all-ones value or busy loop. -edit("src/dec_bsr.c", " return -1;", ' webhtv_hpm_fail(-1, "truncated bitstream", __LINE__);', count=2) -edit("src/dec_bsr.c", " return ((u32)-1);", ' webhtv_hpm_fail(-1, "truncated bitstream", __LINE__);') -edit("src/dec_bsr.c", "com_assert(size > 0);", "com_assert(size > 0 && size <= 32);", count=2) -edit("src/dec_bsr.c", " clz += len;", " clz += len;\n HPM_REQUIRE(clz <= 15 && len + clz + 1 <= 32);") -edit("src/dec_bsr.c", " cur += byte;", " HPM_REQUIRE(byte >= 0);\n cur += byte;") -# A 32-bit shift is undefined on every architecture, not just x86. -edit("inc/dec_bsr.h", "#if defined(X86F)", "#if 1") - -# Preserve sequence display metadata that the CLI used to discard. -edit("src/dec_eco.c", " com_bsr_read1(bs); // sample_range", - " int range = com_bsr_read1(bs);\n webhtv_hpm_set_color(range, -1, -1, -1); // sample_range") -edit("src/dec_eco.c", " com_bsr_read(bs, 8); // matrix_coefficients", - " int matrix = com_bsr_read(bs, 8);\n webhtv_hpm_set_color(range, colour_primaries, transfer_characteristics, matrix); // matrix_coefficients") diff --git a/third_party/avs3-hpm/vendor/hpm-15.0.tar.gz b/third_party/avs3-hpm/vendor/hpm-15.0.tar.gz deleted file mode 100644 index 98d67f152b..0000000000 Binary files a/third_party/avs3-hpm/vendor/hpm-15.0.tar.gz and /dev/null differ diff --git a/third_party/avs3-hpm/vendor/sse2neon.h b/third_party/avs3-hpm/vendor/sse2neon.h deleted file mode 100644 index 79b90fe864..0000000000 --- a/third_party/avs3-hpm/vendor/sse2neon.h +++ /dev/null @@ -1,9417 +0,0 @@ -#ifndef SSE2NEON_H -#define SSE2NEON_H - -/* - * sse2neon is freely redistributable under the MIT License. - * - * Copyright (c) 2015-2024 SSE2NEON Contributors. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -// This header file provides a simple API translation layer -// between SSE intrinsics to their corresponding Arm/Aarch64 NEON versions -// -// Contributors to this work are: -// John W. Ratcliff -// Brandon Rowlett -// Ken Fast -// Eric van Beurden -// Alexander Potylitsin -// Hasindu Gamaarachchi -// Jim Huang -// Mark Cheng -// Malcolm James MacLeod -// Devin Hussey (easyaspi314) -// Sebastian Pop -// Developer Ecosystem Engineering -// Danila Kutenin -// François Turban (JishinMaster) -// Pei-Hsuan Hung -// Yang-Hao Yuan -// Syoyo Fujita -// Brecht Van Lommel -// Jonathan Hue -// Cuda Chen -// Aymen Qader -// Anthony Roberts -// Sean Luchen - -/* Tunable configurations */ - -/* Enable precise implementation of math operations - * This would slow down the computation a bit, but gives consistent result with - * x86 SSE. (e.g. would solve a hole or NaN pixel in the rendering result) - */ -/* _mm_min|max_ps|ss|pd|sd */ -#ifndef SSE2NEON_PRECISE_MINMAX -#define SSE2NEON_PRECISE_MINMAX (0) -#endif -/* _mm_rcp_ps */ -#ifndef SSE2NEON_PRECISE_DIV -#define SSE2NEON_PRECISE_DIV (0) -#endif -/* _mm_sqrt_ps and _mm_rsqrt_ps */ -#ifndef SSE2NEON_PRECISE_SQRT -#define SSE2NEON_PRECISE_SQRT (0) -#endif -/* _mm_dp_pd */ -#ifndef SSE2NEON_PRECISE_DP -#define SSE2NEON_PRECISE_DP (0) -#endif - -/* Enable inclusion of windows.h on MSVC platforms - * This makes _mm_clflush functional on windows, as there is no builtin. - */ -#ifndef SSE2NEON_INCLUDE_WINDOWS_H -#define SSE2NEON_INCLUDE_WINDOWS_H (0) -#endif - -/* compiler specific definitions */ -#if defined(__GNUC__) || defined(__clang__) -#pragma push_macro("FORCE_INLINE") -#pragma push_macro("ALIGN_STRUCT") -#define FORCE_INLINE static inline __attribute__((always_inline)) -#define ALIGN_STRUCT(x) __attribute__((aligned(x))) -#define _sse2neon_likely(x) __builtin_expect(!!(x), 1) -#define _sse2neon_unlikely(x) __builtin_expect(!!(x), 0) -#elif defined(_MSC_VER) -#if _MSVC_TRADITIONAL -#error Using the traditional MSVC preprocessor is not supported! Use /Zc:preprocessor instead. -#endif -#ifndef FORCE_INLINE -#define FORCE_INLINE static inline -#endif -#ifndef ALIGN_STRUCT -#define ALIGN_STRUCT(x) __declspec(align(x)) -#endif -#define _sse2neon_likely(x) (x) -#define _sse2neon_unlikely(x) (x) -#else -#pragma message("Macro name collisions may happen with unsupported compilers.") -#endif - -#if !defined(__clang__) && defined(__GNUC__) && __GNUC__ < 10 -#warning "GCC versions earlier than 10 are not supported." -#endif - -#if defined(__OPTIMIZE__) && !defined(SSE2NEON_SUPPRESS_WARNINGS) -#warning \ - "Report any potential compiler optimization issues when using SSE2NEON. See the 'Optimization' section at https://github.com/DLTcollab/sse2neon." -#endif - -/* C language does not allow initializing a variable with a function call. */ -#ifdef __cplusplus -#define _sse2neon_const static const -#else -#define _sse2neon_const const -#endif - -#include -#include -#include -#include - -FORCE_INLINE double sse2neon_recast_u64_f64(uint64_t u64) -{ - double f64; - memcpy(&f64, &u64, sizeof(uint64_t)); - return f64; -} -FORCE_INLINE int64_t sse2neon_recast_f64_s64(double f64) -{ - int64_t i64; - memcpy(&i64, &f64, sizeof(uint64_t)); - return i64; -} - -#if defined(_WIN32) && !defined(__MINGW32__) -/* Definitions for _mm_{malloc,free} are provided by from MSVC. */ -#define SSE2NEON_ALLOC_DEFINED -#endif - -/* If using MSVC */ -#ifdef _MSC_VER -#include -#if SSE2NEON_INCLUDE_WINDOWS_H -#include -#include -#endif - -#if !defined(__cplusplus) -#error SSE2NEON only supports C++ compilation with this compiler -#endif - -#ifdef SSE2NEON_ALLOC_DEFINED -#include -#endif - -#if (defined(_M_AMD64) || defined(__x86_64__)) || \ - (defined(_M_ARM64) || defined(__arm64__)) -#define SSE2NEON_HAS_BITSCAN64 -#endif -#endif - -#if defined(__GNUC__) || defined(__clang__) -#define _sse2neon_define0(type, s, body) \ - __extension__({ \ - type _a = (s); \ - body \ - }) -#define _sse2neon_define1(type, s, body) \ - __extension__({ \ - type _a = (s); \ - body \ - }) -#define _sse2neon_define2(type, a, b, body) \ - __extension__({ \ - type _a = (a), _b = (b); \ - body \ - }) -#define _sse2neon_return(ret) (ret) -#else -#define _sse2neon_define0(type, a, body) [=](type _a) { body }(a) -#define _sse2neon_define1(type, a, body) [](type _a) { body }(a) -#define _sse2neon_define2(type, a, b, body) \ - [](type _a, type _b) { body }((a), (b)) -#define _sse2neon_return(ret) return ret -#endif - -#define _sse2neon_init(...) \ - { \ - __VA_ARGS__ \ - } - -/* Compiler barrier */ -#if defined(_MSC_VER) && !defined(__clang__) -#define SSE2NEON_BARRIER() _ReadWriteBarrier() -#else -#define SSE2NEON_BARRIER() \ - do { \ - __asm__ __volatile__("" ::: "memory"); \ - (void) 0; \ - } while (0) -#endif - -/* Memory barriers - * __atomic_thread_fence does not include a compiler barrier; instead, - * the barrier is part of __atomic_load/__atomic_store's "volatile-like" - * semantics. - */ -#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 201112L) -#include -#endif - -FORCE_INLINE void _sse2neon_smp_mb(void) -{ - SSE2NEON_BARRIER(); -#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 201112L) && \ - !defined(__STDC_NO_ATOMICS__) - atomic_thread_fence(memory_order_seq_cst); -#elif defined(__GNUC__) || defined(__clang__) - __atomic_thread_fence(__ATOMIC_SEQ_CST); -#else /* MSVC */ - __dmb(_ARM64_BARRIER_ISH); -#endif -} - -/* Architecture-specific build options */ -/* FIXME: #pragma GCC push_options is only available on GCC */ -#if defined(__GNUC__) -#if defined(__arm__) && __ARM_ARCH == 7 -/* According to ARM C Language Extensions Architecture specification, - * __ARM_NEON is defined to a value indicating the Advanced SIMD (NEON) - * architecture supported. - */ -#if !defined(__ARM_NEON) || !defined(__ARM_NEON__) -#error "You must enable NEON instructions (e.g. -mfpu=neon) to use SSE2NEON." -#endif -#if !defined(__clang__) -#pragma GCC push_options -#pragma GCC target("fpu=neon") -#endif -#elif defined(__aarch64__) || defined(_M_ARM64) -#if !defined(__clang__) && !defined(_MSC_VER) -#pragma GCC push_options -#pragma GCC target("+simd") -#endif -#elif __ARM_ARCH == 8 -#if !defined(__ARM_NEON) || !defined(__ARM_NEON__) -#error \ - "You must enable NEON instructions (e.g. -mfpu=neon-fp-armv8) to use SSE2NEON." -#endif -#if !defined(__clang__) && !defined(_MSC_VER) -#pragma GCC push_options -#endif -#else -#error \ - "Unsupported target. Must be either ARMv7-A+NEON or ARMv8-A \ -(you could try setting target explicitly with -march or -mcpu)" -#endif -#endif - -#include -#if (!defined(__aarch64__) && !defined(_M_ARM64)) && (__ARM_ARCH == 8) -#if defined __has_include && __has_include() -#include -#endif -#endif - -/* Apple Silicon cache lines are double of what is commonly used by Intel, AMD - * and other Arm microarchitectures use. - * From sysctl -a on Apple M1: - * hw.cachelinesize: 128 - */ -#if defined(__APPLE__) && (defined(__aarch64__) || defined(__arm64__)) -#define SSE2NEON_CACHELINE_SIZE 128 -#else -#define SSE2NEON_CACHELINE_SIZE 64 -#endif - -/* Rounding functions require either Aarch64 instructions or libm fallback */ -#if !defined(__aarch64__) && !defined(_M_ARM64) -#include -#endif - -/* On ARMv7, some registers, such as PMUSERENR and PMCCNTR, are read-only - * or even not accessible in user mode. - * To write or access to these registers in user mode, - * we have to perform syscall instead. - */ -#if (!defined(__aarch64__) && !defined(_M_ARM64)) -#include -#endif - -/* "__has_builtin" can be used to query support for built-in functions - * provided by gcc/clang and other compilers that support it. - */ -#ifndef __has_builtin /* GCC prior to 10 or non-clang compilers */ -/* Compatibility with gcc <= 9 */ -#if defined(__GNUC__) && (__GNUC__ <= 9) -#define __has_builtin(x) HAS##x -#define HAS__builtin_popcount 1 -#define HAS__builtin_popcountll 1 - -// __builtin_shuffle introduced in GCC 4.7.0 -#if (__GNUC__ >= 5) || ((__GNUC__ == 4) && (__GNUC_MINOR__ >= 7)) -#define HAS__builtin_shuffle 1 -#else -#define HAS__builtin_shuffle 0 -#endif - -#define HAS__builtin_shufflevector 0 -#define HAS__builtin_nontemporal_store 0 -#else -#define __has_builtin(x) 0 -#endif -#endif - -/** - * MACRO for shuffle parameter for _mm_shuffle_ps(). - * Argument fp3 is a digit[0123] that represents the fp from argument "b" - * of mm_shuffle_ps that will be placed in fp3 of result. fp2 is the same - * for fp2 in result. fp1 is a digit[0123] that represents the fp from - * argument "a" of mm_shuffle_ps that will be places in fp1 of result. - * fp0 is the same for fp0 of result. - */ -#define _MM_SHUFFLE(fp3, fp2, fp1, fp0) \ - (((fp3) << 6) | ((fp2) << 4) | ((fp1) << 2) | ((fp0))) - -/** - * MACRO for shuffle parameter for _mm_shuffle_pd(). - * Argument fp1 is a digit[01] that represents the fp from argument "b" - * of mm_shuffle_pd that will be placed in fp1 of result. - * fp0 is a digit[01] that represents the fp from argument "a" of mm_shuffle_pd - * that will be placed in fp0 of result. - */ -#define _MM_SHUFFLE2(fp1, fp0) (((fp1) << 1) | (fp0)) - -#if __has_builtin(__builtin_shufflevector) -#define _sse2neon_shuffle(type, a, b, ...) \ - __builtin_shufflevector(a, b, __VA_ARGS__) -#elif __has_builtin(__builtin_shuffle) -#define _sse2neon_shuffle(type, a, b, ...) \ - __extension__({ \ - type tmp = {__VA_ARGS__}; \ - __builtin_shuffle(a, b, tmp); \ - }) -#endif - -#ifdef _sse2neon_shuffle -#define vshuffle_s16(a, b, ...) _sse2neon_shuffle(int16x4_t, a, b, __VA_ARGS__) -#define vshuffleq_s16(a, b, ...) _sse2neon_shuffle(int16x8_t, a, b, __VA_ARGS__) -#define vshuffle_s32(a, b, ...) _sse2neon_shuffle(int32x2_t, a, b, __VA_ARGS__) -#define vshuffleq_s32(a, b, ...) _sse2neon_shuffle(int32x4_t, a, b, __VA_ARGS__) -#define vshuffle_s64(a, b, ...) _sse2neon_shuffle(int64x1_t, a, b, __VA_ARGS__) -#define vshuffleq_s64(a, b, ...) _sse2neon_shuffle(int64x2_t, a, b, __VA_ARGS__) -#endif - -/* Rounding mode macros. */ -#define _MM_FROUND_TO_NEAREST_INT 0x00 -#define _MM_FROUND_TO_NEG_INF 0x01 -#define _MM_FROUND_TO_POS_INF 0x02 -#define _MM_FROUND_TO_ZERO 0x03 -#define _MM_FROUND_CUR_DIRECTION 0x04 -#define _MM_FROUND_NO_EXC 0x08 -#define _MM_FROUND_RAISE_EXC 0x00 -#define _MM_FROUND_NINT (_MM_FROUND_TO_NEAREST_INT | _MM_FROUND_RAISE_EXC) -#define _MM_FROUND_FLOOR (_MM_FROUND_TO_NEG_INF | _MM_FROUND_RAISE_EXC) -#define _MM_FROUND_CEIL (_MM_FROUND_TO_POS_INF | _MM_FROUND_RAISE_EXC) -#define _MM_FROUND_TRUNC (_MM_FROUND_TO_ZERO | _MM_FROUND_RAISE_EXC) -#define _MM_FROUND_RINT (_MM_FROUND_CUR_DIRECTION | _MM_FROUND_RAISE_EXC) -#define _MM_FROUND_NEARBYINT (_MM_FROUND_CUR_DIRECTION | _MM_FROUND_NO_EXC) -#define _MM_ROUND_NEAREST 0x0000 -#define _MM_ROUND_DOWN 0x2000 -#define _MM_ROUND_UP 0x4000 -#define _MM_ROUND_TOWARD_ZERO 0x6000 -/* Flush zero mode macros. */ -#define _MM_FLUSH_ZERO_MASK 0x8000 -#define _MM_FLUSH_ZERO_ON 0x8000 -#define _MM_FLUSH_ZERO_OFF 0x0000 -/* Denormals are zeros mode macros. */ -#define _MM_DENORMALS_ZERO_MASK 0x0040 -#define _MM_DENORMALS_ZERO_ON 0x0040 -#define _MM_DENORMALS_ZERO_OFF 0x0000 - -/* indicate immediate constant argument in a given range */ -#define __constrange(a, b) const - -/* A few intrinsics accept traditional data types like ints or floats, but - * most operate on data types that are specific to SSE. - * If a vector type ends in d, it contains doubles, and if it does not have - * a suffix, it contains floats. An integer vector type can contain any type - * of integer, from chars to shorts to unsigned long longs. - */ -typedef int64x1_t __m64; -typedef float32x4_t __m128; /* 128-bit vector containing 4 floats */ -// On ARM 32-bit architecture, the float64x2_t is not supported. -// The data type __m128d should be represented in a different way for related -// intrinsic conversion. -#if defined(__aarch64__) || defined(_M_ARM64) -typedef float64x2_t __m128d; /* 128-bit vector containing 2 doubles */ -#else -typedef float32x4_t __m128d; -#endif -typedef int64x2_t __m128i; /* 128-bit vector containing integers */ - -// Some intrinsics operate on unaligned data types. -typedef int16_t ALIGN_STRUCT(1) unaligned_int16_t; -typedef int32_t ALIGN_STRUCT(1) unaligned_int32_t; -typedef int64_t ALIGN_STRUCT(1) unaligned_int64_t; - -// __int64 is defined in the Intrinsics Guide which maps to different datatype -// in different data model -#if !(defined(_WIN32) || defined(_WIN64) || defined(__int64)) -#if (defined(__x86_64__) || defined(__i386__)) -#define __int64 long long -#else -#define __int64 int64_t -#endif -#endif - -/* type-safe casting between types */ - -#define vreinterpretq_m128_f16(x) vreinterpretq_f32_f16(x) -#define vreinterpretq_m128_f32(x) (x) -#define vreinterpretq_m128_f64(x) vreinterpretq_f32_f64(x) - -#define vreinterpretq_m128_u8(x) vreinterpretq_f32_u8(x) -#define vreinterpretq_m128_u16(x) vreinterpretq_f32_u16(x) -#define vreinterpretq_m128_u32(x) vreinterpretq_f32_u32(x) -#define vreinterpretq_m128_u64(x) vreinterpretq_f32_u64(x) - -#define vreinterpretq_m128_s8(x) vreinterpretq_f32_s8(x) -#define vreinterpretq_m128_s16(x) vreinterpretq_f32_s16(x) -#define vreinterpretq_m128_s32(x) vreinterpretq_f32_s32(x) -#define vreinterpretq_m128_s64(x) vreinterpretq_f32_s64(x) - -#define vreinterpretq_f16_m128(x) vreinterpretq_f16_f32(x) -#define vreinterpretq_f32_m128(x) (x) -#define vreinterpretq_f64_m128(x) vreinterpretq_f64_f32(x) - -#define vreinterpretq_u8_m128(x) vreinterpretq_u8_f32(x) -#define vreinterpretq_u16_m128(x) vreinterpretq_u16_f32(x) -#define vreinterpretq_u32_m128(x) vreinterpretq_u32_f32(x) -#define vreinterpretq_u64_m128(x) vreinterpretq_u64_f32(x) - -#define vreinterpretq_s8_m128(x) vreinterpretq_s8_f32(x) -#define vreinterpretq_s16_m128(x) vreinterpretq_s16_f32(x) -#define vreinterpretq_s32_m128(x) vreinterpretq_s32_f32(x) -#define vreinterpretq_s64_m128(x) vreinterpretq_s64_f32(x) - -#define vreinterpretq_m128i_s8(x) vreinterpretq_s64_s8(x) -#define vreinterpretq_m128i_s16(x) vreinterpretq_s64_s16(x) -#define vreinterpretq_m128i_s32(x) vreinterpretq_s64_s32(x) -#define vreinterpretq_m128i_s64(x) (x) - -#define vreinterpretq_m128i_u8(x) vreinterpretq_s64_u8(x) -#define vreinterpretq_m128i_u16(x) vreinterpretq_s64_u16(x) -#define vreinterpretq_m128i_u32(x) vreinterpretq_s64_u32(x) -#define vreinterpretq_m128i_u64(x) vreinterpretq_s64_u64(x) - -#define vreinterpretq_f32_m128i(x) vreinterpretq_f32_s64(x) -#define vreinterpretq_f64_m128i(x) vreinterpretq_f64_s64(x) - -#define vreinterpretq_s8_m128i(x) vreinterpretq_s8_s64(x) -#define vreinterpretq_s16_m128i(x) vreinterpretq_s16_s64(x) -#define vreinterpretq_s32_m128i(x) vreinterpretq_s32_s64(x) -#define vreinterpretq_s64_m128i(x) (x) - -#define vreinterpretq_u8_m128i(x) vreinterpretq_u8_s64(x) -#define vreinterpretq_u16_m128i(x) vreinterpretq_u16_s64(x) -#define vreinterpretq_u32_m128i(x) vreinterpretq_u32_s64(x) -#define vreinterpretq_u64_m128i(x) vreinterpretq_u64_s64(x) - -#define vreinterpret_m64_s8(x) vreinterpret_s64_s8(x) -#define vreinterpret_m64_s16(x) vreinterpret_s64_s16(x) -#define vreinterpret_m64_s32(x) vreinterpret_s64_s32(x) -#define vreinterpret_m64_s64(x) (x) - -#define vreinterpret_m64_u8(x) vreinterpret_s64_u8(x) -#define vreinterpret_m64_u16(x) vreinterpret_s64_u16(x) -#define vreinterpret_m64_u32(x) vreinterpret_s64_u32(x) -#define vreinterpret_m64_u64(x) vreinterpret_s64_u64(x) - -#define vreinterpret_m64_f16(x) vreinterpret_s64_f16(x) -#define vreinterpret_m64_f32(x) vreinterpret_s64_f32(x) -#define vreinterpret_m64_f64(x) vreinterpret_s64_f64(x) - -#define vreinterpret_u8_m64(x) vreinterpret_u8_s64(x) -#define vreinterpret_u16_m64(x) vreinterpret_u16_s64(x) -#define vreinterpret_u32_m64(x) vreinterpret_u32_s64(x) -#define vreinterpret_u64_m64(x) vreinterpret_u64_s64(x) - -#define vreinterpret_s8_m64(x) vreinterpret_s8_s64(x) -#define vreinterpret_s16_m64(x) vreinterpret_s16_s64(x) -#define vreinterpret_s32_m64(x) vreinterpret_s32_s64(x) -#define vreinterpret_s64_m64(x) (x) - -#define vreinterpret_f32_m64(x) vreinterpret_f32_s64(x) - -#if defined(__aarch64__) || defined(_M_ARM64) -#define vreinterpretq_m128d_s32(x) vreinterpretq_f64_s32(x) -#define vreinterpretq_m128d_s64(x) vreinterpretq_f64_s64(x) - -#define vreinterpretq_m128d_u64(x) vreinterpretq_f64_u64(x) - -#define vreinterpretq_m128d_f32(x) vreinterpretq_f64_f32(x) -#define vreinterpretq_m128d_f64(x) (x) - -#define vreinterpretq_s64_m128d(x) vreinterpretq_s64_f64(x) - -#define vreinterpretq_u32_m128d(x) vreinterpretq_u32_f64(x) -#define vreinterpretq_u64_m128d(x) vreinterpretq_u64_f64(x) - -#define vreinterpretq_f64_m128d(x) (x) -#define vreinterpretq_f32_m128d(x) vreinterpretq_f32_f64(x) -#else -#define vreinterpretq_m128d_s32(x) vreinterpretq_f32_s32(x) -#define vreinterpretq_m128d_s64(x) vreinterpretq_f32_s64(x) - -#define vreinterpretq_m128d_u32(x) vreinterpretq_f32_u32(x) -#define vreinterpretq_m128d_u64(x) vreinterpretq_f32_u64(x) - -#define vreinterpretq_m128d_f32(x) (x) - -#define vreinterpretq_s64_m128d(x) vreinterpretq_s64_f32(x) - -#define vreinterpretq_u32_m128d(x) vreinterpretq_u32_f32(x) -#define vreinterpretq_u64_m128d(x) vreinterpretq_u64_f32(x) - -#define vreinterpretq_f32_m128d(x) (x) -#endif - -// A struct is defined in this header file called 'SIMDVec' which can be used -// by applications which attempt to access the contents of an __m128 struct -// directly. It is important to note that accessing the __m128 struct directly -// is bad coding practice by Microsoft: @see: -// https://learn.microsoft.com/en-us/cpp/cpp/m128 -// -// However, some legacy source code may try to access the contents of an __m128 -// struct directly so the developer can use the SIMDVec as an alias for it. Any -// casting must be done manually by the developer, as you cannot cast or -// otherwise alias the base NEON data type for intrinsic operations. -// -// union intended to allow direct access to an __m128 variable using the names -// that the MSVC compiler provides. This union should really only be used when -// trying to access the members of the vector as integer values. GCC/clang -// allow native access to the float members through a simple array access -// operator (in C since 4.6, in C++ since 4.8). -// -// Ideally direct accesses to SIMD vectors should not be used since it can cause -// a performance hit. If it really is needed however, the original __m128 -// variable can be aliased with a pointer to this union and used to access -// individual components. The use of this union should be hidden behind a macro -// that is used throughout the codebase to access the members instead of always -// declaring this type of variable. -typedef union ALIGN_STRUCT(16) SIMDVec { - float m128_f32[4]; // as floats - DON'T USE. Added for convenience. - int8_t m128_i8[16]; // as signed 8-bit integers. - int16_t m128_i16[8]; // as signed 16-bit integers. - int32_t m128_i32[4]; // as signed 32-bit integers. - int64_t m128_i64[2]; // as signed 64-bit integers. - uint8_t m128_u8[16]; // as unsigned 8-bit integers. - uint16_t m128_u16[8]; // as unsigned 16-bit integers. - uint32_t m128_u32[4]; // as unsigned 32-bit integers. - uint64_t m128_u64[2]; // as unsigned 64-bit integers. -} SIMDVec; - -// casting using SIMDVec -#define vreinterpretq_nth_u64_m128i(x, n) (((SIMDVec *) &x)->m128_u64[n]) -#define vreinterpretq_nth_u32_m128i(x, n) (((SIMDVec *) &x)->m128_u32[n]) -#define vreinterpretq_nth_u8_m128i(x, n) (((SIMDVec *) &x)->m128_u8[n]) - -/* SSE macros */ -#define _MM_GET_FLUSH_ZERO_MODE _sse2neon_mm_get_flush_zero_mode -#define _MM_SET_FLUSH_ZERO_MODE _sse2neon_mm_set_flush_zero_mode -#define _MM_GET_DENORMALS_ZERO_MODE _sse2neon_mm_get_denormals_zero_mode -#define _MM_SET_DENORMALS_ZERO_MODE _sse2neon_mm_set_denormals_zero_mode - -// Function declaration -// SSE -FORCE_INLINE unsigned int _MM_GET_ROUNDING_MODE(void); -FORCE_INLINE __m128 _mm_move_ss(__m128, __m128); -FORCE_INLINE __m128 _mm_or_ps(__m128, __m128); -FORCE_INLINE __m128 _mm_set_ps1(float); -FORCE_INLINE __m128 _mm_setzero_ps(void); -// SSE2 -FORCE_INLINE __m128i _mm_and_si128(__m128i, __m128i); -FORCE_INLINE __m128i _mm_castps_si128(__m128); -FORCE_INLINE __m128i _mm_cmpeq_epi32(__m128i, __m128i); -FORCE_INLINE __m128i _mm_cvtps_epi32(__m128); -FORCE_INLINE __m128d _mm_move_sd(__m128d, __m128d); -FORCE_INLINE __m128i _mm_or_si128(__m128i, __m128i); -FORCE_INLINE __m128i _mm_set_epi32(int, int, int, int); -FORCE_INLINE __m128i _mm_set_epi64x(int64_t, int64_t); -FORCE_INLINE __m128d _mm_set_pd(double, double); -FORCE_INLINE __m128i _mm_set1_epi32(int); -FORCE_INLINE __m128i _mm_setzero_si128(void); -// SSE4.1 -FORCE_INLINE __m128d _mm_ceil_pd(__m128d); -FORCE_INLINE __m128 _mm_ceil_ps(__m128); -FORCE_INLINE __m128d _mm_floor_pd(__m128d); -FORCE_INLINE __m128 _mm_floor_ps(__m128); -FORCE_INLINE __m128d _mm_round_pd(__m128d, int); -FORCE_INLINE __m128 _mm_round_ps(__m128, int); -// SSE4.2 -FORCE_INLINE uint32_t _mm_crc32_u8(uint32_t, uint8_t); - -/* Backwards compatibility for compilers with lack of specific type support */ - -// Older gcc does not define vld1q_u8_x4 type -#if defined(__GNUC__) && !defined(__clang__) && \ - ((__GNUC__ <= 13 && defined(__arm__)) || \ - (__GNUC__ == 10 && __GNUC_MINOR__ < 3 && defined(__aarch64__)) || \ - (__GNUC__ <= 9 && defined(__aarch64__))) -FORCE_INLINE uint8x16x4_t _sse2neon_vld1q_u8_x4(const uint8_t *p) -{ - uint8x16x4_t ret; - ret.val[0] = vld1q_u8(p + 0); - ret.val[1] = vld1q_u8(p + 16); - ret.val[2] = vld1q_u8(p + 32); - ret.val[3] = vld1q_u8(p + 48); - return ret; -} -#else -// Wraps vld1q_u8_x4 -FORCE_INLINE uint8x16x4_t _sse2neon_vld1q_u8_x4(const uint8_t *p) -{ - return vld1q_u8_x4(p); -} -#endif - -#if !defined(__aarch64__) && !defined(_M_ARM64) -/* emulate vaddv u8 variant */ -FORCE_INLINE uint8_t _sse2neon_vaddv_u8(uint8x8_t v8) -{ - const uint64x1_t v1 = vpaddl_u32(vpaddl_u16(vpaddl_u8(v8))); - return vget_lane_u8(vreinterpret_u8_u64(v1), 0); -} -#else -// Wraps vaddv_u8 -FORCE_INLINE uint8_t _sse2neon_vaddv_u8(uint8x8_t v8) -{ - return vaddv_u8(v8); -} -#endif - -#if !defined(__aarch64__) && !defined(_M_ARM64) -/* emulate vaddvq u8 variant */ -FORCE_INLINE uint8_t _sse2neon_vaddvq_u8(uint8x16_t a) -{ - uint8x8_t tmp = vpadd_u8(vget_low_u8(a), vget_high_u8(a)); - uint8_t res = 0; - for (int i = 0; i < 8; ++i) - res += tmp[i]; - return res; -} -#else -// Wraps vaddvq_u8 -FORCE_INLINE uint8_t _sse2neon_vaddvq_u8(uint8x16_t a) -{ - return vaddvq_u8(a); -} -#endif - -#if !defined(__aarch64__) && !defined(_M_ARM64) -/* emulate vaddvq u16 variant */ -FORCE_INLINE uint16_t _sse2neon_vaddvq_u16(uint16x8_t a) -{ - uint32x4_t m = vpaddlq_u16(a); - uint64x2_t n = vpaddlq_u32(m); - uint64x1_t o = vget_low_u64(n) + vget_high_u64(n); - - return vget_lane_u32((uint32x2_t) o, 0); -} -#else -// Wraps vaddvq_u16 -FORCE_INLINE uint16_t _sse2neon_vaddvq_u16(uint16x8_t a) -{ - return vaddvq_u16(a); -} -#endif - -/* Function Naming Conventions - * The naming convention of SSE intrinsics is straightforward. A generic SSE - * intrinsic function is given as follows: - * _mm__ - * - * The parts of this format are given as follows: - * 1. describes the operation performed by the intrinsic - * 2. identifies the data type of the function's primary arguments - * - * This last part, , is a little complicated. It identifies the - * content of the input values, and can be set to any of the following values: - * + ps - vectors contain floats (ps stands for packed single-precision) - * + pd - vectors contain doubles (pd stands for packed double-precision) - * + epi8/epi16/epi32/epi64 - vectors contain 8-bit/16-bit/32-bit/64-bit - * signed integers - * + epu8/epu16/epu32/epu64 - vectors contain 8-bit/16-bit/32-bit/64-bit - * unsigned integers - * + si128 - unspecified 128-bit vector or 256-bit vector - * + m128/m128i/m128d - identifies input vector types when they are different - * than the type of the returned vector - * - * For example, _mm_setzero_ps. The _mm implies that the function returns - * a 128-bit vector. The _ps at the end implies that the argument vectors - * contain floats. - * - * A complete example: Byte Shuffle - pshufb (_mm_shuffle_epi8) - * // Set packed 16-bit integers. 128 bits, 8 short, per 16 bits - * __m128i v_in = _mm_setr_epi16(1, 2, 3, 4, 5, 6, 7, 8); - * // Set packed 8-bit integers - * // 128 bits, 16 chars, per 8 bits - * __m128i v_perm = _mm_setr_epi8(1, 0, 2, 3, 8, 9, 10, 11, - * 4, 5, 12, 13, 6, 7, 14, 15); - * // Shuffle packed 8-bit integers - * __m128i v_out = _mm_shuffle_epi8(v_in, v_perm); // pshufb - */ - -/* Constants for use with _mm_prefetch. */ -enum _mm_hint { - _MM_HINT_NTA = 0, /* load data to L1 and L2 cache, mark it as NTA */ - _MM_HINT_T0 = 1, /* load data to L1 and L2 cache */ - _MM_HINT_T1 = 2, /* load data to L2 cache only */ - _MM_HINT_T2 = 3, /* load data to L2 cache only, mark it as NTA */ -}; - -// The bit field mapping to the FPCR(floating-point control register) -typedef struct { - uint16_t res0; - uint8_t res1 : 6; - uint8_t bit22 : 1; - uint8_t bit23 : 1; - uint8_t bit24 : 1; - uint8_t res2 : 7; -#if defined(__aarch64__) || defined(_M_ARM64) - uint32_t res3; -#endif -} fpcr_bitfield; - -// Takes the upper 64 bits of a and places it in the low end of the result -// Takes the lower 64 bits of b and places it into the high end of the result. -FORCE_INLINE __m128 _mm_shuffle_ps_1032(__m128 a, __m128 b) -{ - float32x2_t a32 = vget_high_f32(vreinterpretq_f32_m128(a)); - float32x2_t b10 = vget_low_f32(vreinterpretq_f32_m128(b)); - return vreinterpretq_m128_f32(vcombine_f32(a32, b10)); -} - -// takes the lower two 32-bit values from a and swaps them and places in high -// end of result takes the higher two 32 bit values from b and swaps them and -// places in low end of result. -FORCE_INLINE __m128 _mm_shuffle_ps_2301(__m128 a, __m128 b) -{ - float32x2_t a01 = vrev64_f32(vget_low_f32(vreinterpretq_f32_m128(a))); - float32x2_t b23 = vrev64_f32(vget_high_f32(vreinterpretq_f32_m128(b))); - return vreinterpretq_m128_f32(vcombine_f32(a01, b23)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_0321(__m128 a, __m128 b) -{ - float32x2_t a21 = vget_high_f32( - vextq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(a), 3)); - float32x2_t b03 = vget_low_f32( - vextq_f32(vreinterpretq_f32_m128(b), vreinterpretq_f32_m128(b), 3)); - return vreinterpretq_m128_f32(vcombine_f32(a21, b03)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_2103(__m128 a, __m128 b) -{ - float32x2_t a03 = vget_low_f32( - vextq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(a), 3)); - float32x2_t b21 = vget_high_f32( - vextq_f32(vreinterpretq_f32_m128(b), vreinterpretq_f32_m128(b), 3)); - return vreinterpretq_m128_f32(vcombine_f32(a03, b21)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_1010(__m128 a, __m128 b) -{ - float32x2_t a10 = vget_low_f32(vreinterpretq_f32_m128(a)); - float32x2_t b10 = vget_low_f32(vreinterpretq_f32_m128(b)); - return vreinterpretq_m128_f32(vcombine_f32(a10, b10)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_1001(__m128 a, __m128 b) -{ - float32x2_t a01 = vrev64_f32(vget_low_f32(vreinterpretq_f32_m128(a))); - float32x2_t b10 = vget_low_f32(vreinterpretq_f32_m128(b)); - return vreinterpretq_m128_f32(vcombine_f32(a01, b10)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_0101(__m128 a, __m128 b) -{ - float32x2_t a01 = vrev64_f32(vget_low_f32(vreinterpretq_f32_m128(a))); - float32x2_t b01 = vrev64_f32(vget_low_f32(vreinterpretq_f32_m128(b))); - return vreinterpretq_m128_f32(vcombine_f32(a01, b01)); -} - -// keeps the low 64 bits of b in the low and puts the high 64 bits of a in the -// high -FORCE_INLINE __m128 _mm_shuffle_ps_3210(__m128 a, __m128 b) -{ - float32x2_t a10 = vget_low_f32(vreinterpretq_f32_m128(a)); - float32x2_t b32 = vget_high_f32(vreinterpretq_f32_m128(b)); - return vreinterpretq_m128_f32(vcombine_f32(a10, b32)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_0011(__m128 a, __m128 b) -{ - float32x2_t a11 = vdup_lane_f32(vget_low_f32(vreinterpretq_f32_m128(a)), 1); - float32x2_t b00 = vdup_lane_f32(vget_low_f32(vreinterpretq_f32_m128(b)), 0); - return vreinterpretq_m128_f32(vcombine_f32(a11, b00)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_0022(__m128 a, __m128 b) -{ - float32x2_t a22 = - vdup_lane_f32(vget_high_f32(vreinterpretq_f32_m128(a)), 0); - float32x2_t b00 = vdup_lane_f32(vget_low_f32(vreinterpretq_f32_m128(b)), 0); - return vreinterpretq_m128_f32(vcombine_f32(a22, b00)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_2200(__m128 a, __m128 b) -{ - float32x2_t a00 = vdup_lane_f32(vget_low_f32(vreinterpretq_f32_m128(a)), 0); - float32x2_t b22 = - vdup_lane_f32(vget_high_f32(vreinterpretq_f32_m128(b)), 0); - return vreinterpretq_m128_f32(vcombine_f32(a00, b22)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_3202(__m128 a, __m128 b) -{ - float32_t a0 = vgetq_lane_f32(vreinterpretq_f32_m128(a), 0); - float32x2_t a22 = - vdup_lane_f32(vget_high_f32(vreinterpretq_f32_m128(a)), 0); - float32x2_t a02 = vset_lane_f32(a0, a22, 1); /* TODO: use vzip ?*/ - float32x2_t b32 = vget_high_f32(vreinterpretq_f32_m128(b)); - return vreinterpretq_m128_f32(vcombine_f32(a02, b32)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_1133(__m128 a, __m128 b) -{ - float32x2_t a33 = - vdup_lane_f32(vget_high_f32(vreinterpretq_f32_m128(a)), 1); - float32x2_t b11 = vdup_lane_f32(vget_low_f32(vreinterpretq_f32_m128(b)), 1); - return vreinterpretq_m128_f32(vcombine_f32(a33, b11)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_2010(__m128 a, __m128 b) -{ - float32x2_t a10 = vget_low_f32(vreinterpretq_f32_m128(a)); - float32_t b2 = vgetq_lane_f32(vreinterpretq_f32_m128(b), 2); - float32x2_t b00 = vdup_lane_f32(vget_low_f32(vreinterpretq_f32_m128(b)), 0); - float32x2_t b20 = vset_lane_f32(b2, b00, 1); - return vreinterpretq_m128_f32(vcombine_f32(a10, b20)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_2001(__m128 a, __m128 b) -{ - float32x2_t a01 = vrev64_f32(vget_low_f32(vreinterpretq_f32_m128(a))); - float32_t b2 = vgetq_lane_f32(b, 2); - float32x2_t b00 = vdup_lane_f32(vget_low_f32(vreinterpretq_f32_m128(b)), 0); - float32x2_t b20 = vset_lane_f32(b2, b00, 1); - return vreinterpretq_m128_f32(vcombine_f32(a01, b20)); -} - -FORCE_INLINE __m128 _mm_shuffle_ps_2032(__m128 a, __m128 b) -{ - float32x2_t a32 = vget_high_f32(vreinterpretq_f32_m128(a)); - float32_t b2 = vgetq_lane_f32(b, 2); - float32x2_t b00 = vdup_lane_f32(vget_low_f32(vreinterpretq_f32_m128(b)), 0); - float32x2_t b20 = vset_lane_f32(b2, b00, 1); - return vreinterpretq_m128_f32(vcombine_f32(a32, b20)); -} - -// For MSVC, we check only if it is ARM64, as every single ARM64 processor -// supported by WoA has crypto extensions. If this changes in the future, -// this can be verified via the runtime-only method of: -// IsProcessorFeaturePresent(PF_ARM_V8_CRYPTO_INSTRUCTIONS_AVAILABLE) -#if (defined(_M_ARM64) && !defined(__clang__)) || \ - (defined(__ARM_FEATURE_CRYPTO) && \ - (defined(__aarch64__) || __has_builtin(__builtin_arm_crypto_vmullp64))) -// Wraps vmull_p64 -FORCE_INLINE uint64x2_t _sse2neon_vmull_p64(uint64x1_t _a, uint64x1_t _b) -{ - poly64_t a = vget_lane_p64(vreinterpret_p64_u64(_a), 0); - poly64_t b = vget_lane_p64(vreinterpret_p64_u64(_b), 0); -#if defined(_MSC_VER) && !defined(__clang__) - __n64 a1 = {a}, b1 = {b}; - return vreinterpretq_u64_p128(vmull_p64(a1, b1)); -#else - return vreinterpretq_u64_p128(vmull_p64(a, b)); -#endif -} -#else // ARMv7 polyfill -// ARMv7/some A64 lacks vmull_p64, but it has vmull_p8. -// -// vmull_p8 calculates 8 8-bit->16-bit polynomial multiplies, but we need a -// 64-bit->128-bit polynomial multiply. -// -// It needs some work and is somewhat slow, but it is still faster than all -// known scalar methods. -// -// Algorithm adapted to C from -// https://www.workofard.com/2017/07/ghash-for-low-end-cores/, which is adapted -// from "Fast Software Polynomial Multiplication on ARM Processors Using the -// NEON Engine" by Danilo Camara, Conrado Gouvea, Julio Lopez and Ricardo Dahab -// (https://hal.inria.fr/hal-01506572) -static uint64x2_t _sse2neon_vmull_p64(uint64x1_t _a, uint64x1_t _b) -{ - poly8x8_t a = vreinterpret_p8_u64(_a); - poly8x8_t b = vreinterpret_p8_u64(_b); - - // Masks - uint8x16_t k48_32 = vcombine_u8(vcreate_u8(0x0000ffffffffffff), - vcreate_u8(0x00000000ffffffff)); - uint8x16_t k16_00 = vcombine_u8(vcreate_u8(0x000000000000ffff), - vcreate_u8(0x0000000000000000)); - - // Do the multiplies, rotating with vext to get all combinations - uint8x16_t d = vreinterpretq_u8_p16(vmull_p8(a, b)); // D = A0 * B0 - uint8x16_t e = - vreinterpretq_u8_p16(vmull_p8(a, vext_p8(b, b, 1))); // E = A0 * B1 - uint8x16_t f = - vreinterpretq_u8_p16(vmull_p8(vext_p8(a, a, 1), b)); // F = A1 * B0 - uint8x16_t g = - vreinterpretq_u8_p16(vmull_p8(a, vext_p8(b, b, 2))); // G = A0 * B2 - uint8x16_t h = - vreinterpretq_u8_p16(vmull_p8(vext_p8(a, a, 2), b)); // H = A2 * B0 - uint8x16_t i = - vreinterpretq_u8_p16(vmull_p8(a, vext_p8(b, b, 3))); // I = A0 * B3 - uint8x16_t j = - vreinterpretq_u8_p16(vmull_p8(vext_p8(a, a, 3), b)); // J = A3 * B0 - uint8x16_t k = - vreinterpretq_u8_p16(vmull_p8(a, vext_p8(b, b, 4))); // L = A0 * B4 - - // Add cross products - uint8x16_t l = veorq_u8(e, f); // L = E + F - uint8x16_t m = veorq_u8(g, h); // M = G + H - uint8x16_t n = veorq_u8(i, j); // N = I + J - - // Interleave. Using vzip1 and vzip2 prevents Clang from emitting TBL - // instructions. -#if defined(__aarch64__) - uint8x16_t lm_p0 = vreinterpretq_u8_u64( - vzip1q_u64(vreinterpretq_u64_u8(l), vreinterpretq_u64_u8(m))); - uint8x16_t lm_p1 = vreinterpretq_u8_u64( - vzip2q_u64(vreinterpretq_u64_u8(l), vreinterpretq_u64_u8(m))); - uint8x16_t nk_p0 = vreinterpretq_u8_u64( - vzip1q_u64(vreinterpretq_u64_u8(n), vreinterpretq_u64_u8(k))); - uint8x16_t nk_p1 = vreinterpretq_u8_u64( - vzip2q_u64(vreinterpretq_u64_u8(n), vreinterpretq_u64_u8(k))); -#else - uint8x16_t lm_p0 = vcombine_u8(vget_low_u8(l), vget_low_u8(m)); - uint8x16_t lm_p1 = vcombine_u8(vget_high_u8(l), vget_high_u8(m)); - uint8x16_t nk_p0 = vcombine_u8(vget_low_u8(n), vget_low_u8(k)); - uint8x16_t nk_p1 = vcombine_u8(vget_high_u8(n), vget_high_u8(k)); -#endif - // t0 = (L) (P0 + P1) << 8 - // t1 = (M) (P2 + P3) << 16 - uint8x16_t t0t1_tmp = veorq_u8(lm_p0, lm_p1); - uint8x16_t t0t1_h = vandq_u8(lm_p1, k48_32); - uint8x16_t t0t1_l = veorq_u8(t0t1_tmp, t0t1_h); - - // t2 = (N) (P4 + P5) << 24 - // t3 = (K) (P6 + P7) << 32 - uint8x16_t t2t3_tmp = veorq_u8(nk_p0, nk_p1); - uint8x16_t t2t3_h = vandq_u8(nk_p1, k16_00); - uint8x16_t t2t3_l = veorq_u8(t2t3_tmp, t2t3_h); - - // De-interleave -#if defined(__aarch64__) - uint8x16_t t0 = vreinterpretq_u8_u64( - vuzp1q_u64(vreinterpretq_u64_u8(t0t1_l), vreinterpretq_u64_u8(t0t1_h))); - uint8x16_t t1 = vreinterpretq_u8_u64( - vuzp2q_u64(vreinterpretq_u64_u8(t0t1_l), vreinterpretq_u64_u8(t0t1_h))); - uint8x16_t t2 = vreinterpretq_u8_u64( - vuzp1q_u64(vreinterpretq_u64_u8(t2t3_l), vreinterpretq_u64_u8(t2t3_h))); - uint8x16_t t3 = vreinterpretq_u8_u64( - vuzp2q_u64(vreinterpretq_u64_u8(t2t3_l), vreinterpretq_u64_u8(t2t3_h))); -#else - uint8x16_t t1 = vcombine_u8(vget_high_u8(t0t1_l), vget_high_u8(t0t1_h)); - uint8x16_t t0 = vcombine_u8(vget_low_u8(t0t1_l), vget_low_u8(t0t1_h)); - uint8x16_t t3 = vcombine_u8(vget_high_u8(t2t3_l), vget_high_u8(t2t3_h)); - uint8x16_t t2 = vcombine_u8(vget_low_u8(t2t3_l), vget_low_u8(t2t3_h)); -#endif - // Shift the cross products - uint8x16_t t0_shift = vextq_u8(t0, t0, 15); // t0 << 8 - uint8x16_t t1_shift = vextq_u8(t1, t1, 14); // t1 << 16 - uint8x16_t t2_shift = vextq_u8(t2, t2, 13); // t2 << 24 - uint8x16_t t3_shift = vextq_u8(t3, t3, 12); // t3 << 32 - - // Accumulate the products - uint8x16_t cross1 = veorq_u8(t0_shift, t1_shift); - uint8x16_t cross2 = veorq_u8(t2_shift, t3_shift); - uint8x16_t mix = veorq_u8(d, cross1); - uint8x16_t r = veorq_u8(mix, cross2); - return vreinterpretq_u64_u8(r); -} -#endif // ARMv7 polyfill - -// C equivalent: -// __m128i _mm_shuffle_epi32_default(__m128i a, -// __constrange(0, 255) int imm) { -// __m128i ret; -// ret[0] = a[imm & 0x3]; ret[1] = a[(imm >> 2) & 0x3]; -// ret[2] = a[(imm >> 4) & 0x03]; ret[3] = a[(imm >> 6) & 0x03]; -// return ret; -// } -#define _mm_shuffle_epi32_default(a, imm) \ - vreinterpretq_m128i_s32(vsetq_lane_s32( \ - vgetq_lane_s32(vreinterpretq_s32_m128i(a), ((imm) >> 6) & 0x3), \ - vsetq_lane_s32( \ - vgetq_lane_s32(vreinterpretq_s32_m128i(a), ((imm) >> 4) & 0x3), \ - vsetq_lane_s32(vgetq_lane_s32(vreinterpretq_s32_m128i(a), \ - ((imm) >> 2) & 0x3), \ - vmovq_n_s32(vgetq_lane_s32( \ - vreinterpretq_s32_m128i(a), (imm) & (0x3))), \ - 1), \ - 2), \ - 3)) - -// Takes the upper 64 bits of a and places it in the low end of the result -// Takes the lower 64 bits of a and places it into the high end of the result. -FORCE_INLINE __m128i _mm_shuffle_epi_1032(__m128i a) -{ - int32x2_t a32 = vget_high_s32(vreinterpretq_s32_m128i(a)); - int32x2_t a10 = vget_low_s32(vreinterpretq_s32_m128i(a)); - return vreinterpretq_m128i_s32(vcombine_s32(a32, a10)); -} - -// takes the lower two 32-bit values from a and swaps them and places in low end -// of result takes the higher two 32 bit values from a and swaps them and places -// in high end of result. -FORCE_INLINE __m128i _mm_shuffle_epi_2301(__m128i a) -{ - int32x2_t a01 = vrev64_s32(vget_low_s32(vreinterpretq_s32_m128i(a))); - int32x2_t a23 = vrev64_s32(vget_high_s32(vreinterpretq_s32_m128i(a))); - return vreinterpretq_m128i_s32(vcombine_s32(a01, a23)); -} - -// rotates the least significant 32 bits into the most significant 32 bits, and -// shifts the rest down -FORCE_INLINE __m128i _mm_shuffle_epi_0321(__m128i a) -{ - return vreinterpretq_m128i_s32( - vextq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(a), 1)); -} - -// rotates the most significant 32 bits into the least significant 32 bits, and -// shifts the rest up -FORCE_INLINE __m128i _mm_shuffle_epi_2103(__m128i a) -{ - return vreinterpretq_m128i_s32( - vextq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(a), 3)); -} - -// gets the lower 64 bits of a, and places it in the upper 64 bits -// gets the lower 64 bits of a and places it in the lower 64 bits -FORCE_INLINE __m128i _mm_shuffle_epi_1010(__m128i a) -{ - int32x2_t a10 = vget_low_s32(vreinterpretq_s32_m128i(a)); - return vreinterpretq_m128i_s32(vcombine_s32(a10, a10)); -} - -// gets the lower 64 bits of a, swaps the 0 and 1 elements, and places it in the -// lower 64 bits gets the lower 64 bits of a, and places it in the upper 64 bits -FORCE_INLINE __m128i _mm_shuffle_epi_1001(__m128i a) -{ - int32x2_t a01 = vrev64_s32(vget_low_s32(vreinterpretq_s32_m128i(a))); - int32x2_t a10 = vget_low_s32(vreinterpretq_s32_m128i(a)); - return vreinterpretq_m128i_s32(vcombine_s32(a01, a10)); -} - -// gets the lower 64 bits of a, swaps the 0 and 1 elements and places it in the -// upper 64 bits gets the lower 64 bits of a, swaps the 0 and 1 elements, and -// places it in the lower 64 bits -FORCE_INLINE __m128i _mm_shuffle_epi_0101(__m128i a) -{ - int32x2_t a01 = vrev64_s32(vget_low_s32(vreinterpretq_s32_m128i(a))); - return vreinterpretq_m128i_s32(vcombine_s32(a01, a01)); -} - -FORCE_INLINE __m128i _mm_shuffle_epi_2211(__m128i a) -{ - int32x2_t a11 = vdup_lane_s32(vget_low_s32(vreinterpretq_s32_m128i(a)), 1); - int32x2_t a22 = vdup_lane_s32(vget_high_s32(vreinterpretq_s32_m128i(a)), 0); - return vreinterpretq_m128i_s32(vcombine_s32(a11, a22)); -} - -FORCE_INLINE __m128i _mm_shuffle_epi_0122(__m128i a) -{ - int32x2_t a22 = vdup_lane_s32(vget_high_s32(vreinterpretq_s32_m128i(a)), 0); - int32x2_t a01 = vrev64_s32(vget_low_s32(vreinterpretq_s32_m128i(a))); - return vreinterpretq_m128i_s32(vcombine_s32(a22, a01)); -} - -FORCE_INLINE __m128i _mm_shuffle_epi_3332(__m128i a) -{ - int32x2_t a32 = vget_high_s32(vreinterpretq_s32_m128i(a)); - int32x2_t a33 = vdup_lane_s32(vget_high_s32(vreinterpretq_s32_m128i(a)), 1); - return vreinterpretq_m128i_s32(vcombine_s32(a32, a33)); -} - -#if defined(__aarch64__) || defined(_M_ARM64) -#define _mm_shuffle_epi32_splat(a, imm) \ - vreinterpretq_m128i_s32(vdupq_laneq_s32(vreinterpretq_s32_m128i(a), (imm))) -#else -#define _mm_shuffle_epi32_splat(a, imm) \ - vreinterpretq_m128i_s32( \ - vdupq_n_s32(vgetq_lane_s32(vreinterpretq_s32_m128i(a), (imm)))) -#endif - -// NEON does not support a general purpose permute intrinsic. -// Shuffle single-precision (32-bit) floating-point elements in a using the -// control in imm8, and store the results in dst. -// -// C equivalent: -// __m128 _mm_shuffle_ps_default(__m128 a, __m128 b, -// __constrange(0, 255) int imm) { -// __m128 ret; -// ret[0] = a[imm & 0x3]; ret[1] = a[(imm >> 2) & 0x3]; -// ret[2] = b[(imm >> 4) & 0x03]; ret[3] = b[(imm >> 6) & 0x03]; -// return ret; -// } -// -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_shuffle_ps -#define _mm_shuffle_ps_default(a, b, imm) \ - vreinterpretq_m128_f32(vsetq_lane_f32( \ - vgetq_lane_f32(vreinterpretq_f32_m128(b), ((imm) >> 6) & 0x3), \ - vsetq_lane_f32( \ - vgetq_lane_f32(vreinterpretq_f32_m128(b), ((imm) >> 4) & 0x3), \ - vsetq_lane_f32( \ - vgetq_lane_f32(vreinterpretq_f32_m128(a), ((imm) >> 2) & 0x3), \ - vmovq_n_f32( \ - vgetq_lane_f32(vreinterpretq_f32_m128(a), (imm) & (0x3))), \ - 1), \ - 2), \ - 3)) - -// Shuffle 16-bit integers in the low 64 bits of a using the control in imm8. -// Store the results in the low 64 bits of dst, with the high 64 bits being -// copied from a to dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_shufflelo_epi16 -#define _mm_shufflelo_epi16_function(a, imm) \ - _sse2neon_define1( \ - __m128i, a, int16x8_t ret = vreinterpretq_s16_m128i(_a); \ - int16x4_t lowBits = vget_low_s16(ret); \ - ret = vsetq_lane_s16(vget_lane_s16(lowBits, (imm) & (0x3)), ret, 0); \ - ret = vsetq_lane_s16(vget_lane_s16(lowBits, ((imm) >> 2) & 0x3), ret, \ - 1); \ - ret = vsetq_lane_s16(vget_lane_s16(lowBits, ((imm) >> 4) & 0x3), ret, \ - 2); \ - ret = vsetq_lane_s16(vget_lane_s16(lowBits, ((imm) >> 6) & 0x3), ret, \ - 3); \ - _sse2neon_return(vreinterpretq_m128i_s16(ret));) - -// Shuffle 16-bit integers in the high 64 bits of a using the control in imm8. -// Store the results in the high 64 bits of dst, with the low 64 bits being -// copied from a to dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_shufflehi_epi16 -#define _mm_shufflehi_epi16_function(a, imm) \ - _sse2neon_define1( \ - __m128i, a, int16x8_t ret = vreinterpretq_s16_m128i(_a); \ - int16x4_t highBits = vget_high_s16(ret); \ - ret = vsetq_lane_s16(vget_lane_s16(highBits, (imm) & (0x3)), ret, 4); \ - ret = vsetq_lane_s16(vget_lane_s16(highBits, ((imm) >> 2) & 0x3), ret, \ - 5); \ - ret = vsetq_lane_s16(vget_lane_s16(highBits, ((imm) >> 4) & 0x3), ret, \ - 6); \ - ret = vsetq_lane_s16(vget_lane_s16(highBits, ((imm) >> 6) & 0x3), ret, \ - 7); \ - _sse2neon_return(vreinterpretq_m128i_s16(ret));) - -/* MMX */ - -//_mm_empty is a no-op on arm -FORCE_INLINE void _mm_empty(void) {} - -/* SSE */ - -// Add packed single-precision (32-bit) floating-point elements in a and b, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_add_ps -FORCE_INLINE __m128 _mm_add_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_f32( - vaddq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -} - -// Add the lower single-precision (32-bit) floating-point element in a and b, -// store the result in the lower element of dst, and copy the upper 3 packed -// elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_add_ss -FORCE_INLINE __m128 _mm_add_ss(__m128 a, __m128 b) -{ - float32_t b0 = vgetq_lane_f32(vreinterpretq_f32_m128(b), 0); - float32x4_t value = vsetq_lane_f32(b0, vdupq_n_f32(0), 0); - // the upper values in the result must be the remnants of . - return vreinterpretq_m128_f32(vaddq_f32(a, value)); -} - -// Compute the bitwise AND of packed single-precision (32-bit) floating-point -// elements in a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_and_ps -FORCE_INLINE __m128 _mm_and_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_s32( - vandq_s32(vreinterpretq_s32_m128(a), vreinterpretq_s32_m128(b))); -} - -// Compute the bitwise NOT of packed single-precision (32-bit) floating-point -// elements in a and then AND with b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_andnot_ps -FORCE_INLINE __m128 _mm_andnot_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_s32( - vbicq_s32(vreinterpretq_s32_m128(b), - vreinterpretq_s32_m128(a))); // *NOTE* argument swap -} - -// Average packed unsigned 16-bit integers in a and b, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_avg_pu16 -FORCE_INLINE __m64 _mm_avg_pu16(__m64 a, __m64 b) -{ - return vreinterpret_m64_u16( - vrhadd_u16(vreinterpret_u16_m64(a), vreinterpret_u16_m64(b))); -} - -// Average packed unsigned 8-bit integers in a and b, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_avg_pu8 -FORCE_INLINE __m64 _mm_avg_pu8(__m64 a, __m64 b) -{ - return vreinterpret_m64_u8( - vrhadd_u8(vreinterpret_u8_m64(a), vreinterpret_u8_m64(b))); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// for equality, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpeq_ps -FORCE_INLINE __m128 _mm_cmpeq_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_u32( - vceqq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b for equality, store the result in the lower element of dst, and copy the -// upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpeq_ss -FORCE_INLINE __m128 _mm_cmpeq_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmpeq_ps(a, b)); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// for greater-than-or-equal, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpge_ps -FORCE_INLINE __m128 _mm_cmpge_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_u32( - vcgeq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b for greater-than-or-equal, store the result in the lower element of dst, -// and copy the upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpge_ss -FORCE_INLINE __m128 _mm_cmpge_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmpge_ps(a, b)); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// for greater-than, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpgt_ps -FORCE_INLINE __m128 _mm_cmpgt_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_u32( - vcgtq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b for greater-than, store the result in the lower element of dst, and copy -// the upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpgt_ss -FORCE_INLINE __m128 _mm_cmpgt_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmpgt_ps(a, b)); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// for less-than-or-equal, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmple_ps -FORCE_INLINE __m128 _mm_cmple_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_u32( - vcleq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b for less-than-or-equal, store the result in the lower element of dst, and -// copy the upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmple_ss -FORCE_INLINE __m128 _mm_cmple_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmple_ps(a, b)); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// for less-than, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmplt_ps -FORCE_INLINE __m128 _mm_cmplt_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_u32( - vcltq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b for less-than, store the result in the lower element of dst, and copy the -// upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmplt_ss -FORCE_INLINE __m128 _mm_cmplt_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmplt_ps(a, b)); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// for not-equal, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpneq_ps -FORCE_INLINE __m128 _mm_cmpneq_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_u32(vmvnq_u32( - vceqq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b)))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b for not-equal, store the result in the lower element of dst, and copy the -// upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpneq_ss -FORCE_INLINE __m128 _mm_cmpneq_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmpneq_ps(a, b)); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// for not-greater-than-or-equal, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnge_ps -FORCE_INLINE __m128 _mm_cmpnge_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_u32(vmvnq_u32( - vcgeq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b)))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b for not-greater-than-or-equal, store the result in the lower element of -// dst, and copy the upper 3 packed elements from a to the upper elements of -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnge_ss -FORCE_INLINE __m128 _mm_cmpnge_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmpnge_ps(a, b)); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// for not-greater-than, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpngt_ps -FORCE_INLINE __m128 _mm_cmpngt_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_u32(vmvnq_u32( - vcgtq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b)))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b for not-greater-than, store the result in the lower element of dst, and -// copy the upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpngt_ss -FORCE_INLINE __m128 _mm_cmpngt_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmpngt_ps(a, b)); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// for not-less-than-or-equal, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnle_ps -FORCE_INLINE __m128 _mm_cmpnle_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_u32(vmvnq_u32( - vcleq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b)))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b for not-less-than-or-equal, store the result in the lower element of dst, -// and copy the upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnle_ss -FORCE_INLINE __m128 _mm_cmpnle_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmpnle_ps(a, b)); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// for not-less-than, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnlt_ps -FORCE_INLINE __m128 _mm_cmpnlt_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_u32(vmvnq_u32( - vcltq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b)))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b for not-less-than, store the result in the lower element of dst, and copy -// the upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnlt_ss -FORCE_INLINE __m128 _mm_cmpnlt_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmpnlt_ps(a, b)); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// to see if neither is NaN, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpord_ps -// -// See also: -// http://stackoverflow.com/questions/8627331/what-does-ordered-unordered-comparison-mean -// http://stackoverflow.com/questions/29349621/neon-isnanval-intrinsics -FORCE_INLINE __m128 _mm_cmpord_ps(__m128 a, __m128 b) -{ - // Note: NEON does not have ordered compare builtin - // Need to compare a eq a and b eq b to check for NaN - // Do AND of results to get final - uint32x4_t ceqaa = - vceqq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(a)); - uint32x4_t ceqbb = - vceqq_f32(vreinterpretq_f32_m128(b), vreinterpretq_f32_m128(b)); - return vreinterpretq_m128_u32(vandq_u32(ceqaa, ceqbb)); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b to see if neither is NaN, store the result in the lower element of dst, and -// copy the upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpord_ss -FORCE_INLINE __m128 _mm_cmpord_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmpord_ps(a, b)); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b -// to see if either is NaN, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpunord_ps -FORCE_INLINE __m128 _mm_cmpunord_ps(__m128 a, __m128 b) -{ - uint32x4_t f32a = - vceqq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(a)); - uint32x4_t f32b = - vceqq_f32(vreinterpretq_f32_m128(b), vreinterpretq_f32_m128(b)); - return vreinterpretq_m128_u32(vmvnq_u32(vandq_u32(f32a, f32b))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b to see if either is NaN, store the result in the lower element of dst, and -// copy the upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpunord_ss -FORCE_INLINE __m128 _mm_cmpunord_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_cmpunord_ps(a, b)); -} - -// Compare the lower single-precision (32-bit) floating-point element in a and b -// for equality, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comieq_ss -FORCE_INLINE int _mm_comieq_ss(__m128 a, __m128 b) -{ - uint32x4_t a_eq_b = - vceqq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b)); - return vgetq_lane_u32(a_eq_b, 0) & 0x1; -} - -// Compare the lower single-precision (32-bit) floating-point element in a and b -// for greater-than-or-equal, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comige_ss -FORCE_INLINE int _mm_comige_ss(__m128 a, __m128 b) -{ - uint32x4_t a_ge_b = - vcgeq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b)); - return vgetq_lane_u32(a_ge_b, 0) & 0x1; -} - -// Compare the lower single-precision (32-bit) floating-point element in a and b -// for greater-than, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comigt_ss -FORCE_INLINE int _mm_comigt_ss(__m128 a, __m128 b) -{ - uint32x4_t a_gt_b = - vcgtq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b)); - return vgetq_lane_u32(a_gt_b, 0) & 0x1; -} - -// Compare the lower single-precision (32-bit) floating-point element in a and b -// for less-than-or-equal, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comile_ss -FORCE_INLINE int _mm_comile_ss(__m128 a, __m128 b) -{ - uint32x4_t a_le_b = - vcleq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b)); - return vgetq_lane_u32(a_le_b, 0) & 0x1; -} - -// Compare the lower single-precision (32-bit) floating-point element in a and b -// for less-than, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comilt_ss -FORCE_INLINE int _mm_comilt_ss(__m128 a, __m128 b) -{ - uint32x4_t a_lt_b = - vcltq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b)); - return vgetq_lane_u32(a_lt_b, 0) & 0x1; -} - -// Compare the lower single-precision (32-bit) floating-point element in a and b -// for not-equal, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comineq_ss -FORCE_INLINE int _mm_comineq_ss(__m128 a, __m128 b) -{ - return !_mm_comieq_ss(a, b); -} - -// Convert packed signed 32-bit integers in b to packed single-precision -// (32-bit) floating-point elements, store the results in the lower 2 elements -// of dst, and copy the upper 2 packed elements from a to the upper elements of -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvt_pi2ps -FORCE_INLINE __m128 _mm_cvt_pi2ps(__m128 a, __m64 b) -{ - return vreinterpretq_m128_f32( - vcombine_f32(vcvt_f32_s32(vreinterpret_s32_m64(b)), - vget_high_f32(vreinterpretq_f32_m128(a)))); -} - -// Convert packed single-precision (32-bit) floating-point elements in a to -// packed 32-bit integers, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvt_ps2pi -FORCE_INLINE __m64 _mm_cvt_ps2pi(__m128 a) -{ -#if (defined(__aarch64__) || defined(_M_ARM64)) || \ - defined(__ARM_FEATURE_DIRECTED_ROUNDING) - return vreinterpret_m64_s32( - vget_low_s32(vcvtnq_s32_f32(vrndiq_f32(vreinterpretq_f32_m128(a))))); -#else - return vreinterpret_m64_s32(vcvt_s32_f32(vget_low_f32( - vreinterpretq_f32_m128(_mm_round_ps(a, _MM_FROUND_CUR_DIRECTION))))); -#endif -} - -// Convert the signed 32-bit integer b to a single-precision (32-bit) -// floating-point element, store the result in the lower element of dst, and -// copy the upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvt_si2ss -FORCE_INLINE __m128 _mm_cvt_si2ss(__m128 a, int b) -{ - return vreinterpretq_m128_f32( - vsetq_lane_f32((float) b, vreinterpretq_f32_m128(a), 0)); -} - -// Convert the lower single-precision (32-bit) floating-point element in a to a -// 32-bit integer, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvt_ss2si -FORCE_INLINE int _mm_cvt_ss2si(__m128 a) -{ -#if (defined(__aarch64__) || defined(_M_ARM64)) || \ - defined(__ARM_FEATURE_DIRECTED_ROUNDING) - return vgetq_lane_s32(vcvtnq_s32_f32(vrndiq_f32(vreinterpretq_f32_m128(a))), - 0); -#else - float32_t data = vgetq_lane_f32( - vreinterpretq_f32_m128(_mm_round_ps(a, _MM_FROUND_CUR_DIRECTION)), 0); - return (int32_t) data; -#endif -} - -// Convert packed 16-bit integers in a to packed single-precision (32-bit) -// floating-point elements, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtpi16_ps -FORCE_INLINE __m128 _mm_cvtpi16_ps(__m64 a) -{ - return vreinterpretq_m128_f32( - vcvtq_f32_s32(vmovl_s16(vreinterpret_s16_m64(a)))); -} - -// Convert packed 32-bit integers in b to packed single-precision (32-bit) -// floating-point elements, store the results in the lower 2 elements of dst, -// and copy the upper 2 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtpi32_ps -FORCE_INLINE __m128 _mm_cvtpi32_ps(__m128 a, __m64 b) -{ - return vreinterpretq_m128_f32( - vcombine_f32(vcvt_f32_s32(vreinterpret_s32_m64(b)), - vget_high_f32(vreinterpretq_f32_m128(a)))); -} - -// Convert packed signed 32-bit integers in a to packed single-precision -// (32-bit) floating-point elements, store the results in the lower 2 elements -// of dst, then convert the packed signed 32-bit integers in b to -// single-precision (32-bit) floating-point element, and store the results in -// the upper 2 elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtpi32x2_ps -FORCE_INLINE __m128 _mm_cvtpi32x2_ps(__m64 a, __m64 b) -{ - return vreinterpretq_m128_f32(vcvtq_f32_s32( - vcombine_s32(vreinterpret_s32_m64(a), vreinterpret_s32_m64(b)))); -} - -// Convert the lower packed 8-bit integers in a to packed single-precision -// (32-bit) floating-point elements, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtpi8_ps -FORCE_INLINE __m128 _mm_cvtpi8_ps(__m64 a) -{ - return vreinterpretq_m128_f32(vcvtq_f32_s32( - vmovl_s16(vget_low_s16(vmovl_s8(vreinterpret_s8_m64(a)))))); -} - -// Convert packed single-precision (32-bit) floating-point elements in a to -// packed 16-bit integers, and store the results in dst. Note: this intrinsic -// will generate 0x7FFF, rather than 0x8000, for input values between 0x7FFF and -// 0x7FFFFFFF. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtps_pi16 -FORCE_INLINE __m64 _mm_cvtps_pi16(__m128 a) -{ - return vreinterpret_m64_s16( - vqmovn_s32(vreinterpretq_s32_m128i(_mm_cvtps_epi32(a)))); -} - -// Convert packed single-precision (32-bit) floating-point elements in a to -// packed 32-bit integers, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtps_pi32 -#define _mm_cvtps_pi32(a) _mm_cvt_ps2pi(a) - -// Convert packed single-precision (32-bit) floating-point elements in a to -// packed 8-bit integers, and store the results in lower 4 elements of dst. -// Note: this intrinsic will generate 0x7F, rather than 0x80, for input values -// between 0x7F and 0x7FFFFFFF. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtps_pi8 -FORCE_INLINE __m64 _mm_cvtps_pi8(__m128 a) -{ - return vreinterpret_m64_s8(vqmovn_s16( - vcombine_s16(vreinterpret_s16_m64(_mm_cvtps_pi16(a)), vdup_n_s16(0)))); -} - -// Convert packed unsigned 16-bit integers in a to packed single-precision -// (32-bit) floating-point elements, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtpu16_ps -FORCE_INLINE __m128 _mm_cvtpu16_ps(__m64 a) -{ - return vreinterpretq_m128_f32( - vcvtq_f32_u32(vmovl_u16(vreinterpret_u16_m64(a)))); -} - -// Convert the lower packed unsigned 8-bit integers in a to packed -// single-precision (32-bit) floating-point elements, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtpu8_ps -FORCE_INLINE __m128 _mm_cvtpu8_ps(__m64 a) -{ - return vreinterpretq_m128_f32(vcvtq_f32_u32( - vmovl_u16(vget_low_u16(vmovl_u8(vreinterpret_u8_m64(a)))))); -} - -// Convert the signed 32-bit integer b to a single-precision (32-bit) -// floating-point element, store the result in the lower element of dst, and -// copy the upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi32_ss -#define _mm_cvtsi32_ss(a, b) _mm_cvt_si2ss(a, b) - -// Convert the signed 64-bit integer b to a single-precision (32-bit) -// floating-point element, store the result in the lower element of dst, and -// copy the upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi64_ss -FORCE_INLINE __m128 _mm_cvtsi64_ss(__m128 a, int64_t b) -{ - return vreinterpretq_m128_f32( - vsetq_lane_f32((float) b, vreinterpretq_f32_m128(a), 0)); -} - -// Copy the lower single-precision (32-bit) floating-point element of a to dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtss_f32 -FORCE_INLINE float _mm_cvtss_f32(__m128 a) -{ - return vgetq_lane_f32(vreinterpretq_f32_m128(a), 0); -} - -// Convert the lower single-precision (32-bit) floating-point element in a to a -// 32-bit integer, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtss_si32 -#define _mm_cvtss_si32(a) _mm_cvt_ss2si(a) - -// Convert the lower single-precision (32-bit) floating-point element in a to a -// 64-bit integer, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtss_si64 -FORCE_INLINE int64_t _mm_cvtss_si64(__m128 a) -{ -#if (defined(__aarch64__) || defined(_M_ARM64)) || \ - defined(__ARM_FEATURE_DIRECTED_ROUNDING) - return (int64_t) vgetq_lane_f32(vrndiq_f32(vreinterpretq_f32_m128(a)), 0); -#else - float32_t data = vgetq_lane_f32( - vreinterpretq_f32_m128(_mm_round_ps(a, _MM_FROUND_CUR_DIRECTION)), 0); - return (int64_t) data; -#endif -} - -// Convert packed single-precision (32-bit) floating-point elements in a to -// packed 32-bit integers with truncation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtt_ps2pi -FORCE_INLINE __m64 _mm_cvtt_ps2pi(__m128 a) -{ - return vreinterpret_m64_s32( - vget_low_s32(vcvtq_s32_f32(vreinterpretq_f32_m128(a)))); -} - -// Convert the lower single-precision (32-bit) floating-point element in a to a -// 32-bit integer with truncation, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtt_ss2si -FORCE_INLINE int _mm_cvtt_ss2si(__m128 a) -{ - return vgetq_lane_s32(vcvtq_s32_f32(vreinterpretq_f32_m128(a)), 0); -} - -// Convert packed single-precision (32-bit) floating-point elements in a to -// packed 32-bit integers with truncation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvttps_pi32 -#define _mm_cvttps_pi32(a) _mm_cvtt_ps2pi(a) - -// Convert the lower single-precision (32-bit) floating-point element in a to a -// 32-bit integer with truncation, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvttss_si32 -#define _mm_cvttss_si32(a) _mm_cvtt_ss2si(a) - -// Convert the lower single-precision (32-bit) floating-point element in a to a -// 64-bit integer with truncation, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvttss_si64 -FORCE_INLINE int64_t _mm_cvttss_si64(__m128 a) -{ - return (int64_t) vgetq_lane_f32(vreinterpretq_f32_m128(a), 0); -} - -// Divide packed single-precision (32-bit) floating-point elements in a by -// packed elements in b, and store the results in dst. -// Due to ARMv7-A NEON's lack of a precise division intrinsic, we implement -// division by multiplying a by b's reciprocal before using the Newton-Raphson -// method to approximate the results. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_div_ps -FORCE_INLINE __m128 _mm_div_ps(__m128 a, __m128 b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128_f32( - vdivq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -#else - float32x4_t recip = vrecpeq_f32(vreinterpretq_f32_m128(b)); - recip = vmulq_f32(recip, vrecpsq_f32(recip, vreinterpretq_f32_m128(b))); - // Additional Netwon-Raphson iteration for accuracy - recip = vmulq_f32(recip, vrecpsq_f32(recip, vreinterpretq_f32_m128(b))); - return vreinterpretq_m128_f32(vmulq_f32(vreinterpretq_f32_m128(a), recip)); -#endif -} - -// Divide the lower single-precision (32-bit) floating-point element in a by the -// lower single-precision (32-bit) floating-point element in b, store the result -// in the lower element of dst, and copy the upper 3 packed elements from a to -// the upper elements of dst. -// Warning: ARMv7-A does not produce the same result compared to Intel and not -// IEEE-compliant. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_div_ss -FORCE_INLINE __m128 _mm_div_ss(__m128 a, __m128 b) -{ - float32_t value = - vgetq_lane_f32(vreinterpretq_f32_m128(_mm_div_ps(a, b)), 0); - return vreinterpretq_m128_f32( - vsetq_lane_f32(value, vreinterpretq_f32_m128(a), 0)); -} - -// Extract a 16-bit integer from a, selected with imm8, and store the result in -// the lower element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_extract_pi16 -#define _mm_extract_pi16(a, imm) \ - (int32_t) vget_lane_u16(vreinterpret_u16_m64(a), (imm)) - -// Free aligned memory that was allocated with _mm_malloc. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_free -#if !defined(SSE2NEON_ALLOC_DEFINED) -FORCE_INLINE void _mm_free(void *addr) -{ -#if defined(_WIN32) - _aligned_free(addr); -#else - free(addr); -#endif -} -#endif - -FORCE_INLINE uint64_t _sse2neon_get_fpcr(void) -{ - uint64_t value; -#if defined(_MSC_VER) && !defined(__clang__) - value = _ReadStatusReg(ARM64_FPCR); -#else - __asm__ __volatile__("mrs %0, FPCR" : "=r"(value)); /* read */ -#endif - return value; -} - -FORCE_INLINE void _sse2neon_set_fpcr(uint64_t value) -{ -#if defined(_MSC_VER) && !defined(__clang__) - _WriteStatusReg(ARM64_FPCR, value); -#else - __asm__ __volatile__("msr FPCR, %0" ::"r"(value)); /* write */ -#endif -} - -// Macro: Get the flush zero bits from the MXCSR control and status register. -// The flush zero may contain any of the following flags: _MM_FLUSH_ZERO_ON or -// _MM_FLUSH_ZERO_OFF -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_MM_GET_FLUSH_ZERO_MODE -FORCE_INLINE unsigned int _sse2neon_mm_get_flush_zero_mode(void) -{ - union { - fpcr_bitfield field; -#if defined(__aarch64__) || defined(_M_ARM64) - uint64_t value; -#else - uint32_t value; -#endif - } r; - -#if defined(__aarch64__) || defined(_M_ARM64) - r.value = _sse2neon_get_fpcr(); -#else - __asm__ __volatile__("vmrs %0, FPSCR" : "=r"(r.value)); /* read */ -#endif - - return r.field.bit24 ? _MM_FLUSH_ZERO_ON : _MM_FLUSH_ZERO_OFF; -} - -// Macro: Get the rounding mode bits from the MXCSR control and status register. -// The rounding mode may contain any of the following flags: _MM_ROUND_NEAREST, -// _MM_ROUND_DOWN, _MM_ROUND_UP, _MM_ROUND_TOWARD_ZERO -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_MM_GET_ROUNDING_MODE -FORCE_INLINE unsigned int _MM_GET_ROUNDING_MODE(void) -{ - switch (fegetround()) { - case FE_TONEAREST: - return _MM_ROUND_NEAREST; - case FE_DOWNWARD: - return _MM_ROUND_DOWN; - case FE_UPWARD: - return _MM_ROUND_UP; - case FE_TOWARDZERO: - return _MM_ROUND_TOWARD_ZERO; - default: - // fegetround() must return _MM_ROUND_NEAREST, _MM_ROUND_DOWN, - // _MM_ROUND_UP, _MM_ROUND_TOWARD_ZERO on success. all the other error - // cases we treat them as FE_TOWARDZERO (truncate). - return _MM_ROUND_TOWARD_ZERO; - } -} - -// Copy a to dst, and insert the 16-bit integer i into dst at the location -// specified by imm8. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_insert_pi16 -#define _mm_insert_pi16(a, b, imm) \ - vreinterpret_m64_s16(vset_lane_s16((b), vreinterpret_s16_m64(a), (imm))) - -// Load 128-bits (composed of 4 packed single-precision (32-bit) floating-point -// elements) from memory into dst. mem_addr must be aligned on a 16-byte -// boundary or a general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_load_ps -FORCE_INLINE __m128 _mm_load_ps(const float *p) -{ - return vreinterpretq_m128_f32(vld1q_f32(p)); -} - -// Load a single-precision (32-bit) floating-point element from memory into all -// elements of dst. -// -// dst[31:0] := MEM[mem_addr+31:mem_addr] -// dst[63:32] := MEM[mem_addr+31:mem_addr] -// dst[95:64] := MEM[mem_addr+31:mem_addr] -// dst[127:96] := MEM[mem_addr+31:mem_addr] -// -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_load_ps1 -#define _mm_load_ps1 _mm_load1_ps - -// Load a single-precision (32-bit) floating-point element from memory into the -// lower of dst, and zero the upper 3 elements. mem_addr does not need to be -// aligned on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_load_ss -FORCE_INLINE __m128 _mm_load_ss(const float *p) -{ - return vreinterpretq_m128_f32(vsetq_lane_f32(*p, vdupq_n_f32(0), 0)); -} - -// Load a single-precision (32-bit) floating-point element from memory into all -// elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_load1_ps -FORCE_INLINE __m128 _mm_load1_ps(const float *p) -{ - return vreinterpretq_m128_f32(vld1q_dup_f32(p)); -} - -// Load 2 single-precision (32-bit) floating-point elements from memory into the -// upper 2 elements of dst, and copy the lower 2 elements from a to dst. -// mem_addr does not need to be aligned on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadh_pi -FORCE_INLINE __m128 _mm_loadh_pi(__m128 a, __m64 const *p) -{ - return vreinterpretq_m128_f32( - vcombine_f32(vget_low_f32(a), vld1_f32((const float32_t *) p))); -} - -// Load 2 single-precision (32-bit) floating-point elements from memory into the -// lower 2 elements of dst, and copy the upper 2 elements from a to dst. -// mem_addr does not need to be aligned on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadl_pi -FORCE_INLINE __m128 _mm_loadl_pi(__m128 a, __m64 const *p) -{ - return vreinterpretq_m128_f32( - vcombine_f32(vld1_f32((const float32_t *) p), vget_high_f32(a))); -} - -// Load 4 single-precision (32-bit) floating-point elements from memory into dst -// in reverse order. mem_addr must be aligned on a 16-byte boundary or a -// general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadr_ps -FORCE_INLINE __m128 _mm_loadr_ps(const float *p) -{ - float32x4_t v = vrev64q_f32(vld1q_f32(p)); - return vreinterpretq_m128_f32(vextq_f32(v, v, 2)); -} - -// Load 128-bits (composed of 4 packed single-precision (32-bit) floating-point -// elements) from memory into dst. mem_addr does not need to be aligned on any -// particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadu_ps -FORCE_INLINE __m128 _mm_loadu_ps(const float *p) -{ - // for neon, alignment doesn't matter, so _mm_load_ps and _mm_loadu_ps are - // equivalent for neon - return vreinterpretq_m128_f32(vld1q_f32(p)); -} - -// Load unaligned 16-bit integer from memory into the first element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadu_si16 -FORCE_INLINE __m128i _mm_loadu_si16(const void *p) -{ - return vreinterpretq_m128i_s16( - vsetq_lane_s16(*(const unaligned_int16_t *) p, vdupq_n_s16(0), 0)); -} - -// Load unaligned 64-bit integer from memory into the first element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadu_si64 -FORCE_INLINE __m128i _mm_loadu_si64(const void *p) -{ - return vreinterpretq_m128i_s64( - vsetq_lane_s64(*(const unaligned_int64_t *) p, vdupq_n_s64(0), 0)); -} - -// Allocate size bytes of memory, aligned to the alignment specified in align, -// and return a pointer to the allocated memory. _mm_free should be used to free -// memory that is allocated with _mm_malloc. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_malloc -#if !defined(SSE2NEON_ALLOC_DEFINED) -FORCE_INLINE void *_mm_malloc(size_t size, size_t align) -{ - void *ptr; - if (align == 1) - return malloc(size); - if (align == 2 || (sizeof(void *) == 8 && align == 4)) - align = sizeof(void *); -#if defined(_WIN32) - ptr = _aligned_malloc(size, align); - if (ptr) - return ptr; -#else - if (!posix_memalign(&ptr, align, size)) - return ptr; -#endif - return NULL; -} -#endif - -// Conditionally store 8-bit integer elements from a into memory using mask -// (elements are not stored when the highest bit is not set in the corresponding -// element) and a non-temporal memory hint. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_maskmove_si64 -FORCE_INLINE void _mm_maskmove_si64(__m64 a, __m64 mask, char *mem_addr) -{ - int8x8_t shr_mask = vshr_n_s8(vreinterpret_s8_m64(mask), 7); - __m128 b = _mm_load_ps((const float *) mem_addr); - int8x8_t masked = - vbsl_s8(vreinterpret_u8_s8(shr_mask), vreinterpret_s8_m64(a), - vreinterpret_s8_u64(vget_low_u64(vreinterpretq_u64_m128(b)))); - vst1_s8((int8_t *) mem_addr, masked); -} - -// Conditionally store 8-bit integer elements from a into memory using mask -// (elements are not stored when the highest bit is not set in the corresponding -// element) and a non-temporal memory hint. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_m_maskmovq -#define _m_maskmovq(a, mask, mem_addr) _mm_maskmove_si64(a, mask, mem_addr) - -// Compare packed signed 16-bit integers in a and b, and store packed maximum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_pi16 -FORCE_INLINE __m64 _mm_max_pi16(__m64 a, __m64 b) -{ - return vreinterpret_m64_s16( - vmax_s16(vreinterpret_s16_m64(a), vreinterpret_s16_m64(b))); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b, -// and store packed maximum values in dst. dst does not follow the IEEE Standard -// for Floating-Point Arithmetic (IEEE 754) maximum value when inputs are NaN or -// signed-zero values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_ps -FORCE_INLINE __m128 _mm_max_ps(__m128 a, __m128 b) -{ -#if SSE2NEON_PRECISE_MINMAX - float32x4_t _a = vreinterpretq_f32_m128(a); - float32x4_t _b = vreinterpretq_f32_m128(b); - return vreinterpretq_m128_f32(vbslq_f32(vcgtq_f32(_a, _b), _a, _b)); -#else - return vreinterpretq_m128_f32( - vmaxq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -#endif -} - -// Compare packed unsigned 8-bit integers in a and b, and store packed maximum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_pu8 -FORCE_INLINE __m64 _mm_max_pu8(__m64 a, __m64 b) -{ - return vreinterpret_m64_u8( - vmax_u8(vreinterpret_u8_m64(a), vreinterpret_u8_m64(b))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b, store the maximum value in the lower element of dst, and copy the upper 3 -// packed elements from a to the upper element of dst. dst does not follow the -// IEEE Standard for Floating-Point Arithmetic (IEEE 754) maximum value when -// inputs are NaN or signed-zero values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_ss -FORCE_INLINE __m128 _mm_max_ss(__m128 a, __m128 b) -{ - float32_t value = vgetq_lane_f32(_mm_max_ps(a, b), 0); - return vreinterpretq_m128_f32( - vsetq_lane_f32(value, vreinterpretq_f32_m128(a), 0)); -} - -// Compare packed signed 16-bit integers in a and b, and store packed minimum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_min_pi16 -FORCE_INLINE __m64 _mm_min_pi16(__m64 a, __m64 b) -{ - return vreinterpret_m64_s16( - vmin_s16(vreinterpret_s16_m64(a), vreinterpret_s16_m64(b))); -} - -// Compare packed single-precision (32-bit) floating-point elements in a and b, -// and store packed minimum values in dst. dst does not follow the IEEE Standard -// for Floating-Point Arithmetic (IEEE 754) minimum value when inputs are NaN or -// signed-zero values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_min_ps -FORCE_INLINE __m128 _mm_min_ps(__m128 a, __m128 b) -{ -#if SSE2NEON_PRECISE_MINMAX - float32x4_t _a = vreinterpretq_f32_m128(a); - float32x4_t _b = vreinterpretq_f32_m128(b); - return vreinterpretq_m128_f32(vbslq_f32(vcltq_f32(_a, _b), _a, _b)); -#else - return vreinterpretq_m128_f32( - vminq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -#endif -} - -// Compare packed unsigned 8-bit integers in a and b, and store packed minimum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_min_pu8 -FORCE_INLINE __m64 _mm_min_pu8(__m64 a, __m64 b) -{ - return vreinterpret_m64_u8( - vmin_u8(vreinterpret_u8_m64(a), vreinterpret_u8_m64(b))); -} - -// Compare the lower single-precision (32-bit) floating-point elements in a and -// b, store the minimum value in the lower element of dst, and copy the upper 3 -// packed elements from a to the upper element of dst. dst does not follow the -// IEEE Standard for Floating-Point Arithmetic (IEEE 754) minimum value when -// inputs are NaN or signed-zero values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_min_ss -FORCE_INLINE __m128 _mm_min_ss(__m128 a, __m128 b) -{ - float32_t value = vgetq_lane_f32(_mm_min_ps(a, b), 0); - return vreinterpretq_m128_f32( - vsetq_lane_f32(value, vreinterpretq_f32_m128(a), 0)); -} - -// Move the lower single-precision (32-bit) floating-point element from b to the -// lower element of dst, and copy the upper 3 packed elements from a to the -// upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_move_ss -FORCE_INLINE __m128 _mm_move_ss(__m128 a, __m128 b) -{ - return vreinterpretq_m128_f32( - vsetq_lane_f32(vgetq_lane_f32(vreinterpretq_f32_m128(b), 0), - vreinterpretq_f32_m128(a), 0)); -} - -// Move the upper 2 single-precision (32-bit) floating-point elements from b to -// the lower 2 elements of dst, and copy the upper 2 elements from a to the -// upper 2 elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_movehl_ps -FORCE_INLINE __m128 _mm_movehl_ps(__m128 a, __m128 b) -{ -#if defined(aarch64__) - return vreinterpretq_m128_u64( - vzip2q_u64(vreinterpretq_u64_m128(b), vreinterpretq_u64_m128(a))); -#else - float32x2_t a32 = vget_high_f32(vreinterpretq_f32_m128(a)); - float32x2_t b32 = vget_high_f32(vreinterpretq_f32_m128(b)); - return vreinterpretq_m128_f32(vcombine_f32(b32, a32)); -#endif -} - -// Move the lower 2 single-precision (32-bit) floating-point elements from b to -// the upper 2 elements of dst, and copy the lower 2 elements from a to the -// lower 2 elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_movelh_ps -FORCE_INLINE __m128 _mm_movelh_ps(__m128 __A, __m128 __B) -{ - float32x2_t a10 = vget_low_f32(vreinterpretq_f32_m128(__A)); - float32x2_t b10 = vget_low_f32(vreinterpretq_f32_m128(__B)); - return vreinterpretq_m128_f32(vcombine_f32(a10, b10)); -} - -// Create mask from the most significant bit of each 8-bit element in a, and -// store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_movemask_pi8 -FORCE_INLINE int _mm_movemask_pi8(__m64 a) -{ - uint8x8_t input = vreinterpret_u8_m64(a); -#if defined(__aarch64__) || defined(_M_ARM64) - static const int8_t shift[8] = {0, 1, 2, 3, 4, 5, 6, 7}; - uint8x8_t tmp = vshr_n_u8(input, 7); - return vaddv_u8(vshl_u8(tmp, vld1_s8(shift))); -#else - // Refer the implementation of `_mm_movemask_epi8` - uint16x4_t high_bits = vreinterpret_u16_u8(vshr_n_u8(input, 7)); - uint32x2_t paired16 = - vreinterpret_u32_u16(vsra_n_u16(high_bits, high_bits, 7)); - uint8x8_t paired32 = - vreinterpret_u8_u32(vsra_n_u32(paired16, paired16, 14)); - return vget_lane_u8(paired32, 0) | ((int) vget_lane_u8(paired32, 4) << 4); -#endif -} - -// Set each bit of mask dst based on the most significant bit of the -// corresponding packed single-precision (32-bit) floating-point element in a. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_movemask_ps -FORCE_INLINE int _mm_movemask_ps(__m128 a) -{ - uint32x4_t input = vreinterpretq_u32_m128(a); -#if defined(__aarch64__) || defined(_M_ARM64) - static const int32_t shift[4] = {0, 1, 2, 3}; - uint32x4_t tmp = vshrq_n_u32(input, 31); - return vaddvq_u32(vshlq_u32(tmp, vld1q_s32(shift))); -#else - // Uses the exact same method as _mm_movemask_epi8, see that for details. - // Shift out everything but the sign bits with a 32-bit unsigned shift - // right. - uint64x2_t high_bits = vreinterpretq_u64_u32(vshrq_n_u32(input, 31)); - // Merge the two pairs together with a 64-bit unsigned shift right + add. - uint8x16_t paired = - vreinterpretq_u8_u64(vsraq_n_u64(high_bits, high_bits, 31)); - // Extract the result. - return vgetq_lane_u8(paired, 0) | (vgetq_lane_u8(paired, 8) << 2); -#endif -} - -// Multiply packed single-precision (32-bit) floating-point elements in a and b, -// and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mul_ps -FORCE_INLINE __m128 _mm_mul_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_f32( - vmulq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -} - -// Multiply the lower single-precision (32-bit) floating-point element in a and -// b, store the result in the lower element of dst, and copy the upper 3 packed -// elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mul_ss -FORCE_INLINE __m128 _mm_mul_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_mul_ps(a, b)); -} - -// Multiply the packed unsigned 16-bit integers in a and b, producing -// intermediate 32-bit integers, and store the high 16 bits of the intermediate -// integers in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mulhi_pu16 -FORCE_INLINE __m64 _mm_mulhi_pu16(__m64 a, __m64 b) -{ - return vreinterpret_m64_u16(vshrn_n_u32( - vmull_u16(vreinterpret_u16_m64(a), vreinterpret_u16_m64(b)), 16)); -} - -// Compute the bitwise OR of packed single-precision (32-bit) floating-point -// elements in a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_or_ps -FORCE_INLINE __m128 _mm_or_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_s32( - vorrq_s32(vreinterpretq_s32_m128(a), vreinterpretq_s32_m128(b))); -} - -// Average packed unsigned 8-bit integers in a and b, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_m_pavgb -#define _m_pavgb(a, b) _mm_avg_pu8(a, b) - -// Average packed unsigned 16-bit integers in a and b, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_m_pavgw -#define _m_pavgw(a, b) _mm_avg_pu16(a, b) - -// Extract a 16-bit integer from a, selected with imm8, and store the result in -// the lower element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_m_pextrw -#define _m_pextrw(a, imm) _mm_extract_pi16(a, imm) - -// Copy a to dst, and insert the 16-bit integer i into dst at the location -// specified by imm8. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=m_pinsrw -#define _m_pinsrw(a, i, imm) _mm_insert_pi16(a, i, imm) - -// Compare packed signed 16-bit integers in a and b, and store packed maximum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_m_pmaxsw -#define _m_pmaxsw(a, b) _mm_max_pi16(a, b) - -// Compare packed unsigned 8-bit integers in a and b, and store packed maximum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_m_pmaxub -#define _m_pmaxub(a, b) _mm_max_pu8(a, b) - -// Compare packed signed 16-bit integers in a and b, and store packed minimum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_m_pminsw -#define _m_pminsw(a, b) _mm_min_pi16(a, b) - -// Compare packed unsigned 8-bit integers in a and b, and store packed minimum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_m_pminub -#define _m_pminub(a, b) _mm_min_pu8(a, b) - -// Create mask from the most significant bit of each 8-bit element in a, and -// store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_m_pmovmskb -#define _m_pmovmskb(a) _mm_movemask_pi8(a) - -// Multiply the packed unsigned 16-bit integers in a and b, producing -// intermediate 32-bit integers, and store the high 16 bits of the intermediate -// integers in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_m_pmulhuw -#define _m_pmulhuw(a, b) _mm_mulhi_pu16(a, b) - -// Fetch the line of data from memory that contains address p to a location in -// the cache hierarchy specified by the locality hint i. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_prefetch -FORCE_INLINE void _mm_prefetch(char const *p, int i) -{ - (void) i; -#if defined(_MSC_VER) && !defined(__clang__) - switch (i) { - case _MM_HINT_NTA: - __prefetch2(p, 1); - break; - case _MM_HINT_T0: - __prefetch2(p, 0); - break; - case _MM_HINT_T1: - __prefetch2(p, 2); - break; - case _MM_HINT_T2: - __prefetch2(p, 4); - break; - } -#else - switch (i) { - case _MM_HINT_NTA: - __builtin_prefetch(p, 0, 0); - break; - case _MM_HINT_T0: - __builtin_prefetch(p, 0, 3); - break; - case _MM_HINT_T1: - __builtin_prefetch(p, 0, 2); - break; - case _MM_HINT_T2: - __builtin_prefetch(p, 0, 1); - break; - } -#endif -} - -// Compute the absolute differences of packed unsigned 8-bit integers in a and -// b, then horizontally sum each consecutive 8 differences to produce four -// unsigned 16-bit integers, and pack these unsigned 16-bit integers in the low -// 16 bits of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=m_psadbw -#define _m_psadbw(a, b) _mm_sad_pu8(a, b) - -// Shuffle 16-bit integers in a using the control in imm8, and store the results -// in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_m_pshufw -#define _m_pshufw(a, imm) _mm_shuffle_pi16(a, imm) - -// Compute the approximate reciprocal of packed single-precision (32-bit) -// floating-point elements in a, and store the results in dst. The maximum -// relative error for this approximation is less than 1.5*2^-12. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_rcp_ps -FORCE_INLINE __m128 _mm_rcp_ps(__m128 in) -{ - float32x4_t recip = vrecpeq_f32(vreinterpretq_f32_m128(in)); - recip = vmulq_f32(recip, vrecpsq_f32(recip, vreinterpretq_f32_m128(in))); -#if SSE2NEON_PRECISE_DIV - // Additional Netwon-Raphson iteration for accuracy - recip = vmulq_f32(recip, vrecpsq_f32(recip, vreinterpretq_f32_m128(in))); -#endif - return vreinterpretq_m128_f32(recip); -} - -// Compute the approximate reciprocal of the lower single-precision (32-bit) -// floating-point element in a, store the result in the lower element of dst, -// and copy the upper 3 packed elements from a to the upper elements of dst. The -// maximum relative error for this approximation is less than 1.5*2^-12. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_rcp_ss -FORCE_INLINE __m128 _mm_rcp_ss(__m128 a) -{ - return _mm_move_ss(a, _mm_rcp_ps(a)); -} - -// Compute the approximate reciprocal square root of packed single-precision -// (32-bit) floating-point elements in a, and store the results in dst. The -// maximum relative error for this approximation is less than 1.5*2^-12. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_rsqrt_ps -FORCE_INLINE __m128 _mm_rsqrt_ps(__m128 in) -{ - float32x4_t out = vrsqrteq_f32(vreinterpretq_f32_m128(in)); - - // Generate masks for detecting whether input has any 0.0f/-0.0f - // (which becomes positive/negative infinity by IEEE-754 arithmetic rules). - const uint32x4_t pos_inf = vdupq_n_u32(0x7F800000); - const uint32x4_t neg_inf = vdupq_n_u32(0xFF800000); - const uint32x4_t has_pos_zero = - vceqq_u32(pos_inf, vreinterpretq_u32_f32(out)); - const uint32x4_t has_neg_zero = - vceqq_u32(neg_inf, vreinterpretq_u32_f32(out)); - - out = vmulq_f32( - out, vrsqrtsq_f32(vmulq_f32(vreinterpretq_f32_m128(in), out), out)); -#if SSE2NEON_PRECISE_SQRT - // Additional Netwon-Raphson iteration for accuracy - out = vmulq_f32( - out, vrsqrtsq_f32(vmulq_f32(vreinterpretq_f32_m128(in), out), out)); -#endif - - // Set output vector element to infinity/negative-infinity if - // the corresponding input vector element is 0.0f/-0.0f. - out = vbslq_f32(has_pos_zero, (float32x4_t) pos_inf, out); - out = vbslq_f32(has_neg_zero, (float32x4_t) neg_inf, out); - - return vreinterpretq_m128_f32(out); -} - -// Compute the approximate reciprocal square root of the lower single-precision -// (32-bit) floating-point element in a, store the result in the lower element -// of dst, and copy the upper 3 packed elements from a to the upper elements of -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_rsqrt_ss -FORCE_INLINE __m128 _mm_rsqrt_ss(__m128 in) -{ - return vsetq_lane_f32(vgetq_lane_f32(_mm_rsqrt_ps(in), 0), in, 0); -} - -// Compute the absolute differences of packed unsigned 8-bit integers in a and -// b, then horizontally sum each consecutive 8 differences to produce four -// unsigned 16-bit integers, and pack these unsigned 16-bit integers in the low -// 16 bits of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sad_pu8 -FORCE_INLINE __m64 _mm_sad_pu8(__m64 a, __m64 b) -{ - uint64x1_t t = vpaddl_u32(vpaddl_u16( - vpaddl_u8(vabd_u8(vreinterpret_u8_m64(a), vreinterpret_u8_m64(b))))); - return vreinterpret_m64_u16( - vset_lane_u16((int) vget_lane_u64(t, 0), vdup_n_u16(0), 0)); -} - -// Macro: Set the flush zero bits of the MXCSR control and status register to -// the value in unsigned 32-bit integer a. The flush zero may contain any of the -// following flags: _MM_FLUSH_ZERO_ON or _MM_FLUSH_ZERO_OFF -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_MM_SET_FLUSH_ZERO_MODE -FORCE_INLINE void _sse2neon_mm_set_flush_zero_mode(unsigned int flag) -{ - // AArch32 Advanced SIMD arithmetic always uses the Flush-to-zero setting, - // regardless of the value of the FZ bit. - union { - fpcr_bitfield field; -#if defined(__aarch64__) || defined(_M_ARM64) - uint64_t value; -#else - uint32_t value; -#endif - } r; - -#if defined(__aarch64__) || defined(_M_ARM64) - r.value = _sse2neon_get_fpcr(); -#else - __asm__ __volatile__("vmrs %0, FPSCR" : "=r"(r.value)); /* read */ -#endif - - r.field.bit24 = (flag & _MM_FLUSH_ZERO_MASK) == _MM_FLUSH_ZERO_ON; - -#if defined(__aarch64__) || defined(_M_ARM64) - _sse2neon_set_fpcr(r.value); -#else - __asm__ __volatile__("vmsr FPSCR, %0" ::"r"(r)); /* write */ -#endif -} - -// Set packed single-precision (32-bit) floating-point elements in dst with the -// supplied values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set_ps -FORCE_INLINE __m128 _mm_set_ps(float w, float z, float y, float x) -{ - float ALIGN_STRUCT(16) data[4] = {x, y, z, w}; - return vreinterpretq_m128_f32(vld1q_f32(data)); -} - -// Broadcast single-precision (32-bit) floating-point value a to all elements of -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set_ps1 -FORCE_INLINE __m128 _mm_set_ps1(float _w) -{ - return vreinterpretq_m128_f32(vdupq_n_f32(_w)); -} - -// Macro: Set the rounding mode bits of the MXCSR control and status register to -// the value in unsigned 32-bit integer a. The rounding mode may contain any of -// the following flags: _MM_ROUND_NEAREST, _MM_ROUND_DOWN, _MM_ROUND_UP, -// _MM_ROUND_TOWARD_ZERO -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_MM_SET_ROUNDING_MODE -FORCE_INLINE void _MM_SET_ROUNDING_MODE(int rounding) -{ - switch (rounding) { - case _MM_ROUND_NEAREST: - rounding = FE_TONEAREST; - break; - case _MM_ROUND_DOWN: - rounding = FE_DOWNWARD; - break; - case _MM_ROUND_UP: - rounding = FE_UPWARD; - break; - case _MM_ROUND_TOWARD_ZERO: - rounding = FE_TOWARDZERO; - break; - default: - // rounding must be _MM_ROUND_NEAREST, _MM_ROUND_DOWN, _MM_ROUND_UP, - // _MM_ROUND_TOWARD_ZERO. all the other invalid values we treat them as - // FE_TOWARDZERO (truncate). - rounding = FE_TOWARDZERO; - } - fesetround(rounding); -} - -// Copy single-precision (32-bit) floating-point element a to the lower element -// of dst, and zero the upper 3 elements. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set_ss -FORCE_INLINE __m128 _mm_set_ss(float a) -{ - return vreinterpretq_m128_f32(vsetq_lane_f32(a, vdupq_n_f32(0), 0)); -} - -// Broadcast single-precision (32-bit) floating-point value a to all elements of -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set1_ps -FORCE_INLINE __m128 _mm_set1_ps(float _w) -{ - return vreinterpretq_m128_f32(vdupq_n_f32(_w)); -} - -// Set the MXCSR control and status register with the value in unsigned 32-bit -// integer a. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_setcsr -// FIXME: _mm_setcsr() implementation supports changing the rounding mode only. -FORCE_INLINE void _mm_setcsr(unsigned int a) -{ - _MM_SET_ROUNDING_MODE(a); -} - -// Get the unsigned 32-bit value of the MXCSR control and status register. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_getcsr -// FIXME: _mm_getcsr() implementation supports reading the rounding mode only. -FORCE_INLINE unsigned int _mm_getcsr(void) -{ - return _MM_GET_ROUNDING_MODE(); -} - -// Set packed single-precision (32-bit) floating-point elements in dst with the -// supplied values in reverse order. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_setr_ps -FORCE_INLINE __m128 _mm_setr_ps(float w, float z, float y, float x) -{ - float ALIGN_STRUCT(16) data[4] = {w, z, y, x}; - return vreinterpretq_m128_f32(vld1q_f32(data)); -} - -// Return vector of type __m128 with all elements set to zero. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_setzero_ps -FORCE_INLINE __m128 _mm_setzero_ps(void) -{ - return vreinterpretq_m128_f32(vdupq_n_f32(0)); -} - -// Shuffle 16-bit integers in a using the control in imm8, and store the results -// in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_shuffle_pi16 -#ifdef _sse2neon_shuffle -#define _mm_shuffle_pi16(a, imm) \ - vreinterpret_m64_s16(vshuffle_s16( \ - vreinterpret_s16_m64(a), vreinterpret_s16_m64(a), (imm & 0x3), \ - ((imm >> 2) & 0x3), ((imm >> 4) & 0x3), ((imm >> 6) & 0x3))) -#else -#define _mm_shuffle_pi16(a, imm) \ - _sse2neon_define1( \ - __m64, a, int16x4_t ret; \ - ret = vmov_n_s16( \ - vget_lane_s16(vreinterpret_s16_m64(_a), (imm) & (0x3))); \ - ret = vset_lane_s16( \ - vget_lane_s16(vreinterpret_s16_m64(_a), ((imm) >> 2) & 0x3), ret, \ - 1); \ - ret = vset_lane_s16( \ - vget_lane_s16(vreinterpret_s16_m64(_a), ((imm) >> 4) & 0x3), ret, \ - 2); \ - ret = vset_lane_s16( \ - vget_lane_s16(vreinterpret_s16_m64(_a), ((imm) >> 6) & 0x3), ret, \ - 3); \ - _sse2neon_return(vreinterpret_m64_s16(ret));) -#endif - -// Perform a serializing operation on all store-to-memory instructions that were -// issued prior to this instruction. Guarantees that every store instruction -// that precedes, in program order, is globally visible before any store -// instruction which follows the fence in program order. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sfence -FORCE_INLINE void _mm_sfence(void) -{ - _sse2neon_smp_mb(); -} - -// Perform a serializing operation on all load-from-memory and store-to-memory -// instructions that were issued prior to this instruction. Guarantees that -// every memory access that precedes, in program order, the memory fence -// instruction is globally visible before any memory instruction which follows -// the fence in program order. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mfence -FORCE_INLINE void _mm_mfence(void) -{ - _sse2neon_smp_mb(); -} - -// Perform a serializing operation on all load-from-memory instructions that -// were issued prior to this instruction. Guarantees that every load instruction -// that precedes, in program order, is globally visible before any load -// instruction which follows the fence in program order. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_lfence -FORCE_INLINE void _mm_lfence(void) -{ - _sse2neon_smp_mb(); -} - -// FORCE_INLINE __m128 _mm_shuffle_ps(__m128 a, __m128 b, __constrange(0,255) -// int imm) -#ifdef _sse2neon_shuffle -#define _mm_shuffle_ps(a, b, imm) \ - __extension__({ \ - float32x4_t _input1 = vreinterpretq_f32_m128(a); \ - float32x4_t _input2 = vreinterpretq_f32_m128(b); \ - float32x4_t _shuf = \ - vshuffleq_s32(_input1, _input2, (imm) & (0x3), ((imm) >> 2) & 0x3, \ - (((imm) >> 4) & 0x3) + 4, (((imm) >> 6) & 0x3) + 4); \ - vreinterpretq_m128_f32(_shuf); \ - }) -#else // generic -#define _mm_shuffle_ps(a, b, imm) \ - _sse2neon_define2( \ - __m128, a, b, __m128 ret; switch (imm) { \ - case _MM_SHUFFLE(1, 0, 3, 2): \ - ret = _mm_shuffle_ps_1032(_a, _b); \ - break; \ - case _MM_SHUFFLE(2, 3, 0, 1): \ - ret = _mm_shuffle_ps_2301(_a, _b); \ - break; \ - case _MM_SHUFFLE(0, 3, 2, 1): \ - ret = _mm_shuffle_ps_0321(_a, _b); \ - break; \ - case _MM_SHUFFLE(2, 1, 0, 3): \ - ret = _mm_shuffle_ps_2103(_a, _b); \ - break; \ - case _MM_SHUFFLE(1, 0, 1, 0): \ - ret = _mm_movelh_ps(_a, _b); \ - break; \ - case _MM_SHUFFLE(1, 0, 0, 1): \ - ret = _mm_shuffle_ps_1001(_a, _b); \ - break; \ - case _MM_SHUFFLE(0, 1, 0, 1): \ - ret = _mm_shuffle_ps_0101(_a, _b); \ - break; \ - case _MM_SHUFFLE(3, 2, 1, 0): \ - ret = _mm_shuffle_ps_3210(_a, _b); \ - break; \ - case _MM_SHUFFLE(0, 0, 1, 1): \ - ret = _mm_shuffle_ps_0011(_a, _b); \ - break; \ - case _MM_SHUFFLE(0, 0, 2, 2): \ - ret = _mm_shuffle_ps_0022(_a, _b); \ - break; \ - case _MM_SHUFFLE(2, 2, 0, 0): \ - ret = _mm_shuffle_ps_2200(_a, _b); \ - break; \ - case _MM_SHUFFLE(3, 2, 0, 2): \ - ret = _mm_shuffle_ps_3202(_a, _b); \ - break; \ - case _MM_SHUFFLE(3, 2, 3, 2): \ - ret = _mm_movehl_ps(_b, _a); \ - break; \ - case _MM_SHUFFLE(1, 1, 3, 3): \ - ret = _mm_shuffle_ps_1133(_a, _b); \ - break; \ - case _MM_SHUFFLE(2, 0, 1, 0): \ - ret = _mm_shuffle_ps_2010(_a, _b); \ - break; \ - case _MM_SHUFFLE(2, 0, 0, 1): \ - ret = _mm_shuffle_ps_2001(_a, _b); \ - break; \ - case _MM_SHUFFLE(2, 0, 3, 2): \ - ret = _mm_shuffle_ps_2032(_a, _b); \ - break; \ - default: \ - ret = _mm_shuffle_ps_default(_a, _b, (imm)); \ - break; \ - } _sse2neon_return(ret);) -#endif - -// Compute the square root of packed single-precision (32-bit) floating-point -// elements in a, and store the results in dst. -// Due to ARMv7-A NEON's lack of a precise square root intrinsic, we implement -// square root by multiplying input in with its reciprocal square root before -// using the Newton-Raphson method to approximate the results. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sqrt_ps -FORCE_INLINE __m128 _mm_sqrt_ps(__m128 in) -{ -#if (defined(__aarch64__) || defined(_M_ARM64)) && !SSE2NEON_PRECISE_SQRT - return vreinterpretq_m128_f32(vsqrtq_f32(vreinterpretq_f32_m128(in))); -#else - float32x4_t recip = vrsqrteq_f32(vreinterpretq_f32_m128(in)); - - // Test for vrsqrteq_f32(0) -> positive infinity case. - // Change to zero, so that s * 1/sqrt(s) result is zero too. - const uint32x4_t pos_inf = vdupq_n_u32(0x7F800000); - const uint32x4_t div_by_zero = - vceqq_u32(pos_inf, vreinterpretq_u32_f32(recip)); - recip = vreinterpretq_f32_u32( - vandq_u32(vmvnq_u32(div_by_zero), vreinterpretq_u32_f32(recip))); - - recip = vmulq_f32( - vrsqrtsq_f32(vmulq_f32(recip, recip), vreinterpretq_f32_m128(in)), - recip); - // Additional Netwon-Raphson iteration for accuracy - recip = vmulq_f32( - vrsqrtsq_f32(vmulq_f32(recip, recip), vreinterpretq_f32_m128(in)), - recip); - - // sqrt(s) = s * 1/sqrt(s) - return vreinterpretq_m128_f32(vmulq_f32(vreinterpretq_f32_m128(in), recip)); -#endif -} - -// Compute the square root of the lower single-precision (32-bit) floating-point -// element in a, store the result in the lower element of dst, and copy the -// upper 3 packed elements from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sqrt_ss -FORCE_INLINE __m128 _mm_sqrt_ss(__m128 in) -{ - float32_t value = - vgetq_lane_f32(vreinterpretq_f32_m128(_mm_sqrt_ps(in)), 0); - return vreinterpretq_m128_f32( - vsetq_lane_f32(value, vreinterpretq_f32_m128(in), 0)); -} - -// Store 128-bits (composed of 4 packed single-precision (32-bit) floating-point -// elements) from a into memory. mem_addr must be aligned on a 16-byte boundary -// or a general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_store_ps -FORCE_INLINE void _mm_store_ps(float *p, __m128 a) -{ - vst1q_f32(p, vreinterpretq_f32_m128(a)); -} - -// Store the lower single-precision (32-bit) floating-point element from a into -// 4 contiguous elements in memory. mem_addr must be aligned on a 16-byte -// boundary or a general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_store_ps1 -FORCE_INLINE void _mm_store_ps1(float *p, __m128 a) -{ - float32_t a0 = vgetq_lane_f32(vreinterpretq_f32_m128(a), 0); - vst1q_f32(p, vdupq_n_f32(a0)); -} - -// Store the lower single-precision (32-bit) floating-point element from a into -// memory. mem_addr does not need to be aligned on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_store_ss -FORCE_INLINE void _mm_store_ss(float *p, __m128 a) -{ - vst1q_lane_f32(p, vreinterpretq_f32_m128(a), 0); -} - -// Store the lower single-precision (32-bit) floating-point element from a into -// 4 contiguous elements in memory. mem_addr must be aligned on a 16-byte -// boundary or a general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_store1_ps -#define _mm_store1_ps _mm_store_ps1 - -// Store the upper 2 single-precision (32-bit) floating-point elements from a -// into memory. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storeh_pi -FORCE_INLINE void _mm_storeh_pi(__m64 *p, __m128 a) -{ - *p = vreinterpret_m64_f32(vget_high_f32(a)); -} - -// Store the lower 2 single-precision (32-bit) floating-point elements from a -// into memory. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storel_pi -FORCE_INLINE void _mm_storel_pi(__m64 *p, __m128 a) -{ - *p = vreinterpret_m64_f32(vget_low_f32(a)); -} - -// Store 4 single-precision (32-bit) floating-point elements from a into memory -// in reverse order. mem_addr must be aligned on a 16-byte boundary or a -// general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storer_ps -FORCE_INLINE void _mm_storer_ps(float *p, __m128 a) -{ - float32x4_t tmp = vrev64q_f32(vreinterpretq_f32_m128(a)); - float32x4_t rev = vextq_f32(tmp, tmp, 2); - vst1q_f32(p, rev); -} - -// Store 128-bits (composed of 4 packed single-precision (32-bit) floating-point -// elements) from a into memory. mem_addr does not need to be aligned on any -// particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storeu_ps -FORCE_INLINE void _mm_storeu_ps(float *p, __m128 a) -{ - vst1q_f32(p, vreinterpretq_f32_m128(a)); -} - -// Stores 16-bits of integer data a at the address p. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storeu_si16 -FORCE_INLINE void _mm_storeu_si16(void *p, __m128i a) -{ - vst1q_lane_s16((int16_t *) p, vreinterpretq_s16_m128i(a), 0); -} - -// Stores 64-bits of integer data a at the address p. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storeu_si64 -FORCE_INLINE void _mm_storeu_si64(void *p, __m128i a) -{ - vst1q_lane_s64((int64_t *) p, vreinterpretq_s64_m128i(a), 0); -} - -// Store 64-bits of integer data from a into memory using a non-temporal memory -// hint. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_stream_pi -FORCE_INLINE void _mm_stream_pi(__m64 *p, __m64 a) -{ - vst1_s64((int64_t *) p, vreinterpret_s64_m64(a)); -} - -// Store 128-bits (composed of 4 packed single-precision (32-bit) floating- -// point elements) from a into memory using a non-temporal memory hint. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_stream_ps -FORCE_INLINE void _mm_stream_ps(float *p, __m128 a) -{ -#if __has_builtin(__builtin_nontemporal_store) - __builtin_nontemporal_store(a, (float32x4_t *) p); -#else - vst1q_f32(p, vreinterpretq_f32_m128(a)); -#endif -} - -// Subtract packed single-precision (32-bit) floating-point elements in b from -// packed single-precision (32-bit) floating-point elements in a, and store the -// results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sub_ps -FORCE_INLINE __m128 _mm_sub_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_f32( - vsubq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -} - -// Subtract the lower single-precision (32-bit) floating-point element in b from -// the lower single-precision (32-bit) floating-point element in a, store the -// result in the lower element of dst, and copy the upper 3 packed elements from -// a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sub_ss -FORCE_INLINE __m128 _mm_sub_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_sub_ps(a, b)); -} - -// Macro: Transpose the 4x4 matrix formed by the 4 rows of single-precision -// (32-bit) floating-point elements in row0, row1, row2, and row3, and store the -// transposed matrix in these vectors (row0 now contains column 0, etc.). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=MM_TRANSPOSE4_PS -#define _MM_TRANSPOSE4_PS(row0, row1, row2, row3) \ - do { \ - float32x4x2_t ROW01 = vtrnq_f32(row0, row1); \ - float32x4x2_t ROW23 = vtrnq_f32(row2, row3); \ - row0 = vcombine_f32(vget_low_f32(ROW01.val[0]), \ - vget_low_f32(ROW23.val[0])); \ - row1 = vcombine_f32(vget_low_f32(ROW01.val[1]), \ - vget_low_f32(ROW23.val[1])); \ - row2 = vcombine_f32(vget_high_f32(ROW01.val[0]), \ - vget_high_f32(ROW23.val[0])); \ - row3 = vcombine_f32(vget_high_f32(ROW01.val[1]), \ - vget_high_f32(ROW23.val[1])); \ - } while (0) - -// according to the documentation, these intrinsics behave the same as the -// non-'u' versions. We'll just alias them here. -#define _mm_ucomieq_ss _mm_comieq_ss -#define _mm_ucomige_ss _mm_comige_ss -#define _mm_ucomigt_ss _mm_comigt_ss -#define _mm_ucomile_ss _mm_comile_ss -#define _mm_ucomilt_ss _mm_comilt_ss -#define _mm_ucomineq_ss _mm_comineq_ss - -// Return vector of type __m128i with undefined elements. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=mm_undefined_si128 -FORCE_INLINE __m128i _mm_undefined_si128(void) -{ -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wuninitialized" -#endif - __m128i a; -#if defined(_MSC_VER) - a = _mm_setzero_si128(); -#endif - return a; -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic pop -#endif -} - -// Return vector of type __m128 with undefined elements. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_undefined_ps -FORCE_INLINE __m128 _mm_undefined_ps(void) -{ -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wuninitialized" -#endif - __m128 a; -#if defined(_MSC_VER) - a = _mm_setzero_ps(); -#endif - return a; -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic pop -#endif -} - -// Unpack and interleave single-precision (32-bit) floating-point elements from -// the high half a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpackhi_ps -FORCE_INLINE __m128 _mm_unpackhi_ps(__m128 a, __m128 b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128_f32( - vzip2q_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -#else - float32x2_t a1 = vget_high_f32(vreinterpretq_f32_m128(a)); - float32x2_t b1 = vget_high_f32(vreinterpretq_f32_m128(b)); - float32x2x2_t result = vzip_f32(a1, b1); - return vreinterpretq_m128_f32(vcombine_f32(result.val[0], result.val[1])); -#endif -} - -// Unpack and interleave single-precision (32-bit) floating-point elements from -// the low half of a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpacklo_ps -FORCE_INLINE __m128 _mm_unpacklo_ps(__m128 a, __m128 b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128_f32( - vzip1q_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -#else - float32x2_t a1 = vget_low_f32(vreinterpretq_f32_m128(a)); - float32x2_t b1 = vget_low_f32(vreinterpretq_f32_m128(b)); - float32x2x2_t result = vzip_f32(a1, b1); - return vreinterpretq_m128_f32(vcombine_f32(result.val[0], result.val[1])); -#endif -} - -// Compute the bitwise XOR of packed single-precision (32-bit) floating-point -// elements in a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_xor_ps -FORCE_INLINE __m128 _mm_xor_ps(__m128 a, __m128 b) -{ - return vreinterpretq_m128_s32( - veorq_s32(vreinterpretq_s32_m128(a), vreinterpretq_s32_m128(b))); -} - -/* SSE2 */ - -// Add packed 16-bit integers in a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_add_epi16 -FORCE_INLINE __m128i _mm_add_epi16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s16( - vaddq_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -} - -// Add packed 32-bit integers in a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_add_epi32 -FORCE_INLINE __m128i _mm_add_epi32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s32( - vaddq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -} - -// Add packed 64-bit integers in a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_add_epi64 -FORCE_INLINE __m128i _mm_add_epi64(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s64( - vaddq_s64(vreinterpretq_s64_m128i(a), vreinterpretq_s64_m128i(b))); -} - -// Add packed 8-bit integers in a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_add_epi8 -FORCE_INLINE __m128i _mm_add_epi8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s8( - vaddq_s8(vreinterpretq_s8_m128i(a), vreinterpretq_s8_m128i(b))); -} - -// Add packed double-precision (64-bit) floating-point elements in a and b, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_add_pd -FORCE_INLINE __m128d _mm_add_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vaddq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - double c[2]; - c[0] = a0 + b0; - c[1] = a1 + b1; - return vld1q_f32((float32_t *) c); -#endif -} - -// Add the lower double-precision (64-bit) floating-point element in a and b, -// store the result in the lower element of dst, and copy the upper element from -// a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_add_sd -FORCE_INLINE __m128d _mm_add_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return _mm_move_sd(a, _mm_add_pd(a, b)); -#else - double a0, a1, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - a1 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double c[2]; - c[0] = a0 + b0; - c[1] = a1; - return vld1q_f32((float32_t *) c); -#endif -} - -// Add 64-bit integers a and b, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_add_si64 -FORCE_INLINE __m64 _mm_add_si64(__m64 a, __m64 b) -{ - return vreinterpret_m64_s64( - vadd_s64(vreinterpret_s64_m64(a), vreinterpret_s64_m64(b))); -} - -// Add packed signed 16-bit integers in a and b using saturation, and store the -// results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_adds_epi16 -FORCE_INLINE __m128i _mm_adds_epi16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s16( - vqaddq_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -} - -// Add packed signed 8-bit integers in a and b using saturation, and store the -// results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_adds_epi8 -FORCE_INLINE __m128i _mm_adds_epi8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s8( - vqaddq_s8(vreinterpretq_s8_m128i(a), vreinterpretq_s8_m128i(b))); -} - -// Add packed unsigned 16-bit integers in a and b using saturation, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_adds_epu16 -FORCE_INLINE __m128i _mm_adds_epu16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u16( - vqaddq_u16(vreinterpretq_u16_m128i(a), vreinterpretq_u16_m128i(b))); -} - -// Add packed unsigned 8-bit integers in a and b using saturation, and store the -// results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_adds_epu8 -FORCE_INLINE __m128i _mm_adds_epu8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u8( - vqaddq_u8(vreinterpretq_u8_m128i(a), vreinterpretq_u8_m128i(b))); -} - -// Compute the bitwise AND of packed double-precision (64-bit) floating-point -// elements in a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_and_pd -FORCE_INLINE __m128d _mm_and_pd(__m128d a, __m128d b) -{ - return vreinterpretq_m128d_s64( - vandq_s64(vreinterpretq_s64_m128d(a), vreinterpretq_s64_m128d(b))); -} - -// Compute the bitwise AND of 128 bits (representing integer data) in a and b, -// and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_and_si128 -FORCE_INLINE __m128i _mm_and_si128(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s32( - vandq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -} - -// Compute the bitwise NOT of packed double-precision (64-bit) floating-point -// elements in a and then AND with b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_andnot_pd -FORCE_INLINE __m128d _mm_andnot_pd(__m128d a, __m128d b) -{ - // *NOTE* argument swap - return vreinterpretq_m128d_s64( - vbicq_s64(vreinterpretq_s64_m128d(b), vreinterpretq_s64_m128d(a))); -} - -// Compute the bitwise NOT of 128 bits (representing integer data) in a and then -// AND with b, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_andnot_si128 -FORCE_INLINE __m128i _mm_andnot_si128(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s32( - vbicq_s32(vreinterpretq_s32_m128i(b), - vreinterpretq_s32_m128i(a))); // *NOTE* argument swap -} - -// Average packed unsigned 16-bit integers in a and b, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_avg_epu16 -FORCE_INLINE __m128i _mm_avg_epu16(__m128i a, __m128i b) -{ - return (__m128i) vrhaddq_u16(vreinterpretq_u16_m128i(a), - vreinterpretq_u16_m128i(b)); -} - -// Average packed unsigned 8-bit integers in a and b, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_avg_epu8 -FORCE_INLINE __m128i _mm_avg_epu8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u8( - vrhaddq_u8(vreinterpretq_u8_m128i(a), vreinterpretq_u8_m128i(b))); -} - -// Shift a left by imm8 bytes while shifting in zeros, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_bslli_si128 -#define _mm_bslli_si128(a, imm) _mm_slli_si128(a, imm) - -// Shift a right by imm8 bytes while shifting in zeros, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_bsrli_si128 -#define _mm_bsrli_si128(a, imm) _mm_srli_si128(a, imm) - -// Cast vector of type __m128d to type __m128. This intrinsic is only used for -// compilation and does not generate any instructions, thus it has zero latency. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_castpd_ps -FORCE_INLINE __m128 _mm_castpd_ps(__m128d a) -{ - return vreinterpretq_m128_s64(vreinterpretq_s64_m128d(a)); -} - -// Cast vector of type __m128d to type __m128i. This intrinsic is only used for -// compilation and does not generate any instructions, thus it has zero latency. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_castpd_si128 -FORCE_INLINE __m128i _mm_castpd_si128(__m128d a) -{ - return vreinterpretq_m128i_s64(vreinterpretq_s64_m128d(a)); -} - -// Cast vector of type __m128 to type __m128d. This intrinsic is only used for -// compilation and does not generate any instructions, thus it has zero latency. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_castps_pd -FORCE_INLINE __m128d _mm_castps_pd(__m128 a) -{ - return vreinterpretq_m128d_s32(vreinterpretq_s32_m128(a)); -} - -// Cast vector of type __m128 to type __m128i. This intrinsic is only used for -// compilation and does not generate any instructions, thus it has zero latency. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_castps_si128 -FORCE_INLINE __m128i _mm_castps_si128(__m128 a) -{ - return vreinterpretq_m128i_s32(vreinterpretq_s32_m128(a)); -} - -// Cast vector of type __m128i to type __m128d. This intrinsic is only used for -// compilation and does not generate any instructions, thus it has zero latency. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_castsi128_pd -FORCE_INLINE __m128d _mm_castsi128_pd(__m128i a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vreinterpretq_f64_m128i(a)); -#else - return vreinterpretq_m128d_f32(vreinterpretq_f32_m128i(a)); -#endif -} - -// Cast vector of type __m128i to type __m128. This intrinsic is only used for -// compilation and does not generate any instructions, thus it has zero latency. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_castsi128_ps -FORCE_INLINE __m128 _mm_castsi128_ps(__m128i a) -{ - return vreinterpretq_m128_s32(vreinterpretq_s32_m128i(a)); -} - -// Invalidate and flush the cache line that contains p from all levels of the -// cache hierarchy. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_clflush -#if defined(__APPLE__) -#include -#endif -FORCE_INLINE void _mm_clflush(void const *p) -{ - (void) p; - - /* sys_icache_invalidate is supported since macOS 10.5. - * However, it does not work on non-jailbroken iOS devices, although the - * compilation is successful. - */ -#if defined(__APPLE__) - sys_icache_invalidate((void *) (uintptr_t) p, SSE2NEON_CACHELINE_SIZE); -#elif defined(__GNUC__) || defined(__clang__) - uintptr_t ptr = (uintptr_t) p; - __builtin___clear_cache((char *) ptr, - (char *) ptr + SSE2NEON_CACHELINE_SIZE); -#elif (_MSC_VER) && SSE2NEON_INCLUDE_WINDOWS_H - FlushInstructionCache(GetCurrentProcess(), p, SSE2NEON_CACHELINE_SIZE); -#endif -} - -// Compare packed 16-bit integers in a and b for equality, and store the results -// in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpeq_epi16 -FORCE_INLINE __m128i _mm_cmpeq_epi16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u16( - vceqq_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -} - -// Compare packed 32-bit integers in a and b for equality, and store the results -// in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpeq_epi32 -FORCE_INLINE __m128i _mm_cmpeq_epi32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u32( - vceqq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -} - -// Compare packed 8-bit integers in a and b for equality, and store the results -// in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpeq_epi8 -FORCE_INLINE __m128i _mm_cmpeq_epi8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u8( - vceqq_s8(vreinterpretq_s8_m128i(a), vreinterpretq_s8_m128i(b))); -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// for equality, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpeq_pd -FORCE_INLINE __m128d _mm_cmpeq_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_u64( - vceqq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - // (a == b) -> (a_lo == b_lo) && (a_hi == b_hi) - uint32x4_t cmp = - vceqq_u32(vreinterpretq_u32_m128d(a), vreinterpretq_u32_m128d(b)); - uint32x4_t swapped = vrev64q_u32(cmp); - return vreinterpretq_m128d_u32(vandq_u32(cmp, swapped)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b for equality, store the result in the lower element of dst, and copy the -// upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpeq_sd -FORCE_INLINE __m128d _mm_cmpeq_sd(__m128d a, __m128d b) -{ - return _mm_move_sd(a, _mm_cmpeq_pd(a, b)); -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// for greater-than-or-equal, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpge_pd -FORCE_INLINE __m128d _mm_cmpge_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_u64( - vcgeq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - uint64_t d[2]; - d[0] = a0 >= b0 ? ~UINT64_C(0) : UINT64_C(0); - d[1] = a1 >= b1 ? ~UINT64_C(0) : UINT64_C(0); - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b for greater-than-or-equal, store the result in the lower element of dst, -// and copy the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpge_sd -FORCE_INLINE __m128d _mm_cmpge_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return _mm_move_sd(a, _mm_cmpge_pd(a, b)); -#else - // expand "_mm_cmpge_pd()" to reduce unnecessary operations - double a0, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - uint64_t a1 = vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - uint64_t d[2]; - d[0] = a0 >= b0 ? ~UINT64_C(0) : UINT64_C(0); - d[1] = a1; - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare packed signed 16-bit integers in a and b for greater-than, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpgt_epi16 -FORCE_INLINE __m128i _mm_cmpgt_epi16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u16( - vcgtq_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -} - -// Compare packed signed 32-bit integers in a and b for greater-than, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpgt_epi32 -FORCE_INLINE __m128i _mm_cmpgt_epi32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u32( - vcgtq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -} - -// Compare packed signed 8-bit integers in a and b for greater-than, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpgt_epi8 -FORCE_INLINE __m128i _mm_cmpgt_epi8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u8( - vcgtq_s8(vreinterpretq_s8_m128i(a), vreinterpretq_s8_m128i(b))); -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// for greater-than, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpgt_pd -FORCE_INLINE __m128d _mm_cmpgt_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_u64( - vcgtq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - uint64_t d[2]; - d[0] = a0 > b0 ? ~UINT64_C(0) : UINT64_C(0); - d[1] = a1 > b1 ? ~UINT64_C(0) : UINT64_C(0); - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b for greater-than, store the result in the lower element of dst, and copy -// the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpgt_sd -FORCE_INLINE __m128d _mm_cmpgt_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return _mm_move_sd(a, _mm_cmpgt_pd(a, b)); -#else - // expand "_mm_cmpge_pd()" to reduce unnecessary operations - double a0, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - uint64_t a1 = vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - uint64_t d[2]; - d[0] = a0 > b0 ? ~UINT64_C(0) : UINT64_C(0); - d[1] = a1; - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// for less-than-or-equal, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmple_pd -FORCE_INLINE __m128d _mm_cmple_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_u64( - vcleq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - uint64_t d[2]; - d[0] = a0 <= b0 ? ~UINT64_C(0) : UINT64_C(0); - d[1] = a1 <= b1 ? ~UINT64_C(0) : UINT64_C(0); - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b for less-than-or-equal, store the result in the lower element of dst, and -// copy the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmple_sd -FORCE_INLINE __m128d _mm_cmple_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return _mm_move_sd(a, _mm_cmple_pd(a, b)); -#else - // expand "_mm_cmpge_pd()" to reduce unnecessary operations - double a0, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - uint64_t a1 = vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - uint64_t d[2]; - d[0] = a0 <= b0 ? ~UINT64_C(0) : UINT64_C(0); - d[1] = a1; - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare packed signed 16-bit integers in a and b for less-than, and store the -// results in dst. Note: This intrinsic emits the pcmpgtw instruction with the -// order of the operands switched. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmplt_epi16 -FORCE_INLINE __m128i _mm_cmplt_epi16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u16( - vcltq_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -} - -// Compare packed signed 32-bit integers in a and b for less-than, and store the -// results in dst. Note: This intrinsic emits the pcmpgtd instruction with the -// order of the operands switched. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmplt_epi32 -FORCE_INLINE __m128i _mm_cmplt_epi32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u32( - vcltq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -} - -// Compare packed signed 8-bit integers in a and b for less-than, and store the -// results in dst. Note: This intrinsic emits the pcmpgtb instruction with the -// order of the operands switched. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmplt_epi8 -FORCE_INLINE __m128i _mm_cmplt_epi8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u8( - vcltq_s8(vreinterpretq_s8_m128i(a), vreinterpretq_s8_m128i(b))); -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// for less-than, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmplt_pd -FORCE_INLINE __m128d _mm_cmplt_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_u64( - vcltq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - uint64_t d[2]; - d[0] = a0 < b0 ? ~UINT64_C(0) : UINT64_C(0); - d[1] = a1 < b1 ? ~UINT64_C(0) : UINT64_C(0); - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b for less-than, store the result in the lower element of dst, and copy the -// upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmplt_sd -FORCE_INLINE __m128d _mm_cmplt_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return _mm_move_sd(a, _mm_cmplt_pd(a, b)); -#else - double a0, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - uint64_t a1 = vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - uint64_t d[2]; - d[0] = a0 < b0 ? ~UINT64_C(0) : UINT64_C(0); - d[1] = a1; - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// for not-equal, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpneq_pd -FORCE_INLINE __m128d _mm_cmpneq_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_s32(vmvnq_s32(vreinterpretq_s32_u64( - vceqq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))))); -#else - // (a == b) -> (a_lo == b_lo) && (a_hi == b_hi) - uint32x4_t cmp = - vceqq_u32(vreinterpretq_u32_m128d(a), vreinterpretq_u32_m128d(b)); - uint32x4_t swapped = vrev64q_u32(cmp); - return vreinterpretq_m128d_u32(vmvnq_u32(vandq_u32(cmp, swapped))); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b for not-equal, store the result in the lower element of dst, and copy the -// upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpneq_sd -FORCE_INLINE __m128d _mm_cmpneq_sd(__m128d a, __m128d b) -{ - return _mm_move_sd(a, _mm_cmpneq_pd(a, b)); -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// for not-greater-than-or-equal, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnge_pd -FORCE_INLINE __m128d _mm_cmpnge_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_u64(veorq_u64( - vcgeq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b)), - vdupq_n_u64(UINT64_MAX))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - uint64_t d[2]; - d[0] = !(a0 >= b0) ? ~UINT64_C(0) : UINT64_C(0); - d[1] = !(a1 >= b1) ? ~UINT64_C(0) : UINT64_C(0); - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b for not-greater-than-or-equal, store the result in the lower element of -// dst, and copy the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnge_sd -FORCE_INLINE __m128d _mm_cmpnge_sd(__m128d a, __m128d b) -{ - return _mm_move_sd(a, _mm_cmpnge_pd(a, b)); -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// for not-greater-than, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_cmpngt_pd -FORCE_INLINE __m128d _mm_cmpngt_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_u64(veorq_u64( - vcgtq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b)), - vdupq_n_u64(UINT64_MAX))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - uint64_t d[2]; - d[0] = !(a0 > b0) ? ~UINT64_C(0) : UINT64_C(0); - d[1] = !(a1 > b1) ? ~UINT64_C(0) : UINT64_C(0); - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b for not-greater-than, store the result in the lower element of dst, and -// copy the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpngt_sd -FORCE_INLINE __m128d _mm_cmpngt_sd(__m128d a, __m128d b) -{ - return _mm_move_sd(a, _mm_cmpngt_pd(a, b)); -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// for not-less-than-or-equal, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnle_pd -FORCE_INLINE __m128d _mm_cmpnle_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_u64(veorq_u64( - vcleq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b)), - vdupq_n_u64(UINT64_MAX))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - uint64_t d[2]; - d[0] = !(a0 <= b0) ? ~UINT64_C(0) : UINT64_C(0); - d[1] = !(a1 <= b1) ? ~UINT64_C(0) : UINT64_C(0); - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b for not-less-than-or-equal, store the result in the lower element of dst, -// and copy the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnle_sd -FORCE_INLINE __m128d _mm_cmpnle_sd(__m128d a, __m128d b) -{ - return _mm_move_sd(a, _mm_cmpnle_pd(a, b)); -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// for not-less-than, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnlt_pd -FORCE_INLINE __m128d _mm_cmpnlt_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_u64(veorq_u64( - vcltq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b)), - vdupq_n_u64(UINT64_MAX))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - uint64_t d[2]; - d[0] = !(a0 < b0) ? ~UINT64_C(0) : UINT64_C(0); - d[1] = !(a1 < b1) ? ~UINT64_C(0) : UINT64_C(0); - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b for not-less-than, store the result in the lower element of dst, and copy -// the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpnlt_sd -FORCE_INLINE __m128d _mm_cmpnlt_sd(__m128d a, __m128d b) -{ - return _mm_move_sd(a, _mm_cmpnlt_pd(a, b)); -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// to see if neither is NaN, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpord_pd -FORCE_INLINE __m128d _mm_cmpord_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - // Excluding NaNs, any two floating point numbers can be compared. - uint64x2_t not_nan_a = - vceqq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(a)); - uint64x2_t not_nan_b = - vceqq_f64(vreinterpretq_f64_m128d(b), vreinterpretq_f64_m128d(b)); - return vreinterpretq_m128d_u64(vandq_u64(not_nan_a, not_nan_b)); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - uint64_t d[2]; - d[0] = (a0 == a0 && b0 == b0) ? ~UINT64_C(0) : UINT64_C(0); - d[1] = (a1 == a1 && b1 == b1) ? ~UINT64_C(0) : UINT64_C(0); - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b to see if neither is NaN, store the result in the lower element of dst, and -// copy the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpord_sd -FORCE_INLINE __m128d _mm_cmpord_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return _mm_move_sd(a, _mm_cmpord_pd(a, b)); -#else - double a0, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - uint64_t a1 = vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - uint64_t d[2]; - d[0] = (a0 == a0 && b0 == b0) ? ~UINT64_C(0) : UINT64_C(0); - d[1] = a1; - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b -// to see if either is NaN, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpunord_pd -FORCE_INLINE __m128d _mm_cmpunord_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - // Two NaNs are not equal in comparison operation. - uint64x2_t not_nan_a = - vceqq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(a)); - uint64x2_t not_nan_b = - vceqq_f64(vreinterpretq_f64_m128d(b), vreinterpretq_f64_m128d(b)); - return vreinterpretq_m128d_s32( - vmvnq_s32(vreinterpretq_s32_u64(vandq_u64(not_nan_a, not_nan_b)))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - uint64_t d[2]; - d[0] = (a0 == a0 && b0 == b0) ? UINT64_C(0) : ~UINT64_C(0); - d[1] = (a1 == a1 && b1 == b1) ? UINT64_C(0) : ~UINT64_C(0); - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b to see if either is NaN, store the result in the lower element of dst, and -// copy the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpunord_sd -FORCE_INLINE __m128d _mm_cmpunord_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return _mm_move_sd(a, _mm_cmpunord_pd(a, b)); -#else - double a0, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - uint64_t a1 = vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - uint64_t d[2]; - d[0] = (a0 == a0 && b0 == b0) ? UINT64_C(0) : ~UINT64_C(0); - d[1] = a1; - - return vreinterpretq_m128d_u64(vld1q_u64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point element in a and b -// for greater-than-or-equal, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comige_sd -FORCE_INLINE int _mm_comige_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vgetq_lane_u64(vcgeq_f64(a, b), 0) & 0x1; -#else - double a0, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - return a0 >= b0; -#endif -} - -// Compare the lower double-precision (64-bit) floating-point element in a and b -// for greater-than, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comigt_sd -FORCE_INLINE int _mm_comigt_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vgetq_lane_u64(vcgtq_f64(a, b), 0) & 0x1; -#else - double a0, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - - return a0 > b0; -#endif -} - -// Compare the lower double-precision (64-bit) floating-point element in a and b -// for less-than-or-equal, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comile_sd -FORCE_INLINE int _mm_comile_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vgetq_lane_u64(vcleq_f64(a, b), 0) & 0x1; -#else - double a0, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - - return a0 <= b0; -#endif -} - -// Compare the lower double-precision (64-bit) floating-point element in a and b -// for less-than, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comilt_sd -FORCE_INLINE int _mm_comilt_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vgetq_lane_u64(vcltq_f64(a, b), 0) & 0x1; -#else - double a0, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - - return a0 < b0; -#endif -} - -// Compare the lower double-precision (64-bit) floating-point element in a and b -// for equality, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comieq_sd -FORCE_INLINE int _mm_comieq_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vgetq_lane_u64(vceqq_f64(a, b), 0) & 0x1; -#else - uint32x4_t a_not_nan = - vceqq_u32(vreinterpretq_u32_m128d(a), vreinterpretq_u32_m128d(a)); - uint32x4_t b_not_nan = - vceqq_u32(vreinterpretq_u32_m128d(b), vreinterpretq_u32_m128d(b)); - uint32x4_t a_and_b_not_nan = vandq_u32(a_not_nan, b_not_nan); - uint32x4_t a_eq_b = - vceqq_u32(vreinterpretq_u32_m128d(a), vreinterpretq_u32_m128d(b)); - uint64x2_t and_results = vandq_u64(vreinterpretq_u64_u32(a_and_b_not_nan), - vreinterpretq_u64_u32(a_eq_b)); - return vgetq_lane_u64(and_results, 0) & 0x1; -#endif -} - -// Compare the lower double-precision (64-bit) floating-point element in a and b -// for not-equal, and return the boolean result (0 or 1). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_comineq_sd -FORCE_INLINE int _mm_comineq_sd(__m128d a, __m128d b) -{ - return !_mm_comieq_sd(a, b); -} - -// Convert packed signed 32-bit integers in a to packed double-precision -// (64-bit) floating-point elements, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepi32_pd -FORCE_INLINE __m128d _mm_cvtepi32_pd(__m128i a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vcvtq_f64_s64(vmovl_s32(vget_low_s32(vreinterpretq_s32_m128i(a))))); -#else - double a0 = (double) vgetq_lane_s32(vreinterpretq_s32_m128i(a), 0); - double a1 = (double) vgetq_lane_s32(vreinterpretq_s32_m128i(a), 1); - return _mm_set_pd(a1, a0); -#endif -} - -// Convert packed signed 32-bit integers in a to packed single-precision -// (32-bit) floating-point elements, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepi32_ps -FORCE_INLINE __m128 _mm_cvtepi32_ps(__m128i a) -{ - return vreinterpretq_m128_f32(vcvtq_f32_s32(vreinterpretq_s32_m128i(a))); -} - -// Convert packed double-precision (64-bit) floating-point elements in a to -// packed 32-bit integers, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtpd_epi32 -FORCE_INLINE __m128i _mm_cvtpd_epi32(__m128d a) -{ -// vrnd32xq_f64 not supported on clang -#if defined(__ARM_FEATURE_FRINT) && !defined(__clang__) - float64x2_t rounded = vrnd32xq_f64(vreinterpretq_f64_m128d(a)); - int64x2_t integers = vcvtq_s64_f64(rounded); - return vreinterpretq_m128i_s32( - vcombine_s32(vmovn_s64(integers), vdup_n_s32(0))); -#else - __m128d rnd = _mm_round_pd(a, _MM_FROUND_CUR_DIRECTION); - double d0, d1; - d0 = sse2neon_recast_u64_f64( - vgetq_lane_u64(vreinterpretq_u64_m128d(rnd), 0)); - d1 = sse2neon_recast_u64_f64( - vgetq_lane_u64(vreinterpretq_u64_m128d(rnd), 1)); - return _mm_set_epi32(0, 0, (int32_t) d1, (int32_t) d0); -#endif -} - -// Convert packed double-precision (64-bit) floating-point elements in a to -// packed 32-bit integers, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtpd_pi32 -FORCE_INLINE __m64 _mm_cvtpd_pi32(__m128d a) -{ - __m128d rnd = _mm_round_pd(a, _MM_FROUND_CUR_DIRECTION); - double d0, d1; - d0 = sse2neon_recast_u64_f64( - vgetq_lane_u64(vreinterpretq_u64_m128d(rnd), 0)); - d1 = sse2neon_recast_u64_f64( - vgetq_lane_u64(vreinterpretq_u64_m128d(rnd), 1)); - int32_t ALIGN_STRUCT(16) data[2] = {(int32_t) d0, (int32_t) d1}; - return vreinterpret_m64_s32(vld1_s32(data)); -} - -// Convert packed double-precision (64-bit) floating-point elements in a to -// packed single-precision (32-bit) floating-point elements, and store the -// results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtpd_ps -FORCE_INLINE __m128 _mm_cvtpd_ps(__m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - float32x2_t tmp = vcvt_f32_f64(vreinterpretq_f64_m128d(a)); - return vreinterpretq_m128_f32(vcombine_f32(tmp, vdup_n_f32(0))); -#else - double a0, a1; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - a1 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - return _mm_set_ps(0, 0, (float) a1, (float) a0); -#endif -} - -// Convert packed signed 32-bit integers in a to packed double-precision -// (64-bit) floating-point elements, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtpi32_pd -FORCE_INLINE __m128d _mm_cvtpi32_pd(__m64 a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vcvtq_f64_s64(vmovl_s32(vreinterpret_s32_m64(a)))); -#else - double a0 = (double) vget_lane_s32(vreinterpret_s32_m64(a), 0); - double a1 = (double) vget_lane_s32(vreinterpret_s32_m64(a), 1); - return _mm_set_pd(a1, a0); -#endif -} - -// Convert packed single-precision (32-bit) floating-point elements in a to -// packed 32-bit integers, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtps_epi32 -// *NOTE*. The default rounding mode on SSE is 'round to even', which ARMv7-A -// does not support! It is supported on ARMv8-A however. -FORCE_INLINE __m128i _mm_cvtps_epi32(__m128 a) -{ -#if defined(__ARM_FEATURE_FRINT) - return vreinterpretq_m128i_s32(vcvtq_s32_f32(vrnd32xq_f32(a))); -#elif (defined(__aarch64__) || defined(_M_ARM64)) || \ - defined(__ARM_FEATURE_DIRECTED_ROUNDING) - switch (_MM_GET_ROUNDING_MODE()) { - case _MM_ROUND_NEAREST: - return vreinterpretq_m128i_s32(vcvtnq_s32_f32(a)); - case _MM_ROUND_DOWN: - return vreinterpretq_m128i_s32(vcvtmq_s32_f32(a)); - case _MM_ROUND_UP: - return vreinterpretq_m128i_s32(vcvtpq_s32_f32(a)); - default: // _MM_ROUND_TOWARD_ZERO - return vreinterpretq_m128i_s32(vcvtq_s32_f32(a)); - } -#else - float *f = (float *) &a; - switch (_MM_GET_ROUNDING_MODE()) { - case _MM_ROUND_NEAREST: { - uint32x4_t signmask = vdupq_n_u32(0x80000000); - float32x4_t half = vbslq_f32(signmask, vreinterpretq_f32_m128(a), - vdupq_n_f32(0.5f)); /* +/- 0.5 */ - int32x4_t r_normal = vcvtq_s32_f32(vaddq_f32( - vreinterpretq_f32_m128(a), half)); /* round to integer: [a + 0.5]*/ - int32x4_t r_trunc = vcvtq_s32_f32( - vreinterpretq_f32_m128(a)); /* truncate to integer: [a] */ - int32x4_t plusone = vreinterpretq_s32_u32(vshrq_n_u32( - vreinterpretq_u32_s32(vnegq_s32(r_trunc)), 31)); /* 1 or 0 */ - int32x4_t r_even = vbicq_s32(vaddq_s32(r_trunc, plusone), - vdupq_n_s32(1)); /* ([a] + {0,1}) & ~1 */ - float32x4_t delta = vsubq_f32( - vreinterpretq_f32_m128(a), - vcvtq_f32_s32(r_trunc)); /* compute delta: delta = (a - [a]) */ - uint32x4_t is_delta_half = - vceqq_f32(delta, half); /* delta == +/- 0.5 */ - return vreinterpretq_m128i_s32( - vbslq_s32(is_delta_half, r_even, r_normal)); - } - case _MM_ROUND_DOWN: - return _mm_set_epi32(floorf(f[3]), floorf(f[2]), floorf(f[1]), - floorf(f[0])); - case _MM_ROUND_UP: - return _mm_set_epi32(ceilf(f[3]), ceilf(f[2]), ceilf(f[1]), - ceilf(f[0])); - default: // _MM_ROUND_TOWARD_ZERO - return _mm_set_epi32((int32_t) f[3], (int32_t) f[2], (int32_t) f[1], - (int32_t) f[0]); - } -#endif -} - -// Convert packed single-precision (32-bit) floating-point elements in a to -// packed double-precision (64-bit) floating-point elements, and store the -// results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtps_pd -FORCE_INLINE __m128d _mm_cvtps_pd(__m128 a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vcvt_f64_f32(vget_low_f32(vreinterpretq_f32_m128(a)))); -#else - double a0 = (double) vgetq_lane_f32(vreinterpretq_f32_m128(a), 0); - double a1 = (double) vgetq_lane_f32(vreinterpretq_f32_m128(a), 1); - return _mm_set_pd(a1, a0); -#endif -} - -// Copy the lower double-precision (64-bit) floating-point element of a to dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsd_f64 -FORCE_INLINE double _mm_cvtsd_f64(__m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return (double) vgetq_lane_f64(vreinterpretq_f64_m128d(a), 0); -#else - double _a = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - return _a; -#endif -} - -// Convert the lower double-precision (64-bit) floating-point element in a to a -// 32-bit integer, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsd_si32 -FORCE_INLINE int32_t _mm_cvtsd_si32(__m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return (int32_t) vgetq_lane_f64(vrndiq_f64(vreinterpretq_f64_m128d(a)), 0); -#else - __m128d rnd = _mm_round_pd(a, _MM_FROUND_CUR_DIRECTION); - double ret = sse2neon_recast_u64_f64( - vgetq_lane_u64(vreinterpretq_u64_m128d(rnd), 0)); - return (int32_t) ret; -#endif -} - -// Convert the lower double-precision (64-bit) floating-point element in a to a -// 64-bit integer, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsd_si64 -FORCE_INLINE int64_t _mm_cvtsd_si64(__m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return (int64_t) vgetq_lane_f64(vrndiq_f64(vreinterpretq_f64_m128d(a)), 0); -#else - __m128d rnd = _mm_round_pd(a, _MM_FROUND_CUR_DIRECTION); - double ret = sse2neon_recast_u64_f64( - vgetq_lane_u64(vreinterpretq_u64_m128d(rnd), 0)); - return (int64_t) ret; -#endif -} - -// Convert the lower double-precision (64-bit) floating-point element in a to a -// 64-bit integer, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsd_si64x -#define _mm_cvtsd_si64x _mm_cvtsd_si64 - -// Convert the lower double-precision (64-bit) floating-point element in b to a -// single-precision (32-bit) floating-point element, store the result in the -// lower element of dst, and copy the upper 3 packed elements from a to the -// upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsd_ss -FORCE_INLINE __m128 _mm_cvtsd_ss(__m128 a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128_f32(vsetq_lane_f32( - vget_lane_f32(vcvt_f32_f64(vreinterpretq_f64_m128d(b)), 0), - vreinterpretq_f32_m128(a), 0)); -#else - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - return vreinterpretq_m128_f32( - vsetq_lane_f32((float) b0, vreinterpretq_f32_m128(a), 0)); -#endif -} - -// Copy the lower 32-bit integer in a to dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi128_si32 -FORCE_INLINE int _mm_cvtsi128_si32(__m128i a) -{ - return vgetq_lane_s32(vreinterpretq_s32_m128i(a), 0); -} - -// Copy the lower 64-bit integer in a to dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi128_si64 -FORCE_INLINE int64_t _mm_cvtsi128_si64(__m128i a) -{ - return vgetq_lane_s64(vreinterpretq_s64_m128i(a), 0); -} - -// Copy the lower 64-bit integer in a to dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi128_si64x -#define _mm_cvtsi128_si64x(a) _mm_cvtsi128_si64(a) - -// Convert the signed 32-bit integer b to a double-precision (64-bit) -// floating-point element, store the result in the lower element of dst, and -// copy the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi32_sd -FORCE_INLINE __m128d _mm_cvtsi32_sd(__m128d a, int32_t b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vsetq_lane_f64((double) b, vreinterpretq_f64_m128d(a), 0)); -#else - int64_t _b = sse2neon_recast_f64_s64((double) b); - return vreinterpretq_m128d_s64( - vsetq_lane_s64(_b, vreinterpretq_s64_m128d(a), 0)); -#endif -} - -// Copy the lower 64-bit integer in a to dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi128_si64x -#define _mm_cvtsi128_si64x(a) _mm_cvtsi128_si64(a) - -// Copy 32-bit integer a to the lower elements of dst, and zero the upper -// elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi32_si128 -FORCE_INLINE __m128i _mm_cvtsi32_si128(int a) -{ - return vreinterpretq_m128i_s32(vsetq_lane_s32(a, vdupq_n_s32(0), 0)); -} - -// Convert the signed 64-bit integer b to a double-precision (64-bit) -// floating-point element, store the result in the lower element of dst, and -// copy the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi64_sd -FORCE_INLINE __m128d _mm_cvtsi64_sd(__m128d a, int64_t b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vsetq_lane_f64((double) b, vreinterpretq_f64_m128d(a), 0)); -#else - int64_t _b = sse2neon_recast_f64_s64((double) b); - return vreinterpretq_m128d_s64( - vsetq_lane_s64(_b, vreinterpretq_s64_m128d(a), 0)); -#endif -} - -// Copy 64-bit integer a to the lower element of dst, and zero the upper -// element. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi64_si128 -FORCE_INLINE __m128i _mm_cvtsi64_si128(int64_t a) -{ - return vreinterpretq_m128i_s64(vsetq_lane_s64(a, vdupq_n_s64(0), 0)); -} - -// Copy 64-bit integer a to the lower element of dst, and zero the upper -// element. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi64x_si128 -#define _mm_cvtsi64x_si128(a) _mm_cvtsi64_si128(a) - -// Convert the signed 64-bit integer b to a double-precision (64-bit) -// floating-point element, store the result in the lower element of dst, and -// copy the upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtsi64x_sd -#define _mm_cvtsi64x_sd(a, b) _mm_cvtsi64_sd(a, b) - -// Convert the lower single-precision (32-bit) floating-point element in b to a -// double-precision (64-bit) floating-point element, store the result in the -// lower element of dst, and copy the upper element from a to the upper element -// of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtss_sd -FORCE_INLINE __m128d _mm_cvtss_sd(__m128d a, __m128 b) -{ - double d = (double) vgetq_lane_f32(vreinterpretq_f32_m128(b), 0); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vsetq_lane_f64(d, vreinterpretq_f64_m128d(a), 0)); -#else - return vreinterpretq_m128d_s64(vsetq_lane_s64( - sse2neon_recast_f64_s64(d), vreinterpretq_s64_m128d(a), 0)); -#endif -} - -// Convert packed double-precision (64-bit) floating-point elements in a to -// packed 32-bit integers with truncation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvttpd_epi32 -FORCE_INLINE __m128i _mm_cvttpd_epi32(__m128d a) -{ - double a0, a1; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - a1 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - return _mm_set_epi32(0, 0, (int32_t) a1, (int32_t) a0); -} - -// Convert packed double-precision (64-bit) floating-point elements in a to -// packed 32-bit integers with truncation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvttpd_pi32 -FORCE_INLINE __m64 _mm_cvttpd_pi32(__m128d a) -{ - double a0, a1; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - a1 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - int32_t ALIGN_STRUCT(16) data[2] = {(int32_t) a0, (int32_t) a1}; - return vreinterpret_m64_s32(vld1_s32(data)); -} - -// Convert packed single-precision (32-bit) floating-point elements in a to -// packed 32-bit integers with truncation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvttps_epi32 -FORCE_INLINE __m128i _mm_cvttps_epi32(__m128 a) -{ - return vreinterpretq_m128i_s32(vcvtq_s32_f32(vreinterpretq_f32_m128(a))); -} - -// Convert the lower double-precision (64-bit) floating-point element in a to a -// 32-bit integer with truncation, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvttsd_si32 -FORCE_INLINE int32_t _mm_cvttsd_si32(__m128d a) -{ - double _a = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - return (int32_t) _a; -} - -// Convert the lower double-precision (64-bit) floating-point element in a to a -// 64-bit integer with truncation, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvttsd_si64 -FORCE_INLINE int64_t _mm_cvttsd_si64(__m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vgetq_lane_s64(vcvtq_s64_f64(vreinterpretq_f64_m128d(a)), 0); -#else - double _a = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - return (int64_t) _a; -#endif -} - -// Convert the lower double-precision (64-bit) floating-point element in a to a -// 64-bit integer with truncation, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvttsd_si64x -#define _mm_cvttsd_si64x(a) _mm_cvttsd_si64(a) - -// Divide packed double-precision (64-bit) floating-point elements in a by -// packed elements in b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_div_pd -FORCE_INLINE __m128d _mm_div_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vdivq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - double c[2]; - c[0] = a0 / b0; - c[1] = a1 / b1; - return vld1q_f32((float32_t *) c); -#endif -} - -// Divide the lower double-precision (64-bit) floating-point element in a by the -// lower double-precision (64-bit) floating-point element in b, store the result -// in the lower element of dst, and copy the upper element from a to the upper -// element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_div_sd -FORCE_INLINE __m128d _mm_div_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - float64x2_t tmp = - vdivq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b)); - return vreinterpretq_m128d_f64( - vsetq_lane_f64(vgetq_lane_f64(vreinterpretq_f64_m128d(a), 1), tmp, 1)); -#else - return _mm_move_sd(a, _mm_div_pd(a, b)); -#endif -} - -// Extract a 16-bit integer from a, selected with imm8, and store the result in -// the lower element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_extract_epi16 -// FORCE_INLINE int _mm_extract_epi16(__m128i a, __constrange(0,8) int imm) -#define _mm_extract_epi16(a, imm) \ - vgetq_lane_u16(vreinterpretq_u16_m128i(a), (imm)) - -// Copy a to dst, and insert the 16-bit integer i into dst at the location -// specified by imm8. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_insert_epi16 -// FORCE_INLINE __m128i _mm_insert_epi16(__m128i a, int b, -// __constrange(0,8) int imm) -#define _mm_insert_epi16(a, b, imm) \ - vreinterpretq_m128i_s16( \ - vsetq_lane_s16((b), vreinterpretq_s16_m128i(a), (imm))) - -// Load 128-bits (composed of 2 packed double-precision (64-bit) floating-point -// elements) from memory into dst. mem_addr must be aligned on a 16-byte -// boundary or a general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_load_pd -FORCE_INLINE __m128d _mm_load_pd(const double *p) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vld1q_f64(p)); -#else - const float *fp = (const float *) p; - float ALIGN_STRUCT(16) data[4] = {fp[0], fp[1], fp[2], fp[3]}; - return vreinterpretq_m128d_f32(vld1q_f32(data)); -#endif -} - -// Load a double-precision (64-bit) floating-point element from memory into both -// elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_load_pd1 -#define _mm_load_pd1 _mm_load1_pd - -// Load a double-precision (64-bit) floating-point element from memory into the -// lower of dst, and zero the upper element. mem_addr does not need to be -// aligned on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_load_sd -FORCE_INLINE __m128d _mm_load_sd(const double *p) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vsetq_lane_f64(*p, vdupq_n_f64(0), 0)); -#else - const float *fp = (const float *) p; - float ALIGN_STRUCT(16) data[4] = {fp[0], fp[1], 0, 0}; - return vreinterpretq_m128d_f32(vld1q_f32(data)); -#endif -} - -// Load 128-bits of integer data from memory into dst. mem_addr must be aligned -// on a 16-byte boundary or a general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_load_si128 -FORCE_INLINE __m128i _mm_load_si128(const __m128i *p) -{ - return vreinterpretq_m128i_s32(vld1q_s32((const int32_t *) p)); -} - -// Load a double-precision (64-bit) floating-point element from memory into both -// elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_load1_pd -FORCE_INLINE __m128d _mm_load1_pd(const double *p) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vld1q_dup_f64(p)); -#else - return vreinterpretq_m128d_s64(vdupq_n_s64(*(const int64_t *) p)); -#endif -} - -// Load a double-precision (64-bit) floating-point element from memory into the -// upper element of dst, and copy the lower element from a to dst. mem_addr does -// not need to be aligned on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadh_pd -FORCE_INLINE __m128d _mm_loadh_pd(__m128d a, const double *p) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vcombine_f64(vget_low_f64(vreinterpretq_f64_m128d(a)), vld1_f64(p))); -#else - return vreinterpretq_m128d_f32(vcombine_f32( - vget_low_f32(vreinterpretq_f32_m128d(a)), vld1_f32((const float *) p))); -#endif -} - -// Load 64-bit integer from memory into the first element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadl_epi64 -FORCE_INLINE __m128i _mm_loadl_epi64(__m128i const *p) -{ - /* Load the lower 64 bits of the value pointed to by p into the - * lower 64 bits of the result, zeroing the upper 64 bits of the result. - */ - return vreinterpretq_m128i_s32( - vcombine_s32(vld1_s32((int32_t const *) p), vcreate_s32(0))); -} - -// Load a double-precision (64-bit) floating-point element from memory into the -// lower element of dst, and copy the upper element from a to dst. mem_addr does -// not need to be aligned on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadl_pd -FORCE_INLINE __m128d _mm_loadl_pd(__m128d a, const double *p) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vcombine_f64(vld1_f64(p), vget_high_f64(vreinterpretq_f64_m128d(a)))); -#else - return vreinterpretq_m128d_f32( - vcombine_f32(vld1_f32((const float *) p), - vget_high_f32(vreinterpretq_f32_m128d(a)))); -#endif -} - -// Load 2 double-precision (64-bit) floating-point elements from memory into dst -// in reverse order. mem_addr must be aligned on a 16-byte boundary or a -// general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadr_pd -FORCE_INLINE __m128d _mm_loadr_pd(const double *p) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - float64x2_t v = vld1q_f64(p); - return vreinterpretq_m128d_f64(vextq_f64(v, v, 1)); -#else - int64x2_t v = vld1q_s64((const int64_t *) p); - return vreinterpretq_m128d_s64(vextq_s64(v, v, 1)); -#endif -} - -// Loads two double-precision from unaligned memory, floating-point values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadu_pd -FORCE_INLINE __m128d _mm_loadu_pd(const double *p) -{ - return _mm_load_pd(p); -} - -// Load 128-bits of integer data from memory into dst. mem_addr does not need to -// be aligned on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadu_si128 -FORCE_INLINE __m128i _mm_loadu_si128(const __m128i *p) -{ - return vreinterpretq_m128i_s32(vld1q_s32((const unaligned_int32_t *) p)); -} - -// Load unaligned 32-bit integer from memory into the first element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loadu_si32 -FORCE_INLINE __m128i _mm_loadu_si32(const void *p) -{ - return vreinterpretq_m128i_s32( - vsetq_lane_s32(*(const unaligned_int32_t *) p, vdupq_n_s32(0), 0)); -} - -// Multiply packed signed 16-bit integers in a and b, producing intermediate -// signed 32-bit integers. Horizontally add adjacent pairs of intermediate -// 32-bit integers, and pack the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_madd_epi16 -FORCE_INLINE __m128i _mm_madd_epi16(__m128i a, __m128i b) -{ - int32x4_t low = vmull_s16(vget_low_s16(vreinterpretq_s16_m128i(a)), - vget_low_s16(vreinterpretq_s16_m128i(b))); -#if defined(__aarch64__) || defined(_M_ARM64) - int32x4_t high = - vmull_high_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b)); - - return vreinterpretq_m128i_s32(vpaddq_s32(low, high)); -#else - int32x4_t high = vmull_s16(vget_high_s16(vreinterpretq_s16_m128i(a)), - vget_high_s16(vreinterpretq_s16_m128i(b))); - - int32x2_t low_sum = vpadd_s32(vget_low_s32(low), vget_high_s32(low)); - int32x2_t high_sum = vpadd_s32(vget_low_s32(high), vget_high_s32(high)); - - return vreinterpretq_m128i_s32(vcombine_s32(low_sum, high_sum)); -#endif -} - -// Conditionally store 8-bit integer elements from a into memory using mask -// (elements are not stored when the highest bit is not set in the corresponding -// element) and a non-temporal memory hint. mem_addr does not need to be aligned -// on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_maskmoveu_si128 -FORCE_INLINE void _mm_maskmoveu_si128(__m128i a, __m128i mask, char *mem_addr) -{ - int8x16_t shr_mask = vshrq_n_s8(vreinterpretq_s8_m128i(mask), 7); - __m128 b = _mm_load_ps((const float *) mem_addr); - int8x16_t masked = - vbslq_s8(vreinterpretq_u8_s8(shr_mask), vreinterpretq_s8_m128i(a), - vreinterpretq_s8_m128(b)); - vst1q_s8((int8_t *) mem_addr, masked); -} - -// Compare packed signed 16-bit integers in a and b, and store packed maximum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_epi16 -FORCE_INLINE __m128i _mm_max_epi16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s16( - vmaxq_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -} - -// Compare packed unsigned 8-bit integers in a and b, and store packed maximum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_epu8 -FORCE_INLINE __m128i _mm_max_epu8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u8( - vmaxq_u8(vreinterpretq_u8_m128i(a), vreinterpretq_u8_m128i(b))); -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b, -// and store packed maximum values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_pd -FORCE_INLINE __m128d _mm_max_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) -#if SSE2NEON_PRECISE_MINMAX - float64x2_t _a = vreinterpretq_f64_m128d(a); - float64x2_t _b = vreinterpretq_f64_m128d(b); - return vreinterpretq_m128d_f64(vbslq_f64(vcgtq_f64(_a, _b), _a, _b)); -#else - return vreinterpretq_m128d_f64( - vmaxq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#endif -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - int64_t d[2]; - d[0] = a0 > b0 ? sse2neon_recast_f64_s64(a0) : sse2neon_recast_f64_s64(b0); - d[1] = a1 > b1 ? sse2neon_recast_f64_s64(a1) : sse2neon_recast_f64_s64(b1); - - return vreinterpretq_m128d_s64(vld1q_s64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b, store the maximum value in the lower element of dst, and copy the upper -// element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_sd -FORCE_INLINE __m128d _mm_max_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return _mm_move_sd(a, _mm_max_pd(a, b)); -#else - double a0, a1, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - a1 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double c[2] = {a0 > b0 ? a0 : b0, a1}; - return vreinterpretq_m128d_f32(vld1q_f32((float32_t *) c)); -#endif -} - -// Compare packed signed 16-bit integers in a and b, and store packed minimum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_min_epi16 -FORCE_INLINE __m128i _mm_min_epi16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s16( - vminq_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -} - -// Compare packed unsigned 8-bit integers in a and b, and store packed minimum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_min_epu8 -FORCE_INLINE __m128i _mm_min_epu8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u8( - vminq_u8(vreinterpretq_u8_m128i(a), vreinterpretq_u8_m128i(b))); -} - -// Compare packed double-precision (64-bit) floating-point elements in a and b, -// and store packed minimum values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_min_pd -FORCE_INLINE __m128d _mm_min_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) -#if SSE2NEON_PRECISE_MINMAX - float64x2_t _a = vreinterpretq_f64_m128d(a); - float64x2_t _b = vreinterpretq_f64_m128d(b); - return vreinterpretq_m128d_f64(vbslq_f64(vcltq_f64(_a, _b), _a, _b)); -#else - return vreinterpretq_m128d_f64( - vminq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#endif -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - int64_t d[2]; - d[0] = a0 < b0 ? sse2neon_recast_f64_s64(a0) : sse2neon_recast_f64_s64(b0); - d[1] = a1 < b1 ? sse2neon_recast_f64_s64(a1) : sse2neon_recast_f64_s64(b1); - return vreinterpretq_m128d_s64(vld1q_s64(d)); -#endif -} - -// Compare the lower double-precision (64-bit) floating-point elements in a and -// b, store the minimum value in the lower element of dst, and copy the upper -// element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_min_sd -FORCE_INLINE __m128d _mm_min_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return _mm_move_sd(a, _mm_min_pd(a, b)); -#else - double a0, a1, b0; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - a1 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - b0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double c[2] = {a0 < b0 ? a0 : b0, a1}; - return vreinterpretq_m128d_f32(vld1q_f32((float32_t *) c)); -#endif -} - -// Copy the lower 64-bit integer in a to the lower element of dst, and zero the -// upper element. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_move_epi64 -FORCE_INLINE __m128i _mm_move_epi64(__m128i a) -{ - return vreinterpretq_m128i_s64( - vsetq_lane_s64(0, vreinterpretq_s64_m128i(a), 1)); -} - -// Move the lower double-precision (64-bit) floating-point element from b to the -// lower element of dst, and copy the upper element from a to the upper element -// of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_move_sd -FORCE_INLINE __m128d _mm_move_sd(__m128d a, __m128d b) -{ - return vreinterpretq_m128d_f32( - vcombine_f32(vget_low_f32(vreinterpretq_f32_m128d(b)), - vget_high_f32(vreinterpretq_f32_m128d(a)))); -} - -// Create mask from the most significant bit of each 8-bit element in a, and -// store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_movemask_epi8 -FORCE_INLINE int _mm_movemask_epi8(__m128i a) -{ - // Use increasingly wide shifts+adds to collect the sign bits - // together. - // Since the widening shifts would be rather confusing to follow in little - // endian, everything will be illustrated in big endian order instead. This - // has a different result - the bits would actually be reversed on a big - // endian machine. - - // Starting input (only half the elements are shown): - // 89 ff 1d c0 00 10 99 33 - uint8x16_t input = vreinterpretq_u8_m128i(a); - - // Shift out everything but the sign bits with an unsigned shift right. - // - // Bytes of the vector:: - // 89 ff 1d c0 00 10 99 33 - // \ \ \ \ \ \ \ \ high_bits = (uint16x4_t)(input >> 7) - // | | | | | | | | - // 01 01 00 01 00 00 01 00 - // - // Bits of first important lane(s): - // 10001001 (89) - // \______ - // | - // 00000001 (01) - uint16x8_t high_bits = vreinterpretq_u16_u8(vshrq_n_u8(input, 7)); - - // Merge the even lanes together with a 16-bit unsigned shift right + add. - // 'xx' represents garbage data which will be ignored in the final result. - // In the important bytes, the add functions like a binary OR. - // - // 01 01 00 01 00 00 01 00 - // \_ | \_ | \_ | \_ | paired16 = (uint32x4_t)(input + (input >> 7)) - // \| \| \| \| - // xx 03 xx 01 xx 00 xx 02 - // - // 00000001 00000001 (01 01) - // \_______ | - // \| - // xxxxxxxx xxxxxx11 (xx 03) - uint32x4_t paired16 = - vreinterpretq_u32_u16(vsraq_n_u16(high_bits, high_bits, 7)); - - // Repeat with a wider 32-bit shift + add. - // xx 03 xx 01 xx 00 xx 02 - // \____ | \____ | paired32 = (uint64x1_t)(paired16 + (paired16 >> - // 14)) - // \| \| - // xx xx xx 0d xx xx xx 02 - // - // 00000011 00000001 (03 01) - // \\_____ || - // '----.\|| - // xxxxxxxx xxxx1101 (xx 0d) - uint64x2_t paired32 = - vreinterpretq_u64_u32(vsraq_n_u32(paired16, paired16, 14)); - - // Last, an even wider 64-bit shift + add to get our result in the low 8 bit - // lanes. xx xx xx 0d xx xx xx 02 - // \_________ | paired64 = (uint8x8_t)(paired32 + (paired32 >> - // 28)) - // \| - // xx xx xx xx xx xx xx d2 - // - // 00001101 00000010 (0d 02) - // \ \___ | | - // '---. \| | - // xxxxxxxx 11010010 (xx d2) - uint8x16_t paired64 = - vreinterpretq_u8_u64(vsraq_n_u64(paired32, paired32, 28)); - - // Extract the low 8 bits from each 64-bit lane with 2 8-bit extracts. - // xx xx xx xx xx xx xx d2 - // || return paired64[0] - // d2 - // Note: Little endian would return the correct value 4b (01001011) instead. - return vgetq_lane_u8(paired64, 0) | ((int) vgetq_lane_u8(paired64, 8) << 8); -} - -// Set each bit of mask dst based on the most significant bit of the -// corresponding packed double-precision (64-bit) floating-point element in a. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_movemask_pd -FORCE_INLINE int _mm_movemask_pd(__m128d a) -{ - uint64x2_t input = vreinterpretq_u64_m128d(a); - uint64x2_t high_bits = vshrq_n_u64(input, 63); - return (int) (vgetq_lane_u64(high_bits, 0) | - (vgetq_lane_u64(high_bits, 1) << 1)); -} - -// Copy the lower 64-bit integer in a to dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_movepi64_pi64 -FORCE_INLINE __m64 _mm_movepi64_pi64(__m128i a) -{ - return vreinterpret_m64_s64(vget_low_s64(vreinterpretq_s64_m128i(a))); -} - -// Copy the 64-bit integer a to the lower element of dst, and zero the upper -// element. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_movpi64_epi64 -FORCE_INLINE __m128i _mm_movpi64_epi64(__m64 a) -{ - return vreinterpretq_m128i_s64( - vcombine_s64(vreinterpret_s64_m64(a), vdup_n_s64(0))); -} - -// Multiply the low unsigned 32-bit integers from each packed 64-bit element in -// a and b, and store the unsigned 64-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mul_epu32 -FORCE_INLINE __m128i _mm_mul_epu32(__m128i a, __m128i b) -{ - // vmull_u32 upcasts instead of masking, so we downcast. - uint32x2_t a_lo = vmovn_u64(vreinterpretq_u64_m128i(a)); - uint32x2_t b_lo = vmovn_u64(vreinterpretq_u64_m128i(b)); - return vreinterpretq_m128i_u64(vmull_u32(a_lo, b_lo)); -} - -// Multiply packed double-precision (64-bit) floating-point elements in a and b, -// and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mul_pd -FORCE_INLINE __m128d _mm_mul_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vmulq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - double c[2]; - c[0] = a0 * b0; - c[1] = a1 * b1; - return vld1q_f32((float32_t *) c); -#endif -} - -// Multiply the lower double-precision (64-bit) floating-point element in a and -// b, store the result in the lower element of dst, and copy the upper element -// from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=mm_mul_sd -FORCE_INLINE __m128d _mm_mul_sd(__m128d a, __m128d b) -{ - return _mm_move_sd(a, _mm_mul_pd(a, b)); -} - -// Multiply the low unsigned 32-bit integers from a and b, and store the -// unsigned 64-bit result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mul_su32 -FORCE_INLINE __m64 _mm_mul_su32(__m64 a, __m64 b) -{ - return vreinterpret_m64_u64(vget_low_u64( - vmull_u32(vreinterpret_u32_m64(a), vreinterpret_u32_m64(b)))); -} - -// Multiply the packed signed 16-bit integers in a and b, producing intermediate -// 32-bit integers, and store the high 16 bits of the intermediate integers in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mulhi_epi16 -FORCE_INLINE __m128i _mm_mulhi_epi16(__m128i a, __m128i b) -{ - /* FIXME: issue with large values because of result saturation */ - // int16x8_t ret = vqdmulhq_s16(vreinterpretq_s16_m128i(a), - // vreinterpretq_s16_m128i(b)); /* =2*a*b */ return - // vreinterpretq_m128i_s16(vshrq_n_s16(ret, 1)); - int16x4_t a3210 = vget_low_s16(vreinterpretq_s16_m128i(a)); - int16x4_t b3210 = vget_low_s16(vreinterpretq_s16_m128i(b)); - int32x4_t ab3210 = vmull_s16(a3210, b3210); /* 3333222211110000 */ - int16x4_t a7654 = vget_high_s16(vreinterpretq_s16_m128i(a)); - int16x4_t b7654 = vget_high_s16(vreinterpretq_s16_m128i(b)); - int32x4_t ab7654 = vmull_s16(a7654, b7654); /* 7777666655554444 */ - uint16x8x2_t r = - vuzpq_u16(vreinterpretq_u16_s32(ab3210), vreinterpretq_u16_s32(ab7654)); - return vreinterpretq_m128i_u16(r.val[1]); -} - -// Multiply the packed unsigned 16-bit integers in a and b, producing -// intermediate 32-bit integers, and store the high 16 bits of the intermediate -// integers in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mulhi_epu16 -FORCE_INLINE __m128i _mm_mulhi_epu16(__m128i a, __m128i b) -{ - uint16x4_t a3210 = vget_low_u16(vreinterpretq_u16_m128i(a)); - uint16x4_t b3210 = vget_low_u16(vreinterpretq_u16_m128i(b)); - uint32x4_t ab3210 = vmull_u16(a3210, b3210); -#if defined(__aarch64__) || defined(_M_ARM64) - uint32x4_t ab7654 = - vmull_high_u16(vreinterpretq_u16_m128i(a), vreinterpretq_u16_m128i(b)); - uint16x8_t r = vuzp2q_u16(vreinterpretq_u16_u32(ab3210), - vreinterpretq_u16_u32(ab7654)); - return vreinterpretq_m128i_u16(r); -#else - uint16x4_t a7654 = vget_high_u16(vreinterpretq_u16_m128i(a)); - uint16x4_t b7654 = vget_high_u16(vreinterpretq_u16_m128i(b)); - uint32x4_t ab7654 = vmull_u16(a7654, b7654); - uint16x8x2_t r = - vuzpq_u16(vreinterpretq_u16_u32(ab3210), vreinterpretq_u16_u32(ab7654)); - return vreinterpretq_m128i_u16(r.val[1]); -#endif -} - -// Multiply the packed 16-bit integers in a and b, producing intermediate 32-bit -// integers, and store the low 16 bits of the intermediate integers in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mullo_epi16 -FORCE_INLINE __m128i _mm_mullo_epi16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s16( - vmulq_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -} - -// Compute the bitwise OR of packed double-precision (64-bit) floating-point -// elements in a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=mm_or_pd -FORCE_INLINE __m128d _mm_or_pd(__m128d a, __m128d b) -{ - return vreinterpretq_m128d_s64( - vorrq_s64(vreinterpretq_s64_m128d(a), vreinterpretq_s64_m128d(b))); -} - -// Compute the bitwise OR of 128 bits (representing integer data) in a and b, -// and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_or_si128 -FORCE_INLINE __m128i _mm_or_si128(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s32( - vorrq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -} - -// Convert packed signed 16-bit integers from a and b to packed 8-bit integers -// using signed saturation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_packs_epi16 -FORCE_INLINE __m128i _mm_packs_epi16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s8( - vcombine_s8(vqmovn_s16(vreinterpretq_s16_m128i(a)), - vqmovn_s16(vreinterpretq_s16_m128i(b)))); -} - -// Convert packed signed 32-bit integers from a and b to packed 16-bit integers -// using signed saturation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_packs_epi32 -FORCE_INLINE __m128i _mm_packs_epi32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s16( - vcombine_s16(vqmovn_s32(vreinterpretq_s32_m128i(a)), - vqmovn_s32(vreinterpretq_s32_m128i(b)))); -} - -// Convert packed signed 16-bit integers from a and b to packed 8-bit integers -// using unsigned saturation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_packus_epi16 -FORCE_INLINE __m128i _mm_packus_epi16(const __m128i a, const __m128i b) -{ - return vreinterpretq_m128i_u8( - vcombine_u8(vqmovun_s16(vreinterpretq_s16_m128i(a)), - vqmovun_s16(vreinterpretq_s16_m128i(b)))); -} - -// Pause the processor. This is typically used in spin-wait loops and depending -// on the x86 processor typical values are in the 40-100 cycle range. The -// 'yield' instruction isn't a good fit because it's effectively a nop on most -// Arm cores. Experience with several databases has shown has shown an 'isb' is -// a reasonable approximation. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_pause -FORCE_INLINE void _mm_pause(void) -{ -#if defined(_MSC_VER) && !defined(__clang__) - __isb(_ARM64_BARRIER_SY); -#else - __asm__ __volatile__("isb\n"); -#endif -} - -// Compute the absolute differences of packed unsigned 8-bit integers in a and -// b, then horizontally sum each consecutive 8 differences to produce two -// unsigned 16-bit integers, and pack these unsigned 16-bit integers in the low -// 16 bits of 64-bit elements in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sad_epu8 -FORCE_INLINE __m128i _mm_sad_epu8(__m128i a, __m128i b) -{ - uint16x8_t t = vpaddlq_u8(vabdq_u8((uint8x16_t) a, (uint8x16_t) b)); - return vreinterpretq_m128i_u64(vpaddlq_u32(vpaddlq_u16(t))); -} - -// Set packed 16-bit integers in dst with the supplied values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set_epi16 -FORCE_INLINE __m128i _mm_set_epi16(short i7, - short i6, - short i5, - short i4, - short i3, - short i2, - short i1, - short i0) -{ - int16_t ALIGN_STRUCT(16) data[8] = {i0, i1, i2, i3, i4, i5, i6, i7}; - return vreinterpretq_m128i_s16(vld1q_s16(data)); -} - -// Set packed 32-bit integers in dst with the supplied values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set_epi32 -FORCE_INLINE __m128i _mm_set_epi32(int i3, int i2, int i1, int i0) -{ - int32_t ALIGN_STRUCT(16) data[4] = {i0, i1, i2, i3}; - return vreinterpretq_m128i_s32(vld1q_s32(data)); -} - -// Set packed 64-bit integers in dst with the supplied values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set_epi64 -FORCE_INLINE __m128i _mm_set_epi64(__m64 i1, __m64 i2) -{ - return _mm_set_epi64x(vget_lane_s64(i1, 0), vget_lane_s64(i2, 0)); -} - -// Set packed 64-bit integers in dst with the supplied values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set_epi64x -FORCE_INLINE __m128i _mm_set_epi64x(int64_t i1, int64_t i2) -{ - return vreinterpretq_m128i_s64( - vcombine_s64(vcreate_s64(i2), vcreate_s64(i1))); -} - -// Set packed 8-bit integers in dst with the supplied values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set_epi8 -FORCE_INLINE __m128i _mm_set_epi8(signed char b15, - signed char b14, - signed char b13, - signed char b12, - signed char b11, - signed char b10, - signed char b9, - signed char b8, - signed char b7, - signed char b6, - signed char b5, - signed char b4, - signed char b3, - signed char b2, - signed char b1, - signed char b0) -{ - int8_t ALIGN_STRUCT(16) data[16] = { - (int8_t) b0, (int8_t) b1, (int8_t) b2, (int8_t) b3, - (int8_t) b4, (int8_t) b5, (int8_t) b6, (int8_t) b7, - (int8_t) b8, (int8_t) b9, (int8_t) b10, (int8_t) b11, - (int8_t) b12, (int8_t) b13, (int8_t) b14, (int8_t) b15}; - return (__m128i) vld1q_s8(data); -} - -// Set packed double-precision (64-bit) floating-point elements in dst with the -// supplied values. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set_pd -FORCE_INLINE __m128d _mm_set_pd(double e1, double e0) -{ - double ALIGN_STRUCT(16) data[2] = {e0, e1}; -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vld1q_f64((float64_t *) data)); -#else - return vreinterpretq_m128d_f32(vld1q_f32((float32_t *) data)); -#endif -} - -// Broadcast double-precision (64-bit) floating-point value a to all elements of -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set_pd1 -#define _mm_set_pd1 _mm_set1_pd - -// Copy double-precision (64-bit) floating-point element a to the lower element -// of dst, and zero the upper element. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set_sd -FORCE_INLINE __m128d _mm_set_sd(double a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vsetq_lane_f64(a, vdupq_n_f64(0), 0)); -#else - return _mm_set_pd(0, a); -#endif -} - -// Broadcast 16-bit integer a to all elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set1_epi16 -FORCE_INLINE __m128i _mm_set1_epi16(short w) -{ - return vreinterpretq_m128i_s16(vdupq_n_s16(w)); -} - -// Broadcast 32-bit integer a to all elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set1_epi32 -FORCE_INLINE __m128i _mm_set1_epi32(int _i) -{ - return vreinterpretq_m128i_s32(vdupq_n_s32(_i)); -} - -// Broadcast 64-bit integer a to all elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set1_epi64 -FORCE_INLINE __m128i _mm_set1_epi64(__m64 _i) -{ - return vreinterpretq_m128i_s64(vdupq_lane_s64(_i, 0)); -} - -// Broadcast 64-bit integer a to all elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set1_epi64x -FORCE_INLINE __m128i _mm_set1_epi64x(int64_t _i) -{ - return vreinterpretq_m128i_s64(vdupq_n_s64(_i)); -} - -// Broadcast 8-bit integer a to all elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set1_epi8 -FORCE_INLINE __m128i _mm_set1_epi8(signed char w) -{ - return vreinterpretq_m128i_s8(vdupq_n_s8(w)); -} - -// Broadcast double-precision (64-bit) floating-point value a to all elements of -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_set1_pd -FORCE_INLINE __m128d _mm_set1_pd(double d) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vdupq_n_f64(d)); -#else - int64_t _d = sse2neon_recast_f64_s64(d); - return vreinterpretq_m128d_s64(vdupq_n_s64(_d)); -#endif -} - -// Set packed 16-bit integers in dst with the supplied values in reverse order. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_setr_epi16 -FORCE_INLINE __m128i _mm_setr_epi16(short w0, - short w1, - short w2, - short w3, - short w4, - short w5, - short w6, - short w7) -{ - int16_t ALIGN_STRUCT(16) data[8] = {w0, w1, w2, w3, w4, w5, w6, w7}; - return vreinterpretq_m128i_s16(vld1q_s16((int16_t *) data)); -} - -// Set packed 32-bit integers in dst with the supplied values in reverse order. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_setr_epi32 -FORCE_INLINE __m128i _mm_setr_epi32(int i3, int i2, int i1, int i0) -{ - int32_t ALIGN_STRUCT(16) data[4] = {i3, i2, i1, i0}; - return vreinterpretq_m128i_s32(vld1q_s32(data)); -} - -// Set packed 64-bit integers in dst with the supplied values in reverse order. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_setr_epi64 -FORCE_INLINE __m128i _mm_setr_epi64(__m64 e1, __m64 e0) -{ - return vreinterpretq_m128i_s64(vcombine_s64(e1, e0)); -} - -// Set packed 8-bit integers in dst with the supplied values in reverse order. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_setr_epi8 -FORCE_INLINE __m128i _mm_setr_epi8(signed char b0, - signed char b1, - signed char b2, - signed char b3, - signed char b4, - signed char b5, - signed char b6, - signed char b7, - signed char b8, - signed char b9, - signed char b10, - signed char b11, - signed char b12, - signed char b13, - signed char b14, - signed char b15) -{ - int8_t ALIGN_STRUCT(16) data[16] = { - (int8_t) b0, (int8_t) b1, (int8_t) b2, (int8_t) b3, - (int8_t) b4, (int8_t) b5, (int8_t) b6, (int8_t) b7, - (int8_t) b8, (int8_t) b9, (int8_t) b10, (int8_t) b11, - (int8_t) b12, (int8_t) b13, (int8_t) b14, (int8_t) b15}; - return (__m128i) vld1q_s8(data); -} - -// Set packed double-precision (64-bit) floating-point elements in dst with the -// supplied values in reverse order. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_setr_pd -FORCE_INLINE __m128d _mm_setr_pd(double e1, double e0) -{ - return _mm_set_pd(e0, e1); -} - -// Return vector of type __m128d with all elements set to zero. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_setzero_pd -FORCE_INLINE __m128d _mm_setzero_pd(void) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vdupq_n_f64(0)); -#else - return vreinterpretq_m128d_f32(vdupq_n_f32(0)); -#endif -} - -// Return vector of type __m128i with all elements set to zero. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_setzero_si128 -FORCE_INLINE __m128i _mm_setzero_si128(void) -{ - return vreinterpretq_m128i_s32(vdupq_n_s32(0)); -} - -// Shuffle 32-bit integers in a using the control in imm8, and store the results -// in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_shuffle_epi32 -// FORCE_INLINE __m128i _mm_shuffle_epi32(__m128i a, -// __constrange(0,255) int imm) -#if defined(_sse2neon_shuffle) -#define _mm_shuffle_epi32(a, imm) \ - __extension__({ \ - int32x4_t _input = vreinterpretq_s32_m128i(a); \ - int32x4_t _shuf = \ - vshuffleq_s32(_input, _input, (imm) & (0x3), ((imm) >> 2) & 0x3, \ - ((imm) >> 4) & 0x3, ((imm) >> 6) & 0x3); \ - vreinterpretq_m128i_s32(_shuf); \ - }) -#else // generic -#define _mm_shuffle_epi32(a, imm) \ - _sse2neon_define1( \ - __m128i, a, __m128i ret; switch (imm) { \ - case _MM_SHUFFLE(1, 0, 3, 2): \ - ret = _mm_shuffle_epi_1032(_a); \ - break; \ - case _MM_SHUFFLE(2, 3, 0, 1): \ - ret = _mm_shuffle_epi_2301(_a); \ - break; \ - case _MM_SHUFFLE(0, 3, 2, 1): \ - ret = _mm_shuffle_epi_0321(_a); \ - break; \ - case _MM_SHUFFLE(2, 1, 0, 3): \ - ret = _mm_shuffle_epi_2103(_a); \ - break; \ - case _MM_SHUFFLE(1, 0, 1, 0): \ - ret = _mm_shuffle_epi_1010(_a); \ - break; \ - case _MM_SHUFFLE(1, 0, 0, 1): \ - ret = _mm_shuffle_epi_1001(_a); \ - break; \ - case _MM_SHUFFLE(0, 1, 0, 1): \ - ret = _mm_shuffle_epi_0101(_a); \ - break; \ - case _MM_SHUFFLE(2, 2, 1, 1): \ - ret = _mm_shuffle_epi_2211(_a); \ - break; \ - case _MM_SHUFFLE(0, 1, 2, 2): \ - ret = _mm_shuffle_epi_0122(_a); \ - break; \ - case _MM_SHUFFLE(3, 3, 3, 2): \ - ret = _mm_shuffle_epi_3332(_a); \ - break; \ - case _MM_SHUFFLE(0, 0, 0, 0): \ - ret = _mm_shuffle_epi32_splat(_a, 0); \ - break; \ - case _MM_SHUFFLE(1, 1, 1, 1): \ - ret = _mm_shuffle_epi32_splat(_a, 1); \ - break; \ - case _MM_SHUFFLE(2, 2, 2, 2): \ - ret = _mm_shuffle_epi32_splat(_a, 2); \ - break; \ - case _MM_SHUFFLE(3, 3, 3, 3): \ - ret = _mm_shuffle_epi32_splat(_a, 3); \ - break; \ - default: \ - ret = _mm_shuffle_epi32_default(_a, (imm)); \ - break; \ - } _sse2neon_return(ret);) -#endif - -// Shuffle double-precision (64-bit) floating-point elements using the control -// in imm8, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_shuffle_pd -#ifdef _sse2neon_shuffle -#define _mm_shuffle_pd(a, b, imm8) \ - vreinterpretq_m128d_s64( \ - vshuffleq_s64(vreinterpretq_s64_m128d(a), vreinterpretq_s64_m128d(b), \ - imm8 & 0x1, ((imm8 & 0x2) >> 1) + 2)) -#else -#define _mm_shuffle_pd(a, b, imm8) \ - _mm_castsi128_pd(_mm_set_epi64x( \ - vgetq_lane_s64(vreinterpretq_s64_m128d(b), (imm8 & 0x2) >> 1), \ - vgetq_lane_s64(vreinterpretq_s64_m128d(a), imm8 & 0x1))) -#endif - -// FORCE_INLINE __m128i _mm_shufflehi_epi16(__m128i a, -// __constrange(0,255) int imm) -#if defined(_sse2neon_shuffle) -#define _mm_shufflehi_epi16(a, imm) \ - __extension__({ \ - int16x8_t _input = vreinterpretq_s16_m128i(a); \ - int16x8_t _shuf = \ - vshuffleq_s16(_input, _input, 0, 1, 2, 3, ((imm) & (0x3)) + 4, \ - (((imm) >> 2) & 0x3) + 4, (((imm) >> 4) & 0x3) + 4, \ - (((imm) >> 6) & 0x3) + 4); \ - vreinterpretq_m128i_s16(_shuf); \ - }) -#else // generic -#define _mm_shufflehi_epi16(a, imm) _mm_shufflehi_epi16_function((a), (imm)) -#endif - -// FORCE_INLINE __m128i _mm_shufflelo_epi16(__m128i a, -// __constrange(0,255) int imm) -#if defined(_sse2neon_shuffle) -#define _mm_shufflelo_epi16(a, imm) \ - __extension__({ \ - int16x8_t _input = vreinterpretq_s16_m128i(a); \ - int16x8_t _shuf = vshuffleq_s16( \ - _input, _input, ((imm) & (0x3)), (((imm) >> 2) & 0x3), \ - (((imm) >> 4) & 0x3), (((imm) >> 6) & 0x3), 4, 5, 6, 7); \ - vreinterpretq_m128i_s16(_shuf); \ - }) -#else // generic -#define _mm_shufflelo_epi16(a, imm) _mm_shufflelo_epi16_function((a), (imm)) -#endif - -// Shift packed 16-bit integers in a left by count while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sll_epi16 -FORCE_INLINE __m128i _mm_sll_epi16(__m128i a, __m128i count) -{ - uint64_t c = vreinterpretq_nth_u64_m128i(count, 0); - if (_sse2neon_unlikely(c & ~15)) - return _mm_setzero_si128(); - - int16x8_t vc = vdupq_n_s16((int16_t) c); - return vreinterpretq_m128i_s16(vshlq_s16(vreinterpretq_s16_m128i(a), vc)); -} - -// Shift packed 32-bit integers in a left by count while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sll_epi32 -FORCE_INLINE __m128i _mm_sll_epi32(__m128i a, __m128i count) -{ - uint64_t c = vreinterpretq_nth_u64_m128i(count, 0); - if (_sse2neon_unlikely(c & ~31)) - return _mm_setzero_si128(); - - int32x4_t vc = vdupq_n_s32((int32_t) c); - return vreinterpretq_m128i_s32(vshlq_s32(vreinterpretq_s32_m128i(a), vc)); -} - -// Shift packed 64-bit integers in a left by count while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sll_epi64 -FORCE_INLINE __m128i _mm_sll_epi64(__m128i a, __m128i count) -{ - uint64_t c = vreinterpretq_nth_u64_m128i(count, 0); - if (_sse2neon_unlikely(c & ~63)) - return _mm_setzero_si128(); - - int64x2_t vc = vdupq_n_s64((int64_t) c); - return vreinterpretq_m128i_s64(vshlq_s64(vreinterpretq_s64_m128i(a), vc)); -} - -// Shift packed 16-bit integers in a left by imm8 while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_slli_epi16 -FORCE_INLINE __m128i _mm_slli_epi16(__m128i a, int imm) -{ - if (_sse2neon_unlikely(imm & ~15)) - return _mm_setzero_si128(); - return vreinterpretq_m128i_s16( - vshlq_s16(vreinterpretq_s16_m128i(a), vdupq_n_s16(imm))); -} - -// Shift packed 32-bit integers in a left by imm8 while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_slli_epi32 -FORCE_INLINE __m128i _mm_slli_epi32(__m128i a, int imm) -{ - if (_sse2neon_unlikely(imm & ~31)) - return _mm_setzero_si128(); - return vreinterpretq_m128i_s32( - vshlq_s32(vreinterpretq_s32_m128i(a), vdupq_n_s32(imm))); -} - -// Shift packed 64-bit integers in a left by imm8 while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_slli_epi64 -FORCE_INLINE __m128i _mm_slli_epi64(__m128i a, int imm) -{ - if (_sse2neon_unlikely(imm & ~63)) - return _mm_setzero_si128(); - return vreinterpretq_m128i_s64( - vshlq_s64(vreinterpretq_s64_m128i(a), vdupq_n_s64(imm))); -} - -// Shift a left by imm8 bytes while shifting in zeros, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_slli_si128 -#define _mm_slli_si128(a, imm) \ - _sse2neon_define1( \ - __m128i, a, int8x16_t ret; \ - if (_sse2neon_unlikely(imm == 0)) ret = vreinterpretq_s8_m128i(_a); \ - else if (_sse2neon_unlikely((imm) & ~15)) ret = vdupq_n_s8(0); \ - else ret = vextq_s8(vdupq_n_s8(0), vreinterpretq_s8_m128i(_a), \ - ((imm <= 0 || imm > 15) ? 0 : (16 - imm))); \ - _sse2neon_return(vreinterpretq_m128i_s8(ret));) - -// Compute the square root of packed double-precision (64-bit) floating-point -// elements in a, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sqrt_pd -FORCE_INLINE __m128d _mm_sqrt_pd(__m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vsqrtq_f64(vreinterpretq_f64_m128d(a))); -#else - double a0, a1; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - a1 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double _a0 = sqrt(a0); - double _a1 = sqrt(a1); - return _mm_set_pd(_a1, _a0); -#endif -} - -// Compute the square root of the lower double-precision (64-bit) floating-point -// element in b, store the result in the lower element of dst, and copy the -// upper element from a to the upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sqrt_sd -FORCE_INLINE __m128d _mm_sqrt_sd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return _mm_move_sd(a, _mm_sqrt_pd(b)); -#else - double _a, _b; - _a = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - _b = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - return _mm_set_pd(_a, sqrt(_b)); -#endif -} - -// Shift packed 16-bit integers in a right by count while shifting in sign bits, -// and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sra_epi16 -FORCE_INLINE __m128i _mm_sra_epi16(__m128i a, __m128i count) -{ - int64_t c = vgetq_lane_s64(count, 0); - if (_sse2neon_unlikely(c & ~15)) - return _mm_cmplt_epi16(a, _mm_setzero_si128()); - return vreinterpretq_m128i_s16( - vshlq_s16((int16x8_t) a, vdupq_n_s16((int) -c))); -} - -// Shift packed 32-bit integers in a right by count while shifting in sign bits, -// and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sra_epi32 -FORCE_INLINE __m128i _mm_sra_epi32(__m128i a, __m128i count) -{ - int64_t c = vgetq_lane_s64(count, 0); - if (_sse2neon_unlikely(c & ~31)) - return _mm_cmplt_epi32(a, _mm_setzero_si128()); - return vreinterpretq_m128i_s32( - vshlq_s32((int32x4_t) a, vdupq_n_s32((int) -c))); -} - -// Shift packed 16-bit integers in a right by imm8 while shifting in sign -// bits, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_srai_epi16 -FORCE_INLINE __m128i _mm_srai_epi16(__m128i a, int imm) -{ - const int count = (imm & ~15) ? 15 : imm; - return (__m128i) vshlq_s16((int16x8_t) a, vdupq_n_s16(-count)); -} - -// Shift packed 32-bit integers in a right by imm8 while shifting in sign bits, -// and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_srai_epi32 -// FORCE_INLINE __m128i _mm_srai_epi32(__m128i a, __constrange(0,255) int imm) -#define _mm_srai_epi32(a, imm) \ - _sse2neon_define0( \ - __m128i, a, __m128i ret; if (_sse2neon_unlikely((imm) == 0)) { \ - ret = _a; \ - } else if (_sse2neon_likely(0 < (imm) && (imm) < 32)) { \ - ret = vreinterpretq_m128i_s32( \ - vshlq_s32(vreinterpretq_s32_m128i(_a), vdupq_n_s32(-(imm)))); \ - } else { \ - ret = vreinterpretq_m128i_s32( \ - vshrq_n_s32(vreinterpretq_s32_m128i(_a), 31)); \ - } _sse2neon_return(ret);) - -// Shift packed 16-bit integers in a right by count while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_srl_epi16 -FORCE_INLINE __m128i _mm_srl_epi16(__m128i a, __m128i count) -{ - uint64_t c = vreinterpretq_nth_u64_m128i(count, 0); - if (_sse2neon_unlikely(c & ~15)) - return _mm_setzero_si128(); - - int16x8_t vc = vdupq_n_s16(-(int16_t) c); - return vreinterpretq_m128i_u16(vshlq_u16(vreinterpretq_u16_m128i(a), vc)); -} - -// Shift packed 32-bit integers in a right by count while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_srl_epi32 -FORCE_INLINE __m128i _mm_srl_epi32(__m128i a, __m128i count) -{ - uint64_t c = vreinterpretq_nth_u64_m128i(count, 0); - if (_sse2neon_unlikely(c & ~31)) - return _mm_setzero_si128(); - - int32x4_t vc = vdupq_n_s32(-(int32_t) c); - return vreinterpretq_m128i_u32(vshlq_u32(vreinterpretq_u32_m128i(a), vc)); -} - -// Shift packed 64-bit integers in a right by count while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_srl_epi64 -FORCE_INLINE __m128i _mm_srl_epi64(__m128i a, __m128i count) -{ - uint64_t c = vreinterpretq_nth_u64_m128i(count, 0); - if (_sse2neon_unlikely(c & ~63)) - return _mm_setzero_si128(); - - int64x2_t vc = vdupq_n_s64(-(int64_t) c); - return vreinterpretq_m128i_u64(vshlq_u64(vreinterpretq_u64_m128i(a), vc)); -} - -// Shift packed 16-bit integers in a right by imm8 while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_srli_epi16 -#define _mm_srli_epi16(a, imm) \ - _sse2neon_define0( \ - __m128i, a, __m128i ret; if (_sse2neon_unlikely((imm) & ~15)) { \ - ret = _mm_setzero_si128(); \ - } else { \ - ret = vreinterpretq_m128i_u16( \ - vshlq_u16(vreinterpretq_u16_m128i(_a), vdupq_n_s16(-(imm)))); \ - } _sse2neon_return(ret);) - -// Shift packed 32-bit integers in a right by imm8 while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_srli_epi32 -// FORCE_INLINE __m128i _mm_srli_epi32(__m128i a, __constrange(0,255) int imm) -#define _mm_srli_epi32(a, imm) \ - _sse2neon_define0( \ - __m128i, a, __m128i ret; if (_sse2neon_unlikely((imm) & ~31)) { \ - ret = _mm_setzero_si128(); \ - } else { \ - ret = vreinterpretq_m128i_u32( \ - vshlq_u32(vreinterpretq_u32_m128i(_a), vdupq_n_s32(-(imm)))); \ - } _sse2neon_return(ret);) - -// Shift packed 64-bit integers in a right by imm8 while shifting in zeros, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_srli_epi64 -#define _mm_srli_epi64(a, imm) \ - _sse2neon_define0( \ - __m128i, a, __m128i ret; if (_sse2neon_unlikely((imm) & ~63)) { \ - ret = _mm_setzero_si128(); \ - } else { \ - ret = vreinterpretq_m128i_u64( \ - vshlq_u64(vreinterpretq_u64_m128i(_a), vdupq_n_s64(-(imm)))); \ - } _sse2neon_return(ret);) - -// Shift a right by imm8 bytes while shifting in zeros, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_srli_si128 -#define _mm_srli_si128(a, imm) \ - _sse2neon_define1( \ - __m128i, a, int8x16_t ret; \ - if (_sse2neon_unlikely((imm) & ~15)) ret = vdupq_n_s8(0); \ - else ret = vextq_s8(vreinterpretq_s8_m128i(_a), vdupq_n_s8(0), \ - (imm > 15 ? 0 : imm)); \ - _sse2neon_return(vreinterpretq_m128i_s8(ret));) - -// Store 128-bits (composed of 2 packed double-precision (64-bit) floating-point -// elements) from a into memory. mem_addr must be aligned on a 16-byte boundary -// or a general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_store_pd -FORCE_INLINE void _mm_store_pd(double *mem_addr, __m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - vst1q_f64((float64_t *) mem_addr, vreinterpretq_f64_m128d(a)); -#else - vst1q_f32((float32_t *) mem_addr, vreinterpretq_f32_m128d(a)); -#endif -} - -// Store the lower double-precision (64-bit) floating-point element from a into -// 2 contiguous elements in memory. mem_addr must be aligned on a 16-byte -// boundary or a general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_store_pd1 -FORCE_INLINE void _mm_store_pd1(double *mem_addr, __m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - float64x1_t a_low = vget_low_f64(vreinterpretq_f64_m128d(a)); - vst1q_f64((float64_t *) mem_addr, - vreinterpretq_f64_m128d(vcombine_f64(a_low, a_low))); -#else - float32x2_t a_low = vget_low_f32(vreinterpretq_f32_m128d(a)); - vst1q_f32((float32_t *) mem_addr, - vreinterpretq_f32_m128d(vcombine_f32(a_low, a_low))); -#endif -} - -// Store the lower double-precision (64-bit) floating-point element from a into -// memory. mem_addr does not need to be aligned on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=mm_store_sd -FORCE_INLINE void _mm_store_sd(double *mem_addr, __m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - vst1_f64((float64_t *) mem_addr, vget_low_f64(vreinterpretq_f64_m128d(a))); -#else - vst1_u64((uint64_t *) mem_addr, vget_low_u64(vreinterpretq_u64_m128d(a))); -#endif -} - -// Store 128-bits of integer data from a into memory. mem_addr must be aligned -// on a 16-byte boundary or a general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_store_si128 -FORCE_INLINE void _mm_store_si128(__m128i *p, __m128i a) -{ - vst1q_s32((int32_t *) p, vreinterpretq_s32_m128i(a)); -} - -// Store the lower double-precision (64-bit) floating-point element from a into -// 2 contiguous elements in memory. mem_addr must be aligned on a 16-byte -// boundary or a general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#expand=9,526,5601&text=_mm_store1_pd -#define _mm_store1_pd _mm_store_pd1 - -// Store the upper double-precision (64-bit) floating-point element from a into -// memory. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storeh_pd -FORCE_INLINE void _mm_storeh_pd(double *mem_addr, __m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - vst1_f64((float64_t *) mem_addr, vget_high_f64(vreinterpretq_f64_m128d(a))); -#else - vst1_f32((float32_t *) mem_addr, vget_high_f32(vreinterpretq_f32_m128d(a))); -#endif -} - -// Store 64-bit integer from the first element of a into memory. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storel_epi64 -FORCE_INLINE void _mm_storel_epi64(__m128i *a, __m128i b) -{ - vst1_u64((uint64_t *) a, vget_low_u64(vreinterpretq_u64_m128i(b))); -} - -// Store the lower double-precision (64-bit) floating-point element from a into -// memory. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storel_pd -FORCE_INLINE void _mm_storel_pd(double *mem_addr, __m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - vst1_f64((float64_t *) mem_addr, vget_low_f64(vreinterpretq_f64_m128d(a))); -#else - vst1_f32((float32_t *) mem_addr, vget_low_f32(vreinterpretq_f32_m128d(a))); -#endif -} - -// Store 2 double-precision (64-bit) floating-point elements from a into memory -// in reverse order. mem_addr must be aligned on a 16-byte boundary or a -// general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storer_pd -FORCE_INLINE void _mm_storer_pd(double *mem_addr, __m128d a) -{ - float32x4_t f = vreinterpretq_f32_m128d(a); - _mm_store_pd(mem_addr, vreinterpretq_m128d_f32(vextq_f32(f, f, 2))); -} - -// Store 128-bits (composed of 2 packed double-precision (64-bit) floating-point -// elements) from a into memory. mem_addr does not need to be aligned on any -// particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storeu_pd -FORCE_INLINE void _mm_storeu_pd(double *mem_addr, __m128d a) -{ - _mm_store_pd(mem_addr, a); -} - -// Store 128-bits of integer data from a into memory. mem_addr does not need to -// be aligned on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storeu_si128 -FORCE_INLINE void _mm_storeu_si128(__m128i *p, __m128i a) -{ - vst1q_s32((int32_t *) p, vreinterpretq_s32_m128i(a)); -} - -// Store 32-bit integer from the first element of a into memory. mem_addr does -// not need to be aligned on any particular boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_storeu_si32 -FORCE_INLINE void _mm_storeu_si32(void *p, __m128i a) -{ - vst1q_lane_s32((int32_t *) p, vreinterpretq_s32_m128i(a), 0); -} - -// Store 128-bits (composed of 2 packed double-precision (64-bit) floating-point -// elements) from a into memory using a non-temporal memory hint. mem_addr must -// be aligned on a 16-byte boundary or a general-protection exception may be -// generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_stream_pd -FORCE_INLINE void _mm_stream_pd(double *p, __m128d a) -{ -#if __has_builtin(__builtin_nontemporal_store) - __builtin_nontemporal_store(a, (__m128d *) p); -#elif defined(__aarch64__) || defined(_M_ARM64) - vst1q_f64(p, vreinterpretq_f64_m128d(a)); -#else - vst1q_s64((int64_t *) p, vreinterpretq_s64_m128d(a)); -#endif -} - -// Store 128-bits of integer data from a into memory using a non-temporal memory -// hint. mem_addr must be aligned on a 16-byte boundary or a general-protection -// exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_stream_si128 -FORCE_INLINE void _mm_stream_si128(__m128i *p, __m128i a) -{ -#if __has_builtin(__builtin_nontemporal_store) - __builtin_nontemporal_store(a, p); -#else - vst1q_s64((int64_t *) p, vreinterpretq_s64_m128i(a)); -#endif -} - -// Store 32-bit integer a into memory using a non-temporal hint to minimize -// cache pollution. If the cache line containing address mem_addr is already in -// the cache, the cache will be updated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_stream_si32 -FORCE_INLINE void _mm_stream_si32(int *p, int a) -{ - vst1q_lane_s32((int32_t *) p, vdupq_n_s32(a), 0); -} - -// Store 64-bit integer a into memory using a non-temporal hint to minimize -// cache pollution. If the cache line containing address mem_addr is already in -// the cache, the cache will be updated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_stream_si64 -FORCE_INLINE void _mm_stream_si64(__int64 *p, __int64 a) -{ - vst1_s64((int64_t *) p, vdup_n_s64((int64_t) a)); -} - -// Subtract packed 16-bit integers in b from packed 16-bit integers in a, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sub_epi16 -FORCE_INLINE __m128i _mm_sub_epi16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s16( - vsubq_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -} - -// Subtract packed 32-bit integers in b from packed 32-bit integers in a, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sub_epi32 -FORCE_INLINE __m128i _mm_sub_epi32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s32( - vsubq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -} - -// Subtract packed 64-bit integers in b from packed 64-bit integers in a, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sub_epi64 -FORCE_INLINE __m128i _mm_sub_epi64(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s64( - vsubq_s64(vreinterpretq_s64_m128i(a), vreinterpretq_s64_m128i(b))); -} - -// Subtract packed 8-bit integers in b from packed 8-bit integers in a, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sub_epi8 -FORCE_INLINE __m128i _mm_sub_epi8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s8( - vsubq_s8(vreinterpretq_s8_m128i(a), vreinterpretq_s8_m128i(b))); -} - -// Subtract packed double-precision (64-bit) floating-point elements in b from -// packed double-precision (64-bit) floating-point elements in a, and store the -// results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=mm_sub_pd -FORCE_INLINE __m128d _mm_sub_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vsubq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - double c[2]; - c[0] = a0 - b0; - c[1] = a1 - b1; - return vld1q_f32((float32_t *) c); -#endif -} - -// Subtract the lower double-precision (64-bit) floating-point element in b from -// the lower double-precision (64-bit) floating-point element in a, store the -// result in the lower element of dst, and copy the upper element from a to the -// upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sub_sd -FORCE_INLINE __m128d _mm_sub_sd(__m128d a, __m128d b) -{ - return _mm_move_sd(a, _mm_sub_pd(a, b)); -} - -// Subtract 64-bit integer b from 64-bit integer a, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sub_si64 -FORCE_INLINE __m64 _mm_sub_si64(__m64 a, __m64 b) -{ - return vreinterpret_m64_s64( - vsub_s64(vreinterpret_s64_m64(a), vreinterpret_s64_m64(b))); -} - -// Subtract packed signed 16-bit integers in b from packed 16-bit integers in a -// using saturation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_subs_epi16 -FORCE_INLINE __m128i _mm_subs_epi16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s16( - vqsubq_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -} - -// Subtract packed signed 8-bit integers in b from packed 8-bit integers in a -// using saturation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_subs_epi8 -FORCE_INLINE __m128i _mm_subs_epi8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s8( - vqsubq_s8(vreinterpretq_s8_m128i(a), vreinterpretq_s8_m128i(b))); -} - -// Subtract packed unsigned 16-bit integers in b from packed unsigned 16-bit -// integers in a using saturation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_subs_epu16 -FORCE_INLINE __m128i _mm_subs_epu16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u16( - vqsubq_u16(vreinterpretq_u16_m128i(a), vreinterpretq_u16_m128i(b))); -} - -// Subtract packed unsigned 8-bit integers in b from packed unsigned 8-bit -// integers in a using saturation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_subs_epu8 -FORCE_INLINE __m128i _mm_subs_epu8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u8( - vqsubq_u8(vreinterpretq_u8_m128i(a), vreinterpretq_u8_m128i(b))); -} - -#define _mm_ucomieq_sd _mm_comieq_sd -#define _mm_ucomige_sd _mm_comige_sd -#define _mm_ucomigt_sd _mm_comigt_sd -#define _mm_ucomile_sd _mm_comile_sd -#define _mm_ucomilt_sd _mm_comilt_sd -#define _mm_ucomineq_sd _mm_comineq_sd - -// Return vector of type __m128d with undefined elements. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_undefined_pd -FORCE_INLINE __m128d _mm_undefined_pd(void) -{ -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wuninitialized" -#endif - __m128d a; -#if defined(_MSC_VER) && !defined(__clang__) - a = _mm_setzero_pd(); -#endif - return a; -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic pop -#endif -} - -// Unpack and interleave 16-bit integers from the high half of a and b, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpackhi_epi16 -FORCE_INLINE __m128i _mm_unpackhi_epi16(__m128i a, __m128i b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s16( - vzip2q_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -#else - int16x4_t a1 = vget_high_s16(vreinterpretq_s16_m128i(a)); - int16x4_t b1 = vget_high_s16(vreinterpretq_s16_m128i(b)); - int16x4x2_t result = vzip_s16(a1, b1); - return vreinterpretq_m128i_s16(vcombine_s16(result.val[0], result.val[1])); -#endif -} - -// Unpack and interleave 32-bit integers from the high half of a and b, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpackhi_epi32 -FORCE_INLINE __m128i _mm_unpackhi_epi32(__m128i a, __m128i b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s32( - vzip2q_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -#else - int32x2_t a1 = vget_high_s32(vreinterpretq_s32_m128i(a)); - int32x2_t b1 = vget_high_s32(vreinterpretq_s32_m128i(b)); - int32x2x2_t result = vzip_s32(a1, b1); - return vreinterpretq_m128i_s32(vcombine_s32(result.val[0], result.val[1])); -#endif -} - -// Unpack and interleave 64-bit integers from the high half of a and b, and -// store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpackhi_epi64 -FORCE_INLINE __m128i _mm_unpackhi_epi64(__m128i a, __m128i b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s64( - vzip2q_s64(vreinterpretq_s64_m128i(a), vreinterpretq_s64_m128i(b))); -#else - int64x1_t a_h = vget_high_s64(vreinterpretq_s64_m128i(a)); - int64x1_t b_h = vget_high_s64(vreinterpretq_s64_m128i(b)); - return vreinterpretq_m128i_s64(vcombine_s64(a_h, b_h)); -#endif -} - -// Unpack and interleave 8-bit integers from the high half of a and b, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpackhi_epi8 -FORCE_INLINE __m128i _mm_unpackhi_epi8(__m128i a, __m128i b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s8( - vzip2q_s8(vreinterpretq_s8_m128i(a), vreinterpretq_s8_m128i(b))); -#else - int8x8_t a1 = - vreinterpret_s8_s16(vget_high_s16(vreinterpretq_s16_m128i(a))); - int8x8_t b1 = - vreinterpret_s8_s16(vget_high_s16(vreinterpretq_s16_m128i(b))); - int8x8x2_t result = vzip_s8(a1, b1); - return vreinterpretq_m128i_s8(vcombine_s8(result.val[0], result.val[1])); -#endif -} - -// Unpack and interleave double-precision (64-bit) floating-point elements from -// the high half of a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpackhi_pd -FORCE_INLINE __m128d _mm_unpackhi_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vzip2q_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - return vreinterpretq_m128d_s64( - vcombine_s64(vget_high_s64(vreinterpretq_s64_m128d(a)), - vget_high_s64(vreinterpretq_s64_m128d(b)))); -#endif -} - -// Unpack and interleave 16-bit integers from the low half of a and b, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpacklo_epi16 -FORCE_INLINE __m128i _mm_unpacklo_epi16(__m128i a, __m128i b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s16( - vzip1q_s16(vreinterpretq_s16_m128i(a), vreinterpretq_s16_m128i(b))); -#else - int16x4_t a1 = vget_low_s16(vreinterpretq_s16_m128i(a)); - int16x4_t b1 = vget_low_s16(vreinterpretq_s16_m128i(b)); - int16x4x2_t result = vzip_s16(a1, b1); - return vreinterpretq_m128i_s16(vcombine_s16(result.val[0], result.val[1])); -#endif -} - -// Unpack and interleave 32-bit integers from the low half of a and b, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpacklo_epi32 -FORCE_INLINE __m128i _mm_unpacklo_epi32(__m128i a, __m128i b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s32( - vzip1q_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -#else - int32x2_t a1 = vget_low_s32(vreinterpretq_s32_m128i(a)); - int32x2_t b1 = vget_low_s32(vreinterpretq_s32_m128i(b)); - int32x2x2_t result = vzip_s32(a1, b1); - return vreinterpretq_m128i_s32(vcombine_s32(result.val[0], result.val[1])); -#endif -} - -// Unpack and interleave 64-bit integers from the low half of a and b, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpacklo_epi64 -FORCE_INLINE __m128i _mm_unpacklo_epi64(__m128i a, __m128i b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s64( - vzip1q_s64(vreinterpretq_s64_m128i(a), vreinterpretq_s64_m128i(b))); -#else - int64x1_t a_l = vget_low_s64(vreinterpretq_s64_m128i(a)); - int64x1_t b_l = vget_low_s64(vreinterpretq_s64_m128i(b)); - return vreinterpretq_m128i_s64(vcombine_s64(a_l, b_l)); -#endif -} - -// Unpack and interleave 8-bit integers from the low half of a and b, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpacklo_epi8 -FORCE_INLINE __m128i _mm_unpacklo_epi8(__m128i a, __m128i b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s8( - vzip1q_s8(vreinterpretq_s8_m128i(a), vreinterpretq_s8_m128i(b))); -#else - int8x8_t a1 = vreinterpret_s8_s16(vget_low_s16(vreinterpretq_s16_m128i(a))); - int8x8_t b1 = vreinterpret_s8_s16(vget_low_s16(vreinterpretq_s16_m128i(b))); - int8x8x2_t result = vzip_s8(a1, b1); - return vreinterpretq_m128i_s8(vcombine_s8(result.val[0], result.val[1])); -#endif -} - -// Unpack and interleave double-precision (64-bit) floating-point elements from -// the low half of a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_unpacklo_pd -FORCE_INLINE __m128d _mm_unpacklo_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vzip1q_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - return vreinterpretq_m128d_s64( - vcombine_s64(vget_low_s64(vreinterpretq_s64_m128d(a)), - vget_low_s64(vreinterpretq_s64_m128d(b)))); -#endif -} - -// Compute the bitwise XOR of packed double-precision (64-bit) floating-point -// elements in a and b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_xor_pd -FORCE_INLINE __m128d _mm_xor_pd(__m128d a, __m128d b) -{ - return vreinterpretq_m128d_s64( - veorq_s64(vreinterpretq_s64_m128d(a), vreinterpretq_s64_m128d(b))); -} - -// Compute the bitwise XOR of 128 bits (representing integer data) in a and b, -// and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_xor_si128 -FORCE_INLINE __m128i _mm_xor_si128(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s32( - veorq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -} - -/* SSE3 */ - -// Alternatively add and subtract packed double-precision (64-bit) -// floating-point elements in a to/from packed elements in b, and store the -// results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_addsub_pd -FORCE_INLINE __m128d _mm_addsub_pd(__m128d a, __m128d b) -{ - _sse2neon_const __m128d mask = _mm_set_pd(1.0f, -1.0f); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vfmaq_f64(vreinterpretq_f64_m128d(a), - vreinterpretq_f64_m128d(b), - vreinterpretq_f64_m128d(mask))); -#else - return _mm_add_pd(_mm_mul_pd(b, mask), a); -#endif -} - -// Alternatively add and subtract packed single-precision (32-bit) -// floating-point elements in a to/from packed elements in b, and store the -// results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=addsub_ps -FORCE_INLINE __m128 _mm_addsub_ps(__m128 a, __m128 b) -{ - _sse2neon_const __m128 mask = _mm_setr_ps(-1.0f, 1.0f, -1.0f, 1.0f); -#if (defined(__aarch64__) || defined(_M_ARM64)) || \ - defined(__ARM_FEATURE_FMA) /* VFPv4+ */ - return vreinterpretq_m128_f32(vfmaq_f32(vreinterpretq_f32_m128(a), - vreinterpretq_f32_m128(mask), - vreinterpretq_f32_m128(b))); -#else - return _mm_add_ps(_mm_mul_ps(b, mask), a); -#endif -} - -// Horizontally add adjacent pairs of double-precision (64-bit) floating-point -// elements in a and b, and pack the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hadd_pd -FORCE_INLINE __m128d _mm_hadd_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vpaddq_f64(vreinterpretq_f64_m128d(a), vreinterpretq_f64_m128d(b))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - double c[] = {a0 + a1, b0 + b1}; - return vreinterpretq_m128d_u64(vld1q_u64((uint64_t *) c)); -#endif -} - -// Horizontally add adjacent pairs of single-precision (32-bit) floating-point -// elements in a and b, and pack the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hadd_ps -FORCE_INLINE __m128 _mm_hadd_ps(__m128 a, __m128 b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128_f32( - vpaddq_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(b))); -#else - float32x2_t a10 = vget_low_f32(vreinterpretq_f32_m128(a)); - float32x2_t a32 = vget_high_f32(vreinterpretq_f32_m128(a)); - float32x2_t b10 = vget_low_f32(vreinterpretq_f32_m128(b)); - float32x2_t b32 = vget_high_f32(vreinterpretq_f32_m128(b)); - return vreinterpretq_m128_f32( - vcombine_f32(vpadd_f32(a10, a32), vpadd_f32(b10, b32))); -#endif -} - -// Horizontally subtract adjacent pairs of double-precision (64-bit) -// floating-point elements in a and b, and pack the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hsub_pd -FORCE_INLINE __m128d _mm_hsub_pd(__m128d a, __m128d b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - float64x2_t _a = vreinterpretq_f64_m128d(a); - float64x2_t _b = vreinterpretq_f64_m128d(b); - return vreinterpretq_m128d_f64( - vsubq_f64(vuzp1q_f64(_a, _b), vuzp2q_f64(_a, _b))); -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - double c[] = {a0 - a1, b0 - b1}; - return vreinterpretq_m128d_u64(vld1q_u64((uint64_t *) c)); -#endif -} - -// Horizontally subtract adjacent pairs of single-precision (32-bit) -// floating-point elements in a and b, and pack the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hsub_ps -FORCE_INLINE __m128 _mm_hsub_ps(__m128 _a, __m128 _b) -{ - float32x4_t a = vreinterpretq_f32_m128(_a); - float32x4_t b = vreinterpretq_f32_m128(_b); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128_f32( - vsubq_f32(vuzp1q_f32(a, b), vuzp2q_f32(a, b))); -#else - float32x4x2_t c = vuzpq_f32(a, b); - return vreinterpretq_m128_f32(vsubq_f32(c.val[0], c.val[1])); -#endif -} - -// Load 128-bits of integer data from unaligned memory into dst. This intrinsic -// may perform better than _mm_loadu_si128 when the data crosses a cache line -// boundary. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_lddqu_si128 -#define _mm_lddqu_si128 _mm_loadu_si128 - -// Load a double-precision (64-bit) floating-point element from memory into both -// elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_loaddup_pd -#define _mm_loaddup_pd _mm_load1_pd - -// Duplicate the low double-precision (64-bit) floating-point element from a, -// and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_movedup_pd -FORCE_INLINE __m128d _mm_movedup_pd(__m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64( - vdupq_laneq_f64(vreinterpretq_f64_m128d(a), 0)); -#else - return vreinterpretq_m128d_u64( - vdupq_n_u64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0))); -#endif -} - -// Duplicate odd-indexed single-precision (32-bit) floating-point elements -// from a, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_movehdup_ps -FORCE_INLINE __m128 _mm_movehdup_ps(__m128 a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128_f32( - vtrn2q_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(a))); -#elif defined(_sse2neon_shuffle) - return vreinterpretq_m128_f32(vshuffleq_s32( - vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(a), 1, 1, 3, 3)); -#else - float32_t a1 = vgetq_lane_f32(vreinterpretq_f32_m128(a), 1); - float32_t a3 = vgetq_lane_f32(vreinterpretq_f32_m128(a), 3); - float ALIGN_STRUCT(16) data[4] = {a1, a1, a3, a3}; - return vreinterpretq_m128_f32(vld1q_f32(data)); -#endif -} - -// Duplicate even-indexed single-precision (32-bit) floating-point elements -// from a, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_moveldup_ps -FORCE_INLINE __m128 _mm_moveldup_ps(__m128 a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128_f32( - vtrn1q_f32(vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(a))); -#elif defined(_sse2neon_shuffle) - return vreinterpretq_m128_f32(vshuffleq_s32( - vreinterpretq_f32_m128(a), vreinterpretq_f32_m128(a), 0, 0, 2, 2)); -#else - float32_t a0 = vgetq_lane_f32(vreinterpretq_f32_m128(a), 0); - float32_t a2 = vgetq_lane_f32(vreinterpretq_f32_m128(a), 2); - float ALIGN_STRUCT(16) data[4] = {a0, a0, a2, a2}; - return vreinterpretq_m128_f32(vld1q_f32(data)); -#endif -} - -/* SSSE3 */ - -// Compute the absolute value of packed signed 16-bit integers in a, and store -// the unsigned results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_abs_epi16 -FORCE_INLINE __m128i _mm_abs_epi16(__m128i a) -{ - return vreinterpretq_m128i_s16(vabsq_s16(vreinterpretq_s16_m128i(a))); -} - -// Compute the absolute value of packed signed 32-bit integers in a, and store -// the unsigned results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_abs_epi32 -FORCE_INLINE __m128i _mm_abs_epi32(__m128i a) -{ - return vreinterpretq_m128i_s32(vabsq_s32(vreinterpretq_s32_m128i(a))); -} - -// Compute the absolute value of packed signed 8-bit integers in a, and store -// the unsigned results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_abs_epi8 -FORCE_INLINE __m128i _mm_abs_epi8(__m128i a) -{ - return vreinterpretq_m128i_s8(vabsq_s8(vreinterpretq_s8_m128i(a))); -} - -// Compute the absolute value of packed signed 16-bit integers in a, and store -// the unsigned results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_abs_pi16 -FORCE_INLINE __m64 _mm_abs_pi16(__m64 a) -{ - return vreinterpret_m64_s16(vabs_s16(vreinterpret_s16_m64(a))); -} - -// Compute the absolute value of packed signed 32-bit integers in a, and store -// the unsigned results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_abs_pi32 -FORCE_INLINE __m64 _mm_abs_pi32(__m64 a) -{ - return vreinterpret_m64_s32(vabs_s32(vreinterpret_s32_m64(a))); -} - -// Compute the absolute value of packed signed 8-bit integers in a, and store -// the unsigned results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_abs_pi8 -FORCE_INLINE __m64 _mm_abs_pi8(__m64 a) -{ - return vreinterpret_m64_s8(vabs_s8(vreinterpret_s8_m64(a))); -} - -// Concatenate 16-byte blocks in a and b into a 32-byte temporary result, shift -// the result right by imm8 bytes, and store the low 16 bytes in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_alignr_epi8 -#if defined(__GNUC__) && !defined(__clang__) -#define _mm_alignr_epi8(a, b, imm) \ - __extension__({ \ - uint8x16_t _a = vreinterpretq_u8_m128i(a); \ - uint8x16_t _b = vreinterpretq_u8_m128i(b); \ - __m128i ret; \ - if (_sse2neon_unlikely((imm) & ~31)) \ - ret = vreinterpretq_m128i_u8(vdupq_n_u8(0)); \ - else if (imm >= 16) \ - ret = _mm_srli_si128(a, imm >= 16 ? imm - 16 : 0); \ - else \ - ret = \ - vreinterpretq_m128i_u8(vextq_u8(_b, _a, imm < 16 ? imm : 0)); \ - ret; \ - }) - -#else -#define _mm_alignr_epi8(a, b, imm) \ - _sse2neon_define2( \ - __m128i, a, b, uint8x16_t __a = vreinterpretq_u8_m128i(_a); \ - uint8x16_t __b = vreinterpretq_u8_m128i(_b); __m128i ret; \ - if (_sse2neon_unlikely((imm) & ~31)) ret = \ - vreinterpretq_m128i_u8(vdupq_n_u8(0)); \ - else if (imm >= 16) ret = \ - _mm_srli_si128(_a, imm >= 16 ? imm - 16 : 0); \ - else ret = \ - vreinterpretq_m128i_u8(vextq_u8(__b, __a, imm < 16 ? imm : 0)); \ - _sse2neon_return(ret);) - -#endif - -// Concatenate 8-byte blocks in a and b into a 16-byte temporary result, shift -// the result right by imm8 bytes, and store the low 8 bytes in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_alignr_pi8 -#define _mm_alignr_pi8(a, b, imm) \ - _sse2neon_define2( \ - __m64, a, b, __m64 ret; if (_sse2neon_unlikely((imm) >= 16)) { \ - ret = vreinterpret_m64_s8(vdup_n_s8(0)); \ - } else { \ - uint8x8_t tmp_low; \ - uint8x8_t tmp_high; \ - if ((imm) >= 8) { \ - const int idx = (imm) - 8; \ - tmp_low = vreinterpret_u8_m64(_a); \ - tmp_high = vdup_n_u8(0); \ - ret = vreinterpret_m64_u8(vext_u8(tmp_low, tmp_high, idx)); \ - } else { \ - const int idx = (imm); \ - tmp_low = vreinterpret_u8_m64(_b); \ - tmp_high = vreinterpret_u8_m64(_a); \ - ret = vreinterpret_m64_u8(vext_u8(tmp_low, tmp_high, idx)); \ - } \ - } _sse2neon_return(ret);) - -// Horizontally add adjacent pairs of 16-bit integers in a and b, and pack the -// signed 16-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hadd_epi16 -FORCE_INLINE __m128i _mm_hadd_epi16(__m128i _a, __m128i _b) -{ - int16x8_t a = vreinterpretq_s16_m128i(_a); - int16x8_t b = vreinterpretq_s16_m128i(_b); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s16(vpaddq_s16(a, b)); -#else - return vreinterpretq_m128i_s16( - vcombine_s16(vpadd_s16(vget_low_s16(a), vget_high_s16(a)), - vpadd_s16(vget_low_s16(b), vget_high_s16(b)))); -#endif -} - -// Horizontally add adjacent pairs of 32-bit integers in a and b, and pack the -// signed 32-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hadd_epi32 -FORCE_INLINE __m128i _mm_hadd_epi32(__m128i _a, __m128i _b) -{ - int32x4_t a = vreinterpretq_s32_m128i(_a); - int32x4_t b = vreinterpretq_s32_m128i(_b); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s32(vpaddq_s32(a, b)); -#else - return vreinterpretq_m128i_s32( - vcombine_s32(vpadd_s32(vget_low_s32(a), vget_high_s32(a)), - vpadd_s32(vget_low_s32(b), vget_high_s32(b)))); -#endif -} - -// Horizontally add adjacent pairs of 16-bit integers in a and b, and pack the -// signed 16-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hadd_pi16 -FORCE_INLINE __m64 _mm_hadd_pi16(__m64 a, __m64 b) -{ - return vreinterpret_m64_s16( - vpadd_s16(vreinterpret_s16_m64(a), vreinterpret_s16_m64(b))); -} - -// Horizontally add adjacent pairs of 32-bit integers in a and b, and pack the -// signed 32-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hadd_pi32 -FORCE_INLINE __m64 _mm_hadd_pi32(__m64 a, __m64 b) -{ - return vreinterpret_m64_s32( - vpadd_s32(vreinterpret_s32_m64(a), vreinterpret_s32_m64(b))); -} - -// Horizontally add adjacent pairs of signed 16-bit integers in a and b using -// saturation, and pack the signed 16-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hadds_epi16 -FORCE_INLINE __m128i _mm_hadds_epi16(__m128i _a, __m128i _b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - int16x8_t a = vreinterpretq_s16_m128i(_a); - int16x8_t b = vreinterpretq_s16_m128i(_b); - return vreinterpretq_s64_s16( - vqaddq_s16(vuzp1q_s16(a, b), vuzp2q_s16(a, b))); -#else - int32x4_t a = vreinterpretq_s32_m128i(_a); - int32x4_t b = vreinterpretq_s32_m128i(_b); - // Interleave using vshrn/vmovn - // [a0|a2|a4|a6|b0|b2|b4|b6] - // [a1|a3|a5|a7|b1|b3|b5|b7] - int16x8_t ab0246 = vcombine_s16(vmovn_s32(a), vmovn_s32(b)); - int16x8_t ab1357 = vcombine_s16(vshrn_n_s32(a, 16), vshrn_n_s32(b, 16)); - // Saturated add - return vreinterpretq_m128i_s16(vqaddq_s16(ab0246, ab1357)); -#endif -} - -// Horizontally add adjacent pairs of signed 16-bit integers in a and b using -// saturation, and pack the signed 16-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hadds_pi16 -FORCE_INLINE __m64 _mm_hadds_pi16(__m64 _a, __m64 _b) -{ - int16x4_t a = vreinterpret_s16_m64(_a); - int16x4_t b = vreinterpret_s16_m64(_b); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpret_s64_s16(vqadd_s16(vuzp1_s16(a, b), vuzp2_s16(a, b))); -#else - int16x4x2_t res = vuzp_s16(a, b); - return vreinterpret_s64_s16(vqadd_s16(res.val[0], res.val[1])); -#endif -} - -// Horizontally subtract adjacent pairs of 16-bit integers in a and b, and pack -// the signed 16-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hsub_epi16 -FORCE_INLINE __m128i _mm_hsub_epi16(__m128i _a, __m128i _b) -{ - int16x8_t a = vreinterpretq_s16_m128i(_a); - int16x8_t b = vreinterpretq_s16_m128i(_b); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s16( - vsubq_s16(vuzp1q_s16(a, b), vuzp2q_s16(a, b))); -#else - int16x8x2_t c = vuzpq_s16(a, b); - return vreinterpretq_m128i_s16(vsubq_s16(c.val[0], c.val[1])); -#endif -} - -// Horizontally subtract adjacent pairs of 32-bit integers in a and b, and pack -// the signed 32-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hsub_epi32 -FORCE_INLINE __m128i _mm_hsub_epi32(__m128i _a, __m128i _b) -{ - int32x4_t a = vreinterpretq_s32_m128i(_a); - int32x4_t b = vreinterpretq_s32_m128i(_b); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s32( - vsubq_s32(vuzp1q_s32(a, b), vuzp2q_s32(a, b))); -#else - int32x4x2_t c = vuzpq_s32(a, b); - return vreinterpretq_m128i_s32(vsubq_s32(c.val[0], c.val[1])); -#endif -} - -// Horizontally subtract adjacent pairs of 16-bit integers in a and b, and pack -// the signed 16-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hsub_pi16 -FORCE_INLINE __m64 _mm_hsub_pi16(__m64 _a, __m64 _b) -{ - int16x4_t a = vreinterpret_s16_m64(_a); - int16x4_t b = vreinterpret_s16_m64(_b); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpret_m64_s16(vsub_s16(vuzp1_s16(a, b), vuzp2_s16(a, b))); -#else - int16x4x2_t c = vuzp_s16(a, b); - return vreinterpret_m64_s16(vsub_s16(c.val[0], c.val[1])); -#endif -} - -// Horizontally subtract adjacent pairs of 32-bit integers in a and b, and pack -// the signed 32-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=mm_hsub_pi32 -FORCE_INLINE __m64 _mm_hsub_pi32(__m64 _a, __m64 _b) -{ - int32x2_t a = vreinterpret_s32_m64(_a); - int32x2_t b = vreinterpret_s32_m64(_b); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpret_m64_s32(vsub_s32(vuzp1_s32(a, b), vuzp2_s32(a, b))); -#else - int32x2x2_t c = vuzp_s32(a, b); - return vreinterpret_m64_s32(vsub_s32(c.val[0], c.val[1])); -#endif -} - -// Horizontally subtract adjacent pairs of signed 16-bit integers in a and b -// using saturation, and pack the signed 16-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hsubs_epi16 -FORCE_INLINE __m128i _mm_hsubs_epi16(__m128i _a, __m128i _b) -{ - int16x8_t a = vreinterpretq_s16_m128i(_a); - int16x8_t b = vreinterpretq_s16_m128i(_b); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s16( - vqsubq_s16(vuzp1q_s16(a, b), vuzp2q_s16(a, b))); -#else - int16x8x2_t c = vuzpq_s16(a, b); - return vreinterpretq_m128i_s16(vqsubq_s16(c.val[0], c.val[1])); -#endif -} - -// Horizontally subtract adjacent pairs of signed 16-bit integers in a and b -// using saturation, and pack the signed 16-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_hsubs_pi16 -FORCE_INLINE __m64 _mm_hsubs_pi16(__m64 _a, __m64 _b) -{ - int16x4_t a = vreinterpret_s16_m64(_a); - int16x4_t b = vreinterpret_s16_m64(_b); -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpret_m64_s16(vqsub_s16(vuzp1_s16(a, b), vuzp2_s16(a, b))); -#else - int16x4x2_t c = vuzp_s16(a, b); - return vreinterpret_m64_s16(vqsub_s16(c.val[0], c.val[1])); -#endif -} - -// Vertically multiply each unsigned 8-bit integer from a with the corresponding -// signed 8-bit integer from b, producing intermediate signed 16-bit integers. -// Horizontally add adjacent pairs of intermediate signed 16-bit integers, -// and pack the saturated results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_maddubs_epi16 -FORCE_INLINE __m128i _mm_maddubs_epi16(__m128i _a, __m128i _b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - uint8x16_t a = vreinterpretq_u8_m128i(_a); - int8x16_t b = vreinterpretq_s8_m128i(_b); - int16x8_t tl = vmulq_s16(vreinterpretq_s16_u16(vmovl_u8(vget_low_u8(a))), - vmovl_s8(vget_low_s8(b))); - int16x8_t th = vmulq_s16(vreinterpretq_s16_u16(vmovl_u8(vget_high_u8(a))), - vmovl_s8(vget_high_s8(b))); - return vreinterpretq_m128i_s16( - vqaddq_s16(vuzp1q_s16(tl, th), vuzp2q_s16(tl, th))); -#else - // This would be much simpler if x86 would choose to zero extend OR sign - // extend, not both. This could probably be optimized better. - uint16x8_t a = vreinterpretq_u16_m128i(_a); - int16x8_t b = vreinterpretq_s16_m128i(_b); - - // Zero extend a - int16x8_t a_odd = vreinterpretq_s16_u16(vshrq_n_u16(a, 8)); - int16x8_t a_even = vreinterpretq_s16_u16(vbicq_u16(a, vdupq_n_u16(0xff00))); - - // Sign extend by shifting left then shifting right. - int16x8_t b_even = vshrq_n_s16(vshlq_n_s16(b, 8), 8); - int16x8_t b_odd = vshrq_n_s16(b, 8); - - // multiply - int16x8_t prod1 = vmulq_s16(a_even, b_even); - int16x8_t prod2 = vmulq_s16(a_odd, b_odd); - - // saturated add - return vreinterpretq_m128i_s16(vqaddq_s16(prod1, prod2)); -#endif -} - -// Vertically multiply each unsigned 8-bit integer from a with the corresponding -// signed 8-bit integer from b, producing intermediate signed 16-bit integers. -// Horizontally add adjacent pairs of intermediate signed 16-bit integers, and -// pack the saturated results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_maddubs_pi16 -FORCE_INLINE __m64 _mm_maddubs_pi16(__m64 _a, __m64 _b) -{ - uint16x4_t a = vreinterpret_u16_m64(_a); - int16x4_t b = vreinterpret_s16_m64(_b); - - // Zero extend a - int16x4_t a_odd = vreinterpret_s16_u16(vshr_n_u16(a, 8)); - int16x4_t a_even = vreinterpret_s16_u16(vand_u16(a, vdup_n_u16(0xff))); - - // Sign extend by shifting left then shifting right. - int16x4_t b_even = vshr_n_s16(vshl_n_s16(b, 8), 8); - int16x4_t b_odd = vshr_n_s16(b, 8); - - // multiply - int16x4_t prod1 = vmul_s16(a_even, b_even); - int16x4_t prod2 = vmul_s16(a_odd, b_odd); - - // saturated add - return vreinterpret_m64_s16(vqadd_s16(prod1, prod2)); -} - -// Multiply packed signed 16-bit integers in a and b, producing intermediate -// signed 32-bit integers. Shift right by 15 bits while rounding up, and store -// the packed 16-bit integers in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mulhrs_epi16 -FORCE_INLINE __m128i _mm_mulhrs_epi16(__m128i a, __m128i b) -{ - // Has issues due to saturation - // return vreinterpretq_m128i_s16(vqrdmulhq_s16(a, b)); - - // Multiply - int32x4_t mul_lo = vmull_s16(vget_low_s16(vreinterpretq_s16_m128i(a)), - vget_low_s16(vreinterpretq_s16_m128i(b))); - int32x4_t mul_hi = vmull_s16(vget_high_s16(vreinterpretq_s16_m128i(a)), - vget_high_s16(vreinterpretq_s16_m128i(b))); - - // Rounding narrowing shift right - // narrow = (int16_t)((mul + 16384) >> 15); - int16x4_t narrow_lo = vrshrn_n_s32(mul_lo, 15); - int16x4_t narrow_hi = vrshrn_n_s32(mul_hi, 15); - - // Join together - return vreinterpretq_m128i_s16(vcombine_s16(narrow_lo, narrow_hi)); -} - -// Multiply packed signed 16-bit integers in a and b, producing intermediate -// signed 32-bit integers. Truncate each intermediate integer to the 18 most -// significant bits, round by adding 1, and store bits [16:1] to dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mulhrs_pi16 -FORCE_INLINE __m64 _mm_mulhrs_pi16(__m64 a, __m64 b) -{ - int32x4_t mul_extend = - vmull_s16((vreinterpret_s16_m64(a)), (vreinterpret_s16_m64(b))); - - // Rounding narrowing shift right - return vreinterpret_m64_s16(vrshrn_n_s32(mul_extend, 15)); -} - -// Shuffle packed 8-bit integers in a according to shuffle control mask in the -// corresponding 8-bit element of b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_shuffle_epi8 -FORCE_INLINE __m128i _mm_shuffle_epi8(__m128i a, __m128i b) -{ - int8x16_t tbl = vreinterpretq_s8_m128i(a); // input a - uint8x16_t idx = vreinterpretq_u8_m128i(b); // input b - uint8x16_t idx_masked = - vandq_u8(idx, vdupq_n_u8(0x8F)); // avoid using meaningless bits -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_s8(vqtbl1q_s8(tbl, idx_masked)); -#elif defined(__GNUC__) - int8x16_t ret; - // %e and %f represent the even and odd D registers - // respectively. - __asm__ __volatile__( - "vtbl.8 %e[ret], {%e[tbl], %f[tbl]}, %e[idx]\n" - "vtbl.8 %f[ret], {%e[tbl], %f[tbl]}, %f[idx]\n" - : [ret] "=&w"(ret) - : [tbl] "w"(tbl), [idx] "w"(idx_masked)); - return vreinterpretq_m128i_s8(ret); -#else - // use this line if testing on aarch64 - int8x8x2_t a_split = {vget_low_s8(tbl), vget_high_s8(tbl)}; - return vreinterpretq_m128i_s8( - vcombine_s8(vtbl2_s8(a_split, vget_low_u8(idx_masked)), - vtbl2_s8(a_split, vget_high_u8(idx_masked)))); -#endif -} - -// Shuffle packed 8-bit integers in a according to shuffle control mask in the -// corresponding 8-bit element of b, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_shuffle_pi8 -FORCE_INLINE __m64 _mm_shuffle_pi8(__m64 a, __m64 b) -{ - const int8x8_t controlMask = - vand_s8(vreinterpret_s8_m64(b), vdup_n_s8((int8_t) (0x1 << 7 | 0x07))); - int8x8_t res = vtbl1_s8(vreinterpret_s8_m64(a), controlMask); - return vreinterpret_m64_s8(res); -} - -// Negate packed 16-bit integers in a when the corresponding signed -// 16-bit integer in b is negative, and store the results in dst. -// Element in dst are zeroed out when the corresponding element -// in b is zero. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sign_epi16 -FORCE_INLINE __m128i _mm_sign_epi16(__m128i _a, __m128i _b) -{ - int16x8_t a = vreinterpretq_s16_m128i(_a); - int16x8_t b = vreinterpretq_s16_m128i(_b); - - // signed shift right: faster than vclt - // (b < 0) ? 0xFFFF : 0 - uint16x8_t ltMask = vreinterpretq_u16_s16(vshrq_n_s16(b, 15)); - // (b == 0) ? 0xFFFF : 0 -#if defined(__aarch64__) || defined(_M_ARM64) - int16x8_t zeroMask = vreinterpretq_s16_u16(vceqzq_s16(b)); -#else - int16x8_t zeroMask = vreinterpretq_s16_u16(vceqq_s16(b, vdupq_n_s16(0))); -#endif - - // bitwise select either a or negative 'a' (vnegq_s16(a) equals to negative - // 'a') based on ltMask - int16x8_t masked = vbslq_s16(ltMask, vnegq_s16(a), a); - // res = masked & (~zeroMask) - int16x8_t res = vbicq_s16(masked, zeroMask); - return vreinterpretq_m128i_s16(res); -} - -// Negate packed 32-bit integers in a when the corresponding signed -// 32-bit integer in b is negative, and store the results in dst. -// Element in dst are zeroed out when the corresponding element -// in b is zero. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sign_epi32 -FORCE_INLINE __m128i _mm_sign_epi32(__m128i _a, __m128i _b) -{ - int32x4_t a = vreinterpretq_s32_m128i(_a); - int32x4_t b = vreinterpretq_s32_m128i(_b); - - // signed shift right: faster than vclt - // (b < 0) ? 0xFFFFFFFF : 0 - uint32x4_t ltMask = vreinterpretq_u32_s32(vshrq_n_s32(b, 31)); - - // (b == 0) ? 0xFFFFFFFF : 0 -#if defined(__aarch64__) || defined(_M_ARM64) - int32x4_t zeroMask = vreinterpretq_s32_u32(vceqzq_s32(b)); -#else - int32x4_t zeroMask = vreinterpretq_s32_u32(vceqq_s32(b, vdupq_n_s32(0))); -#endif - - // bitwise select either a or negative 'a' (vnegq_s32(a) equals to negative - // 'a') based on ltMask - int32x4_t masked = vbslq_s32(ltMask, vnegq_s32(a), a); - // res = masked & (~zeroMask) - int32x4_t res = vbicq_s32(masked, zeroMask); - return vreinterpretq_m128i_s32(res); -} - -// Negate packed 8-bit integers in a when the corresponding signed -// 8-bit integer in b is negative, and store the results in dst. -// Element in dst are zeroed out when the corresponding element -// in b is zero. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sign_epi8 -FORCE_INLINE __m128i _mm_sign_epi8(__m128i _a, __m128i _b) -{ - int8x16_t a = vreinterpretq_s8_m128i(_a); - int8x16_t b = vreinterpretq_s8_m128i(_b); - - // signed shift right: faster than vclt - // (b < 0) ? 0xFF : 0 - uint8x16_t ltMask = vreinterpretq_u8_s8(vshrq_n_s8(b, 7)); - - // (b == 0) ? 0xFF : 0 -#if defined(__aarch64__) || defined(_M_ARM64) - int8x16_t zeroMask = vreinterpretq_s8_u8(vceqzq_s8(b)); -#else - int8x16_t zeroMask = vreinterpretq_s8_u8(vceqq_s8(b, vdupq_n_s8(0))); -#endif - - // bitwise select either a or negative 'a' (vnegq_s8(a) return negative 'a') - // based on ltMask - int8x16_t masked = vbslq_s8(ltMask, vnegq_s8(a), a); - // res = masked & (~zeroMask) - int8x16_t res = vbicq_s8(masked, zeroMask); - - return vreinterpretq_m128i_s8(res); -} - -// Negate packed 16-bit integers in a when the corresponding signed 16-bit -// integer in b is negative, and store the results in dst. Element in dst are -// zeroed out when the corresponding element in b is zero. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sign_pi16 -FORCE_INLINE __m64 _mm_sign_pi16(__m64 _a, __m64 _b) -{ - int16x4_t a = vreinterpret_s16_m64(_a); - int16x4_t b = vreinterpret_s16_m64(_b); - - // signed shift right: faster than vclt - // (b < 0) ? 0xFFFF : 0 - uint16x4_t ltMask = vreinterpret_u16_s16(vshr_n_s16(b, 15)); - - // (b == 0) ? 0xFFFF : 0 -#if defined(__aarch64__) || defined(_M_ARM64) - int16x4_t zeroMask = vreinterpret_s16_u16(vceqz_s16(b)); -#else - int16x4_t zeroMask = vreinterpret_s16_u16(vceq_s16(b, vdup_n_s16(0))); -#endif - - // bitwise select either a or negative 'a' (vneg_s16(a) return negative 'a') - // based on ltMask - int16x4_t masked = vbsl_s16(ltMask, vneg_s16(a), a); - // res = masked & (~zeroMask) - int16x4_t res = vbic_s16(masked, zeroMask); - - return vreinterpret_m64_s16(res); -} - -// Negate packed 32-bit integers in a when the corresponding signed 32-bit -// integer in b is negative, and store the results in dst. Element in dst are -// zeroed out when the corresponding element in b is zero. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sign_pi32 -FORCE_INLINE __m64 _mm_sign_pi32(__m64 _a, __m64 _b) -{ - int32x2_t a = vreinterpret_s32_m64(_a); - int32x2_t b = vreinterpret_s32_m64(_b); - - // signed shift right: faster than vclt - // (b < 0) ? 0xFFFFFFFF : 0 - uint32x2_t ltMask = vreinterpret_u32_s32(vshr_n_s32(b, 31)); - - // (b == 0) ? 0xFFFFFFFF : 0 -#if defined(__aarch64__) || defined(_M_ARM64) - int32x2_t zeroMask = vreinterpret_s32_u32(vceqz_s32(b)); -#else - int32x2_t zeroMask = vreinterpret_s32_u32(vceq_s32(b, vdup_n_s32(0))); -#endif - - // bitwise select either a or negative 'a' (vneg_s32(a) return negative 'a') - // based on ltMask - int32x2_t masked = vbsl_s32(ltMask, vneg_s32(a), a); - // res = masked & (~zeroMask) - int32x2_t res = vbic_s32(masked, zeroMask); - - return vreinterpret_m64_s32(res); -} - -// Negate packed 8-bit integers in a when the corresponding signed 8-bit integer -// in b is negative, and store the results in dst. Element in dst are zeroed out -// when the corresponding element in b is zero. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_sign_pi8 -FORCE_INLINE __m64 _mm_sign_pi8(__m64 _a, __m64 _b) -{ - int8x8_t a = vreinterpret_s8_m64(_a); - int8x8_t b = vreinterpret_s8_m64(_b); - - // signed shift right: faster than vclt - // (b < 0) ? 0xFF : 0 - uint8x8_t ltMask = vreinterpret_u8_s8(vshr_n_s8(b, 7)); - - // (b == 0) ? 0xFF : 0 -#if defined(__aarch64__) || defined(_M_ARM64) - int8x8_t zeroMask = vreinterpret_s8_u8(vceqz_s8(b)); -#else - int8x8_t zeroMask = vreinterpret_s8_u8(vceq_s8(b, vdup_n_s8(0))); -#endif - - // bitwise select either a or negative 'a' (vneg_s8(a) return negative 'a') - // based on ltMask - int8x8_t masked = vbsl_s8(ltMask, vneg_s8(a), a); - // res = masked & (~zeroMask) - int8x8_t res = vbic_s8(masked, zeroMask); - - return vreinterpret_m64_s8(res); -} - -/* SSE4.1 */ - -// Blend packed 16-bit integers from a and b using control mask imm8, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_blend_epi16 -// FORCE_INLINE __m128i _mm_blend_epi16(__m128i a, __m128i b, -// __constrange(0,255) int imm) -#define _mm_blend_epi16(a, b, imm) \ - _sse2neon_define2( \ - __m128i, a, b, \ - const uint16_t _mask[8] = \ - _sse2neon_init(((imm) & (1 << 0)) ? (uint16_t) - 1 : 0x0, \ - ((imm) & (1 << 1)) ? (uint16_t) - 1 : 0x0, \ - ((imm) & (1 << 2)) ? (uint16_t) - 1 : 0x0, \ - ((imm) & (1 << 3)) ? (uint16_t) - 1 : 0x0, \ - ((imm) & (1 << 4)) ? (uint16_t) - 1 : 0x0, \ - ((imm) & (1 << 5)) ? (uint16_t) - 1 : 0x0, \ - ((imm) & (1 << 6)) ? (uint16_t) - 1 : 0x0, \ - ((imm) & (1 << 7)) ? (uint16_t) - 1 : 0x0); \ - uint16x8_t _mask_vec = vld1q_u16(_mask); \ - uint16x8_t __a = vreinterpretq_u16_m128i(_a); \ - uint16x8_t __b = vreinterpretq_u16_m128i(_b); _sse2neon_return( \ - vreinterpretq_m128i_u16(vbslq_u16(_mask_vec, __b, __a)));) - -// Blend packed double-precision (64-bit) floating-point elements from a and b -// using control mask imm8, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_blend_pd -#define _mm_blend_pd(a, b, imm) \ - _sse2neon_define2( \ - __m128d, a, b, \ - const uint64_t _mask[2] = \ - _sse2neon_init(((imm) & (1 << 0)) ? ~UINT64_C(0) : UINT64_C(0), \ - ((imm) & (1 << 1)) ? ~UINT64_C(0) : UINT64_C(0)); \ - uint64x2_t _mask_vec = vld1q_u64(_mask); \ - uint64x2_t __a = vreinterpretq_u64_m128d(_a); \ - uint64x2_t __b = vreinterpretq_u64_m128d(_b); _sse2neon_return( \ - vreinterpretq_m128d_u64(vbslq_u64(_mask_vec, __b, __a)));) - -// Blend packed single-precision (32-bit) floating-point elements from a and b -// using mask, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_blend_ps -FORCE_INLINE __m128 _mm_blend_ps(__m128 _a, __m128 _b, const char imm8) -{ - const uint32_t - ALIGN_STRUCT(16) data[4] = {((imm8) & (1 << 0)) ? UINT32_MAX : 0, - ((imm8) & (1 << 1)) ? UINT32_MAX : 0, - ((imm8) & (1 << 2)) ? UINT32_MAX : 0, - ((imm8) & (1 << 3)) ? UINT32_MAX : 0}; - uint32x4_t mask = vld1q_u32(data); - float32x4_t a = vreinterpretq_f32_m128(_a); - float32x4_t b = vreinterpretq_f32_m128(_b); - return vreinterpretq_m128_f32(vbslq_f32(mask, b, a)); -} - -// Blend packed 8-bit integers from a and b using mask, and store the results in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_blendv_epi8 -FORCE_INLINE __m128i _mm_blendv_epi8(__m128i _a, __m128i _b, __m128i _mask) -{ - // Use a signed shift right to create a mask with the sign bit - uint8x16_t mask = - vreinterpretq_u8_s8(vshrq_n_s8(vreinterpretq_s8_m128i(_mask), 7)); - uint8x16_t a = vreinterpretq_u8_m128i(_a); - uint8x16_t b = vreinterpretq_u8_m128i(_b); - return vreinterpretq_m128i_u8(vbslq_u8(mask, b, a)); -} - -// Blend packed double-precision (64-bit) floating-point elements from a and b -// using mask, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_blendv_pd -FORCE_INLINE __m128d _mm_blendv_pd(__m128d _a, __m128d _b, __m128d _mask) -{ - uint64x2_t mask = - vreinterpretq_u64_s64(vshrq_n_s64(vreinterpretq_s64_m128d(_mask), 63)); -#if defined(__aarch64__) || defined(_M_ARM64) - float64x2_t a = vreinterpretq_f64_m128d(_a); - float64x2_t b = vreinterpretq_f64_m128d(_b); - return vreinterpretq_m128d_f64(vbslq_f64(mask, b, a)); -#else - uint64x2_t a = vreinterpretq_u64_m128d(_a); - uint64x2_t b = vreinterpretq_u64_m128d(_b); - return vreinterpretq_m128d_u64(vbslq_u64(mask, b, a)); -#endif -} - -// Blend packed single-precision (32-bit) floating-point elements from a and b -// using mask, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_blendv_ps -FORCE_INLINE __m128 _mm_blendv_ps(__m128 _a, __m128 _b, __m128 _mask) -{ - // Use a signed shift right to create a mask with the sign bit - uint32x4_t mask = - vreinterpretq_u32_s32(vshrq_n_s32(vreinterpretq_s32_m128(_mask), 31)); - float32x4_t a = vreinterpretq_f32_m128(_a); - float32x4_t b = vreinterpretq_f32_m128(_b); - return vreinterpretq_m128_f32(vbslq_f32(mask, b, a)); -} - -// Round the packed double-precision (64-bit) floating-point elements in a up -// to an integer value, and store the results as packed double-precision -// floating-point elements in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_ceil_pd -FORCE_INLINE __m128d _mm_ceil_pd(__m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vrndpq_f64(vreinterpretq_f64_m128d(a))); -#else - double a0, a1; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - a1 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - return _mm_set_pd(ceil(a1), ceil(a0)); -#endif -} - -// Round the packed single-precision (32-bit) floating-point elements in a up to -// an integer value, and store the results as packed single-precision -// floating-point elements in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_ceil_ps -FORCE_INLINE __m128 _mm_ceil_ps(__m128 a) -{ -#if (defined(__aarch64__) || defined(_M_ARM64)) || \ - defined(__ARM_FEATURE_DIRECTED_ROUNDING) - return vreinterpretq_m128_f32(vrndpq_f32(vreinterpretq_f32_m128(a))); -#else - float *f = (float *) &a; - return _mm_set_ps(ceilf(f[3]), ceilf(f[2]), ceilf(f[1]), ceilf(f[0])); -#endif -} - -// Round the lower double-precision (64-bit) floating-point element in b up to -// an integer value, store the result as a double-precision floating-point -// element in the lower element of dst, and copy the upper element from a to the -// upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_ceil_sd -FORCE_INLINE __m128d _mm_ceil_sd(__m128d a, __m128d b) -{ - return _mm_move_sd(a, _mm_ceil_pd(b)); -} - -// Round the lower single-precision (32-bit) floating-point element in b up to -// an integer value, store the result as a single-precision floating-point -// element in the lower element of dst, and copy the upper 3 packed elements -// from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_ceil_ss -FORCE_INLINE __m128 _mm_ceil_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_ceil_ps(b)); -} - -// Compare packed 64-bit integers in a and b for equality, and store the results -// in dst -FORCE_INLINE __m128i _mm_cmpeq_epi64(__m128i a, __m128i b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_u64( - vceqq_u64(vreinterpretq_u64_m128i(a), vreinterpretq_u64_m128i(b))); -#else - // ARMv7 lacks vceqq_u64 - // (a == b) -> (a_lo == b_lo) && (a_hi == b_hi) - uint32x4_t cmp = - vceqq_u32(vreinterpretq_u32_m128i(a), vreinterpretq_u32_m128i(b)); - uint32x4_t swapped = vrev64q_u32(cmp); - return vreinterpretq_m128i_u32(vandq_u32(cmp, swapped)); -#endif -} - -// Sign extend packed 16-bit integers in a to packed 32-bit integers, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepi16_epi32 -FORCE_INLINE __m128i _mm_cvtepi16_epi32(__m128i a) -{ - return vreinterpretq_m128i_s32( - vmovl_s16(vget_low_s16(vreinterpretq_s16_m128i(a)))); -} - -// Sign extend packed 16-bit integers in a to packed 64-bit integers, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepi16_epi64 -FORCE_INLINE __m128i _mm_cvtepi16_epi64(__m128i a) -{ - int16x8_t s16x8 = vreinterpretq_s16_m128i(a); /* xxxx xxxx xxxx 0B0A */ - int32x4_t s32x4 = vmovl_s16(vget_low_s16(s16x8)); /* 000x 000x 000B 000A */ - int64x2_t s64x2 = vmovl_s32(vget_low_s32(s32x4)); /* 0000 000B 0000 000A */ - return vreinterpretq_m128i_s64(s64x2); -} - -// Sign extend packed 32-bit integers in a to packed 64-bit integers, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepi32_epi64 -FORCE_INLINE __m128i _mm_cvtepi32_epi64(__m128i a) -{ - return vreinterpretq_m128i_s64( - vmovl_s32(vget_low_s32(vreinterpretq_s32_m128i(a)))); -} - -// Sign extend packed 8-bit integers in a to packed 16-bit integers, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepi8_epi16 -FORCE_INLINE __m128i _mm_cvtepi8_epi16(__m128i a) -{ - int8x16_t s8x16 = vreinterpretq_s8_m128i(a); /* xxxx xxxx xxxx DCBA */ - int16x8_t s16x8 = vmovl_s8(vget_low_s8(s8x16)); /* 0x0x 0x0x 0D0C 0B0A */ - return vreinterpretq_m128i_s16(s16x8); -} - -// Sign extend packed 8-bit integers in a to packed 32-bit integers, and store -// the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepi8_epi32 -FORCE_INLINE __m128i _mm_cvtepi8_epi32(__m128i a) -{ - int8x16_t s8x16 = vreinterpretq_s8_m128i(a); /* xxxx xxxx xxxx DCBA */ - int16x8_t s16x8 = vmovl_s8(vget_low_s8(s8x16)); /* 0x0x 0x0x 0D0C 0B0A */ - int32x4_t s32x4 = vmovl_s16(vget_low_s16(s16x8)); /* 000D 000C 000B 000A */ - return vreinterpretq_m128i_s32(s32x4); -} - -// Sign extend packed 8-bit integers in the low 8 bytes of a to packed 64-bit -// integers, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepi8_epi64 -FORCE_INLINE __m128i _mm_cvtepi8_epi64(__m128i a) -{ - int8x16_t s8x16 = vreinterpretq_s8_m128i(a); /* xxxx xxxx xxxx xxBA */ - int16x8_t s16x8 = vmovl_s8(vget_low_s8(s8x16)); /* 0x0x 0x0x 0x0x 0B0A */ - int32x4_t s32x4 = vmovl_s16(vget_low_s16(s16x8)); /* 000x 000x 000B 000A */ - int64x2_t s64x2 = vmovl_s32(vget_low_s32(s32x4)); /* 0000 000B 0000 000A */ - return vreinterpretq_m128i_s64(s64x2); -} - -// Zero extend packed unsigned 16-bit integers in a to packed 32-bit integers, -// and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepu16_epi32 -FORCE_INLINE __m128i _mm_cvtepu16_epi32(__m128i a) -{ - return vreinterpretq_m128i_u32( - vmovl_u16(vget_low_u16(vreinterpretq_u16_m128i(a)))); -} - -// Zero extend packed unsigned 16-bit integers in a to packed 64-bit integers, -// and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepu16_epi64 -FORCE_INLINE __m128i _mm_cvtepu16_epi64(__m128i a) -{ - uint16x8_t u16x8 = vreinterpretq_u16_m128i(a); /* xxxx xxxx xxxx 0B0A */ - uint32x4_t u32x4 = vmovl_u16(vget_low_u16(u16x8)); /* 000x 000x 000B 000A */ - uint64x2_t u64x2 = vmovl_u32(vget_low_u32(u32x4)); /* 0000 000B 0000 000A */ - return vreinterpretq_m128i_u64(u64x2); -} - -// Zero extend packed unsigned 32-bit integers in a to packed 64-bit integers, -// and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepu32_epi64 -FORCE_INLINE __m128i _mm_cvtepu32_epi64(__m128i a) -{ - return vreinterpretq_m128i_u64( - vmovl_u32(vget_low_u32(vreinterpretq_u32_m128i(a)))); -} - -// Zero extend packed unsigned 8-bit integers in a to packed 16-bit integers, -// and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepu8_epi16 -FORCE_INLINE __m128i _mm_cvtepu8_epi16(__m128i a) -{ - uint8x16_t u8x16 = vreinterpretq_u8_m128i(a); /* xxxx xxxx HGFE DCBA */ - uint16x8_t u16x8 = vmovl_u8(vget_low_u8(u8x16)); /* 0H0G 0F0E 0D0C 0B0A */ - return vreinterpretq_m128i_u16(u16x8); -} - -// Zero extend packed unsigned 8-bit integers in a to packed 32-bit integers, -// and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepu8_epi32 -FORCE_INLINE __m128i _mm_cvtepu8_epi32(__m128i a) -{ - uint8x16_t u8x16 = vreinterpretq_u8_m128i(a); /* xxxx xxxx xxxx DCBA */ - uint16x8_t u16x8 = vmovl_u8(vget_low_u8(u8x16)); /* 0x0x 0x0x 0D0C 0B0A */ - uint32x4_t u32x4 = vmovl_u16(vget_low_u16(u16x8)); /* 000D 000C 000B 000A */ - return vreinterpretq_m128i_u32(u32x4); -} - -// Zero extend packed unsigned 8-bit integers in the low 8 bytes of a to packed -// 64-bit integers, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cvtepu8_epi64 -FORCE_INLINE __m128i _mm_cvtepu8_epi64(__m128i a) -{ - uint8x16_t u8x16 = vreinterpretq_u8_m128i(a); /* xxxx xxxx xxxx xxBA */ - uint16x8_t u16x8 = vmovl_u8(vget_low_u8(u8x16)); /* 0x0x 0x0x 0x0x 0B0A */ - uint32x4_t u32x4 = vmovl_u16(vget_low_u16(u16x8)); /* 000x 000x 000B 000A */ - uint64x2_t u64x2 = vmovl_u32(vget_low_u32(u32x4)); /* 0000 000B 0000 000A */ - return vreinterpretq_m128i_u64(u64x2); -} - -// Conditionally multiply the packed double-precision (64-bit) floating-point -// elements in a and b using the high 4 bits in imm8, sum the four products, and -// conditionally store the sum in dst using the low 4 bits of imm8. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_dp_pd -FORCE_INLINE __m128d _mm_dp_pd(__m128d a, __m128d b, const int imm) -{ - // Generate mask value from constant immediate bit value - const int64_t bit0Mask = imm & 0x01 ? UINT64_MAX : 0; - const int64_t bit1Mask = imm & 0x02 ? UINT64_MAX : 0; -#if !SSE2NEON_PRECISE_DP - const int64_t bit4Mask = imm & 0x10 ? UINT64_MAX : 0; - const int64_t bit5Mask = imm & 0x20 ? UINT64_MAX : 0; -#endif - // Conditional multiplication -#if !SSE2NEON_PRECISE_DP - __m128d mul = _mm_mul_pd(a, b); - const __m128d mulMask = - _mm_castsi128_pd(_mm_set_epi64x(bit5Mask, bit4Mask)); - __m128d tmp = _mm_and_pd(mul, mulMask); -#else -#if defined(__aarch64__) || defined(_M_ARM64) - double d0 = (imm & 0x10) ? vgetq_lane_f64(vreinterpretq_f64_m128d(a), 0) * - vgetq_lane_f64(vreinterpretq_f64_m128d(b), 0) - : 0; - double d1 = (imm & 0x20) ? vgetq_lane_f64(vreinterpretq_f64_m128d(a), 1) * - vgetq_lane_f64(vreinterpretq_f64_m128d(b), 1) - : 0; -#else - double a0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - double a1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - double b0 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 0)); - double b1 = - sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(b), 1)); - double d0 = (imm & 0x10) ? a0 * b0 : 0; - double d1 = (imm & 0x20) ? a1 * b1 : 0; -#endif - __m128d tmp = _mm_set_pd(d1, d0); -#endif - // Sum the products -#if defined(__aarch64__) || defined(_M_ARM64) - double sum = vpaddd_f64(vreinterpretq_f64_m128d(tmp)); -#else - double _tmp0 = sse2neon_recast_u64_f64( - vgetq_lane_u64(vreinterpretq_u64_m128d(tmp), 0)); - double _tmp1 = sse2neon_recast_u64_f64( - vgetq_lane_u64(vreinterpretq_u64_m128d(tmp), 1)); - double sum = _tmp0 + _tmp1; -#endif - // Conditionally store the sum - const __m128d sumMask = - _mm_castsi128_pd(_mm_set_epi64x(bit1Mask, bit0Mask)); - __m128d res = _mm_and_pd(_mm_set_pd1(sum), sumMask); - return res; -} - -// Conditionally multiply the packed single-precision (32-bit) floating-point -// elements in a and b using the high 4 bits in imm8, sum the four products, -// and conditionally store the sum in dst using the low 4 bits of imm. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_dp_ps -FORCE_INLINE __m128 _mm_dp_ps(__m128 a, __m128 b, const int imm) -{ - float32x4_t elementwise_prod = _mm_mul_ps(a, b); - -#if defined(__aarch64__) || defined(_M_ARM64) - /* shortcuts */ - if (imm == 0xFF) { - return _mm_set1_ps(vaddvq_f32(elementwise_prod)); - } - - if ((imm & 0x0F) == 0x0F) { - if (!(imm & (1 << 4))) - elementwise_prod = vsetq_lane_f32(0.0f, elementwise_prod, 0); - if (!(imm & (1 << 5))) - elementwise_prod = vsetq_lane_f32(0.0f, elementwise_prod, 1); - if (!(imm & (1 << 6))) - elementwise_prod = vsetq_lane_f32(0.0f, elementwise_prod, 2); - if (!(imm & (1 << 7))) - elementwise_prod = vsetq_lane_f32(0.0f, elementwise_prod, 3); - - return _mm_set1_ps(vaddvq_f32(elementwise_prod)); - } -#endif - - float s = 0.0f; - - if (imm & (1 << 4)) - s += vgetq_lane_f32(elementwise_prod, 0); - if (imm & (1 << 5)) - s += vgetq_lane_f32(elementwise_prod, 1); - if (imm & (1 << 6)) - s += vgetq_lane_f32(elementwise_prod, 2); - if (imm & (1 << 7)) - s += vgetq_lane_f32(elementwise_prod, 3); - - const float32_t res[4] = { - (imm & 0x1) ? s : 0.0f, - (imm & 0x2) ? s : 0.0f, - (imm & 0x4) ? s : 0.0f, - (imm & 0x8) ? s : 0.0f, - }; - return vreinterpretq_m128_f32(vld1q_f32(res)); -} - -// Extract a 32-bit integer from a, selected with imm8, and store the result in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_extract_epi32 -// FORCE_INLINE int _mm_extract_epi32(__m128i a, __constrange(0,4) int imm) -#define _mm_extract_epi32(a, imm) \ - vgetq_lane_s32(vreinterpretq_s32_m128i(a), (imm)) - -// Extract a 64-bit integer from a, selected with imm8, and store the result in -// dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_extract_epi64 -// FORCE_INLINE __int64 _mm_extract_epi64(__m128i a, __constrange(0,2) int imm) -#define _mm_extract_epi64(a, imm) \ - vgetq_lane_s64(vreinterpretq_s64_m128i(a), (imm)) - -// Extract an 8-bit integer from a, selected with imm8, and store the result in -// the lower element of dst. FORCE_INLINE int _mm_extract_epi8(__m128i a, -// __constrange(0,16) int imm) -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_extract_epi8 -#define _mm_extract_epi8(a, imm) vgetq_lane_u8(vreinterpretq_u8_m128i(a), (imm)) - -// Extracts the selected single-precision (32-bit) floating-point from a. -// FORCE_INLINE int _mm_extract_ps(__m128 a, __constrange(0,4) int imm) -#define _mm_extract_ps(a, imm) vgetq_lane_s32(vreinterpretq_s32_m128(a), (imm)) - -// Round the packed double-precision (64-bit) floating-point elements in a down -// to an integer value, and store the results as packed double-precision -// floating-point elements in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_floor_pd -FORCE_INLINE __m128d _mm_floor_pd(__m128d a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128d_f64(vrndmq_f64(vreinterpretq_f64_m128d(a))); -#else - double a0, a1; - a0 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 0)); - a1 = sse2neon_recast_u64_f64(vgetq_lane_u64(vreinterpretq_u64_m128d(a), 1)); - return _mm_set_pd(floor(a1), floor(a0)); -#endif -} - -// Round the packed single-precision (32-bit) floating-point elements in a down -// to an integer value, and store the results as packed single-precision -// floating-point elements in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_floor_ps -FORCE_INLINE __m128 _mm_floor_ps(__m128 a) -{ -#if (defined(__aarch64__) || defined(_M_ARM64)) || \ - defined(__ARM_FEATURE_DIRECTED_ROUNDING) - return vreinterpretq_m128_f32(vrndmq_f32(vreinterpretq_f32_m128(a))); -#else - float *f = (float *) &a; - return _mm_set_ps(floorf(f[3]), floorf(f[2]), floorf(f[1]), floorf(f[0])); -#endif -} - -// Round the lower double-precision (64-bit) floating-point element in b down to -// an integer value, store the result as a double-precision floating-point -// element in the lower element of dst, and copy the upper element from a to the -// upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_floor_sd -FORCE_INLINE __m128d _mm_floor_sd(__m128d a, __m128d b) -{ - return _mm_move_sd(a, _mm_floor_pd(b)); -} - -// Round the lower single-precision (32-bit) floating-point element in b down to -// an integer value, store the result as a single-precision floating-point -// element in the lower element of dst, and copy the upper 3 packed elements -// from a to the upper elements of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_floor_ss -FORCE_INLINE __m128 _mm_floor_ss(__m128 a, __m128 b) -{ - return _mm_move_ss(a, _mm_floor_ps(b)); -} - -// Copy a to dst, and insert the 32-bit integer i into dst at the location -// specified by imm8. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_insert_epi32 -// FORCE_INLINE __m128i _mm_insert_epi32(__m128i a, int b, -// __constrange(0,4) int imm) -#define _mm_insert_epi32(a, b, imm) \ - vreinterpretq_m128i_s32( \ - vsetq_lane_s32((b), vreinterpretq_s32_m128i(a), (imm))) - -// Copy a to dst, and insert the 64-bit integer i into dst at the location -// specified by imm8. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_insert_epi64 -// FORCE_INLINE __m128i _mm_insert_epi64(__m128i a, __int64 b, -// __constrange(0,2) int imm) -#define _mm_insert_epi64(a, b, imm) \ - vreinterpretq_m128i_s64( \ - vsetq_lane_s64((b), vreinterpretq_s64_m128i(a), (imm))) - -// Copy a to dst, and insert the lower 8-bit integer from i into dst at the -// location specified by imm8. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_insert_epi8 -// FORCE_INLINE __m128i _mm_insert_epi8(__m128i a, int b, -// __constrange(0,16) int imm) -#define _mm_insert_epi8(a, b, imm) \ - vreinterpretq_m128i_s8(vsetq_lane_s8((b), vreinterpretq_s8_m128i(a), (imm))) - -// Copy a to tmp, then insert a single-precision (32-bit) floating-point -// element from b into tmp using the control in imm8. Store tmp to dst using -// the mask in imm8 (elements are zeroed out when the corresponding bit is set). -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=insert_ps -#define _mm_insert_ps(a, b, imm8) \ - _sse2neon_define2( \ - __m128, a, b, \ - float32x4_t tmp1 = \ - vsetq_lane_f32(vgetq_lane_f32(_b, (imm8 >> 6) & 0x3), \ - vreinterpretq_f32_m128(_a), 0); \ - float32x4_t tmp2 = \ - vsetq_lane_f32(vgetq_lane_f32(tmp1, 0), \ - vreinterpretq_f32_m128(_a), ((imm8 >> 4) & 0x3)); \ - const uint32_t data[4] = \ - _sse2neon_init(((imm8) & (1 << 0)) ? UINT32_MAX : 0, \ - ((imm8) & (1 << 1)) ? UINT32_MAX : 0, \ - ((imm8) & (1 << 2)) ? UINT32_MAX : 0, \ - ((imm8) & (1 << 3)) ? UINT32_MAX : 0); \ - uint32x4_t mask = vld1q_u32(data); \ - float32x4_t all_zeros = vdupq_n_f32(0); \ - \ - _sse2neon_return(vreinterpretq_m128_f32( \ - vbslq_f32(mask, all_zeros, vreinterpretq_f32_m128(tmp2))));) - -// Compare packed signed 32-bit integers in a and b, and store packed maximum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_epi32 -FORCE_INLINE __m128i _mm_max_epi32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s32( - vmaxq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -} - -// Compare packed signed 8-bit integers in a and b, and store packed maximum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_epi8 -FORCE_INLINE __m128i _mm_max_epi8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s8( - vmaxq_s8(vreinterpretq_s8_m128i(a), vreinterpretq_s8_m128i(b))); -} - -// Compare packed unsigned 16-bit integers in a and b, and store packed maximum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_epu16 -FORCE_INLINE __m128i _mm_max_epu16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u16( - vmaxq_u16(vreinterpretq_u16_m128i(a), vreinterpretq_u16_m128i(b))); -} - -// Compare packed unsigned 32-bit integers in a and b, and store packed maximum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_epu32 -FORCE_INLINE __m128i _mm_max_epu32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u32( - vmaxq_u32(vreinterpretq_u32_m128i(a), vreinterpretq_u32_m128i(b))); -} - -// Compare packed signed 32-bit integers in a and b, and store packed minimum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_min_epi32 -FORCE_INLINE __m128i _mm_min_epi32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s32( - vminq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -} - -// Compare packed signed 8-bit integers in a and b, and store packed minimum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_min_epi8 -FORCE_INLINE __m128i _mm_min_epi8(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s8( - vminq_s8(vreinterpretq_s8_m128i(a), vreinterpretq_s8_m128i(b))); -} - -// Compare packed unsigned 16-bit integers in a and b, and store packed minimum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_min_epu16 -FORCE_INLINE __m128i _mm_min_epu16(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u16( - vminq_u16(vreinterpretq_u16_m128i(a), vreinterpretq_u16_m128i(b))); -} - -// Compare packed unsigned 32-bit integers in a and b, and store packed minimum -// values in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_max_epu32 -FORCE_INLINE __m128i _mm_min_epu32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u32( - vminq_u32(vreinterpretq_u32_m128i(a), vreinterpretq_u32_m128i(b))); -} - -// Horizontally compute the minimum amongst the packed unsigned 16-bit integers -// in a, store the minimum and index in dst, and zero the remaining bits in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_minpos_epu16 -FORCE_INLINE __m128i _mm_minpos_epu16(__m128i a) -{ - __m128i dst; - uint16_t min, idx = 0; -#if defined(__aarch64__) || defined(_M_ARM64) - // Find the minimum value - min = vminvq_u16(vreinterpretq_u16_m128i(a)); - - // Get the index of the minimum value - static const uint16_t idxv[] = {0, 1, 2, 3, 4, 5, 6, 7}; - uint16x8_t minv = vdupq_n_u16(min); - uint16x8_t cmeq = vceqq_u16(minv, vreinterpretq_u16_m128i(a)); - idx = vminvq_u16(vornq_u16(vld1q_u16(idxv), cmeq)); -#else - // Find the minimum value - __m64 tmp; - tmp = vreinterpret_m64_u16( - vmin_u16(vget_low_u16(vreinterpretq_u16_m128i(a)), - vget_high_u16(vreinterpretq_u16_m128i(a)))); - tmp = vreinterpret_m64_u16( - vpmin_u16(vreinterpret_u16_m64(tmp), vreinterpret_u16_m64(tmp))); - tmp = vreinterpret_m64_u16( - vpmin_u16(vreinterpret_u16_m64(tmp), vreinterpret_u16_m64(tmp))); - min = vget_lane_u16(vreinterpret_u16_m64(tmp), 0); - // Get the index of the minimum value - int i; - for (i = 0; i < 8; i++) { - if (min == vgetq_lane_u16(vreinterpretq_u16_m128i(a), 0)) { - idx = (uint16_t) i; - break; - } - a = _mm_srli_si128(a, 2); - } -#endif - // Generate result - dst = _mm_setzero_si128(); - dst = vreinterpretq_m128i_u16( - vsetq_lane_u16(min, vreinterpretq_u16_m128i(dst), 0)); - dst = vreinterpretq_m128i_u16( - vsetq_lane_u16(idx, vreinterpretq_u16_m128i(dst), 1)); - return dst; -} - -// Compute the sum of absolute differences (SADs) of quadruplets of unsigned -// 8-bit integers in a compared to those in b, and store the 16-bit results in -// dst. Eight SADs are performed using one quadruplet from b and eight -// quadruplets from a. One quadruplet is selected from b starting at on the -// offset specified in imm8. Eight quadruplets are formed from sequential 8-bit -// integers selected from a starting at the offset specified in imm8. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mpsadbw_epu8 -FORCE_INLINE __m128i _mm_mpsadbw_epu8(__m128i a, __m128i b, const int imm) -{ - uint8x16_t _a, _b; - - switch (imm & 0x4) { - case 0: - // do nothing - _a = vreinterpretq_u8_m128i(a); - break; - case 4: - _a = vreinterpretq_u8_u32(vextq_u32(vreinterpretq_u32_m128i(a), - vreinterpretq_u32_m128i(a), 1)); - break; - default: -#if defined(__GNUC__) || defined(__clang__) - __builtin_unreachable(); -#elif defined(_MSC_VER) - __assume(0); -#endif - break; - } - - switch (imm & 0x3) { - case 0: - _b = vreinterpretq_u8_u32( - vdupq_n_u32(vgetq_lane_u32(vreinterpretq_u32_m128i(b), 0))); - break; - case 1: - _b = vreinterpretq_u8_u32( - vdupq_n_u32(vgetq_lane_u32(vreinterpretq_u32_m128i(b), 1))); - break; - case 2: - _b = vreinterpretq_u8_u32( - vdupq_n_u32(vgetq_lane_u32(vreinterpretq_u32_m128i(b), 2))); - break; - case 3: - _b = vreinterpretq_u8_u32( - vdupq_n_u32(vgetq_lane_u32(vreinterpretq_u32_m128i(b), 3))); - break; - default: -#if defined(__GNUC__) || defined(__clang__) - __builtin_unreachable(); -#elif defined(_MSC_VER) - __assume(0); -#endif - break; - } - - int16x8_t c04, c15, c26, c37; - uint8x8_t low_b = vget_low_u8(_b); - c04 = vreinterpretq_s16_u16(vabdl_u8(vget_low_u8(_a), low_b)); - uint8x16_t _a_1 = vextq_u8(_a, _a, 1); - c15 = vreinterpretq_s16_u16(vabdl_u8(vget_low_u8(_a_1), low_b)); - uint8x16_t _a_2 = vextq_u8(_a, _a, 2); - c26 = vreinterpretq_s16_u16(vabdl_u8(vget_low_u8(_a_2), low_b)); - uint8x16_t _a_3 = vextq_u8(_a, _a, 3); - c37 = vreinterpretq_s16_u16(vabdl_u8(vget_low_u8(_a_3), low_b)); -#if defined(__aarch64__) || defined(_M_ARM64) - // |0|4|2|6| - c04 = vpaddq_s16(c04, c26); - // |1|5|3|7| - c15 = vpaddq_s16(c15, c37); - - int32x4_t trn1_c = - vtrn1q_s32(vreinterpretq_s32_s16(c04), vreinterpretq_s32_s16(c15)); - int32x4_t trn2_c = - vtrn2q_s32(vreinterpretq_s32_s16(c04), vreinterpretq_s32_s16(c15)); - return vreinterpretq_m128i_s16(vpaddq_s16(vreinterpretq_s16_s32(trn1_c), - vreinterpretq_s16_s32(trn2_c))); -#else - int16x4_t c01, c23, c45, c67; - c01 = vpadd_s16(vget_low_s16(c04), vget_low_s16(c15)); - c23 = vpadd_s16(vget_low_s16(c26), vget_low_s16(c37)); - c45 = vpadd_s16(vget_high_s16(c04), vget_high_s16(c15)); - c67 = vpadd_s16(vget_high_s16(c26), vget_high_s16(c37)); - - return vreinterpretq_m128i_s16( - vcombine_s16(vpadd_s16(c01, c23), vpadd_s16(c45, c67))); -#endif -} - -// Multiply the low signed 32-bit integers from each packed 64-bit element in -// a and b, and store the signed 64-bit results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mul_epi32 -FORCE_INLINE __m128i _mm_mul_epi32(__m128i a, __m128i b) -{ - // vmull_s32 upcasts instead of masking, so we downcast. - int32x2_t a_lo = vmovn_s64(vreinterpretq_s64_m128i(a)); - int32x2_t b_lo = vmovn_s64(vreinterpretq_s64_m128i(b)); - return vreinterpretq_m128i_s64(vmull_s32(a_lo, b_lo)); -} - -// Multiply the packed 32-bit integers in a and b, producing intermediate 64-bit -// integers, and store the low 32 bits of the intermediate integers in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_mullo_epi32 -FORCE_INLINE __m128i _mm_mullo_epi32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_s32( - vmulq_s32(vreinterpretq_s32_m128i(a), vreinterpretq_s32_m128i(b))); -} - -// Convert packed signed 32-bit integers from a and b to packed 16-bit integers -// using unsigned saturation, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_packus_epi32 -FORCE_INLINE __m128i _mm_packus_epi32(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u16( - vcombine_u16(vqmovun_s32(vreinterpretq_s32_m128i(a)), - vqmovun_s32(vreinterpretq_s32_m128i(b)))); -} - -// Round the packed double-precision (64-bit) floating-point elements in a using -// the rounding parameter, and store the results as packed double-precision -// floating-point elements in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_round_pd -FORCE_INLINE __m128d _mm_round_pd(__m128d a, int rounding) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - switch (rounding) { - case (_MM_FROUND_TO_NEAREST_INT | _MM_FROUND_NO_EXC): - return vreinterpretq_m128d_f64(vrndnq_f64(vreinterpretq_f64_m128d(a))); - case (_MM_FROUND_TO_NEG_INF | _MM_FROUND_NO_EXC): - return _mm_floor_pd(a); - case (_MM_FROUND_TO_POS_INF | _MM_FROUND_NO_EXC): - return _mm_ceil_pd(a); - case (_MM_FROUND_TO_ZERO | _MM_FROUND_NO_EXC): - return vreinterpretq_m128d_f64(vrndq_f64(vreinterpretq_f64_m128d(a))); - default: //_MM_FROUND_CUR_DIRECTION - return vreinterpretq_m128d_f64(vrndiq_f64(vreinterpretq_f64_m128d(a))); - } -#else - double *v_double = (double *) &a; - - if (rounding == (_MM_FROUND_TO_NEAREST_INT | _MM_FROUND_NO_EXC) || - (rounding == _MM_FROUND_CUR_DIRECTION && - _MM_GET_ROUNDING_MODE() == _MM_ROUND_NEAREST)) { - double res[2], tmp; - for (int i = 0; i < 2; i++) { - tmp = (v_double[i] < 0) ? -v_double[i] : v_double[i]; - double roundDown = floor(tmp); // Round down value - double roundUp = ceil(tmp); // Round up value - double diffDown = tmp - roundDown; - double diffUp = roundUp - tmp; - if (diffDown < diffUp) { - /* If it's closer to the round down value, then use it */ - res[i] = roundDown; - } else if (diffDown > diffUp) { - /* If it's closer to the round up value, then use it */ - res[i] = roundUp; - } else { - /* If it's equidistant between round up and round down value, - * pick the one which is an even number */ - double half = roundDown / 2; - if (half != floor(half)) { - /* If the round down value is odd, return the round up value - */ - res[i] = roundUp; - } else { - /* If the round up value is odd, return the round down value - */ - res[i] = roundDown; - } - } - res[i] = (v_double[i] < 0) ? -res[i] : res[i]; - } - return _mm_set_pd(res[1], res[0]); - } else if (rounding == (_MM_FROUND_TO_NEG_INF | _MM_FROUND_NO_EXC) || - (rounding == _MM_FROUND_CUR_DIRECTION && - _MM_GET_ROUNDING_MODE() == _MM_ROUND_DOWN)) { - return _mm_floor_pd(a); - } else if (rounding == (_MM_FROUND_TO_POS_INF | _MM_FROUND_NO_EXC) || - (rounding == _MM_FROUND_CUR_DIRECTION && - _MM_GET_ROUNDING_MODE() == _MM_ROUND_UP)) { - return _mm_ceil_pd(a); - } - return _mm_set_pd(v_double[1] > 0 ? floor(v_double[1]) : ceil(v_double[1]), - v_double[0] > 0 ? floor(v_double[0]) : ceil(v_double[0])); -#endif -} - -// Round the packed single-precision (32-bit) floating-point elements in a using -// the rounding parameter, and store the results as packed single-precision -// floating-point elements in dst. -// software.intel.com/sites/landingpage/IntrinsicsGuide/#text=_mm_round_ps -FORCE_INLINE __m128 _mm_round_ps(__m128 a, int rounding) -{ -#if (defined(__aarch64__) || defined(_M_ARM64)) || \ - defined(__ARM_FEATURE_DIRECTED_ROUNDING) - switch (rounding) { - case (_MM_FROUND_TO_NEAREST_INT | _MM_FROUND_NO_EXC): - return vreinterpretq_m128_f32(vrndnq_f32(vreinterpretq_f32_m128(a))); - case (_MM_FROUND_TO_NEG_INF | _MM_FROUND_NO_EXC): - return _mm_floor_ps(a); - case (_MM_FROUND_TO_POS_INF | _MM_FROUND_NO_EXC): - return _mm_ceil_ps(a); - case (_MM_FROUND_TO_ZERO | _MM_FROUND_NO_EXC): - return vreinterpretq_m128_f32(vrndq_f32(vreinterpretq_f32_m128(a))); - default: //_MM_FROUND_CUR_DIRECTION - return vreinterpretq_m128_f32(vrndiq_f32(vreinterpretq_f32_m128(a))); - } -#else - float *v_float = (float *) &a; - - if (rounding == (_MM_FROUND_TO_NEAREST_INT | _MM_FROUND_NO_EXC) || - (rounding == _MM_FROUND_CUR_DIRECTION && - _MM_GET_ROUNDING_MODE() == _MM_ROUND_NEAREST)) { - uint32x4_t signmask = vdupq_n_u32(0x80000000); - float32x4_t half = vbslq_f32(signmask, vreinterpretq_f32_m128(a), - vdupq_n_f32(0.5f)); /* +/- 0.5 */ - int32x4_t r_normal = vcvtq_s32_f32(vaddq_f32( - vreinterpretq_f32_m128(a), half)); /* round to integer: [a + 0.5]*/ - int32x4_t r_trunc = vcvtq_s32_f32( - vreinterpretq_f32_m128(a)); /* truncate to integer: [a] */ - int32x4_t plusone = vreinterpretq_s32_u32(vshrq_n_u32( - vreinterpretq_u32_s32(vnegq_s32(r_trunc)), 31)); /* 1 or 0 */ - int32x4_t r_even = vbicq_s32(vaddq_s32(r_trunc, plusone), - vdupq_n_s32(1)); /* ([a] + {0,1}) & ~1 */ - float32x4_t delta = vsubq_f32( - vreinterpretq_f32_m128(a), - vcvtq_f32_s32(r_trunc)); /* compute delta: delta = (a - [a]) */ - uint32x4_t is_delta_half = - vceqq_f32(delta, half); /* delta == +/- 0.5 */ - return vreinterpretq_m128_f32( - vcvtq_f32_s32(vbslq_s32(is_delta_half, r_even, r_normal))); - } else if (rounding == (_MM_FROUND_TO_NEG_INF | _MM_FROUND_NO_EXC) || - (rounding == _MM_FROUND_CUR_DIRECTION && - _MM_GET_ROUNDING_MODE() == _MM_ROUND_DOWN)) { - return _mm_floor_ps(a); - } else if (rounding == (_MM_FROUND_TO_POS_INF | _MM_FROUND_NO_EXC) || - (rounding == _MM_FROUND_CUR_DIRECTION && - _MM_GET_ROUNDING_MODE() == _MM_ROUND_UP)) { - return _mm_ceil_ps(a); - } - return _mm_set_ps(v_float[3] > 0 ? floorf(v_float[3]) : ceilf(v_float[3]), - v_float[2] > 0 ? floorf(v_float[2]) : ceilf(v_float[2]), - v_float[1] > 0 ? floorf(v_float[1]) : ceilf(v_float[1]), - v_float[0] > 0 ? floorf(v_float[0]) : ceilf(v_float[0])); -#endif -} - -// Round the lower double-precision (64-bit) floating-point element in b using -// the rounding parameter, store the result as a double-precision floating-point -// element in the lower element of dst, and copy the upper element from a to the -// upper element of dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_round_sd -FORCE_INLINE __m128d _mm_round_sd(__m128d a, __m128d b, int rounding) -{ - return _mm_move_sd(a, _mm_round_pd(b, rounding)); -} - -// Round the lower single-precision (32-bit) floating-point element in b using -// the rounding parameter, store the result as a single-precision floating-point -// element in the lower element of dst, and copy the upper 3 packed elements -// from a to the upper elements of dst. Rounding is done according to the -// rounding[3:0] parameter, which can be one of: -// (_MM_FROUND_TO_NEAREST_INT |_MM_FROUND_NO_EXC) // round to nearest, and -// suppress exceptions -// (_MM_FROUND_TO_NEG_INF |_MM_FROUND_NO_EXC) // round down, and -// suppress exceptions -// (_MM_FROUND_TO_POS_INF |_MM_FROUND_NO_EXC) // round up, and suppress -// exceptions -// (_MM_FROUND_TO_ZERO |_MM_FROUND_NO_EXC) // truncate, and suppress -// exceptions _MM_FROUND_CUR_DIRECTION // use MXCSR.RC; see -// _MM_SET_ROUNDING_MODE -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_round_ss -FORCE_INLINE __m128 _mm_round_ss(__m128 a, __m128 b, int rounding) -{ - return _mm_move_ss(a, _mm_round_ps(b, rounding)); -} - -// Load 128-bits of integer data from memory into dst using a non-temporal -// memory hint. mem_addr must be aligned on a 16-byte boundary or a -// general-protection exception may be generated. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_stream_load_si128 -FORCE_INLINE __m128i _mm_stream_load_si128(__m128i *p) -{ -#if __has_builtin(__builtin_nontemporal_store) - return __builtin_nontemporal_load(p); -#else - return vreinterpretq_m128i_s64(vld1q_s64((int64_t *) p)); -#endif -} - -// Compute the bitwise NOT of a and then AND with a 128-bit vector containing -// all 1's, and return 1 if the result is zero, otherwise return 0. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_test_all_ones -FORCE_INLINE int _mm_test_all_ones(__m128i a) -{ - return (uint64_t) (vgetq_lane_s64(a, 0) & vgetq_lane_s64(a, 1)) == - ~(uint64_t) 0; -} - -// Compute the bitwise AND of 128 bits (representing integer data) in a and -// mask, and return 1 if the result is zero, otherwise return 0. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_test_all_zeros -FORCE_INLINE int _mm_test_all_zeros(__m128i a, __m128i mask) -{ - int64x2_t a_and_mask = - vandq_s64(vreinterpretq_s64_m128i(a), vreinterpretq_s64_m128i(mask)); - return !(vgetq_lane_s64(a_and_mask, 0) | vgetq_lane_s64(a_and_mask, 1)); -} - -// Compute the bitwise AND of 128 bits (representing integer data) in a and -// mask, and set ZF to 1 if the result is zero, otherwise set ZF to 0. Compute -// the bitwise NOT of a and then AND with mask, and set CF to 1 if the result is -// zero, otherwise set CF to 0. Return 1 if both the ZF and CF values are zero, -// otherwise return 0. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=mm_test_mix_ones_zero -// Note: Argument names may be wrong in the Intel intrinsics guide. -FORCE_INLINE int _mm_test_mix_ones_zeros(__m128i a, __m128i mask) -{ - uint64x2_t v = vreinterpretq_u64_m128i(a); - uint64x2_t m = vreinterpretq_u64_m128i(mask); - - // find ones (set-bits) and zeros (clear-bits) under clip mask - uint64x2_t ones = vandq_u64(m, v); - uint64x2_t zeros = vbicq_u64(m, v); - - // If both 128-bit variables are populated (non-zero) then return 1. - // For comparison purposes, first compact each var down to 32-bits. - uint32x2_t reduced = vpmax_u32(vqmovn_u64(ones), vqmovn_u64(zeros)); - - // if folding minimum is non-zero then both vars must be non-zero - return (vget_lane_u32(vpmin_u32(reduced, reduced), 0) != 0); -} - -// Compute the bitwise AND of 128 bits (representing integer data) in a and b, -// and set ZF to 1 if the result is zero, otherwise set ZF to 0. Compute the -// bitwise NOT of a and then AND with b, and set CF to 1 if the result is zero, -// otherwise set CF to 0. Return the CF value. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_testc_si128 -FORCE_INLINE int _mm_testc_si128(__m128i a, __m128i b) -{ - int64x2_t s64 = - vbicq_s64(vreinterpretq_s64_m128i(b), vreinterpretq_s64_m128i(a)); - return !(vgetq_lane_s64(s64, 0) | vgetq_lane_s64(s64, 1)); -} - -// Compute the bitwise AND of 128 bits (representing integer data) in a and b, -// and set ZF to 1 if the result is zero, otherwise set ZF to 0. Compute the -// bitwise NOT of a and then AND with b, and set CF to 1 if the result is zero, -// otherwise set CF to 0. Return 1 if both the ZF and CF values are zero, -// otherwise return 0. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_testnzc_si128 -#define _mm_testnzc_si128(a, b) _mm_test_mix_ones_zeros(a, b) - -// Compute the bitwise AND of 128 bits (representing integer data) in a and b, -// and set ZF to 1 if the result is zero, otherwise set ZF to 0. Compute the -// bitwise NOT of a and then AND with b, and set CF to 1 if the result is zero, -// otherwise set CF to 0. Return the ZF value. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_testz_si128 -FORCE_INLINE int _mm_testz_si128(__m128i a, __m128i b) -{ - int64x2_t s64 = - vandq_s64(vreinterpretq_s64_m128i(a), vreinterpretq_s64_m128i(b)); - return !(vgetq_lane_s64(s64, 0) | vgetq_lane_s64(s64, 1)); -} - -/* SSE4.2 */ - -static const uint16_t ALIGN_STRUCT(16) _sse2neon_cmpestr_mask16b[8] = { - 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, -}; -static const uint8_t ALIGN_STRUCT(16) _sse2neon_cmpestr_mask8b[16] = { - 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, - 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, -}; - -/* specify the source data format */ -#define _SIDD_UBYTE_OPS 0x00 /* unsigned 8-bit characters */ -#define _SIDD_UWORD_OPS 0x01 /* unsigned 16-bit characters */ -#define _SIDD_SBYTE_OPS 0x02 /* signed 8-bit characters */ -#define _SIDD_SWORD_OPS 0x03 /* signed 16-bit characters */ - -/* specify the comparison operation */ -#define _SIDD_CMP_EQUAL_ANY 0x00 /* compare equal any: strchr */ -#define _SIDD_CMP_RANGES 0x04 /* compare ranges */ -#define _SIDD_CMP_EQUAL_EACH 0x08 /* compare equal each: strcmp */ -#define _SIDD_CMP_EQUAL_ORDERED 0x0C /* compare equal ordered */ - -/* specify the polarity */ -#define _SIDD_POSITIVE_POLARITY 0x00 -#define _SIDD_MASKED_POSITIVE_POLARITY 0x20 -#define _SIDD_NEGATIVE_POLARITY 0x10 /* negate results */ -#define _SIDD_MASKED_NEGATIVE_POLARITY \ - 0x30 /* negate results only before end of string */ - -/* specify the output selection in _mm_cmpXstri */ -#define _SIDD_LEAST_SIGNIFICANT 0x00 -#define _SIDD_MOST_SIGNIFICANT 0x40 - -/* specify the output selection in _mm_cmpXstrm */ -#define _SIDD_BIT_MASK 0x00 -#define _SIDD_UNIT_MASK 0x40 - -/* Pattern Matching for C macros. - * https://github.com/pfultz2/Cloak/wiki/C-Preprocessor-tricks,-tips,-and-idioms - */ - -/* catenate */ -#define SSE2NEON_PRIMITIVE_CAT(a, ...) a##__VA_ARGS__ -#define SSE2NEON_CAT(a, b) SSE2NEON_PRIMITIVE_CAT(a, b) - -#define SSE2NEON_IIF(c) SSE2NEON_PRIMITIVE_CAT(SSE2NEON_IIF_, c) -/* run the 2nd parameter */ -#define SSE2NEON_IIF_0(t, ...) __VA_ARGS__ -/* run the 1st parameter */ -#define SSE2NEON_IIF_1(t, ...) t - -#define SSE2NEON_COMPL(b) SSE2NEON_PRIMITIVE_CAT(SSE2NEON_COMPL_, b) -#define SSE2NEON_COMPL_0 1 -#define SSE2NEON_COMPL_1 0 - -#define SSE2NEON_DEC(x) SSE2NEON_PRIMITIVE_CAT(SSE2NEON_DEC_, x) -#define SSE2NEON_DEC_1 0 -#define SSE2NEON_DEC_2 1 -#define SSE2NEON_DEC_3 2 -#define SSE2NEON_DEC_4 3 -#define SSE2NEON_DEC_5 4 -#define SSE2NEON_DEC_6 5 -#define SSE2NEON_DEC_7 6 -#define SSE2NEON_DEC_8 7 -#define SSE2NEON_DEC_9 8 -#define SSE2NEON_DEC_10 9 -#define SSE2NEON_DEC_11 10 -#define SSE2NEON_DEC_12 11 -#define SSE2NEON_DEC_13 12 -#define SSE2NEON_DEC_14 13 -#define SSE2NEON_DEC_15 14 -#define SSE2NEON_DEC_16 15 - -/* detection */ -#define SSE2NEON_CHECK_N(x, n, ...) n -#define SSE2NEON_CHECK(...) SSE2NEON_CHECK_N(__VA_ARGS__, 0, ) -#define SSE2NEON_PROBE(x) x, 1, - -#define SSE2NEON_NOT(x) SSE2NEON_CHECK(SSE2NEON_PRIMITIVE_CAT(SSE2NEON_NOT_, x)) -#define SSE2NEON_NOT_0 SSE2NEON_PROBE(~) - -#define SSE2NEON_BOOL(x) SSE2NEON_COMPL(SSE2NEON_NOT(x)) -#define SSE2NEON_IF(c) SSE2NEON_IIF(SSE2NEON_BOOL(c)) - -#define SSE2NEON_EAT(...) -#define SSE2NEON_EXPAND(...) __VA_ARGS__ -#define SSE2NEON_WHEN(c) SSE2NEON_IF(c)(SSE2NEON_EXPAND, SSE2NEON_EAT) - -/* recursion */ -/* deferred expression */ -#define SSE2NEON_EMPTY() -#define SSE2NEON_DEFER(id) id SSE2NEON_EMPTY() -#define SSE2NEON_OBSTRUCT(...) __VA_ARGS__ SSE2NEON_DEFER(SSE2NEON_EMPTY)() -#define SSE2NEON_EXPAND(...) __VA_ARGS__ - -#define SSE2NEON_EVAL(...) \ - SSE2NEON_EVAL1(SSE2NEON_EVAL1(SSE2NEON_EVAL1(__VA_ARGS__))) -#define SSE2NEON_EVAL1(...) \ - SSE2NEON_EVAL2(SSE2NEON_EVAL2(SSE2NEON_EVAL2(__VA_ARGS__))) -#define SSE2NEON_EVAL2(...) \ - SSE2NEON_EVAL3(SSE2NEON_EVAL3(SSE2NEON_EVAL3(__VA_ARGS__))) -#define SSE2NEON_EVAL3(...) __VA_ARGS__ - -#define SSE2NEON_REPEAT(count, macro, ...) \ - SSE2NEON_WHEN(count) \ - (SSE2NEON_OBSTRUCT(SSE2NEON_REPEAT_INDIRECT)()( \ - SSE2NEON_DEC(count), macro, \ - __VA_ARGS__) SSE2NEON_OBSTRUCT(macro)(SSE2NEON_DEC(count), \ - __VA_ARGS__)) -#define SSE2NEON_REPEAT_INDIRECT() SSE2NEON_REPEAT - -#define SSE2NEON_SIZE_OF_byte 8 -#define SSE2NEON_NUMBER_OF_LANES_byte 16 -#define SSE2NEON_SIZE_OF_word 16 -#define SSE2NEON_NUMBER_OF_LANES_word 8 - -#define SSE2NEON_COMPARE_EQUAL_THEN_FILL_LANE(i, type) \ - mtx[i] = vreinterpretq_m128i_##type(vceqq_##type( \ - vdupq_n_##type(vgetq_lane_##type(vreinterpretq_##type##_m128i(b), i)), \ - vreinterpretq_##type##_m128i(a))); - -#define SSE2NEON_FILL_LANE(i, type) \ - vec_b[i] = \ - vdupq_n_##type(vgetq_lane_##type(vreinterpretq_##type##_m128i(b), i)); - -#define PCMPSTR_RANGES(a, b, mtx, data_type_prefix, type_prefix, size, \ - number_of_lanes, byte_or_word) \ - do { \ - SSE2NEON_CAT( \ - data_type_prefix, \ - SSE2NEON_CAT(size, \ - SSE2NEON_CAT(x, SSE2NEON_CAT(number_of_lanes, _t)))) \ - vec_b[number_of_lanes]; \ - __m128i mask = SSE2NEON_IIF(byte_or_word)( \ - vreinterpretq_m128i_u16(vdupq_n_u16(0xff)), \ - vreinterpretq_m128i_u32(vdupq_n_u32(0xffff))); \ - SSE2NEON_EVAL(SSE2NEON_REPEAT(number_of_lanes, SSE2NEON_FILL_LANE, \ - SSE2NEON_CAT(type_prefix, size))) \ - for (int i = 0; i < number_of_lanes; i++) { \ - mtx[i] = SSE2NEON_CAT(vreinterpretq_m128i_u, \ - size)(SSE2NEON_CAT(vbslq_u, size)( \ - SSE2NEON_CAT(vreinterpretq_u, \ - SSE2NEON_CAT(size, _m128i))(mask), \ - SSE2NEON_CAT(vcgeq_, SSE2NEON_CAT(type_prefix, size))( \ - vec_b[i], \ - SSE2NEON_CAT( \ - vreinterpretq_, \ - SSE2NEON_CAT(type_prefix, \ - SSE2NEON_CAT(size, _m128i(a))))), \ - SSE2NEON_CAT(vcleq_, SSE2NEON_CAT(type_prefix, size))( \ - vec_b[i], \ - SSE2NEON_CAT( \ - vreinterpretq_, \ - SSE2NEON_CAT(type_prefix, \ - SSE2NEON_CAT(size, _m128i(a))))))); \ - } \ - } while (0) - -#define PCMPSTR_EQ(a, b, mtx, size, number_of_lanes) \ - do { \ - SSE2NEON_EVAL(SSE2NEON_REPEAT(number_of_lanes, \ - SSE2NEON_COMPARE_EQUAL_THEN_FILL_LANE, \ - SSE2NEON_CAT(u, size))) \ - } while (0) - -#define SSE2NEON_CMP_EQUAL_ANY_IMPL(type) \ - static int _sse2neon_cmp_##type##_equal_any(__m128i a, int la, __m128i b, \ - int lb) \ - { \ - __m128i mtx[16]; \ - PCMPSTR_EQ(a, b, mtx, SSE2NEON_CAT(SSE2NEON_SIZE_OF_, type), \ - SSE2NEON_CAT(SSE2NEON_NUMBER_OF_LANES_, type)); \ - return SSE2NEON_CAT( \ - _sse2neon_aggregate_equal_any_, \ - SSE2NEON_CAT( \ - SSE2NEON_CAT(SSE2NEON_SIZE_OF_, type), \ - SSE2NEON_CAT(x, SSE2NEON_CAT(SSE2NEON_NUMBER_OF_LANES_, \ - type))))(la, lb, mtx); \ - } - -#define SSE2NEON_CMP_RANGES_IMPL(type, data_type, us, byte_or_word) \ - static int _sse2neon_cmp_##us##type##_ranges(__m128i a, int la, __m128i b, \ - int lb) \ - { \ - __m128i mtx[16]; \ - PCMPSTR_RANGES( \ - a, b, mtx, data_type, us, SSE2NEON_CAT(SSE2NEON_SIZE_OF_, type), \ - SSE2NEON_CAT(SSE2NEON_NUMBER_OF_LANES_, type), byte_or_word); \ - return SSE2NEON_CAT( \ - _sse2neon_aggregate_ranges_, \ - SSE2NEON_CAT( \ - SSE2NEON_CAT(SSE2NEON_SIZE_OF_, type), \ - SSE2NEON_CAT(x, SSE2NEON_CAT(SSE2NEON_NUMBER_OF_LANES_, \ - type))))(la, lb, mtx); \ - } - -#define SSE2NEON_CMP_EQUAL_ORDERED_IMPL(type) \ - static int _sse2neon_cmp_##type##_equal_ordered(__m128i a, int la, \ - __m128i b, int lb) \ - { \ - __m128i mtx[16]; \ - PCMPSTR_EQ(a, b, mtx, SSE2NEON_CAT(SSE2NEON_SIZE_OF_, type), \ - SSE2NEON_CAT(SSE2NEON_NUMBER_OF_LANES_, type)); \ - return SSE2NEON_CAT( \ - _sse2neon_aggregate_equal_ordered_, \ - SSE2NEON_CAT( \ - SSE2NEON_CAT(SSE2NEON_SIZE_OF_, type), \ - SSE2NEON_CAT(x, \ - SSE2NEON_CAT(SSE2NEON_NUMBER_OF_LANES_, type))))( \ - SSE2NEON_CAT(SSE2NEON_NUMBER_OF_LANES_, type), la, lb, mtx); \ - } - -static int _sse2neon_aggregate_equal_any_8x16(int la, int lb, __m128i mtx[16]) -{ - int res = 0; - int m = (1 << la) - 1; - uint8x8_t vec_mask = vld1_u8(_sse2neon_cmpestr_mask8b); - uint8x8_t t_lo = vtst_u8(vdup_n_u8(m & 0xff), vec_mask); - uint8x8_t t_hi = vtst_u8(vdup_n_u8(m >> 8), vec_mask); - uint8x16_t vec = vcombine_u8(t_lo, t_hi); - for (int j = 0; j < lb; j++) { - mtx[j] = vreinterpretq_m128i_u8( - vandq_u8(vec, vreinterpretq_u8_m128i(mtx[j]))); - mtx[j] = vreinterpretq_m128i_u8( - vshrq_n_u8(vreinterpretq_u8_m128i(mtx[j]), 7)); - int tmp = _sse2neon_vaddvq_u8(vreinterpretq_u8_m128i(mtx[j])) ? 1 : 0; - res |= (tmp << j); - } - return res; -} - -static int _sse2neon_aggregate_equal_any_16x8(int la, int lb, __m128i mtx[16]) -{ - int res = 0; - int m = (1 << la) - 1; - uint16x8_t vec = - vtstq_u16(vdupq_n_u16(m), vld1q_u16(_sse2neon_cmpestr_mask16b)); - for (int j = 0; j < lb; j++) { - mtx[j] = vreinterpretq_m128i_u16( - vandq_u16(vec, vreinterpretq_u16_m128i(mtx[j]))); - mtx[j] = vreinterpretq_m128i_u16( - vshrq_n_u16(vreinterpretq_u16_m128i(mtx[j]), 15)); - int tmp = _sse2neon_vaddvq_u16(vreinterpretq_u16_m128i(mtx[j])) ? 1 : 0; - res |= (tmp << j); - } - return res; -} - -/* clang-format off */ -#define SSE2NEON_GENERATE_CMP_EQUAL_ANY(prefix) \ - prefix##IMPL(byte) \ - prefix##IMPL(word) -/* clang-format on */ - -SSE2NEON_GENERATE_CMP_EQUAL_ANY(SSE2NEON_CMP_EQUAL_ANY_) - -static int _sse2neon_aggregate_ranges_16x8(int la, int lb, __m128i mtx[16]) -{ - int res = 0; - int m = (1 << la) - 1; - uint16x8_t vec = - vtstq_u16(vdupq_n_u16(m), vld1q_u16(_sse2neon_cmpestr_mask16b)); - for (int j = 0; j < lb; j++) { - mtx[j] = vreinterpretq_m128i_u16( - vandq_u16(vec, vreinterpretq_u16_m128i(mtx[j]))); - mtx[j] = vreinterpretq_m128i_u16( - vshrq_n_u16(vreinterpretq_u16_m128i(mtx[j]), 15)); - __m128i tmp = vreinterpretq_m128i_u32( - vshrq_n_u32(vreinterpretq_u32_m128i(mtx[j]), 16)); - uint32x4_t vec_res = vandq_u32(vreinterpretq_u32_m128i(mtx[j]), - vreinterpretq_u32_m128i(tmp)); -#if defined(__aarch64__) || defined(_M_ARM64) - int t = vaddvq_u32(vec_res) ? 1 : 0; -#else - uint64x2_t sumh = vpaddlq_u32(vec_res); - int t = vgetq_lane_u64(sumh, 0) + vgetq_lane_u64(sumh, 1); -#endif - res |= (t << j); - } - return res; -} - -static int _sse2neon_aggregate_ranges_8x16(int la, int lb, __m128i mtx[16]) -{ - int res = 0; - int m = (1 << la) - 1; - uint8x8_t vec_mask = vld1_u8(_sse2neon_cmpestr_mask8b); - uint8x8_t t_lo = vtst_u8(vdup_n_u8(m & 0xff), vec_mask); - uint8x8_t t_hi = vtst_u8(vdup_n_u8(m >> 8), vec_mask); - uint8x16_t vec = vcombine_u8(t_lo, t_hi); - for (int j = 0; j < lb; j++) { - mtx[j] = vreinterpretq_m128i_u8( - vandq_u8(vec, vreinterpretq_u8_m128i(mtx[j]))); - mtx[j] = vreinterpretq_m128i_u8( - vshrq_n_u8(vreinterpretq_u8_m128i(mtx[j]), 7)); - __m128i tmp = vreinterpretq_m128i_u16( - vshrq_n_u16(vreinterpretq_u16_m128i(mtx[j]), 8)); - uint16x8_t vec_res = vandq_u16(vreinterpretq_u16_m128i(mtx[j]), - vreinterpretq_u16_m128i(tmp)); - int t = _sse2neon_vaddvq_u16(vec_res) ? 1 : 0; - res |= (t << j); - } - return res; -} - -#define SSE2NEON_CMP_RANGES_IS_BYTE 1 -#define SSE2NEON_CMP_RANGES_IS_WORD 0 - -/* clang-format off */ -#define SSE2NEON_GENERATE_CMP_RANGES(prefix) \ - prefix##IMPL(byte, uint, u, prefix##IS_BYTE) \ - prefix##IMPL(byte, int, s, prefix##IS_BYTE) \ - prefix##IMPL(word, uint, u, prefix##IS_WORD) \ - prefix##IMPL(word, int, s, prefix##IS_WORD) -/* clang-format on */ - -SSE2NEON_GENERATE_CMP_RANGES(SSE2NEON_CMP_RANGES_) - -#undef SSE2NEON_CMP_RANGES_IS_BYTE -#undef SSE2NEON_CMP_RANGES_IS_WORD - -static int _sse2neon_cmp_byte_equal_each(__m128i a, int la, __m128i b, int lb) -{ - uint8x16_t mtx = - vceqq_u8(vreinterpretq_u8_m128i(a), vreinterpretq_u8_m128i(b)); - int m0 = (la < lb) ? 0 : ((1 << la) - (1 << lb)); - int m1 = 0x10000 - (1 << la); - int tb = 0x10000 - (1 << lb); - uint8x8_t vec_mask, vec0_lo, vec0_hi, vec1_lo, vec1_hi; - uint8x8_t tmp_lo, tmp_hi, res_lo, res_hi; - vec_mask = vld1_u8(_sse2neon_cmpestr_mask8b); - vec0_lo = vtst_u8(vdup_n_u8(m0), vec_mask); - vec0_hi = vtst_u8(vdup_n_u8(m0 >> 8), vec_mask); - vec1_lo = vtst_u8(vdup_n_u8(m1), vec_mask); - vec1_hi = vtst_u8(vdup_n_u8(m1 >> 8), vec_mask); - tmp_lo = vtst_u8(vdup_n_u8(tb), vec_mask); - tmp_hi = vtst_u8(vdup_n_u8(tb >> 8), vec_mask); - - res_lo = vbsl_u8(vec0_lo, vdup_n_u8(0), vget_low_u8(mtx)); - res_hi = vbsl_u8(vec0_hi, vdup_n_u8(0), vget_high_u8(mtx)); - res_lo = vbsl_u8(vec1_lo, tmp_lo, res_lo); - res_hi = vbsl_u8(vec1_hi, tmp_hi, res_hi); - res_lo = vand_u8(res_lo, vec_mask); - res_hi = vand_u8(res_hi, vec_mask); - - int res = _sse2neon_vaddv_u8(res_lo) + (_sse2neon_vaddv_u8(res_hi) << 8); - return res; -} - -static int _sse2neon_cmp_word_equal_each(__m128i a, int la, __m128i b, int lb) -{ - uint16x8_t mtx = - vceqq_u16(vreinterpretq_u16_m128i(a), vreinterpretq_u16_m128i(b)); - int m0 = (la < lb) ? 0 : ((1 << la) - (1 << lb)); - int m1 = 0x100 - (1 << la); - int tb = 0x100 - (1 << lb); - uint16x8_t vec_mask = vld1q_u16(_sse2neon_cmpestr_mask16b); - uint16x8_t vec0 = vtstq_u16(vdupq_n_u16(m0), vec_mask); - uint16x8_t vec1 = vtstq_u16(vdupq_n_u16(m1), vec_mask); - uint16x8_t tmp = vtstq_u16(vdupq_n_u16(tb), vec_mask); - mtx = vbslq_u16(vec0, vdupq_n_u16(0), mtx); - mtx = vbslq_u16(vec1, tmp, mtx); - mtx = vandq_u16(mtx, vec_mask); - return _sse2neon_vaddvq_u16(mtx); -} - -#define SSE2NEON_AGGREGATE_EQUAL_ORDER_IS_UBYTE 1 -#define SSE2NEON_AGGREGATE_EQUAL_ORDER_IS_UWORD 0 - -#define SSE2NEON_AGGREGATE_EQUAL_ORDER_IMPL(size, number_of_lanes, data_type) \ - static int _sse2neon_aggregate_equal_ordered_##size##x##number_of_lanes( \ - int bound, int la, int lb, __m128i mtx[16]) \ - { \ - int res = 0; \ - int m1 = SSE2NEON_IIF(data_type)(0x10000, 0x100) - (1 << la); \ - uint##size##x8_t vec_mask = SSE2NEON_IIF(data_type)( \ - vld1_u##size(_sse2neon_cmpestr_mask##size##b), \ - vld1q_u##size(_sse2neon_cmpestr_mask##size##b)); \ - uint##size##x##number_of_lanes##_t vec1 = SSE2NEON_IIF(data_type)( \ - vcombine_u##size(vtst_u##size(vdup_n_u##size(m1), vec_mask), \ - vtst_u##size(vdup_n_u##size(m1 >> 8), vec_mask)), \ - vtstq_u##size(vdupq_n_u##size(m1), vec_mask)); \ - uint##size##x##number_of_lanes##_t vec_minusone = vdupq_n_u##size(-1); \ - uint##size##x##number_of_lanes##_t vec_zero = vdupq_n_u##size(0); \ - for (int j = 0; j < lb; j++) { \ - mtx[j] = vreinterpretq_m128i_u##size(vbslq_u##size( \ - vec1, vec_minusone, vreinterpretq_u##size##_m128i(mtx[j]))); \ - } \ - for (int j = lb; j < bound; j++) { \ - mtx[j] = vreinterpretq_m128i_u##size( \ - vbslq_u##size(vec1, vec_minusone, vec_zero)); \ - } \ - unsigned SSE2NEON_IIF(data_type)(char, short) *ptr = \ - (unsigned SSE2NEON_IIF(data_type)(char, short) *) mtx; \ - for (int i = 0; i < bound; i++) { \ - int val = 1; \ - for (int j = 0, k = i; j < bound - i && k < bound; j++, k++) \ - val &= ptr[k * bound + j]; \ - res += val << i; \ - } \ - return res; \ - } - -/* clang-format off */ -#define SSE2NEON_GENERATE_AGGREGATE_EQUAL_ORDER(prefix) \ - prefix##IMPL(8, 16, prefix##IS_UBYTE) \ - prefix##IMPL(16, 8, prefix##IS_UWORD) -/* clang-format on */ - -SSE2NEON_GENERATE_AGGREGATE_EQUAL_ORDER(SSE2NEON_AGGREGATE_EQUAL_ORDER_) - -#undef SSE2NEON_AGGREGATE_EQUAL_ORDER_IS_UBYTE -#undef SSE2NEON_AGGREGATE_EQUAL_ORDER_IS_UWORD - -/* clang-format off */ -#define SSE2NEON_GENERATE_CMP_EQUAL_ORDERED(prefix) \ - prefix##IMPL(byte) \ - prefix##IMPL(word) -/* clang-format on */ - -SSE2NEON_GENERATE_CMP_EQUAL_ORDERED(SSE2NEON_CMP_EQUAL_ORDERED_) - -#define SSE2NEON_CMPESTR_LIST \ - _(CMP_UBYTE_EQUAL_ANY, cmp_byte_equal_any) \ - _(CMP_UWORD_EQUAL_ANY, cmp_word_equal_any) \ - _(CMP_SBYTE_EQUAL_ANY, cmp_byte_equal_any) \ - _(CMP_SWORD_EQUAL_ANY, cmp_word_equal_any) \ - _(CMP_UBYTE_RANGES, cmp_ubyte_ranges) \ - _(CMP_UWORD_RANGES, cmp_uword_ranges) \ - _(CMP_SBYTE_RANGES, cmp_sbyte_ranges) \ - _(CMP_SWORD_RANGES, cmp_sword_ranges) \ - _(CMP_UBYTE_EQUAL_EACH, cmp_byte_equal_each) \ - _(CMP_UWORD_EQUAL_EACH, cmp_word_equal_each) \ - _(CMP_SBYTE_EQUAL_EACH, cmp_byte_equal_each) \ - _(CMP_SWORD_EQUAL_EACH, cmp_word_equal_each) \ - _(CMP_UBYTE_EQUAL_ORDERED, cmp_byte_equal_ordered) \ - _(CMP_UWORD_EQUAL_ORDERED, cmp_word_equal_ordered) \ - _(CMP_SBYTE_EQUAL_ORDERED, cmp_byte_equal_ordered) \ - _(CMP_SWORD_EQUAL_ORDERED, cmp_word_equal_ordered) - -enum { -#define _(name, func_suffix) name, - SSE2NEON_CMPESTR_LIST -#undef _ -}; -typedef int (*cmpestr_func_t)(__m128i a, int la, __m128i b, int lb); -static cmpestr_func_t _sse2neon_cmpfunc_table[] = { -#define _(name, func_suffix) _sse2neon_##func_suffix, - SSE2NEON_CMPESTR_LIST -#undef _ -}; - -FORCE_INLINE int _sse2neon_sido_negative(int res, int lb, int imm8, int bound) -{ - switch (imm8 & 0x30) { - case _SIDD_NEGATIVE_POLARITY: - res ^= 0xffffffff; - break; - case _SIDD_MASKED_NEGATIVE_POLARITY: - res ^= (1 << lb) - 1; - break; - default: - break; - } - - return res & ((bound == 8) ? 0xFF : 0xFFFF); -} - -FORCE_INLINE int _sse2neon_clz(unsigned int x) -{ -#if defined(_MSC_VER) && !defined(__clang__) - unsigned long cnt = 0; - if (_BitScanReverse(&cnt, x)) - return 31 - cnt; - return 32; -#else - return x != 0 ? __builtin_clz(x) : 32; -#endif -} - -FORCE_INLINE int _sse2neon_ctz(unsigned int x) -{ -#if defined(_MSC_VER) && !defined(__clang__) - unsigned long cnt = 0; - if (_BitScanForward(&cnt, x)) - return cnt; - return 32; -#else - return x != 0 ? __builtin_ctz(x) : 32; -#endif -} - -FORCE_INLINE int _sse2neon_ctzll(unsigned long long x) -{ -#ifdef _MSC_VER - unsigned long cnt; -#if defined(SSE2NEON_HAS_BITSCAN64) - if (_BitScanForward64(&cnt, x)) - return (int) (cnt); -#else - if (_BitScanForward(&cnt, (unsigned long) (x))) - return (int) cnt; - if (_BitScanForward(&cnt, (unsigned long) (x >> 32))) - return (int) (cnt + 32); -#endif /* SSE2NEON_HAS_BITSCAN64 */ - return 64; -#else /* assume GNU compatible compilers */ - return x != 0 ? __builtin_ctzll(x) : 64; -#endif -} - -#define SSE2NEON_MIN(x, y) (x) < (y) ? (x) : (y) - -#define SSE2NEON_CMPSTR_SET_UPPER(var, imm) \ - const int var = (imm & 0x01) ? 8 : 16 - -#define SSE2NEON_CMPESTRX_LEN_PAIR(a, b, la, lb) \ - int tmp1 = la ^ (la >> 31); \ - la = tmp1 - (la >> 31); \ - int tmp2 = lb ^ (lb >> 31); \ - lb = tmp2 - (lb >> 31); \ - la = SSE2NEON_MIN(la, bound); \ - lb = SSE2NEON_MIN(lb, bound) - -// Compare all pairs of character in string a and b, -// then aggregate the result. -// As the only difference of PCMPESTR* and PCMPISTR* is the way to calculate the -// length of string, we use SSE2NEON_CMP{I,E}STRX_GET_LEN to get the length of -// string a and b. -#define SSE2NEON_COMP_AGG(a, b, la, lb, imm8, IE) \ - SSE2NEON_CMPSTR_SET_UPPER(bound, imm8); \ - SSE2NEON_##IE##_LEN_PAIR(a, b, la, lb); \ - int r2 = (_sse2neon_cmpfunc_table[imm8 & 0x0f])(a, la, b, lb); \ - r2 = _sse2neon_sido_negative(r2, lb, imm8, bound) - -#define SSE2NEON_CMPSTR_GENERATE_INDEX(r2, bound, imm8) \ - return (r2 == 0) ? bound \ - : ((imm8 & 0x40) ? (31 - _sse2neon_clz(r2)) \ - : _sse2neon_ctz(r2)) - -#define SSE2NEON_CMPSTR_GENERATE_MASK(dst) \ - __m128i dst = vreinterpretq_m128i_u8(vdupq_n_u8(0)); \ - if (imm8 & 0x40) { \ - if (bound == 8) { \ - uint16x8_t tmp = vtstq_u16(vdupq_n_u16(r2), \ - vld1q_u16(_sse2neon_cmpestr_mask16b)); \ - dst = vreinterpretq_m128i_u16(vbslq_u16( \ - tmp, vdupq_n_u16(-1), vreinterpretq_u16_m128i(dst))); \ - } else { \ - uint8x16_t vec_r2 = \ - vcombine_u8(vdup_n_u8(r2), vdup_n_u8(r2 >> 8)); \ - uint8x16_t tmp = \ - vtstq_u8(vec_r2, vld1q_u8(_sse2neon_cmpestr_mask8b)); \ - dst = vreinterpretq_m128i_u8( \ - vbslq_u8(tmp, vdupq_n_u8(-1), vreinterpretq_u8_m128i(dst))); \ - } \ - } else { \ - if (bound == 16) { \ - dst = vreinterpretq_m128i_u16( \ - vsetq_lane_u16(r2 & 0xffff, vreinterpretq_u16_m128i(dst), 0)); \ - } else { \ - dst = vreinterpretq_m128i_u8( \ - vsetq_lane_u8(r2 & 0xff, vreinterpretq_u8_m128i(dst), 0)); \ - } \ - } \ - return dst - -// Compare packed strings in a and b with lengths la and lb using the control -// in imm8, and returns 1 if b did not contain a null character and the -// resulting mask was zero, and 0 otherwise. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpestra -FORCE_INLINE int _mm_cmpestra(__m128i a, - int la, - __m128i b, - int lb, - const int imm8) -{ - int lb_cpy = lb; - SSE2NEON_COMP_AGG(a, b, la, lb, imm8, CMPESTRX); - return !r2 & (lb_cpy > bound); -} - -// Compare packed strings in a and b with lengths la and lb using the control in -// imm8, and returns 1 if the resulting mask was non-zero, and 0 otherwise. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpestrc -FORCE_INLINE int _mm_cmpestrc(__m128i a, - int la, - __m128i b, - int lb, - const int imm8) -{ - SSE2NEON_COMP_AGG(a, b, la, lb, imm8, CMPESTRX); - return r2 != 0; -} - -// Compare packed strings in a and b with lengths la and lb using the control -// in imm8, and store the generated index in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpestri -FORCE_INLINE int _mm_cmpestri(__m128i a, - int la, - __m128i b, - int lb, - const int imm8) -{ - SSE2NEON_COMP_AGG(a, b, la, lb, imm8, CMPESTRX); - SSE2NEON_CMPSTR_GENERATE_INDEX(r2, bound, imm8); -} - -// Compare packed strings in a and b with lengths la and lb using the control -// in imm8, and store the generated mask in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpestrm -FORCE_INLINE __m128i -_mm_cmpestrm(__m128i a, int la, __m128i b, int lb, const int imm8) -{ - SSE2NEON_COMP_AGG(a, b, la, lb, imm8, CMPESTRX); - SSE2NEON_CMPSTR_GENERATE_MASK(dst); -} - -// Compare packed strings in a and b with lengths la and lb using the control in -// imm8, and returns bit 0 of the resulting bit mask. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpestro -FORCE_INLINE int _mm_cmpestro(__m128i a, - int la, - __m128i b, - int lb, - const int imm8) -{ - SSE2NEON_COMP_AGG(a, b, la, lb, imm8, CMPESTRX); - return r2 & 1; -} - -// Compare packed strings in a and b with lengths la and lb using the control in -// imm8, and returns 1 if any character in a was null, and 0 otherwise. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpestrs -FORCE_INLINE int _mm_cmpestrs(__m128i a, - int la, - __m128i b, - int lb, - const int imm8) -{ - (void) a; - (void) b; - (void) lb; - SSE2NEON_CMPSTR_SET_UPPER(bound, imm8); - return la <= (bound - 1); -} - -// Compare packed strings in a and b with lengths la and lb using the control in -// imm8, and returns 1 if any character in b was null, and 0 otherwise. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpestrz -FORCE_INLINE int _mm_cmpestrz(__m128i a, - int la, - __m128i b, - int lb, - const int imm8) -{ - (void) a; - (void) b; - (void) la; - SSE2NEON_CMPSTR_SET_UPPER(bound, imm8); - return lb <= (bound - 1); -} - -#define SSE2NEON_CMPISTRX_LENGTH(str, len, imm8) \ - do { \ - if (imm8 & 0x01) { \ - uint16x8_t equal_mask_##str = \ - vceqq_u16(vreinterpretq_u16_m128i(str), vdupq_n_u16(0)); \ - uint8x8_t res_##str = vshrn_n_u16(equal_mask_##str, 4); \ - uint64_t matches_##str = \ - vget_lane_u64(vreinterpret_u64_u8(res_##str), 0); \ - len = _sse2neon_ctzll(matches_##str) >> 3; \ - } else { \ - uint16x8_t equal_mask_##str = vreinterpretq_u16_u8( \ - vceqq_u8(vreinterpretq_u8_m128i(str), vdupq_n_u8(0))); \ - uint8x8_t res_##str = vshrn_n_u16(equal_mask_##str, 4); \ - uint64_t matches_##str = \ - vget_lane_u64(vreinterpret_u64_u8(res_##str), 0); \ - len = _sse2neon_ctzll(matches_##str) >> 2; \ - } \ - } while (0) - -#define SSE2NEON_CMPISTRX_LEN_PAIR(a, b, la, lb) \ - int la, lb; \ - do { \ - SSE2NEON_CMPISTRX_LENGTH(a, la, imm8); \ - SSE2NEON_CMPISTRX_LENGTH(b, lb, imm8); \ - } while (0) - -// Compare packed strings with implicit lengths in a and b using the control in -// imm8, and returns 1 if b did not contain a null character and the resulting -// mask was zero, and 0 otherwise. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpistra -FORCE_INLINE int _mm_cmpistra(__m128i a, __m128i b, const int imm8) -{ - SSE2NEON_COMP_AGG(a, b, la, lb, imm8, CMPISTRX); - return !r2 & (lb >= bound); -} - -// Compare packed strings with implicit lengths in a and b using the control in -// imm8, and returns 1 if the resulting mask was non-zero, and 0 otherwise. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpistrc -FORCE_INLINE int _mm_cmpistrc(__m128i a, __m128i b, const int imm8) -{ - SSE2NEON_COMP_AGG(a, b, la, lb, imm8, CMPISTRX); - return r2 != 0; -} - -// Compare packed strings with implicit lengths in a and b using the control in -// imm8, and store the generated index in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpistri -FORCE_INLINE int _mm_cmpistri(__m128i a, __m128i b, const int imm8) -{ - SSE2NEON_COMP_AGG(a, b, la, lb, imm8, CMPISTRX); - SSE2NEON_CMPSTR_GENERATE_INDEX(r2, bound, imm8); -} - -// Compare packed strings with implicit lengths in a and b using the control in -// imm8, and store the generated mask in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpistrm -FORCE_INLINE __m128i _mm_cmpistrm(__m128i a, __m128i b, const int imm8) -{ - SSE2NEON_COMP_AGG(a, b, la, lb, imm8, CMPISTRX); - SSE2NEON_CMPSTR_GENERATE_MASK(dst); -} - -// Compare packed strings with implicit lengths in a and b using the control in -// imm8, and returns bit 0 of the resulting bit mask. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpistro -FORCE_INLINE int _mm_cmpistro(__m128i a, __m128i b, const int imm8) -{ - SSE2NEON_COMP_AGG(a, b, la, lb, imm8, CMPISTRX); - return r2 & 1; -} - -// Compare packed strings with implicit lengths in a and b using the control in -// imm8, and returns 1 if any character in a was null, and 0 otherwise. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpistrs -FORCE_INLINE int _mm_cmpistrs(__m128i a, __m128i b, const int imm8) -{ - (void) b; - SSE2NEON_CMPSTR_SET_UPPER(bound, imm8); - int la; - SSE2NEON_CMPISTRX_LENGTH(a, la, imm8); - return la <= (bound - 1); -} - -// Compare packed strings with implicit lengths in a and b using the control in -// imm8, and returns 1 if any character in b was null, and 0 otherwise. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_cmpistrz -FORCE_INLINE int _mm_cmpistrz(__m128i a, __m128i b, const int imm8) -{ - (void) a; - SSE2NEON_CMPSTR_SET_UPPER(bound, imm8); - int lb; - SSE2NEON_CMPISTRX_LENGTH(b, lb, imm8); - return lb <= (bound - 1); -} - -// Compares the 2 signed 64-bit integers in a and the 2 signed 64-bit integers -// in b for greater than. -FORCE_INLINE __m128i _mm_cmpgt_epi64(__m128i a, __m128i b) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - return vreinterpretq_m128i_u64( - vcgtq_s64(vreinterpretq_s64_m128i(a), vreinterpretq_s64_m128i(b))); -#else - return vreinterpretq_m128i_s64(vshrq_n_s64( - vqsubq_s64(vreinterpretq_s64_m128i(b), vreinterpretq_s64_m128i(a)), - 63)); -#endif -} - -// Starting with the initial value in crc, accumulates a CRC32 value for -// unsigned 16-bit integer v, and stores the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_crc32_u16 -FORCE_INLINE uint32_t _mm_crc32_u16(uint32_t crc, uint16_t v) -{ -#if defined(__aarch64__) && defined(__ARM_FEATURE_CRC32) - __asm__ __volatile__("crc32ch %w[c], %w[c], %w[v]\n\t" - : [c] "+r"(crc) - : [v] "r"(v)); -#elif ((__ARM_ARCH == 8) && defined(__ARM_FEATURE_CRC32)) || \ - (defined(_M_ARM64) && !defined(__clang__)) - crc = __crc32ch(crc, v); -#else - crc = _mm_crc32_u8(crc, v & 0xff); - crc = _mm_crc32_u8(crc, (v >> 8) & 0xff); -#endif - return crc; -} - -// Starting with the initial value in crc, accumulates a CRC32 value for -// unsigned 32-bit integer v, and stores the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_crc32_u32 -FORCE_INLINE uint32_t _mm_crc32_u32(uint32_t crc, uint32_t v) -{ -#if defined(__aarch64__) && defined(__ARM_FEATURE_CRC32) - __asm__ __volatile__("crc32cw %w[c], %w[c], %w[v]\n\t" - : [c] "+r"(crc) - : [v] "r"(v)); -#elif ((__ARM_ARCH == 8) && defined(__ARM_FEATURE_CRC32)) || \ - (defined(_M_ARM64) && !defined(__clang__)) - crc = __crc32cw(crc, v); -#else - crc = _mm_crc32_u16(crc, v & 0xffff); - crc = _mm_crc32_u16(crc, (v >> 16) & 0xffff); -#endif - return crc; -} - -// Starting with the initial value in crc, accumulates a CRC32 value for -// unsigned 64-bit integer v, and stores the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_crc32_u64 -FORCE_INLINE uint64_t _mm_crc32_u64(uint64_t crc, uint64_t v) -{ -#if defined(__aarch64__) && defined(__ARM_FEATURE_CRC32) - __asm__ __volatile__("crc32cx %w[c], %w[c], %x[v]\n\t" - : [c] "+r"(crc) - : [v] "r"(v)); -#elif (defined(_M_ARM64) && !defined(__clang__)) - crc = __crc32cd((uint32_t) crc, v); -#else - crc = _mm_crc32_u32((uint32_t) (crc), v & 0xffffffff); - crc = _mm_crc32_u32((uint32_t) (crc), (v >> 32) & 0xffffffff); -#endif - return crc; -} - -// Starting with the initial value in crc, accumulates a CRC32 value for -// unsigned 8-bit integer v, and stores the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_crc32_u8 -FORCE_INLINE uint32_t _mm_crc32_u8(uint32_t crc, uint8_t v) -{ -#if defined(__aarch64__) && defined(__ARM_FEATURE_CRC32) - __asm__ __volatile__("crc32cb %w[c], %w[c], %w[v]\n\t" - : [c] "+r"(crc) - : [v] "r"(v)); -#elif ((__ARM_ARCH == 8) && defined(__ARM_FEATURE_CRC32)) || \ - (defined(_M_ARM64) && !defined(__clang__)) - crc = __crc32cb(crc, v); -#else - crc ^= v; -#if defined(__ARM_FEATURE_CRYPTO) - // Adapted from: https://mary.rs/lab/crc32/ - // Barrent reduction - uint64x2_t orig = - vcombine_u64(vcreate_u64((uint64_t) (crc) << 24), vcreate_u64(0x0)); - uint64x2_t tmp = orig; - - // Polynomial P(x) of CRC32C - uint64_t p = 0x105EC76F1; - // Barrett Reduction (in bit-reflected form) constant mu_{64} = \lfloor - // 2^{64} / P(x) \rfloor = 0x11f91caf6 - uint64_t mu = 0x1dea713f1; - - // Multiply by mu_{64} - tmp = _sse2neon_vmull_p64(vget_low_u64(tmp), vcreate_u64(mu)); - // Divide by 2^{64} (mask away the unnecessary bits) - tmp = - vandq_u64(tmp, vcombine_u64(vcreate_u64(0xFFFFFFFF), vcreate_u64(0x0))); - // Multiply by P(x) (shifted left by 1 for alignment reasons) - tmp = _sse2neon_vmull_p64(vget_low_u64(tmp), vcreate_u64(p)); - // Subtract original from result - tmp = veorq_u64(tmp, orig); - - // Extract the 'lower' (in bit-reflected sense) 32 bits - crc = vgetq_lane_u32(vreinterpretq_u32_u64(tmp), 1); -#else // Fall back to the generic table lookup approach - // Adapted from: https://create.stephan-brumme.com/crc32/ - // Apply half-byte comparison algorithm for the best ratio between - // performance and lookup table. - - // The lookup table just needs to store every 16th entry - // of the standard look-up table. - static const uint32_t crc32_half_byte_tbl[] = { - 0x00000000, 0x105ec76f, 0x20bd8ede, 0x30e349b1, 0x417b1dbc, 0x5125dad3, - 0x61c69362, 0x7198540d, 0x82f63b78, 0x92a8fc17, 0xa24bb5a6, 0xb21572c9, - 0xc38d26c4, 0xd3d3e1ab, 0xe330a81a, 0xf36e6f75, - }; - - crc = (crc >> 4) ^ crc32_half_byte_tbl[crc & 0x0F]; - crc = (crc >> 4) ^ crc32_half_byte_tbl[crc & 0x0F]; -#endif -#endif - return crc; -} - -/* AES */ - -#if !defined(__ARM_FEATURE_CRYPTO) && (!defined(_M_ARM64) || defined(__clang__)) -/* clang-format off */ -#define SSE2NEON_AES_SBOX(w) \ - { \ - w(0x63), w(0x7c), w(0x77), w(0x7b), w(0xf2), w(0x6b), w(0x6f), \ - w(0xc5), w(0x30), w(0x01), w(0x67), w(0x2b), w(0xfe), w(0xd7), \ - w(0xab), w(0x76), w(0xca), w(0x82), w(0xc9), w(0x7d), w(0xfa), \ - w(0x59), w(0x47), w(0xf0), w(0xad), w(0xd4), w(0xa2), w(0xaf), \ - w(0x9c), w(0xa4), w(0x72), w(0xc0), w(0xb7), w(0xfd), w(0x93), \ - w(0x26), w(0x36), w(0x3f), w(0xf7), w(0xcc), w(0x34), w(0xa5), \ - w(0xe5), w(0xf1), w(0x71), w(0xd8), w(0x31), w(0x15), w(0x04), \ - w(0xc7), w(0x23), w(0xc3), w(0x18), w(0x96), w(0x05), w(0x9a), \ - w(0x07), w(0x12), w(0x80), w(0xe2), w(0xeb), w(0x27), w(0xb2), \ - w(0x75), w(0x09), w(0x83), w(0x2c), w(0x1a), w(0x1b), w(0x6e), \ - w(0x5a), w(0xa0), w(0x52), w(0x3b), w(0xd6), w(0xb3), w(0x29), \ - w(0xe3), w(0x2f), w(0x84), w(0x53), w(0xd1), w(0x00), w(0xed), \ - w(0x20), w(0xfc), w(0xb1), w(0x5b), w(0x6a), w(0xcb), w(0xbe), \ - w(0x39), w(0x4a), w(0x4c), w(0x58), w(0xcf), w(0xd0), w(0xef), \ - w(0xaa), w(0xfb), w(0x43), w(0x4d), w(0x33), w(0x85), w(0x45), \ - w(0xf9), w(0x02), w(0x7f), w(0x50), w(0x3c), w(0x9f), w(0xa8), \ - w(0x51), w(0xa3), w(0x40), w(0x8f), w(0x92), w(0x9d), w(0x38), \ - w(0xf5), w(0xbc), w(0xb6), w(0xda), w(0x21), w(0x10), w(0xff), \ - w(0xf3), w(0xd2), w(0xcd), w(0x0c), w(0x13), w(0xec), w(0x5f), \ - w(0x97), w(0x44), w(0x17), w(0xc4), w(0xa7), w(0x7e), w(0x3d), \ - w(0x64), w(0x5d), w(0x19), w(0x73), w(0x60), w(0x81), w(0x4f), \ - w(0xdc), w(0x22), w(0x2a), w(0x90), w(0x88), w(0x46), w(0xee), \ - w(0xb8), w(0x14), w(0xde), w(0x5e), w(0x0b), w(0xdb), w(0xe0), \ - w(0x32), w(0x3a), w(0x0a), w(0x49), w(0x06), w(0x24), w(0x5c), \ - w(0xc2), w(0xd3), w(0xac), w(0x62), w(0x91), w(0x95), w(0xe4), \ - w(0x79), w(0xe7), w(0xc8), w(0x37), w(0x6d), w(0x8d), w(0xd5), \ - w(0x4e), w(0xa9), w(0x6c), w(0x56), w(0xf4), w(0xea), w(0x65), \ - w(0x7a), w(0xae), w(0x08), w(0xba), w(0x78), w(0x25), w(0x2e), \ - w(0x1c), w(0xa6), w(0xb4), w(0xc6), w(0xe8), w(0xdd), w(0x74), \ - w(0x1f), w(0x4b), w(0xbd), w(0x8b), w(0x8a), w(0x70), w(0x3e), \ - w(0xb5), w(0x66), w(0x48), w(0x03), w(0xf6), w(0x0e), w(0x61), \ - w(0x35), w(0x57), w(0xb9), w(0x86), w(0xc1), w(0x1d), w(0x9e), \ - w(0xe1), w(0xf8), w(0x98), w(0x11), w(0x69), w(0xd9), w(0x8e), \ - w(0x94), w(0x9b), w(0x1e), w(0x87), w(0xe9), w(0xce), w(0x55), \ - w(0x28), w(0xdf), w(0x8c), w(0xa1), w(0x89), w(0x0d), w(0xbf), \ - w(0xe6), w(0x42), w(0x68), w(0x41), w(0x99), w(0x2d), w(0x0f), \ - w(0xb0), w(0x54), w(0xbb), w(0x16) \ - } -#define SSE2NEON_AES_RSBOX(w) \ - { \ - w(0x52), w(0x09), w(0x6a), w(0xd5), w(0x30), w(0x36), w(0xa5), \ - w(0x38), w(0xbf), w(0x40), w(0xa3), w(0x9e), w(0x81), w(0xf3), \ - w(0xd7), w(0xfb), w(0x7c), w(0xe3), w(0x39), w(0x82), w(0x9b), \ - w(0x2f), w(0xff), w(0x87), w(0x34), w(0x8e), w(0x43), w(0x44), \ - w(0xc4), w(0xde), w(0xe9), w(0xcb), w(0x54), w(0x7b), w(0x94), \ - w(0x32), w(0xa6), w(0xc2), w(0x23), w(0x3d), w(0xee), w(0x4c), \ - w(0x95), w(0x0b), w(0x42), w(0xfa), w(0xc3), w(0x4e), w(0x08), \ - w(0x2e), w(0xa1), w(0x66), w(0x28), w(0xd9), w(0x24), w(0xb2), \ - w(0x76), w(0x5b), w(0xa2), w(0x49), w(0x6d), w(0x8b), w(0xd1), \ - w(0x25), w(0x72), w(0xf8), w(0xf6), w(0x64), w(0x86), w(0x68), \ - w(0x98), w(0x16), w(0xd4), w(0xa4), w(0x5c), w(0xcc), w(0x5d), \ - w(0x65), w(0xb6), w(0x92), w(0x6c), w(0x70), w(0x48), w(0x50), \ - w(0xfd), w(0xed), w(0xb9), w(0xda), w(0x5e), w(0x15), w(0x46), \ - w(0x57), w(0xa7), w(0x8d), w(0x9d), w(0x84), w(0x90), w(0xd8), \ - w(0xab), w(0x00), w(0x8c), w(0xbc), w(0xd3), w(0x0a), w(0xf7), \ - w(0xe4), w(0x58), w(0x05), w(0xb8), w(0xb3), w(0x45), w(0x06), \ - w(0xd0), w(0x2c), w(0x1e), w(0x8f), w(0xca), w(0x3f), w(0x0f), \ - w(0x02), w(0xc1), w(0xaf), w(0xbd), w(0x03), w(0x01), w(0x13), \ - w(0x8a), w(0x6b), w(0x3a), w(0x91), w(0x11), w(0x41), w(0x4f), \ - w(0x67), w(0xdc), w(0xea), w(0x97), w(0xf2), w(0xcf), w(0xce), \ - w(0xf0), w(0xb4), w(0xe6), w(0x73), w(0x96), w(0xac), w(0x74), \ - w(0x22), w(0xe7), w(0xad), w(0x35), w(0x85), w(0xe2), w(0xf9), \ - w(0x37), w(0xe8), w(0x1c), w(0x75), w(0xdf), w(0x6e), w(0x47), \ - w(0xf1), w(0x1a), w(0x71), w(0x1d), w(0x29), w(0xc5), w(0x89), \ - w(0x6f), w(0xb7), w(0x62), w(0x0e), w(0xaa), w(0x18), w(0xbe), \ - w(0x1b), w(0xfc), w(0x56), w(0x3e), w(0x4b), w(0xc6), w(0xd2), \ - w(0x79), w(0x20), w(0x9a), w(0xdb), w(0xc0), w(0xfe), w(0x78), \ - w(0xcd), w(0x5a), w(0xf4), w(0x1f), w(0xdd), w(0xa8), w(0x33), \ - w(0x88), w(0x07), w(0xc7), w(0x31), w(0xb1), w(0x12), w(0x10), \ - w(0x59), w(0x27), w(0x80), w(0xec), w(0x5f), w(0x60), w(0x51), \ - w(0x7f), w(0xa9), w(0x19), w(0xb5), w(0x4a), w(0x0d), w(0x2d), \ - w(0xe5), w(0x7a), w(0x9f), w(0x93), w(0xc9), w(0x9c), w(0xef), \ - w(0xa0), w(0xe0), w(0x3b), w(0x4d), w(0xae), w(0x2a), w(0xf5), \ - w(0xb0), w(0xc8), w(0xeb), w(0xbb), w(0x3c), w(0x83), w(0x53), \ - w(0x99), w(0x61), w(0x17), w(0x2b), w(0x04), w(0x7e), w(0xba), \ - w(0x77), w(0xd6), w(0x26), w(0xe1), w(0x69), w(0x14), w(0x63), \ - w(0x55), w(0x21), w(0x0c), w(0x7d) \ - } -/* clang-format on */ - -/* X Macro trick. See https://en.wikipedia.org/wiki/X_Macro */ -#define SSE2NEON_AES_H0(x) (x) -static const uint8_t _sse2neon_sbox[256] = SSE2NEON_AES_SBOX(SSE2NEON_AES_H0); -static const uint8_t _sse2neon_rsbox[256] = SSE2NEON_AES_RSBOX(SSE2NEON_AES_H0); -#undef SSE2NEON_AES_H0 - -/* x_time function and matrix multiply function */ -#if !defined(__aarch64__) && !defined(_M_ARM64) -#define SSE2NEON_XT(x) (((x) << 1) ^ ((((x) >> 7) & 1) * 0x1b)) -#define SSE2NEON_MULTIPLY(x, y) \ - (((y & 1) * x) ^ ((y >> 1 & 1) * SSE2NEON_XT(x)) ^ \ - ((y >> 2 & 1) * SSE2NEON_XT(SSE2NEON_XT(x))) ^ \ - ((y >> 3 & 1) * SSE2NEON_XT(SSE2NEON_XT(SSE2NEON_XT(x)))) ^ \ - ((y >> 4 & 1) * SSE2NEON_XT(SSE2NEON_XT(SSE2NEON_XT(SSE2NEON_XT(x)))))) -#endif - -// In the absence of crypto extensions, implement aesenc using regular NEON -// intrinsics instead. See: -// https://www.workofard.com/2017/01/accelerated-aes-for-the-arm64-linux-kernel/ -// https://www.workofard.com/2017/07/ghash-for-low-end-cores/ and -// for more information. -FORCE_INLINE __m128i _mm_aesenc_si128(__m128i a, __m128i RoundKey) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - static const uint8_t shift_rows[] = { - 0x0, 0x5, 0xa, 0xf, 0x4, 0x9, 0xe, 0x3, - 0x8, 0xd, 0x2, 0x7, 0xc, 0x1, 0x6, 0xb, - }; - static const uint8_t ror32by8[] = { - 0x1, 0x2, 0x3, 0x0, 0x5, 0x6, 0x7, 0x4, - 0x9, 0xa, 0xb, 0x8, 0xd, 0xe, 0xf, 0xc, - }; - - uint8x16_t v; - uint8x16_t w = vreinterpretq_u8_m128i(a); - - /* shift rows */ - w = vqtbl1q_u8(w, vld1q_u8(shift_rows)); - - /* sub bytes */ - // Here, we separate the whole 256-bytes table into 4 64-bytes tables, and - // look up each of the table. After each lookup, we load the next table - // which locates at the next 64-bytes. In the meantime, the index in the - // table would be smaller than it was, so the index parameters of - // `vqtbx4q_u8()` need to be added the same constant as the loaded tables. - v = vqtbl4q_u8(_sse2neon_vld1q_u8_x4(_sse2neon_sbox), w); - // 'w-0x40' equals to 'vsubq_u8(w, vdupq_n_u8(0x40))' - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_sbox + 0x40), w - 0x40); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_sbox + 0x80), w - 0x80); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_sbox + 0xc0), w - 0xc0); - - /* mix columns */ - w = (v << 1) ^ (uint8x16_t) (((int8x16_t) v >> 7) & 0x1b); - w ^= (uint8x16_t) vrev32q_u16((uint16x8_t) v); - w ^= vqtbl1q_u8(v ^ w, vld1q_u8(ror32by8)); - - /* add round key */ - return vreinterpretq_m128i_u8(w) ^ RoundKey; - -#else /* ARMv7-A implementation for a table-based AES */ -#define SSE2NEON_AES_B2W(b0, b1, b2, b3) \ - (((uint32_t) (b3) << 24) | ((uint32_t) (b2) << 16) | \ - ((uint32_t) (b1) << 8) | (uint32_t) (b0)) -// multiplying 'x' by 2 in GF(2^8) -#define SSE2NEON_AES_F2(x) ((x << 1) ^ (((x >> 7) & 1) * 0x011b /* WPOLY */)) -// multiplying 'x' by 3 in GF(2^8) -#define SSE2NEON_AES_F3(x) (SSE2NEON_AES_F2(x) ^ x) -#define SSE2NEON_AES_U0(p) \ - SSE2NEON_AES_B2W(SSE2NEON_AES_F2(p), p, p, SSE2NEON_AES_F3(p)) -#define SSE2NEON_AES_U1(p) \ - SSE2NEON_AES_B2W(SSE2NEON_AES_F3(p), SSE2NEON_AES_F2(p), p, p) -#define SSE2NEON_AES_U2(p) \ - SSE2NEON_AES_B2W(p, SSE2NEON_AES_F3(p), SSE2NEON_AES_F2(p), p) -#define SSE2NEON_AES_U3(p) \ - SSE2NEON_AES_B2W(p, p, SSE2NEON_AES_F3(p), SSE2NEON_AES_F2(p)) - - // this generates a table containing every possible permutation of - // shift_rows() and sub_bytes() with mix_columns(). - static const uint32_t ALIGN_STRUCT(16) aes_table[4][256] = { - SSE2NEON_AES_SBOX(SSE2NEON_AES_U0), - SSE2NEON_AES_SBOX(SSE2NEON_AES_U1), - SSE2NEON_AES_SBOX(SSE2NEON_AES_U2), - SSE2NEON_AES_SBOX(SSE2NEON_AES_U3), - }; -#undef SSE2NEON_AES_B2W -#undef SSE2NEON_AES_F2 -#undef SSE2NEON_AES_F3 -#undef SSE2NEON_AES_U0 -#undef SSE2NEON_AES_U1 -#undef SSE2NEON_AES_U2 -#undef SSE2NEON_AES_U3 - - uint32_t x0 = _mm_cvtsi128_si32(a); // get a[31:0] - uint32_t x1 = - _mm_cvtsi128_si32(_mm_shuffle_epi32(a, 0x55)); // get a[63:32] - uint32_t x2 = - _mm_cvtsi128_si32(_mm_shuffle_epi32(a, 0xAA)); // get a[95:64] - uint32_t x3 = - _mm_cvtsi128_si32(_mm_shuffle_epi32(a, 0xFF)); // get a[127:96] - - // finish the modulo addition step in mix_columns() - __m128i out = _mm_set_epi32( - (aes_table[0][x3 & 0xff] ^ aes_table[1][(x0 >> 8) & 0xff] ^ - aes_table[2][(x1 >> 16) & 0xff] ^ aes_table[3][x2 >> 24]), - (aes_table[0][x2 & 0xff] ^ aes_table[1][(x3 >> 8) & 0xff] ^ - aes_table[2][(x0 >> 16) & 0xff] ^ aes_table[3][x1 >> 24]), - (aes_table[0][x1 & 0xff] ^ aes_table[1][(x2 >> 8) & 0xff] ^ - aes_table[2][(x3 >> 16) & 0xff] ^ aes_table[3][x0 >> 24]), - (aes_table[0][x0 & 0xff] ^ aes_table[1][(x1 >> 8) & 0xff] ^ - aes_table[2][(x2 >> 16) & 0xff] ^ aes_table[3][x3 >> 24])); - - return _mm_xor_si128(out, RoundKey); -#endif -} - -// Perform one round of an AES decryption flow on data (state) in a using the -// round key in RoundKey, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_aesdec_si128 -FORCE_INLINE __m128i _mm_aesdec_si128(__m128i a, __m128i RoundKey) -{ -#if defined(__aarch64__) - static const uint8_t inv_shift_rows[] = { - 0x0, 0xd, 0xa, 0x7, 0x4, 0x1, 0xe, 0xb, - 0x8, 0x5, 0x2, 0xf, 0xc, 0x9, 0x6, 0x3, - }; - static const uint8_t ror32by8[] = { - 0x1, 0x2, 0x3, 0x0, 0x5, 0x6, 0x7, 0x4, - 0x9, 0xa, 0xb, 0x8, 0xd, 0xe, 0xf, 0xc, - }; - - uint8x16_t v; - uint8x16_t w = vreinterpretq_u8_m128i(a); - - // inverse shift rows - w = vqtbl1q_u8(w, vld1q_u8(inv_shift_rows)); - - // inverse sub bytes - v = vqtbl4q_u8(_sse2neon_vld1q_u8_x4(_sse2neon_rsbox), w); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_rsbox + 0x40), w - 0x40); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_rsbox + 0x80), w - 0x80); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_rsbox + 0xc0), w - 0xc0); - - // inverse mix columns - // multiplying 'v' by 4 in GF(2^8) - w = (v << 1) ^ (uint8x16_t) (((int8x16_t) v >> 7) & 0x1b); - w = (w << 1) ^ (uint8x16_t) (((int8x16_t) w >> 7) & 0x1b); - v ^= w; - v ^= (uint8x16_t) vrev32q_u16((uint16x8_t) w); - - w = (v << 1) ^ (uint8x16_t) (((int8x16_t) v >> 7) & - 0x1b); // multiplying 'v' by 2 in GF(2^8) - w ^= (uint8x16_t) vrev32q_u16((uint16x8_t) v); - w ^= vqtbl1q_u8(v ^ w, vld1q_u8(ror32by8)); - - // add round key - return vreinterpretq_m128i_u8(w) ^ RoundKey; - -#else /* ARMv7-A NEON implementation */ - /* FIXME: optimized for NEON */ - uint8_t i, e, f, g, h, v[4][4]; - uint8_t *_a = (uint8_t *) &a; - for (i = 0; i < 16; ++i) { - v[((i / 4) + (i % 4)) % 4][i % 4] = _sse2neon_rsbox[_a[i]]; - } - - // inverse mix columns - for (i = 0; i < 4; ++i) { - e = v[i][0]; - f = v[i][1]; - g = v[i][2]; - h = v[i][3]; - - v[i][0] = SSE2NEON_MULTIPLY(e, 0x0e) ^ SSE2NEON_MULTIPLY(f, 0x0b) ^ - SSE2NEON_MULTIPLY(g, 0x0d) ^ SSE2NEON_MULTIPLY(h, 0x09); - v[i][1] = SSE2NEON_MULTIPLY(e, 0x09) ^ SSE2NEON_MULTIPLY(f, 0x0e) ^ - SSE2NEON_MULTIPLY(g, 0x0b) ^ SSE2NEON_MULTIPLY(h, 0x0d); - v[i][2] = SSE2NEON_MULTIPLY(e, 0x0d) ^ SSE2NEON_MULTIPLY(f, 0x09) ^ - SSE2NEON_MULTIPLY(g, 0x0e) ^ SSE2NEON_MULTIPLY(h, 0x0b); - v[i][3] = SSE2NEON_MULTIPLY(e, 0x0b) ^ SSE2NEON_MULTIPLY(f, 0x0d) ^ - SSE2NEON_MULTIPLY(g, 0x09) ^ SSE2NEON_MULTIPLY(h, 0x0e); - } - - return vreinterpretq_m128i_u8(vld1q_u8((uint8_t *) v)) ^ RoundKey; -#endif -} - -// Perform the last round of an AES encryption flow on data (state) in a using -// the round key in RoundKey, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_aesenclast_si128 -FORCE_INLINE __m128i _mm_aesenclast_si128(__m128i a, __m128i RoundKey) -{ -#if defined(__aarch64__) - static const uint8_t shift_rows[] = { - 0x0, 0x5, 0xa, 0xf, 0x4, 0x9, 0xe, 0x3, - 0x8, 0xd, 0x2, 0x7, 0xc, 0x1, 0x6, 0xb, - }; - - uint8x16_t v; - uint8x16_t w = vreinterpretq_u8_m128i(a); - - // shift rows - w = vqtbl1q_u8(w, vld1q_u8(shift_rows)); - - // sub bytes - v = vqtbl4q_u8(_sse2neon_vld1q_u8_x4(_sse2neon_sbox), w); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_sbox + 0x40), w - 0x40); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_sbox + 0x80), w - 0x80); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_sbox + 0xc0), w - 0xc0); - - // add round key - return vreinterpretq_m128i_u8(v) ^ RoundKey; - -#else /* ARMv7-A implementation */ - uint8_t v[16] = { - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 0)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 5)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 10)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 15)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 4)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 9)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 14)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 3)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 8)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 13)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 2)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 7)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 12)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 1)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 6)], - _sse2neon_sbox[vgetq_lane_u8(vreinterpretq_u8_m128i(a), 11)], - }; - - return vreinterpretq_m128i_u8(vld1q_u8(v)) ^ RoundKey; -#endif -} - -// Perform the last round of an AES decryption flow on data (state) in a using -// the round key in RoundKey, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_aesdeclast_si128 -FORCE_INLINE __m128i _mm_aesdeclast_si128(__m128i a, __m128i RoundKey) -{ -#if defined(__aarch64__) - static const uint8_t inv_shift_rows[] = { - 0x0, 0xd, 0xa, 0x7, 0x4, 0x1, 0xe, 0xb, - 0x8, 0x5, 0x2, 0xf, 0xc, 0x9, 0x6, 0x3, - }; - - uint8x16_t v; - uint8x16_t w = vreinterpretq_u8_m128i(a); - - // inverse shift rows - w = vqtbl1q_u8(w, vld1q_u8(inv_shift_rows)); - - // inverse sub bytes - v = vqtbl4q_u8(_sse2neon_vld1q_u8_x4(_sse2neon_rsbox), w); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_rsbox + 0x40), w - 0x40); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_rsbox + 0x80), w - 0x80); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_rsbox + 0xc0), w - 0xc0); - - // add round key - return vreinterpretq_m128i_u8(v) ^ RoundKey; - -#else /* ARMv7-A NEON implementation */ - /* FIXME: optimized for NEON */ - uint8_t v[4][4]; - uint8_t *_a = (uint8_t *) &a; - for (int i = 0; i < 16; ++i) { - v[((i / 4) + (i % 4)) % 4][i % 4] = _sse2neon_rsbox[_a[i]]; - } - - return vreinterpretq_m128i_u8(vld1q_u8((uint8_t *) v)) ^ RoundKey; -#endif -} - -// Perform the InvMixColumns transformation on a and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_aesimc_si128 -FORCE_INLINE __m128i _mm_aesimc_si128(__m128i a) -{ -#if defined(__aarch64__) - static const uint8_t ror32by8[] = { - 0x1, 0x2, 0x3, 0x0, 0x5, 0x6, 0x7, 0x4, - 0x9, 0xa, 0xb, 0x8, 0xd, 0xe, 0xf, 0xc, - }; - uint8x16_t v = vreinterpretq_u8_m128i(a); - uint8x16_t w; - - // multiplying 'v' by 4 in GF(2^8) - w = (v << 1) ^ (uint8x16_t) (((int8x16_t) v >> 7) & 0x1b); - w = (w << 1) ^ (uint8x16_t) (((int8x16_t) w >> 7) & 0x1b); - v ^= w; - v ^= (uint8x16_t) vrev32q_u16((uint16x8_t) w); - - // multiplying 'v' by 2 in GF(2^8) - w = (v << 1) ^ (uint8x16_t) (((int8x16_t) v >> 7) & 0x1b); - w ^= (uint8x16_t) vrev32q_u16((uint16x8_t) v); - w ^= vqtbl1q_u8(v ^ w, vld1q_u8(ror32by8)); - return vreinterpretq_m128i_u8(w); - -#else /* ARMv7-A NEON implementation */ - uint8_t i, e, f, g, h, v[4][4]; - vst1q_u8((uint8_t *) v, vreinterpretq_u8_m128i(a)); - for (i = 0; i < 4; ++i) { - e = v[i][0]; - f = v[i][1]; - g = v[i][2]; - h = v[i][3]; - - v[i][0] = SSE2NEON_MULTIPLY(e, 0x0e) ^ SSE2NEON_MULTIPLY(f, 0x0b) ^ - SSE2NEON_MULTIPLY(g, 0x0d) ^ SSE2NEON_MULTIPLY(h, 0x09); - v[i][1] = SSE2NEON_MULTIPLY(e, 0x09) ^ SSE2NEON_MULTIPLY(f, 0x0e) ^ - SSE2NEON_MULTIPLY(g, 0x0b) ^ SSE2NEON_MULTIPLY(h, 0x0d); - v[i][2] = SSE2NEON_MULTIPLY(e, 0x0d) ^ SSE2NEON_MULTIPLY(f, 0x09) ^ - SSE2NEON_MULTIPLY(g, 0x0e) ^ SSE2NEON_MULTIPLY(h, 0x0b); - v[i][3] = SSE2NEON_MULTIPLY(e, 0x0b) ^ SSE2NEON_MULTIPLY(f, 0x0d) ^ - SSE2NEON_MULTIPLY(g, 0x09) ^ SSE2NEON_MULTIPLY(h, 0x0e); - } - - return vreinterpretq_m128i_u8(vld1q_u8((uint8_t *) v)); -#endif -} - -// Assist in expanding the AES cipher key by computing steps towards generating -// a round key for encryption cipher using data from a and an 8-bit round -// constant specified in imm8, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_aeskeygenassist_si128 -// -// Emits the Advanced Encryption Standard (AES) instruction aeskeygenassist. -// This instruction generates a round key for AES encryption. See -// https://kazakov.life/2017/11/01/cryptocurrency-mining-on-ios-devices/ -// for details. -FORCE_INLINE __m128i _mm_aeskeygenassist_si128(__m128i a, const int rcon) -{ -#if defined(__aarch64__) - uint8x16_t _a = vreinterpretq_u8_m128i(a); - uint8x16_t v = vqtbl4q_u8(_sse2neon_vld1q_u8_x4(_sse2neon_sbox), _a); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_sbox + 0x40), _a - 0x40); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_sbox + 0x80), _a - 0x80); - v = vqtbx4q_u8(v, _sse2neon_vld1q_u8_x4(_sse2neon_sbox + 0xc0), _a - 0xc0); - - uint32x4_t v_u32 = vreinterpretq_u32_u8(v); - uint32x4_t ror_v = vorrq_u32(vshrq_n_u32(v_u32, 8), vshlq_n_u32(v_u32, 24)); - uint32x4_t ror_xor_v = veorq_u32(ror_v, vdupq_n_u32(rcon)); - - return vreinterpretq_m128i_u32(vtrn2q_u32(v_u32, ror_xor_v)); - -#else /* ARMv7-A NEON implementation */ - uint32_t X1 = _mm_cvtsi128_si32(_mm_shuffle_epi32(a, 0x55)); - uint32_t X3 = _mm_cvtsi128_si32(_mm_shuffle_epi32(a, 0xFF)); - for (int i = 0; i < 4; ++i) { - ((uint8_t *) &X1)[i] = _sse2neon_sbox[((uint8_t *) &X1)[i]]; - ((uint8_t *) &X3)[i] = _sse2neon_sbox[((uint8_t *) &X3)[i]]; - } - return _mm_set_epi32(((X3 >> 8) | (X3 << 24)) ^ rcon, X3, - ((X1 >> 8) | (X1 << 24)) ^ rcon, X1); -#endif -} -#undef SSE2NEON_AES_SBOX -#undef SSE2NEON_AES_RSBOX - -#if defined(__aarch64__) -#undef SSE2NEON_XT -#undef SSE2NEON_MULTIPLY -#endif - -#else /* __ARM_FEATURE_CRYPTO */ -// Implements equivalent of 'aesenc' by combining AESE (with an empty key) and -// AESMC and then manually applying the real key as an xor operation. This -// unfortunately means an additional xor op; the compiler should be able to -// optimize this away for repeated calls however. See -// https://blog.michaelbrase.com/2018/05/08/emulating-x86-aes-intrinsics-on-armv8-a -// for more details. -FORCE_INLINE __m128i _mm_aesenc_si128(__m128i a, __m128i b) -{ - return vreinterpretq_m128i_u8(veorq_u8( - vaesmcq_u8(vaeseq_u8(vreinterpretq_u8_m128i(a), vdupq_n_u8(0))), - vreinterpretq_u8_m128i(b))); -} - -// Perform one round of an AES decryption flow on data (state) in a using the -// round key in RoundKey, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_aesdec_si128 -FORCE_INLINE __m128i _mm_aesdec_si128(__m128i a, __m128i RoundKey) -{ - return vreinterpretq_m128i_u8(veorq_u8( - vaesimcq_u8(vaesdq_u8(vreinterpretq_u8_m128i(a), vdupq_n_u8(0))), - vreinterpretq_u8_m128i(RoundKey))); -} - -// Perform the last round of an AES encryption flow on data (state) in a using -// the round key in RoundKey, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_aesenclast_si128 -FORCE_INLINE __m128i _mm_aesenclast_si128(__m128i a, __m128i RoundKey) -{ - return _mm_xor_si128(vreinterpretq_m128i_u8(vaeseq_u8( - vreinterpretq_u8_m128i(a), vdupq_n_u8(0))), - RoundKey); -} - -// Perform the last round of an AES decryption flow on data (state) in a using -// the round key in RoundKey, and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_aesdeclast_si128 -FORCE_INLINE __m128i _mm_aesdeclast_si128(__m128i a, __m128i RoundKey) -{ - return vreinterpretq_m128i_u8( - veorq_u8(vaesdq_u8(vreinterpretq_u8_m128i(a), vdupq_n_u8(0)), - vreinterpretq_u8_m128i(RoundKey))); -} - -// Perform the InvMixColumns transformation on a and store the result in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_aesimc_si128 -FORCE_INLINE __m128i _mm_aesimc_si128(__m128i a) -{ - return vreinterpretq_m128i_u8(vaesimcq_u8(vreinterpretq_u8_m128i(a))); -} - -// Assist in expanding the AES cipher key by computing steps towards generating -// a round key for encryption cipher using data from a and an 8-bit round -// constant specified in imm8, and store the result in dst." -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_aeskeygenassist_si128 -FORCE_INLINE __m128i _mm_aeskeygenassist_si128(__m128i a, const int rcon) -{ - // AESE does ShiftRows and SubBytes on A - uint8x16_t u8 = vaeseq_u8(vreinterpretq_u8_m128i(a), vdupq_n_u8(0)); - -#if !defined(_MSC_VER) || defined(__clang__) - uint8x16_t dest = { - // Undo ShiftRows step from AESE and extract X1 and X3 - u8[0x4], u8[0x1], u8[0xE], u8[0xB], // SubBytes(X1) - u8[0x1], u8[0xE], u8[0xB], u8[0x4], // ROT(SubBytes(X1)) - u8[0xC], u8[0x9], u8[0x6], u8[0x3], // SubBytes(X3) - u8[0x9], u8[0x6], u8[0x3], u8[0xC], // ROT(SubBytes(X3)) - }; - uint32x4_t r = {0, (unsigned) rcon, 0, (unsigned) rcon}; - return vreinterpretq_m128i_u8(dest) ^ vreinterpretq_m128i_u32(r); -#else - // We have to do this hack because MSVC is strictly adhering to the CPP - // standard, in particular C++03 8.5.1 sub-section 15, which states that - // unions must be initialized by their first member type. - - // As per the Windows ARM64 ABI, it is always little endian, so this works - __n128 dest{ - ((uint64_t) u8.n128_u8[0x4] << 0) | ((uint64_t) u8.n128_u8[0x1] << 8) | - ((uint64_t) u8.n128_u8[0xE] << 16) | - ((uint64_t) u8.n128_u8[0xB] << 24) | - ((uint64_t) u8.n128_u8[0x1] << 32) | - ((uint64_t) u8.n128_u8[0xE] << 40) | - ((uint64_t) u8.n128_u8[0xB] << 48) | - ((uint64_t) u8.n128_u8[0x4] << 56), - ((uint64_t) u8.n128_u8[0xC] << 0) | ((uint64_t) u8.n128_u8[0x9] << 8) | - ((uint64_t) u8.n128_u8[0x6] << 16) | - ((uint64_t) u8.n128_u8[0x3] << 24) | - ((uint64_t) u8.n128_u8[0x9] << 32) | - ((uint64_t) u8.n128_u8[0x6] << 40) | - ((uint64_t) u8.n128_u8[0x3] << 48) | - ((uint64_t) u8.n128_u8[0xC] << 56)}; - - dest.n128_u32[1] = dest.n128_u32[1] ^ rcon; - dest.n128_u32[3] = dest.n128_u32[3] ^ rcon; - - return dest; -#endif -} -#endif - -/* Others */ - -// Perform a carry-less multiplication of two 64-bit integers, selected from a -// and b according to imm8, and store the results in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_clmulepi64_si128 -FORCE_INLINE __m128i _mm_clmulepi64_si128(__m128i _a, __m128i _b, const int imm) -{ - uint64x2_t a = vreinterpretq_u64_m128i(_a); - uint64x2_t b = vreinterpretq_u64_m128i(_b); - switch (imm & 0x11) { - case 0x00: - return vreinterpretq_m128i_u64( - _sse2neon_vmull_p64(vget_low_u64(a), vget_low_u64(b))); - case 0x01: - return vreinterpretq_m128i_u64( - _sse2neon_vmull_p64(vget_high_u64(a), vget_low_u64(b))); - case 0x10: - return vreinterpretq_m128i_u64( - _sse2neon_vmull_p64(vget_low_u64(a), vget_high_u64(b))); - case 0x11: - return vreinterpretq_m128i_u64( - _sse2neon_vmull_p64(vget_high_u64(a), vget_high_u64(b))); - default: - abort(); - } -} - -FORCE_INLINE unsigned int _sse2neon_mm_get_denormals_zero_mode(void) -{ - union { - fpcr_bitfield field; -#if defined(__aarch64__) || defined(_M_ARM64) - uint64_t value; -#else - uint32_t value; -#endif - } r; - -#if defined(__aarch64__) || defined(_M_ARM64) - r.value = _sse2neon_get_fpcr(); -#else - __asm__ __volatile__("vmrs %0, FPSCR" : "=r"(r.value)); /* read */ -#endif - - return r.field.bit24 ? _MM_DENORMALS_ZERO_ON : _MM_DENORMALS_ZERO_OFF; -} - -// Count the number of bits set to 1 in unsigned 32-bit integer a, and -// return that count in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_popcnt_u32 -FORCE_INLINE int _mm_popcnt_u32(unsigned int a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) -#if __has_builtin(__builtin_popcount) - return __builtin_popcount(a); -#elif defined(_MSC_VER) - return _CountOneBits(a); -#else - return (int) vaddlv_u8(vcnt_u8(vcreate_u8((uint64_t) a))); -#endif -#else - uint32_t count = 0; - uint8x8_t input_val, count8x8_val; - uint16x4_t count16x4_val; - uint32x2_t count32x2_val; - - input_val = vld1_u8((uint8_t *) &a); - count8x8_val = vcnt_u8(input_val); - count16x4_val = vpaddl_u8(count8x8_val); - count32x2_val = vpaddl_u16(count16x4_val); - - vst1_u32(&count, count32x2_val); - return count; -#endif -} - -// Count the number of bits set to 1 in unsigned 64-bit integer a, and -// return that count in dst. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=_mm_popcnt_u64 -FORCE_INLINE int64_t _mm_popcnt_u64(uint64_t a) -{ -#if defined(__aarch64__) || defined(_M_ARM64) -#if __has_builtin(__builtin_popcountll) - return __builtin_popcountll(a); -#elif defined(_MSC_VER) - return _CountOneBits64(a); -#else - return (int64_t) vaddlv_u8(vcnt_u8(vcreate_u8(a))); -#endif -#else - uint64_t count = 0; - uint8x8_t input_val, count8x8_val; - uint16x4_t count16x4_val; - uint32x2_t count32x2_val; - uint64x1_t count64x1_val; - - input_val = vld1_u8((uint8_t *) &a); - count8x8_val = vcnt_u8(input_val); - count16x4_val = vpaddl_u8(count8x8_val); - count32x2_val = vpaddl_u16(count16x4_val); - count64x1_val = vpaddl_u32(count32x2_val); - vst1_u64(&count, count64x1_val); - return count; -#endif -} - -FORCE_INLINE void _sse2neon_mm_set_denormals_zero_mode(unsigned int flag) -{ - // AArch32 Advanced SIMD arithmetic always uses the Flush-to-zero setting, - // regardless of the value of the FZ bit. - union { - fpcr_bitfield field; -#if defined(__aarch64__) || defined(_M_ARM64) - uint64_t value; -#else - uint32_t value; -#endif - } r; - -#if defined(__aarch64__) || defined(_M_ARM64) - r.value = _sse2neon_get_fpcr(); -#else - __asm__ __volatile__("vmrs %0, FPSCR" : "=r"(r.value)); /* read */ -#endif - - r.field.bit24 = (flag & _MM_DENORMALS_ZERO_MASK) == _MM_DENORMALS_ZERO_ON; - -#if defined(__aarch64__) || defined(_M_ARM64) - _sse2neon_set_fpcr(r.value); -#else - __asm__ __volatile__("vmsr FPSCR, %0" ::"r"(r)); /* write */ -#endif -} - -// Return the current 64-bit value of the processor's time-stamp counter. -// https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text=rdtsc -FORCE_INLINE uint64_t _rdtsc(void) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - uint64_t val; - - /* According to ARM DDI 0487F.c, from Armv8.0 to Armv8.5 inclusive, the - * system counter is at least 56 bits wide; from Armv8.6, the counter - * must be 64 bits wide. So the system counter could be less than 64 - * bits wide and it is attributed with the flag 'cap_user_time_short' - * is true. - */ -#if defined(_MSC_VER) && !defined(__clang__) - val = _ReadStatusReg(ARM64_SYSREG(3, 3, 14, 0, 2)); -#else - __asm__ __volatile__("mrs %0, cntvct_el0" : "=r"(val)); -#endif - - return val; -#else - uint32_t pmccntr, pmuseren, pmcntenset; - // Read the user mode Performance Monitoring Unit (PMU) - // User Enable Register (PMUSERENR) access permissions. - __asm__ __volatile__("mrc p15, 0, %0, c9, c14, 0" : "=r"(pmuseren)); - if (pmuseren & 1) { // Allows reading PMUSERENR for user mode code. - __asm__ __volatile__("mrc p15, 0, %0, c9, c12, 1" : "=r"(pmcntenset)); - if (pmcntenset & 0x80000000UL) { // Is it counting? - __asm__ __volatile__("mrc p15, 0, %0, c9, c13, 0" : "=r"(pmccntr)); - // The counter is set up to count every 64th cycle - return (uint64_t) (pmccntr) << 6; - } - } - - // Fallback to syscall as we can't enable PMUSERENR in user mode. - struct timeval tv; - gettimeofday(&tv, NULL); - return (uint64_t) (tv.tv_sec) * 1000000 + tv.tv_usec; -#endif -} - -#if defined(__GNUC__) || defined(__clang__) -#pragma pop_macro("ALIGN_STRUCT") -#pragma pop_macro("FORCE_INLINE") -#endif - -#if defined(__GNUC__) && !defined(__clang__) -#pragma GCC pop_options -#endif - -#endif diff --git a/third_party/avs3-hpm/webhtv_hpm.c b/third_party/avs3-hpm/webhtv_hpm.c deleted file mode 100644 index a6609f08ff..0000000000 --- a/third_party/avs3-hpm/webhtv_hpm.c +++ /dev/null @@ -1,398 +0,0 @@ -/* - * HPM 15.0 player boundary. Only this file exposes a stable decoder interface. - * The pinned reference implementation and its notice are in vendor/. - */ -#include "webhtv_hpm.h" -#include "hpm_guard.h" -#include "dec_def.h" -#include "com_tbl.h" -#include "com_util.h" -#include -#include -#include -#include -#include - -#define HPM_MEMORY_LIMIT ((size_t)1024 * 1024 * 1024) -#define HPM_PACKET_LIMIT (64 * 1024 * 1024) -#define HPM_ALLOC_HEADER 64 - -typedef struct HpmAllocation { - struct HpmAllocation *prev, *next; - size_t size; - WebHpm *owner; -} HpmAllocation; -_Static_assert(sizeof(HpmAllocation) <= HPM_ALLOC_HEADER, "allocation header"); - -/* HPM's global workspace is serialized. Dynamic table pointers and the DOI - * history are swapped on every entry, including create/flush/close. Scratch - * coefficient tables are used entirely inside one entry; fixed lookup tables - * are initialized deterministically while holding the same lock. */ -typedef struct HpmGlobals { - __typeof__(com_scan_tbl) scan; -#if USE_SP - __typeof__(com_tbl_raster2trav) raster; - __typeof__(com_tbl_trav2raster) traversal; -#endif - int doi_prev, doi_cycle; - u8 compatible_back; -} HpmGlobals; - -struct WebHpm { - DEC_CTX *ctx; - LibVCData libvc; - HpmGlobals globals; - HpmAllocation *allocations; - size_t bytes; - uint8_t *rbsp; - size_t rbsp_capacity; - COM_IMGB *output; - WebHpmInfo info; - int64_t picture_pts, picture_dts; - int picture_pending, draining, failed; - char error[192]; - jmp_buf escape; -}; - -/* These two variables belonged to app_decoder.c, not DecoderLib. */ -int g_CountDOICyCleTime; -int g_DOIPrev; -static pthread_mutex_t hpm_mutex = PTHREAD_MUTEX_INITIALIZER; -static WebHpm *active; - -static void activate(WebHpm *h) -{ - active = h; - memcpy(com_scan_tbl, h->globals.scan, sizeof(com_scan_tbl)); -#if USE_SP - memcpy(com_tbl_raster2trav, h->globals.raster, sizeof(com_tbl_raster2trav)); - memcpy(com_tbl_trav2raster, h->globals.traversal, sizeof(com_tbl_trav2raster)); -#endif - g_DOIPrev = h->globals.doi_prev; - g_CountDOICyCleTime = h->globals.doi_cycle; - g_compatible_back = h->globals.compatible_back; -} - -static void deactivate(WebHpm *h) -{ - memcpy(h->globals.scan, com_scan_tbl, sizeof(com_scan_tbl)); -#if USE_SP - memcpy(h->globals.raster, com_tbl_raster2trav, sizeof(com_tbl_raster2trav)); - memcpy(h->globals.traversal, com_tbl_trav2raster, sizeof(com_tbl_trav2raster)); -#endif - h->globals.doi_prev = g_DOIPrev; - h->globals.doi_cycle = g_CountDOICyCleTime; - h->globals.compatible_back = g_compatible_back; - active = NULL; -} - -void webhtv_hpm_fail(int code, const char *reason, int line) -{ - /* Every upstream entry is protected by this error boundary. No signal - * handlers, aborts or C++ exception unwinding are used in the App. */ - active->failed = code < 0 ? code : WEBHPM_INVALID; - snprintf(active->error, sizeof(active->error), "%s (line %d)", reason, line); - longjmp(active->escape, 1); -} - -/* Private bounds instrumentation for reference-code fixed arrays. The handler - * is hidden in each separate codec library and needs no sanitizer runtime. */ -__attribute__((visibility("hidden"))) -void __ubsan_handle_out_of_bounds(void *data, uintptr_t index) -{ - const struct { const char *file; uint32_t line, column; } *location = data; - char reason[144]; - const char *file = strrchr(location->file, '/'); - snprintf(reason, sizeof(reason), "array index %llu out of bounds in %s", - (unsigned long long)index, file ? file + 1 : location->file); - webhtv_hpm_fail(WEBHPM_INVALID, reason, location->line); -} - -void *webhtv_hpm_aligned_alloc(size_t alignment, size_t size) -{ - HpmAllocation *node = NULL; - HPM_REQUIRE(active && alignment <= HPM_ALLOC_HEADER); - if (size > HPM_MEMORY_LIMIT || active->bytes > HPM_MEMORY_LIMIT - size) - webhtv_hpm_fail(WEBHPM_NOMEM, "decoder memory limit exceeded", 0); - if (posix_memalign((void **)&node, HPM_ALLOC_HEADER, - HPM_ALLOC_HEADER + (size ? size : 1))) - webhtv_hpm_fail(WEBHPM_NOMEM, "decoder allocation failed", 0); - node->prev = NULL; - node->next = active->allocations; - node->size = size; - node->owner = active; - if (node->next) - node->next->prev = node; - active->allocations = node; - active->bytes += size; - return (uint8_t *)node + HPM_ALLOC_HEADER; -} - -void *webhtv_hpm_alloc(size_t size) -{ - return webhtv_hpm_aligned_alloc(HPM_ALLOC_HEADER, size); -} - -void *webhtv_hpm_calloc(size_t count, size_t size) -{ - if (size && count > HPM_MEMORY_LIMIT / size) - webhtv_hpm_fail(WEBHPM_NOMEM, "decoder allocation overflow", 0); - size *= count; - void *ptr = webhtv_hpm_alloc(size); - memset(ptr, 0, size); - return ptr; -} - -void webhtv_hpm_free(void *ptr) -{ - if (!ptr) - return; - HpmAllocation *node = (HpmAllocation *)((uint8_t *)ptr - HPM_ALLOC_HEADER); - HPM_REQUIRE(node->owner == active); - if (node->prev) - node->prev->next = node->next; - else - active->allocations = node->next; - if (node->next) - node->next->prev = node->prev; - active->bytes -= node->size; - free(node); -} - -static void clear_decoder(WebHpm *h) -{ - /* All HPM resources are C allocations. Bulk ownership also covers partial - * initialization and reference-code error paths which omit local cleanup. - * Never traverse a damaged decoder or release another instance's tables. */ - HpmAllocation *node = h->allocations; - while (node) { - HpmAllocation *next = node->next; - free(node); - node = next; - } - memset(h, 0, sizeof(*h)); -} - -WebHpm *webhtv_hpm_create(void) -{ - return calloc(1, sizeof(WebHpm)); -} - -void webhtv_hpm_flush(WebHpm *h) -{ - if (!h) - return; - pthread_mutex_lock(&hpm_mutex); - clear_decoder(h); - pthread_mutex_unlock(&hpm_mutex); -} - -void webhtv_hpm_close(WebHpm *h) -{ - webhtv_hpm_flush(h); - free(h); -} - -const WebHpmInfo *webhtv_hpm_info(const WebHpm *h) { return &h->info; } -const char *webhtv_hpm_error(const WebHpm *h) { return h->error; } -size_t webhtv_hpm_memory(const WebHpm *h) { return h->bytes; } -const char *webhtv_hpm_version(void) -{ - return "WebHTV HPM 15.0 / 0c7ac42edfac6d18b92b58a5ef43bca58526ca7a / AVS3 0x32"; -} - -void webhtv_hpm_set_color(int range, int primaries, int trc, int matrix) -{ - active->info.color_present = 1; - active->info.color_range = range; - active->info.color_primaries = primaries; - active->info.color_trc = trc; - active->info.colorspace = matrix; -} - -static void ensure_decoder(WebHpm *h) -{ - if (h->ctx) - return; - DEC_CDSC cdsc = {0}; - h->ctx = (DEC_CTX *)dec_create(&cdsc, NULL); - HPM_REQUIRE(h->ctx); - init_libvcdata(&h->libvc); - h->ctx->dpm.libvc_data = &h->libvc; -} - -static void update_info(WebHpm *h) -{ - const COM_SQH *sqh = &h->ctx->info.sqh; - h->info.width = sqh->horizontal_size; - h->info.height = sqh->vertical_size; - h->info.bit_depth = h->ctx->info.bit_depth_internal; - h->info.profile = sqh->profile_id; - h->info.level = sqh->level_id; - h->info.frame_rate_code = sqh->frame_rate_code; - h->info.reorder_delay = sqh->output_reorder_delay; - h->info.aspect_ratio = sqh->aspect_ratio; -} - -static int pull_picture(WebHpm *h, WebHpmFrame *frame, int drain) -{ - COM_IMGB *image = NULL; - if (!h->ctx || !h->info.width) - return 0; - int result = dec_pull_frm(h->ctx, &image, drain); - if (!image) { - HPM_REQUIRE(result == COM_OK_FRM_DELAYED || result == COM_ERR_UNEXPECTED); - return 0; - } - HPM_REQUIRE(result >= 0 && image->np == 3); - h->output = image; - for (int i = 0; i < 3; i++) { - frame->data[i] = image->addr_plane[i]; - frame->stride[i] = image->stride[i]; - } - frame->pts = image->ts[0]; - frame->dts = image->ts[1]; - frame->pict_type = (int)image->ts[2]; - frame->poc = (int)image->ts[3]; - frame->info = h->info; - return 1; -} - -static int chunk_boundary(const uint8_t *p) -{ - return p[0] == 0 && p[1] == 0 && p[2] == 1 && - (p[3] == 0xb0 || p[3] == 0xb1 || p[3] == 0xb3 || - p[3] == 0xb6 || p[3] == 0x00); -} - -static int prepare_rbsp(WebHpm *h, const uint8_t *src, int size) -{ - if (h->rbsp_capacity < (size_t)size + 64) { - webhtv_hpm_free(h->rbsp); - h->rbsp_capacity = (size_t)size + 64; - h->rbsp = webhtv_hpm_alloc(h->rbsp_capacity); - } - /* AVS3 removes two stuffing bits following 00 00 02, not an H.264 03 byte. - * Equivalent to HPM's initParsingConvertPayloadToRBSP, with exact length - * and initialized, bounded lookahead instead of the CLI's stale tail. */ - unsigned zeros = 0, shift = 0; - int written = 0; - for (int i = 0; i < size; i++) { - unsigned current = src[i]; - unsigned value; - if (zeros >= 2 && current == 2) { - value = (current >> 2) << (shift + 2); - shift += 2; - zeros = 0; - if (shift == 8) { - shift = 0; - continue; - } - } else if (zeros >= 2 && current == 1) { - shift = 0; - value = current; - } else { - value = current << shift; - } - if (i + 1 < size) - value |= src[i + 1] >> (8 - shift); - h->rbsp[written++] = value; - zeros = current == 0 ? zeros + 1 : 0; - } - memset(h->rbsp + written, 0, 64); - h->rbsp[written + 2] = 1; - return written; -} - -static int decode_locked(WebHpm *h, const uint8_t *data, int size, - int64_t pts, int64_t dts, int *consumed, WebHpmFrame *frame) -{ - if (h->output) { - h->output->release(h->output); - h->output = NULL; - } - if (!size) - return pull_picture(h, frame, 1); - if (h->draining) { - if (pull_picture(h, frame, 1)) - return 1; - h->draining = 0; - } - ensure_decoder(h); - while (*consumed < size) { - const uint8_t *chunk = data + *consumed; - int length = size - *consumed; - HPM_REQUIRE(length >= 4 && chunk_boundary(chunk)); - for (int i = 4; i + 4 <= length; i++) { - if (chunk_boundary(chunk + i)) { - length = i; - break; - } - } - int type = chunk[3]; - if (type == 0xb0) { - HPM_REQUIRE(length >= 6); - if (chunk[4] != 0x32) - webhtv_hpm_fail(WEBHPM_UNSUPPORTED, "HPM backend requires AVS3 0x32", 0); - h->picture_pending = 0; - } else if (type == 0xb3 || type == 0xb6) { - HPM_REQUIRE(h->ctx->init_flag); - h->picture_pts = pts; - h->picture_dts = dts; - h->picture_pending = 1; - } else if (type == 0x00) { - HPM_REQUIRE(h->picture_pending && h->ctx->init_flag); - } - COM_BITB bitb = {0}; - DEC_STAT stat = {0}; - bitb.ssize = prepare_rbsp(h, chunk, length); - bitb.bsize = (int)h->rbsp_capacity; - bitb.addr = h->rbsp; - int result = dec_cnk(h->ctx, &bitb, &stat); - HPM_REQUIRE(result >= 0); - *consumed += length; - if (type == 0xb0) - update_info(h); - if (type == 0xb1) { - h->draining = 1; - if (pull_picture(h, frame, 1)) - return 1; - h->draining = 0; - } else if (type == 0x00) { - HPM_REQUIRE(h->ctx->pic && h->ctx->pic->imgb && stat.fnum >= 0); - COM_IMGB *image = h->ctx->pic->imgb; - image->ts[0] = h->picture_pts; - image->ts[1] = h->picture_dts; - image->ts[2] = h->ctx->info.pic_header.slice_type; - image->ts[3] = h->ctx->ptr; - h->picture_pending = 0; - if (pull_picture(h, frame, 0)) - return 1; - } - } - return 0; -} - -int webhtv_hpm_decode(WebHpm *h, const uint8_t *data, int size, - int64_t pts, int64_t dts, int *consumed, WebHpmFrame *frame) -{ - if (!h || !consumed || !frame || size < 0 || size > HPM_PACKET_LIMIT || (size && !data)) - return WEBHPM_INVALID; - *consumed = 0; - memset(frame, 0, sizeof(*frame)); - pthread_mutex_lock(&hpm_mutex); - if (h->failed) { - int result = h->failed; - pthread_mutex_unlock(&hpm_mutex); - return result; - } - activate(h); - int result; - if (setjmp(h->escape)) - result = h->failed; - else - result = decode_locked(h, data, size, pts, dts, consumed, frame); - deactivate(h); - pthread_mutex_unlock(&hpm_mutex); - return result; -} diff --git a/third_party/avs3-hpm/webhtv_hpm.h b/third_party/avs3-hpm/webhtv_hpm.h deleted file mode 100644 index 5b7bc1cd49..0000000000 --- a/third_party/avs3-hpm/webhtv_hpm.h +++ /dev/null @@ -1,40 +0,0 @@ -/* WebHTV AVS3 High 10-bit adapter. Upstream HPM retains its own license. */ -#ifndef WEBHTV_HPM_H -#define WEBHTV_HPM_H - -#include -#include - -typedef struct WebHpm WebHpm; - -typedef struct WebHpmInfo { - int width, height, bit_depth, profile, level; - int frame_rate_code, reorder_delay, aspect_ratio; - int color_present, color_range, color_primaries, color_trc, colorspace; -} WebHpmInfo; - -typedef struct WebHpmFrame { - const uint8_t *data[3]; - int stride[3]; - int64_t pts, dts; - int pict_type, poc; - WebHpmInfo info; -} WebHpmFrame; - -enum { WEBHPM_INVALID = -1, WEBHPM_NOMEM = -2, WEBHPM_UNSUPPORTED = -3 }; - -WebHpm *webhtv_hpm_create(void); -void webhtv_hpm_close(WebHpm *decoder); -/* Discards references and all pending pictures. The next input needs a SQH. */ -void webhtv_hpm_flush(WebHpm *decoder); -/* Returns 1 for a picture, 0 for more input/EOS, or a negative error. - * consumed may be zero while draining delayed pictures. size == 0 drains EOS. - * Picture planes remain valid until the next call on this decoder. */ -int webhtv_hpm_decode(WebHpm *decoder, const uint8_t *data, int size, - int64_t pts, int64_t dts, int *consumed, WebHpmFrame *frame); -const WebHpmInfo *webhtv_hpm_info(const WebHpm *decoder); -const char *webhtv_hpm_error(const WebHpm *decoder); -const char *webhtv_hpm_version(void); -size_t webhtv_hpm_memory(const WebHpm *decoder); - -#endif diff --git a/third_party/avs3-hpm/webhtv_hpm_ffmpeg.h b/third_party/avs3-hpm/webhtv_hpm_ffmpeg.h deleted file mode 100644 index 2b8c950747..0000000000 --- a/third_party/avs3-hpm/webhtv_hpm_ffmpeg.h +++ /dev/null @@ -1,90 +0,0 @@ -/* Private FFmpeg adapter, included after uavs3d_context in libuavs3d.c. - * Keep the decoder registration stable for existing nextlib/mpv consumers. */ -#ifndef WEBHTV_HPM_FFMPEG_H -#define WEBHTV_HPM_FFMPEG_H - -static int libhpm_error(AVCodecContext *avctx, int result) -{ - uavs3d_context *h = avctx->priv_data; - av_log(avctx, AV_LOG_ERROR, "AVS3 HPM decode failed: %s\n", webhtv_hpm_error(h->hpm)); - return result == WEBHPM_NOMEM ? AVERROR(ENOMEM) : - result == WEBHPM_UNSUPPORTED ? AVERROR_PATCHWELCOME : AVERROR_INVALIDDATA; -} - -static int libhpm_update_info(AVCodecContext *avctx) -{ - uavs3d_context *h = avctx->priv_data; - const WebHpmInfo *info = webhtv_hpm_info(h->hpm); - int ret; - if (!info->width) - return 0; - if ((ret = ff_set_dimensions(avctx, info->width, info->height)) < 0) - return ret; - avctx->profile = info->profile; - avctx->level = info->level; - avctx->pix_fmt = AV_PIX_FMT_YUV420P10; - avctx->bits_per_raw_sample = info->bit_depth; - avctx->has_b_frames = info->reorder_delay; - /* AVS3 codes 2..4 describe the display ratio, while AVFrame carries the - * pixel ratio. Match Media3 Avs3Config for non-square-pixel pictures. */ - if (info->aspect_ratio == 1) { - avctx->sample_aspect_ratio = (AVRational){1, 1}; - } else if (info->aspect_ratio >= 2 && info->aspect_ratio <= 4) { - const AVRational display[] = {{4, 3}, {16, 9}, {221, 100}}; - avctx->sample_aspect_ratio = av_div_q(display[info->aspect_ratio - 2], - (AVRational){info->width, info->height}); - } - if (info->frame_rate_code > 0 && info->frame_rate_code < FF_ARRAY_ELEMS(ff_avs3_frame_rate_tab)) - avctx->framerate = ff_avs3_frame_rate_tab[info->frame_rate_code]; - if (info->color_present) { - avctx->color_range = info->color_range ? AVCOL_RANGE_JPEG : AVCOL_RANGE_MPEG; - if (info->color_primaries >= 0 && info->color_primaries < FF_ARRAY_ELEMS(ff_avs3_color_primaries_tab)) - avctx->color_primaries = ff_avs3_color_primaries_tab[info->color_primaries]; - if (info->color_trc >= 0 && info->color_trc < FF_ARRAY_ELEMS(ff_avs3_color_transfer_tab)) - avctx->color_trc = ff_avs3_color_transfer_tab[info->color_trc]; - if (info->colorspace >= 0 && info->colorspace < FF_ARRAY_ELEMS(ff_avs3_color_matrix_tab)) - avctx->colorspace = ff_avs3_color_matrix_tab[info->colorspace]; - } - h->got_seqhdr = 1; - return 0; -} - -static int libhpm_decode_frame(AVCodecContext *avctx, AVFrame *frame, - int *got_frame, const AVPacket *packet) -{ - uavs3d_context *h = avctx->priv_data; - WebHpmFrame picture; - int consumed, ret; - *got_frame = 0; - ret = webhtv_hpm_decode(h->hpm, packet->data, packet->size, - packet->pts, packet->dts, &consumed, &picture); - if (ret < 0) - return libhpm_error(avctx, ret); - int has_picture = ret; - if ((ret = libhpm_update_info(avctx)) < 0) - return ret; - if (!has_picture) - return consumed; - if ((ret = ff_get_buffer(avctx, frame, 0)) < 0) - return ret; - for (int plane = 0; plane < 3; plane++) { - int width = (picture.info.width + (plane != 0)) >> (plane != 0); - int height = (picture.info.height + (plane != 0)) >> (plane != 0); - if (picture.stride[plane] < width * 2 || frame->linesize[plane] < width * 2) { - av_frame_unref(frame); - return AVERROR_INVALIDDATA; - } - for (int y = 0; y < height; y++) - memcpy(frame->data[plane] + y * frame->linesize[plane], - picture.data[plane] + y * picture.stride[plane], width * 2); - } - frame->pts = picture.pts; - frame->pkt_dts = picture.dts; - frame->pict_type = picture.pict_type == 1 ? AV_PICTURE_TYPE_I : - picture.pict_type == 2 ? AV_PICTURE_TYPE_P : AV_PICTURE_TYPE_B; - if (frame->pict_type == AV_PICTURE_TYPE_I) - frame->flags |= AV_FRAME_FLAG_KEY; - *got_frame = 1; - return consumed; -} -#endif diff --git a/third_party/exo-ass-lock.json b/third_party/exo-ass-lock.json deleted file mode 100644 index 465aa98691..0000000000 --- a/third_party/exo-ass-lock.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "schema": 1, - "scope": "E4-LIBASS gated standalone ASS: full external scripts and Media3 SSA packet bridge; no MPV or FFmpeg linkage", - "android": { - "ndk": "29.0.14206865", - "api": 24, - "abis": [ - "arm64-v8a" - ], - "stl": "c++_static", - "page_size": 16384 - }, - "sources": { - "freetype2": { - "repo": "https://gitlab.freedesktop.org/freetype/freetype.git", - "commit": "0a0221a1347e2f1e07c395263540026e9a0aa7c7", - "version": "2.14.3", - "meson_options": [ - "-Dharfbuzz=disabled", - "-Dbrotli=disabled", - "-Dbzip2=disabled", - "-Dpng=disabled", - "-Dzlib=internal" - ], - "licenses": [ - "LICENSE.TXT", - "docs/FTL.TXT", - "docs/GPLv2.TXT" - ] - }, - "fribidi": { - "repo": "https://github.com/fribidi/fribidi.git", - "commit": "68162babff4f39c4e2dc164a5e825af93bda9983", - "version": "1.0.16", - "meson_options": [ - "-Dtests=false", - "-Ddocs=false" - ], - "licenses": [ - "COPYING" - ] - }, - "harfbuzz": { - "repo": "https://github.com/harfbuzz/harfbuzz.git", - "commit": "56feae4035bdd48f62ba2b8d8c16232d4d89b3a4", - "version": "14.2.1", - "meson_options": [ - "-Dtests=disabled", - "-Ddocs=disabled", - "-Draster=disabled", - "-Dvector=disabled", - "-Dgpu=disabled", - "-Dsubset=disabled", - "-Dglib=disabled", - "-Dgobject=disabled", - "-Dicu=disabled", - "-Dfreetype=enabled" - ], - "licenses": [ - "COPYING" - ] - }, - "libxml2": { - "url": "https://gitlab.gnome.org/GNOME/libxml2/-/archive/v2.15.3/libxml2-v2.15.3.tar.gz", - "sha256": "0da50c1415f4ec0364569d2119b1436ba837b31df44af28569d234272c23cf1f", - "version": "2.15.3", - "filename": "libxml2-v2.15.3.tar.gz", - "meson_options": [ - "-Dminimum=true", - "-Dpush=enabled", - "-Dsax1=enabled", - "-Ddocs=disabled", - "-Dpython=disabled", - "-Diconv=disabled", - "-Dicu=disabled", - "-Dzlib=disabled" - ], - "licenses": [ - "Copyright" - ] - }, - "fontconfig": { - "repo": "https://gitlab.freedesktop.org/fontconfig/fontconfig.git", - "commit": "a4e25ec391d417e4bca052fbfa5cd7ce5f7fd39e", - "version": "2.18.2", - "meson_options": [ - "-Ddoc=disabled", - "-Dnls=disabled", - "-Dtests=disabled", - "-Dtests-external-fonts=disabled", - "-Dtools=disabled", - "-Dcache-build=disabled", - "-Dxml-backend=libxml2", - "-Diconv=disabled" - ], - "licenses": [ - "COPYING" - ] - }, - "libass": { - "repo": "https://github.com/libass/libass.git", - "commit": "89cc0f4e450d64f74281a17d7f11ed05229665e8", - "version": "git-89cc0f4e", - "meson_options": [ - "-Dtest=disabled", - "-Dcompare=disabled", - "-Dprofile=disabled", - "-Dfuzz=disabled", - "-Dcheckasm=disabled", - "-Dfontconfig=enabled", - "-Dlibunibreak=disabled", - "-Drequire-system-font-provider=true" - ], - "licenses": [ - "COPYING" - ] - } - } -} diff --git a/third_party/exo-ass-native/CMakeLists.txt b/third_party/exo-ass-native/CMakeLists.txt deleted file mode 100644 index 3290226113..0000000000 --- a/third_party/exo-ass-native/CMakeLists.txt +++ /dev/null @@ -1,27 +0,0 @@ -cmake_minimum_required(VERSION 3.22.1) -project(exo_ass LANGUAGES CXX) - -if(NOT ANDROID OR NOT ANDROID_ABI STREQUAL "arm64-v8a") - message(FATAL_ERROR "Stage 1 is an independently built arm64 Android prototype") -endif() -if(NOT DEFINED ASS_PREFIX) - message(FATAL_ERROR "Run scripts/build_exo_ass_native.py with the pinned dependency lock") -endif() - -add_library(exo_ass SHARED exo_ass.cpp) -target_compile_features(exo_ass PRIVATE cxx_std_17) -target_compile_options(exo_ass PRIVATE -Wall -Wextra -Werror=return-type - -fvisibility=hidden -fvisibility-inlines-hidden) -target_include_directories(exo_ass PRIVATE "${ASS_PREFIX}/include") -target_link_options(exo_ass PRIVATE -Wl,--no-undefined -Wl,--exclude-libs,ALL - -Wl,-z,max-page-size=16384 -Wl,--gc-sections) -target_link_libraries(exo_ass PRIVATE - -Wl,--start-group - "${ASS_PREFIX}/lib/libass.a" - "${ASS_PREFIX}/lib/libharfbuzz.a" - "${ASS_PREFIX}/lib/libfribidi.a" - "${ASS_PREFIX}/lib/libfontconfig.a" - "${ASS_PREFIX}/lib/libfreetype.a" - "${ASS_PREFIX}/lib/libxml2.a" - -Wl,--end-group - android log EGL GLESv2 m) diff --git a/third_party/exo-ass-native/MANIFEST.sha256 b/third_party/exo-ass-native/MANIFEST.sha256 deleted file mode 100644 index ec9f62954a..0000000000 --- a/third_party/exo-ass-native/MANIFEST.sha256 +++ /dev/null @@ -1 +0,0 @@ -31e04a1d26c606dd2f5df0b0b81f2916ed0b29c13b3415515a77cff540e83cc2 prebuilt/arm64-v8a/libexo_ass.so diff --git a/third_party/exo-ass-native/build-provenance.json b/third_party/exo-ass-native/build-provenance.json deleted file mode 100644 index 18bd12c6a7..0000000000 --- a/third_party/exo-ass-native/build-provenance.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "schema": 1, - "artifact": { - "path": "prebuilt/arm64-v8a/libexo_ass.so", - "sha256": "31e04a1d26c606dd2f5df0b0b81f2916ed0b29c13b3415515a77cff540e83cc2", - "bytes": 2775600 - }, - "android": { - "ndk": "29.0.14206865", - "api": 24, - "abis": [ - "arm64-v8a" - ], - "stl": "c++_static", - "page_size": 16384 - }, - "inputs": { - "third_party/exo-ass-lock.json": "b524b2904218465093a9d20ebc8e5419529eae322650adfe331b48474fa67000", - "scripts/build_exo_ass_native.py": "be7828a4ba01788258c2ef67328e87b808df57d6d55eed9938df7c0fd286fdb4", - "third_party/exo-ass-native/CMakeLists.txt": "4abec300900d0588502938c62c3e7a90f9fc5cc2a832f0446689356e5df29266", - "third_party/exo-ass-native/exo_ass.cpp": "b333e896881a9a13a8a618cc147a5472914b7570290264db732bda624f4f8470", - "third_party/exo-ass-native/mask_copy.h": "8fa89ecb7904ad82bb3bafd551517e77f1cef6463278897fcf198d12e42db574" - }, - "sources": { - "freetype2": "0a0221a1347e2f1e07c395263540026e9a0aa7c7", - "fribidi": "68162babff4f39c4e2dc164a5e825af93bda9983", - "harfbuzz": "56feae4035bdd48f62ba2b8d8c16232d4d89b3a4", - "libxml2": "0da50c1415f4ec0364569d2119b1436ba837b31df44af28569d234272c23cf1f", - "fontconfig": "a4e25ec391d417e4bca052fbfa5cd7ce5f7fd39e", - "libass": "89cc0f4e450d64f74281a17d7f11ed05229665e8" - }, - "static_archives": { - "libass.a": "f46dbfa6513f2f705ff66a9f1f155bf4cacf8c3e6fd8f5ba9bd5d8e02859ca39", - "libfontconfig.a": "7592cd41dd0e7e5b6716a2f136bcd7df5fa32a2b1372e1774423a9211b6f5d2b", - "libfreetype.a": "03943122ae723a657845c8b34c39d244c1f28640dc5593ca945d89ff4d8b3042", - "libfribidi.a": "4145184909bb7db4421caad799c64c2755d5ea7784916fe3681d1cdd1e4cd07e", - "libharfbuzz.a": "4b3f5576a47c6a73e5cb38bd59adeb818292cedced60da381666c10311ad8de8", - "libxml2.a": "c0e3eb83598b02b7ca1fc5d76aad0acc43bb4df08f8bcba45f8b14d4d181f4c2" - }, - "licenses": { - "licenses/fontconfig/COPYING": "51a51aa9823704fd90bccc616cdd17ebabb5b2b3e9cbde886ca02c7002288067", - "licenses/freetype2/LICENSE.TXT": "bd36c8b474855fa294c2ec5c184544478ef3720aad37d65a6296a4f264fd2d3b", - "licenses/freetype2/docs/FTL.TXT": "5a5ee54c5001bbad1cdc1a57cc3dd4c42199b2da09d39c7ee41fab002d02967f", - "licenses/freetype2/docs/GPLv2.TXT": "c4120c6752c910c299e3bd9cb3a46ff262c268303ca2069b61f92f10a5656c18", - "licenses/fribidi/COPYING": "864bc96d476874ee37d009212ad5d2df0904b43780e5cbcc1cb726485ca6dae4", - "licenses/harfbuzz/COPYING": "ba8f810f2455c2f08e2d56bb49b72f37fcf68f1f4fade38977cfd7372050ad64", - "licenses/libass/COPYING": "f7e30699d02798351e7f839e3d3bfeb29ce65e44efa7735c225464c4fd7dfe9c", - "licenses/libxml2/Copyright": "5d4873884a890122a4b9b20ad56ac6f7da1d796a5bfcf04a427970ac96217626" - }, - "compiler": "Android (13989888, +pgo, -bolt, +lto, -mlgo, based on r563880c) clang version 21.0.0 (https://android.googlesource.com/toolchain/llvm-project 5e96669f06077099aa41290cdb4c5e6fa0f59349)\nTarget: arm64-apple-darwin23.4.0\nThread model: posix\nInstalledDir: /Users/macbookpro/Downloads/bizhi/android-sdk/ndk/29.0.14206865/toolchains/llvm/prebuilt/darwin-x86_64/bin", - "tools": { - "cmake": "cmake version 4.0.2", - "meson": "1.11.2", - "ninja": "1.13.2" - }, - "rebuild": "python3 scripts/build_exo_ass_native.py --jobs 6 --install", - "cache_note": "Source caches supply fixed Git objects only; all static archives belong to this independent build." -} diff --git a/third_party/exo-ass-native/exo_ass.cpp b/third_party/exo-ass-native/exo_ass.cpp deleted file mode 100644 index 84aecd9a68..0000000000 --- a/third_party/exo-ass-native/exo_ass.cpp +++ /dev/null @@ -1,497 +0,0 @@ -// WebHTV Exo ASS presenter. The owning Java worker serializes every entry point. -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "mask_copy.h" - -namespace { -constexpr size_t MAX_SCRIPT = 8 * 1024 * 1024; -constexpr size_t MAX_MASK_PIXELS = 8 * 1024 * 1024; -constexpr int MAX_IMAGES = 256; -constexpr int MAX_SIDE = 4096; -constexpr int MAX_EVENTS = 20000; -constexpr int MAX_STYLES = 512; -constexpr int GLYPH_CACHE_LIMIT = 512; -constexpr int BITMAP_CACHE_MIB = 8; - -int64_t micros() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count(); -} - -struct Texture { GLuint id = 0; int width = 0; int height = 0; }; - -struct Session { - ASS_Library *library = nullptr; - ASS_Renderer *renderer = nullptr; - ASS_Track *track = nullptr; - EGLDisplay display = EGL_NO_DISPLAY; - EGLContext context = EGL_NO_CONTEXT; - EGLSurface surface = EGL_NO_SURFACE; - EGLConfig config = nullptr; - ANativeWindow *window = nullptr; - GLuint program = 0; - GLint position = -1, texcoord = -1, color = -1; - std::vector textures; - std::vector packed; - int width = 0, height = 0; - int storage_width = 0, storage_height = 0; - double pixel_aspect = 0; - int color_space = 0, color_range = 0; - bool force = true; - bool packetized = false; - - void detach() { - if (display != EGL_NO_DISPLAY && context != EGL_NO_CONTEXT - && surface != EGL_NO_SURFACE - && eglMakeCurrent(display, surface, surface, context)) { - for (auto &texture : textures) glDeleteTextures(1, &texture.id); - if (program) glDeleteProgram(program); - } - textures.clear(); - packed.clear(); - program = 0; - if (display != EGL_NO_DISPLAY) { - eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); - if (surface != EGL_NO_SURFACE) eglDestroySurface(display, surface); - if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context); - eglTerminate(display); - eglReleaseThread(); - } - surface = EGL_NO_SURFACE; - context = EGL_NO_CONTEXT; - display = EGL_NO_DISPLAY; - if (window) ANativeWindow_release(window); - window = nullptr; - force = true; - } - - ~Session() { - detach(); - if (track) ass_free_track(track); - if (renderer) ass_renderer_done(renderer); - if (library) ass_library_done(library); - } -}; - -Session *session(jlong handle) { - return reinterpret_cast(static_cast(handle)); -} - -GLuint shader(GLenum type, const char *source) { - GLuint id = glCreateShader(type); - if (!id) return 0; - glShaderSource(id, 1, &source, nullptr); - glCompileShader(id); - GLint ok = GL_FALSE; - glGetShaderiv(id, GL_COMPILE_STATUS, &ok); - if (!ok) { glDeleteShader(id); return 0; } - return id; -} - -bool init_gl(Session &s) { - const char *vertex = - "attribute vec2 aPosition; attribute vec2 aTexCoord; varying vec2 uv;" - "void main(){gl_Position=vec4(aPosition,0.,1.);uv=aTexCoord;}"; - const char *fragment = - "precision mediump float; uniform sampler2D mask; uniform vec4 color;" - "varying vec2 uv; void main(){float a=texture2D(mask,uv).a*color.a;" - "gl_FragColor=vec4(color.rgb*a,a);}"; - GLuint v = shader(GL_VERTEX_SHADER, vertex), f = shader(GL_FRAGMENT_SHADER, fragment); - if (!v || !f) { - if (v) glDeleteShader(v); - if (f) glDeleteShader(f); - return false; - } - s.program = glCreateProgram(); - glAttachShader(s.program, v); - glAttachShader(s.program, f); - glLinkProgram(s.program); - glDeleteShader(v); - glDeleteShader(f); - GLint ok = GL_FALSE; - glGetProgramiv(s.program, GL_LINK_STATUS, &ok); - if (!ok) return false; - s.position = glGetAttribLocation(s.program, "aPosition"); - s.texcoord = glGetAttribLocation(s.program, "aTexCoord"); - s.color = glGetUniformLocation(s.program, "color"); - glUseProgram(s.program); - glUniform1i(glGetUniformLocation(s.program, "mask"), 0); - glActiveTexture(GL_TEXTURE0); - glDisable(GL_DEPTH_TEST); - glEnable(GL_BLEND); - glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); - glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - return glGetError() == GL_NO_ERROR; -} - -bool attach(Session &s, JNIEnv *env, jobject surface, int test_width, int test_height) { - s.detach(); - if (!surface && !test_width) return true; - s.display = eglGetDisplay(EGL_DEFAULT_DISPLAY); - if (s.display == EGL_NO_DISPLAY || !eglInitialize(s.display, nullptr, nullptr)) return false; - const EGLint attributes[] = { EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, - EGL_ALPHA_SIZE, 8, EGL_SURFACE_TYPE, surface ? EGL_WINDOW_BIT : EGL_PBUFFER_BIT, - EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_NONE }; - EGLint count = 0; - if (!eglChooseConfig(s.display, attributes, &s.config, 1, &count) || !count) return false; - const EGLint context_attributes[] = { EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE }; - s.context = eglCreateContext(s.display, s.config, EGL_NO_CONTEXT, context_attributes); - if (s.context == EGL_NO_CONTEXT) return false; - if (surface) { - s.window = ANativeWindow_fromSurface(env, surface); - if (!s.window) return false; - EGLint format = 0; - eglGetConfigAttrib(s.display, s.config, EGL_NATIVE_VISUAL_ID, &format); - if (ANativeWindow_setBuffersGeometry(s.window, 0, 0, format) != 0) return false; - s.surface = eglCreateWindowSurface(s.display, s.config, s.window, nullptr); - } else { - const EGLint pbuffer[] = { EGL_WIDTH, test_width, EGL_HEIGHT, test_height, EGL_NONE }; - s.surface = eglCreatePbufferSurface(s.display, s.config, pbuffer); - } - if (s.surface == EGL_NO_SURFACE - || !eglMakeCurrent(s.display, s.surface, s.surface, s.context)) return false; - // Swap may wait for the compositor, but only this subtitle worker can be delayed. - eglSwapInterval(s.display, 1); - return init_gl(s); -} - -struct Matrix { double kr, kb; bool limited; }; - -Matrix subtitle_matrix(ASS_YCbCrMatrix matrix) { - switch (matrix) { - case YCBCR_BT709_TV: return { .2126, .0722, true }; - case YCBCR_BT709_PC: return { .2126, .0722, false }; - case YCBCR_SMPTE240M_TV: return { .212, .087, true }; - case YCBCR_SMPTE240M_PC: return { .212, .087, false }; - case YCBCR_FCC_TV: return { .30, .11, true }; - case YCBCR_FCC_PC: return { .30, .11, false }; - case YCBCR_BT601_PC: return { .299, .114, false }; - default: return { .299, .114, true }; - } -} - -// ASS describes the historical RGB -> subtitle YCbCr -> video RGB round trip. -// Apply it to straight color, before mask coverage and premultiplication. -void rgba(Session &s, uint32_t color, GLfloat *out) { - double rgb[] = { (color >> 24) / 255.0, ((color >> 16) & 255) / 255.0, - ((color >> 8) & 255) / 255.0 }; - // color_space == 0 is AssNative.COLOR_SPACE_SDR_RGB: HDR/wide-gamut video - // uses an independent SDR subtitle layer, not a video YCbCr round trip. - if (s.color_space != 0 && s.track->YCbCrMatrix != YCBCR_NONE) { - Matrix src = subtitle_matrix(s.track->YCbCrMatrix); - Matrix dst = s.color_space == 2 ? Matrix{.299, .114, s.color_range != 1} - : Matrix{.2126, .0722, s.color_range != 1}; - double y = src.kr * rgb[0] + (1 - src.kr - src.kb) * rgb[1] + src.kb * rgb[2]; - double cb = (rgb[2] - y) / (2 * (1 - src.kb)); - double cr = (rgb[0] - y) / (2 * (1 - src.kr)); - y = (src.limited ? y * 219 + 16 : y * 255) / 255; - cb *= src.limited ? 224.0 / 255 : 1; - cr *= src.limited ? 224.0 / 255 : 1; - if (dst.limited) { y = (y * 255 - 16) / 219; cb *= 255.0 / 224; cr *= 255.0 / 224; } - rgb[0] = y + 2 * (1 - dst.kr) * cr; - rgb[2] = y + 2 * (1 - dst.kb) * cb; - rgb[1] = (y - dst.kr * rgb[0] - dst.kb * rgb[2]) / (1 - dst.kr - dst.kb); - } - for (int i = 0; i < 3; ++i) out[i] = static_cast(std::clamp(rgb[i], 0., 1.)); - out[3] = (255 - (color & 255)) / 255.f; -} - -bool valid_size(int width, int height) { - return width > 0 && height > 0 && width <= MAX_SIDE && height <= MAX_SIDE - && static_cast(width) * height <= MAX_MASK_PIXELS; -} - -int render(Session &s, int64_t time_ms, int width, int height, int storage_width, - int storage_height, double pixel_aspect, int color_space, int color_range, - bool force, jlong *stats) { - if (!s.track || !s.renderer || s.surface == EGL_NO_SURFACE || !valid_size(width, height) - || storage_width <= 0 || storage_height <= 0 || !std::isfinite(pixel_aspect) - || pixel_aspect <= 0) return -1; - bool geometry = s.width != width || s.height != height || s.storage_width != storage_width - || s.storage_height != storage_height || s.pixel_aspect != pixel_aspect; - if (geometry) { - ass_set_frame_size(s.renderer, width, height); - ass_set_storage_size(s.renderer, storage_width, storage_height); - ass_set_pixel_aspect(s.renderer, pixel_aspect); - ass_set_margins(s.renderer, 0, 0, 0, 0); - ass_set_use_margins(s.renderer, 0); - } - force |= s.force || geometry || color_space != s.color_space || color_range != s.color_range; - s.width = width; s.height = height; - s.storage_width = storage_width; s.storage_height = storage_height; - s.pixel_aspect = pixel_aspect; s.color_space = color_space; s.color_range = color_range; - const int64_t start = micros(); - int change = 0; - ASS_Image *images = ass_render_frame(s.renderer, s.track, time_ms, &change); - stats[0] = micros() - start; - stats[5] = change; - if (!force && !change) return 0; - size_t pixels = 0; - int count = 0; - GLint max_texture = 0; - glGetIntegerv(GL_MAX_TEXTURE_SIZE, &max_texture); - for (ASS_Image *img = images; img; img = img->next) { - if (!img->w || !img->h) continue; - if (img->w < 0 || img->h < 0 || img->stride < img->w || !img->bitmap - || img->w > max_texture || img->h > max_texture || ++count > MAX_IMAGES) - return -2; - size_t area = static_cast(img->w) * img->h; - if (area > MAX_MASK_PIXELS || pixels > MAX_MASK_PIXELS - area) return -2; - pixels += area; - } - stats[3] = count; stats[4] = static_cast(pixels); - const int64_t upload = micros(); - glViewport(0, 0, width, height); - glClearColor(0, 0, 0, 0); - glClear(GL_COLOR_BUFFER_BIT); - glUseProgram(s.program); - glEnableVertexAttribArray(s.position); - glEnableVertexAttribArray(s.texcoord); - if (s.textures.size() < static_cast(count)) s.textures.resize(count); - int index = 0; - for (ASS_Image *img = images; img; img = img->next) { - if (img->w <= 0 || img->h <= 0) continue; - Texture &texture = s.textures[index++]; - if (!texture.id) glGenTextures(1, &texture.id); - glBindTexture(GL_TEXTURE_2D, texture.id); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - const uint8_t *mask = img->bitmap; - if (img->stride != img->w) { - if (!exo_ass::copy_mask(s.packed, img->bitmap, img->w, img->h, - img->stride, MAX_MASK_PIXELS)) return -2; - mask = s.packed.data(); - } - if (texture.width != img->w || texture.height != img->h) { - glTexImage2D(GL_TEXTURE_2D, 0, GL_ALPHA, img->w, img->h, 0, - GL_ALPHA, GL_UNSIGNED_BYTE, mask); - texture.width = img->w; texture.height = img->h; - } else { - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, img->w, img->h, - GL_ALPHA, GL_UNSIGNED_BYTE, mask); - } - GLfloat left = 2.f * img->dst_x / width - 1.f; - GLfloat top = 1.f - 2.f * img->dst_y / height; - GLfloat right = left + 2.f * img->w / width; - GLfloat bottom = top - 2.f * img->h / height; - const GLfloat vertices[] = { left, top, right, top, left, bottom, right, bottom }; - const GLfloat uv[] = { 0, 0, 1, 0, 0, 1, 1, 1 }; - GLfloat color[4]; - rgba(s, img->color, color); - glUniform4fv(s.color, 1, color); - glVertexAttribPointer(s.position, 2, GL_FLOAT, GL_FALSE, 0, vertices); - glVertexAttribPointer(s.texcoord, 2, GL_FLOAT, GL_FALSE, 0, uv); - glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); - } - // Delete excess textures so a simpler subsequent frame releases GPU storage. - for (size_t i = count; i < s.textures.size(); ++i) glDeleteTextures(1, &s.textures[i].id); - s.textures.resize(count); - if (glGetError() != GL_NO_ERROR) return -1; - stats[1] = micros() - upload; - const int64_t swap = micros(); - if (s.window) { - if (!eglSwapBuffers(s.display, s.surface)) return -1; - } else { - glFlush(); - } - stats[2] = micros() - swap; - s.force = false; - return count ? 1 : 2; -} -void font_message(int level, const char *format, va_list args, void *) { - // Info/warnings identify provider failures and selected faces; verbose event dumps stay off. - if (level <= 4) - __android_log_vprint(ANDROID_LOG_DEBUG, "ExoAssNative", format, args); -} - -bool add_fonts(JNIEnv *env, ASS_Library *library, jobjectArray names, jobjectArray fonts) { - if (!names || !fonts) return false; - jsize count = env->GetArrayLength(names); - if (count > 64 || env->GetArrayLength(fonts) != count) return false; - size_t total = 0; - for (jsize i = 0; i < count; ++i) { - auto name = static_cast(env->GetObjectArrayElement(names, i)); - auto data = static_cast(env->GetObjectArrayElement(fonts, i)); - if (!name || !data || env->GetStringLength(name) > 512) return false; - jsize length = env->GetArrayLength(data); - if (length <= 0 || length > 16 * 1024 * 1024) return false; - total += static_cast(length); - if (total > 32 * 1024 * 1024) return false; - std::vector bytes(length); - env->GetByteArrayRegion(data, 0, length, reinterpret_cast(bytes.data())); - if (env->ExceptionCheck()) return false; - const char *label = env->GetStringUTFChars(name, nullptr); - if (!label) return false; - ass_add_font(library, label, bytes.data(), length); - env->ReleaseStringUTFChars(name, label); - env->DeleteLocalRef(name); - env->DeleteLocalRef(data); - } - return true; -} -} // namespace - -#define JNI_METHOD(name) Java_com_fongmi_android_tv_player_exo_ass_AssNative_##name - -extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(create)(JNIEnv *env, jclass, jstring font_config, - jobjectArray names, jobjectArray fonts) { - if (!font_config) return 0; - try { - auto s = std::make_unique(); - s->library = ass_library_init(); - if (!s->library) return 0; - ass_set_message_cb(s->library, font_message, nullptr); - ass_set_extract_fonts(s->library, 0); - if (!add_fonts(env, s->library, names, fonts)) return 0; - s->renderer = ass_renderer_init(s->library); - if (!s->renderer) return 0; - ass_set_cache_limits(s->renderer, GLYPH_CACHE_LIMIT, BITMAP_CACHE_MIB); - const char *config = env->GetStringUTFChars(font_config, nullptr); - if (!config) return 0; - ass_set_fonts(s->renderer, nullptr, "sans-serif", ASS_FONTPROVIDER_FONTCONFIG, config, 1); - env->ReleaseStringUTFChars(font_config, config); - return static_cast(reinterpret_cast(s.release())); - } catch (...) { return 0; } -} - -extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(load)(JNIEnv *env, jclass, jlong handle, jbyteArray bytes) { - Session *s = session(handle); - if (!s || !bytes) return JNI_FALSE; - jsize length = env->GetArrayLength(bytes); - if (length <= 0 || static_cast(length) > MAX_SCRIPT) return JNI_FALSE; - try { - std::vector script(static_cast(length) + 1, 0); - env->GetByteArrayRegion(bytes, 0, length, reinterpret_cast(script.data())); - if (env->ExceptionCheck()) return JNI_FALSE; - ASS_Track *track = ass_read_memory(s->library, script.data(), length, nullptr); - if (!track) return JNI_FALSE; - if (track->n_events > MAX_EVENTS || track->n_styles > MAX_STYLES) { - ass_free_track(track); - return JNI_FALSE; - } - if (s->track) ass_free_track(s->track); - s->track = track; - s->packetized = false; - s->force = true; - return JNI_TRUE; - } catch (...) { return JNI_FALSE; } -} - -extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(loadHeader)(JNIEnv *env, jclass, jlong handle, jbyteArray bytes) { - Session *s = session(handle); - if (!s || !bytes) return JNI_FALSE; - jsize length = env->GetArrayLength(bytes); - if (length <= 0 || static_cast(length) > MAX_SCRIPT) return JNI_FALSE; - try { - std::vector header(static_cast(length) + 1, 0); - env->GetByteArrayRegion(bytes, 0, length, reinterpret_cast(header.data())); - if (env->ExceptionCheck()) return JNI_FALSE; - std::unique_ptr track(ass_new_track(s->library), ass_free_track); - if (!track) return JNI_FALSE; - ass_process_codec_private(track.get(), header.data(), length); - if (!track->event_format || track->n_events != 0 || track->n_styles > MAX_STYLES) return JNI_FALSE; - if (s->track) ass_free_track(s->track); - s->track = track.release(); - s->packetized = true; - s->force = true; - return JNI_TRUE; - } catch (...) { return JNI_FALSE; } -} - -extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(chunk)(JNIEnv *env, jclass, jlong handle, - jbyteArray bytes, jlong start_ms, jlong duration_ms) { - Session *s = session(handle); - if (!s || !s->track || !s->packetized || !bytes || duration_ms < 0 - || start_ms > INT64_MAX - duration_ms || s->track->n_events >= MAX_EVENTS) return JNI_FALSE; - jsize length = env->GetArrayLength(bytes); - if (length <= 0 || length > 4 * 1024 * 1024) return JNI_FALSE; - try { - std::vector packet(static_cast(length) + 1, 0); - env->GetByteArrayRegion(bytes, 0, length, reinterpret_cast(packet.data())); - if (env->ExceptionCheck()) return JNI_FALSE; - // libass owns event parsing and ReadOrder duplicate handling. Do not mix this with - // ass_process_data or edit its event array; the worker serializes this with rendering. - ass_process_chunk(s->track, packet.data(), length, start_ms, duration_ms); - return s->track->n_events <= MAX_EVENTS ? JNI_TRUE : JNI_FALSE; - } catch (...) { return JNI_FALSE; } -} - -extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(setSurface)(JNIEnv *env, jclass, jlong handle, jobject surface) { - Session *s = session(handle); - if (!s) return JNI_FALSE; - try { return attach(*s, env, surface, 0, 0) ? JNI_TRUE : JNI_FALSE; } - catch (...) { return JNI_FALSE; } -} - -extern "C" JNIEXPORT jint JNICALL JNI_METHOD(render)(JNIEnv *env, jclass, jlong handle, - jlong time_ms, jint width, jint height, jint storage_width, jint storage_height, - jdouble pixel_aspect, jint color_space, jint color_range, jboolean force, jlongArray stats) { - Session *s = session(handle); - if (!s || !stats || env->GetArrayLength(stats) < 6) return -1; - jlong values[6] = {}; - int result; - try { result = render(*s, time_ms, width, height, storage_width, storage_height, - pixel_aspect, color_space, color_range, force, values); } - catch (...) { result = -2; } - env->SetLongArrayRegion(stats, 0, 6, values); - return result; -} - -extern "C" JNIEXPORT void JNICALL JNI_METHOD(destroy)(JNIEnv *, jclass, jlong handle) { - delete session(handle); -} - -// Offscreen readback is used only by the prototype's instrumentation tests. -extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(createTestFonts)(JNIEnv *env, jclass, jobjectArray names, jobjectArray fonts) { - if (!names || !fonts) return 0; - jsize count = env->GetArrayLength(names); - if (count <= 0 || count > 8 || env->GetArrayLength(fonts) != count) return 0; - try { - auto s = std::make_unique(); - s->library = ass_library_init(); - if (!s->library) return 0; - if (!add_fonts(env, s->library, names, fonts)) return 0; - s->renderer = ass_renderer_init(s->library); - if (!s->renderer) return 0; - ass_set_cache_limits(s->renderer, GLYPH_CACHE_LIMIT, BITMAP_CACHE_MIB); - ass_set_fonts(s->renderer, nullptr, "sans-serif", ASS_FONTPROVIDER_NONE, nullptr, 1); - return static_cast(reinterpret_cast(s.release())); - } catch (...) { return 0; } -} - -extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(testSurface)(JNIEnv *env, jclass, jlong handle, jint width, jint height) { - Session *s = session(handle); - if (!s || !valid_size(width, height)) return JNI_FALSE; - try { return attach(*s, env, nullptr, width, height) ? JNI_TRUE : JNI_FALSE; } - catch (...) { return JNI_FALSE; } -} - -extern "C" JNIEXPORT jbyteArray JNICALL JNI_METHOD(readPixels)(JNIEnv *env, jclass, jlong handle) { - Session *s = session(handle); - if (!s || s->surface == EGL_NO_SURFACE || !valid_size(s->width, s->height)) return nullptr; - try { - size_t length = static_cast(s->width) * s->height * 4; - std::vector pixels(length); - glPixelStorei(GL_PACK_ALIGNMENT, 1); - glReadPixels(0, 0, s->width, s->height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); - if (glGetError() != GL_NO_ERROR) return nullptr; - jbyteArray out = env->NewByteArray(static_cast(length)); - if (!out) return nullptr; - env->SetByteArrayRegion(out, 0, static_cast(length), reinterpret_cast(pixels.data())); - return out; - } catch (...) { return nullptr; } -} diff --git a/third_party/exo-ass-native/licenses/fontconfig/COPYING b/third_party/exo-ass-native/licenses/fontconfig/COPYING deleted file mode 100644 index cd5fca1a03..0000000000 --- a/third_party/exo-ass-native/licenses/fontconfig/COPYING +++ /dev/null @@ -1,200 +0,0 @@ -fontconfig/COPYING - -Copyright © 2000,2001,2002,2003,2004,2006,2007 Keith Packard -Copyright © 2005 Patrick Lam -Copyright © 2007 Dwayne Bailey and Translate.org.za -Copyright © 2009 Roozbeh Pournader -Copyright © 2008,2009,2010,2011,2012,2013,2014,2015,2016,2017,2018,2019,2020 Red Hat, Inc. -Copyright © 2008 Danilo Šegan -Copyright © 2012 Google, Inc. - - -Permission to use, copy, modify, distribute, and sell this software and its -documentation for any purpose is hereby granted without fee, provided that -the above copyright notice appear in all copies and that both that -copyright notice and this permission notice appear in supporting -documentation, and that the name of the author(s) not be used in -advertising or publicity pertaining to distribution of the software without -specific, written prior permission. The authors make no -representations about the suitability of this software for any purpose. It -is provided "as is" without express or implied warranty. - -THE AUTHOR(S) DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, -INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO -EVENT SHALL THE AUTHOR(S) BE LIABLE FOR ANY SPECIAL, INDIRECT OR -CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, -DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER -TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. - - --------------------------------------------------------------------------------- -fontconfig/fc-case/CaseFolding.txt - -© 2019 Unicode®, Inc. -Unicode and the Unicode Logo are registered trademarks of Unicode, Inc. in the U.S. and other countries. -For terms of use, see http://www.unicode.org/terms_of_use.html - - --------------------------------------------------------------------------------- -fontconfig/src/fcatomic.h - -/* - * Mutex operations. Originally copied from HarfBuzz. - * - * Copyright © 2007 Chris Wilson - * Copyright © 2009,2010 Red Hat, Inc. - * Copyright © 2011,2012,2013 Google, Inc. - * - * Permission is hereby granted, without written agreement and without - * license or royalty fees, to use, copy, modify, and distribute this - * software and its documentation for any purpose, provided that the - * above copyright notice and the following two paragraphs appear in - * all copies of this software. - * - * IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR - * DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES - * ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN - * IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH - * DAMAGE. - * - * THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, - * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND - * FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS - * ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO - * PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. - * - * Contributor(s): - * Chris Wilson - * Red Hat Author(s): Behdad Esfahbod - * Google Author(s): Behdad Esfahbod - */ - - --------------------------------------------------------------------------------- -fontconfig/src/fcfoundry.h - -/* - Copyright © 2002-2003 by Juliusz Chroboczek - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. -*/ - - --------------------------------------------------------------------------------- -fontconfig/src/fcmd5.h - -/* - * This code implements the MD5 message-digest algorithm. - * The algorithm is due to Ron Rivest. This code was - * written by Colin Plumb in 1993, no copyright is claimed. - * This code is in the public domain; do with it what you wish. - * - * Equivalent code is available from RSA Data Security, Inc. - * This code has been tested against that, and is equivalent, - * except that you don't need to include two pages of legalese - * with every copy. - * - * To compute the message digest of a chunk of bytes, declare an - * MD5Context structure, pass it to MD5Init, call MD5Update as - * needed on buffers full of bytes, and then call MD5Final, which - * will fill a supplied 16-byte array with the digest. - */ - - --------------------------------------------------------------------------------- -fontconfig/src/fcmutex.h - -/* - * Atomic int and pointer operations. Originally copied from HarfBuzz. - * - * Copyright © 2007 Chris Wilson - * Copyright © 2009,2010 Red Hat, Inc. - * Copyright © 2011,2012,2013 Google, Inc. - * - * Permission is hereby granted, without written agreement and without - * license or royalty fees, to use, copy, modify, and distribute this - * software and its documentation for any purpose, provided that the - * above copyright notice and the following two paragraphs appear in - * all copies of this software. - * - * IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR - * DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES - * ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN - * IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH - * DAMAGE. - * - * THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, - * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND - * FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS - * ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO - * PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. - * - * Contributor(s): - * Chris Wilson - * Red Hat Author(s): Behdad Esfahbod - * Google Author(s): Behdad Esfahbod - */ - - --------------------------------------------------------------------------------- -fontconfig/src/ftglue.[ch] - -/* ftglue.c: Glue code for compiling the OpenType code from - * FreeType 1 using only the public API of FreeType 2 - * - * By David Turner, The FreeType Project (www.freetype.org) - * - * This code is explicitely put in the public domain - * - * ========================================================================== - * - * the OpenType parser codes was originally written as an extension to - * FreeType 1.x. As such, its source code was embedded within the library, - * and used many internal FreeType functions to deal with memory and - * stream i/o. - * - * When it was 'salvaged' for Pango and Qt, the code was "ported" to FreeType 2, - * which basically means that some macro tricks were performed in order to - * directly access FT2 _internal_ functions. - * - * these functions were never part of FT2 public API, and _did_ change between - * various releases. This created chaos for many users: when they upgraded the - * FreeType library on their system, they couldn't run Gnome anymore since - * Pango refused to link. - * - * Very fortunately, it's possible to completely avoid this problem because - * the FT_StreamRec and FT_MemoryRec structure types, which describe how - * memory and stream implementations interface with the rest of the font - * library, have always been part of the public API, and never changed. - * - * What we do thus is re-implement, within the OpenType parser, the few - * functions that depend on them. This only adds one or two kilobytes of - * code, and ensures that the parser can work with _any_ version - * of FreeType installed on your system. How sweet... ! - * - * Note that we assume that Pango doesn't use any other internal functions - * from FreeType. It used to in old versions, but this should no longer - * be the case. (crossing my fingers). - * - * - David Turner - * - The FreeType Project (www.freetype.org) - * - * PS: This "glue" code is explicitely put in the public domain - */ diff --git a/third_party/exo-ass-native/licenses/freetype2/LICENSE.TXT b/third_party/exo-ass-native/licenses/freetype2/LICENSE.TXT deleted file mode 100644 index 0f9df2177a..0000000000 --- a/third_party/exo-ass-native/licenses/freetype2/LICENSE.TXT +++ /dev/null @@ -1,47 +0,0 @@ -FREETYPE LICENSES ------------------ - -The FreeType 2 font engine is copyrighted work and cannot be used -legally without a software license. In order to make this project -usable to a vast majority of developers, we distribute it under two -mutually exclusive open-source licenses. - -This means that *you* must choose *one* of the two licenses described -below, then obey all its terms and conditions when using FreeType 2 in -any of your projects or products. - - - The FreeType License, found in the file `docs/FTL.TXT`, which is - similar to the original BSD license *with* an advertising clause - that forces you to explicitly cite the FreeType project in your - product's documentation. All details are in the license file. - This license is suited to products which don't use the GNU General - Public License. - - Note that this license is compatible to the GNU General Public - License version 3, but not version 2. - - - The GNU General Public License version 2, found in - `docs/GPLv2.TXT` (any later version can be used also), for - programs which already use the GPL. Note that the FTL is - incompatible with GPLv2 due to its advertisement clause. - -The contributed BDF and PCF drivers come with a license similar to -that of the X Window System. It is compatible to the above two -licenses (see files `src/bdf/README` and `src/pcf/README`). The same -holds for the source code files `src/base/fthash.c` and -`include/freetype/internal/fthash.h`; they were part of the BDF driver -in earlier FreeType versions. - -The gzip module uses the zlib license (see `src/gzip/zlib.h`) which -too is compatible to the above two licenses. - -The files `src/autofit/ft-hb-ft.c`, `src/autofit/ft-hb-decls.h`, -`src/autofit/ft-hb-types.h`, and `src/autofit/hb-script-list.h` -contain code taken (almost) verbatim from the HarfBuzz library, which -uses the 'Old MIT' license compatible to the above two licenses. - -The MD5 checksum support (only used for debugging in development -builds) is in the public domain. - - ---- end of LICENSE.TXT --- diff --git a/third_party/exo-ass-native/licenses/freetype2/docs/FTL.TXT b/third_party/exo-ass-native/licenses/freetype2/docs/FTL.TXT deleted file mode 100644 index b9e8cbd66f..0000000000 --- a/third_party/exo-ass-native/licenses/freetype2/docs/FTL.TXT +++ /dev/null @@ -1,169 +0,0 @@ - The FreeType Project LICENSE - ---------------------------- - - 2006-Jan-27 - - Copyright 1996-2002, 2006 by - David Turner, Robert Wilhelm, and Werner Lemberg - - - -Introduction -============ - - The FreeType Project is distributed in several archive packages; - some of them may contain, in addition to the FreeType font engine, - various tools and contributions which rely on, or relate to, the - FreeType Project. - - This license applies to all files found in such packages, and - which do not fall under their own explicit license. The license - affects thus the FreeType font engine, the test programs, - documentation and makefiles, at the very least. - - This license was inspired by the BSD, Artistic, and IJG - (Independent JPEG Group) licenses, which all encourage inclusion - and use of free software in commercial and freeware products - alike. As a consequence, its main points are that: - - o We don't promise that this software works. However, we will be - interested in any kind of bug reports. (`as is' distribution) - - o You can use this software for whatever you want, in parts or - full form, without having to pay us. (`royalty-free' usage) - - o You may not pretend that you wrote this software. If you use - it, or only parts of it, in a program, you must acknowledge - somewhere in your documentation that you have used the - FreeType code. (`credits') - - We specifically permit and encourage the inclusion of this - software, with or without modifications, in commercial products. - We disclaim all warranties covering The FreeType Project and - assume no liability related to The FreeType Project. - - - Finally, many people asked us for a preferred form for a - credit/disclaimer to use in compliance with this license. We thus - encourage you to use the following text: - - """ - Portions of this software are copyright © The FreeType - Project (https://freetype.org). All rights reserved. - """ - - Please replace with the value from the FreeType version you - actually use. - - -Legal Terms -=========== - -0. Definitions --------------- - - Throughout this license, the terms `package', `FreeType Project', - and `FreeType archive' refer to the set of files originally - distributed by the authors (David Turner, Robert Wilhelm, and - Werner Lemberg) as the `FreeType Project', be they named as alpha, - beta or final release. - - `You' refers to the licensee, or person using the project, where - `using' is a generic term including compiling the project's source - code as well as linking it to form a `program' or `executable'. - This program is referred to as `a program using the FreeType - engine'. - - This license applies to all files distributed in the original - FreeType Project, including all source code, binaries and - documentation, unless otherwise stated in the file in its - original, unmodified form as distributed in the original archive. - If you are unsure whether or not a particular file is covered by - this license, you must contact us to verify this. - - The FreeType Project is copyright (C) 1996-2000 by David Turner, - Robert Wilhelm, and Werner Lemberg. All rights reserved except as - specified below. - -1. No Warranty --------------- - - THE FREETYPE PROJECT IS PROVIDED `AS IS' WITHOUT WARRANTY OF ANY - KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, - WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR - PURPOSE. IN NO EVENT WILL ANY OF THE AUTHORS OR COPYRIGHT HOLDERS - BE LIABLE FOR ANY DAMAGES CAUSED BY THE USE OR THE INABILITY TO - USE, OF THE FREETYPE PROJECT. - -2. Redistribution ------------------ - - This license grants a worldwide, royalty-free, perpetual and - irrevocable right and license to use, execute, perform, compile, - display, copy, create derivative works of, distribute and - sublicense the FreeType Project (in both source and object code - forms) and derivative works thereof for any purpose; and to - authorize others to exercise some or all of the rights granted - herein, subject to the following conditions: - - o Redistribution of source code must retain this license file - (`FTL.TXT') unaltered; any additions, deletions or changes to - the original files must be clearly indicated in accompanying - documentation. The copyright notices of the unaltered, - original files must be preserved in all copies of source - files. - - o Redistribution in binary form must provide a disclaimer that - states that the software is based in part of the work of the - FreeType Team, in the distribution documentation. We also - encourage you to put an URL to the FreeType web page in your - documentation, though this isn't mandatory. - - These conditions apply to any software derived from or based on - the FreeType Project, not just the unmodified files. If you use - our work, you must acknowledge us. However, no fee need be paid - to us. - -3. Advertising --------------- - - Neither the FreeType authors and contributors nor you shall use - the name of the other for commercial, advertising, or promotional - purposes without specific prior written permission. - - We suggest, but do not require, that you use one or more of the - following phrases to refer to this software in your documentation - or advertising materials: `FreeType Project', `FreeType Engine', - `FreeType library', or `FreeType Distribution'. - - As you have not signed this license, you are not required to - accept it. However, as the FreeType Project is copyrighted - material, only this license, or another one contracted with the - authors, grants you the right to use, distribute, and modify it. - Therefore, by using, distributing, or modifying the FreeType - Project, you indicate that you understand and accept all the terms - of this license. - -4. Contacts ------------ - - There are two mailing lists related to FreeType: - - o freetype@nongnu.org - - Discusses general use and applications of FreeType, as well as - future and wanted additions to the library and distribution. - If you are looking for support, start in this list if you - haven't found anything to help you in the documentation. - - o freetype-devel@nongnu.org - - Discusses bugs, as well as engine internals, design issues, - specific licenses, porting, etc. - - Our home page can be found at - - https://freetype.org - - ---- end of FTL.TXT --- diff --git a/third_party/exo-ass-native/licenses/freetype2/docs/GPLv2.TXT b/third_party/exo-ass-native/licenses/freetype2/docs/GPLv2.TXT deleted file mode 100644 index b2fe7b6af3..0000000000 --- a/third_party/exo-ass-native/licenses/freetype2/docs/GPLv2.TXT +++ /dev/null @@ -1,340 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc. - 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Library General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Library General -Public License instead of this License. diff --git a/third_party/exo-ass-native/licenses/fribidi/COPYING b/third_party/exo-ass-native/licenses/fribidi/COPYING deleted file mode 100644 index 2314e38ac0..0000000000 --- a/third_party/exo-ass-native/licenses/fribidi/COPYING +++ /dev/null @@ -1,502 +0,0 @@ - GNU LESSER GENERAL PUBLIC LICENSE - Version 2.1, February 1999 - - Copyright (C) 1991, 1999 Free Software Foundation, Inc. - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts - as the successor of the GNU Library Public License, version 2, hence - the version number 2.1.] - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -Licenses are intended to guarantee your freedom to share and change -free software--to make sure the software is free for all its users. - - This license, the Lesser General Public License, applies to some -specially designated software packages--typically libraries--of the -Free Software Foundation and other authors who decide to use it. You -can use it too, but we suggest you first think carefully about whether -this license or the ordinary General Public License is the better -strategy to use in any particular case, based on the explanations below. - - When we speak of free software, we are referring to freedom of use, -not price. Our General Public Licenses are designed to make sure that -you have the freedom to distribute copies of free software (and charge -for this service if you wish); that you receive source code or can get -it if you want it; that you can change the software and use pieces of -it in new free programs; and that you are informed that you can do -these things. - - To protect your rights, we need to make restrictions that forbid -distributors to deny you these rights or to ask you to surrender these -rights. These restrictions translate to certain responsibilities for -you if you distribute copies of the library or if you modify it. - - For example, if you distribute copies of the library, whether gratis -or for a fee, you must give the recipients all the rights that we gave -you. You must make sure that they, too, receive or can get the source -code. If you link other code with the library, you must provide -complete object files to the recipients, so that they can relink them -with the library after making changes to the library and recompiling -it. And you must show them these terms so they know their rights. - - We protect your rights with a two-step method: (1) we copyright the -library, and (2) we offer you this license, which gives you legal -permission to copy, distribute and/or modify the library. - - To protect each distributor, we want to make it very clear that -there is no warranty for the free library. Also, if the library is -modified by someone else and passed on, the recipients should know -that what they have is not the original version, so that the original -author's reputation will not be affected by problems that might be -introduced by others. - - Finally, software patents pose a constant threat to the existence of -any free program. We wish to make sure that a company cannot -effectively restrict the users of a free program by obtaining a -restrictive license from a patent holder. Therefore, we insist that -any patent license obtained for a version of the library must be -consistent with the full freedom of use specified in this license. - - Most GNU software, including some libraries, is covered by the -ordinary GNU General Public License. This license, the GNU Lesser -General Public License, applies to certain designated libraries, and -is quite different from the ordinary General Public License. We use -this license for certain libraries in order to permit linking those -libraries into non-free programs. - - When a program is linked with a library, whether statically or using -a shared library, the combination of the two is legally speaking a -combined work, a derivative of the original library. The ordinary -General Public License therefore permits such linking only if the -entire combination fits its criteria of freedom. The Lesser General -Public License permits more lax criteria for linking other code with -the library. - - We call this license the "Lesser" General Public License because it -does Less to protect the user's freedom than the ordinary General -Public License. It also provides other free software developers Less -of an advantage over competing non-free programs. These disadvantages -are the reason we use the ordinary General Public License for many -libraries. However, the Lesser license provides advantages in certain -special circumstances. - - For example, on rare occasions, there may be a special need to -encourage the widest possible use of a certain library, so that it becomes -a de-facto standard. To achieve this, non-free programs must be -allowed to use the library. A more frequent case is that a free -library does the same job as widely used non-free libraries. In this -case, there is little to gain by limiting the free library to free -software only, so we use the Lesser General Public License. - - In other cases, permission to use a particular library in non-free -programs enables a greater number of people to use a large body of -free software. For example, permission to use the GNU C Library in -non-free programs enables many more people to use the whole GNU -operating system, as well as its variant, the GNU/Linux operating -system. - - Although the Lesser General Public License is Less protective of the -users' freedom, it does ensure that the user of a program that is -linked with the Library has the freedom and the wherewithal to run -that program using a modified version of the Library. - - The precise terms and conditions for copying, distribution and -modification follow. Pay close attention to the difference between a -"work based on the library" and a "work that uses the library". The -former contains code derived from the library, whereas the latter must -be combined with the library in order to run. - - GNU LESSER GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License Agreement applies to any software library or other -program which contains a notice placed by the copyright holder or -other authorized party saying it may be distributed under the terms of -this Lesser General Public License (also called "this License"). -Each licensee is addressed as "you". - - A "library" means a collection of software functions and/or data -prepared so as to be conveniently linked with application programs -(which use some of those functions and data) to form executables. - - The "Library", below, refers to any such software library or work -which has been distributed under these terms. A "work based on the -Library" means either the Library or any derivative work under -copyright law: that is to say, a work containing the Library or a -portion of it, either verbatim or with modifications and/or translated -straightforwardly into another language. (Hereinafter, translation is -included without limitation in the term "modification".) - - "Source code" for a work means the preferred form of the work for -making modifications to it. For a library, complete source code means -all the source code for all modules it contains, plus any associated -interface definition files, plus the scripts used to control compilation -and installation of the library. - - Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running a program using the Library is not restricted, and output from -such a program is covered only if its contents constitute a work based -on the Library (independent of the use of the Library in a tool for -writing it). Whether that is true depends on what the Library does -and what the program that uses the Library does. - - 1. You may copy and distribute verbatim copies of the Library's -complete source code as you receive it, in any medium, provided that -you conspicuously and appropriately publish on each copy an -appropriate copyright notice and disclaimer of warranty; keep intact -all the notices that refer to this License and to the absence of any -warranty; and distribute a copy of this License along with the -Library. - - You may charge a fee for the physical act of transferring a copy, -and you may at your option offer warranty protection in exchange for a -fee. - - 2. You may modify your copy or copies of the Library or any portion -of it, thus forming a work based on the Library, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices - stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no - charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a - table of data to be supplied by an application program that uses - the facility, other than as an argument passed when the facility - is invoked, then you must make a good faith effort to ensure that, - in the event an application does not supply such function or - table, the facility still operates, and performs whatever part of - its purpose remains meaningful. - - (For example, a function in a library to compute square roots has - a purpose that is entirely well-defined independent of the - application. Therefore, Subsection 2d requires that any - application-supplied function or table used by this function must - be optional: if the application does not supply it, the square - root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Library, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Library, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote -it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library -with the Library (or with a work based on the Library) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may opt to apply the terms of the ordinary GNU General Public -License instead of this License to a given copy of the Library. To do -this, you must alter all the notices that refer to this License, so -that they refer to the ordinary GNU General Public License, version 2, -instead of to this License. (If a newer version than version 2 of the -ordinary GNU General Public License has appeared, then you can specify -that version instead if you wish.) Do not make any other change in -these notices. - - Once this change is made in a given copy, it is irreversible for -that copy, so the ordinary GNU General Public License applies to all -subsequent copies and derivative works made from that copy. - - This option is useful when you wish to copy part of the code of -the Library into a program that is not a library. - - 4. You may copy and distribute the Library (or a portion or -derivative of it, under Section 2) in object code or executable form -under the terms of Sections 1 and 2 above provided that you accompany -it with the complete corresponding machine-readable source code, which -must be distributed under the terms of Sections 1 and 2 above on a -medium customarily used for software interchange. - - If distribution of object code is made by offering access to copy -from a designated place, then offering equivalent access to copy the -source code from the same place satisfies the requirement to -distribute the source code, even though third parties are not -compelled to copy the source along with the object code. - - 5. A program that contains no derivative of any portion of the -Library, but is designed to work with the Library by being compiled or -linked with it, is called a "work that uses the Library". Such a -work, in isolation, is not a derivative work of the Library, and -therefore falls outside the scope of this License. - - However, linking a "work that uses the Library" with the Library -creates an executable that is a derivative of the Library (because it -contains portions of the Library), rather than a "work that uses the -library". The executable is therefore covered by this License. -Section 6 states terms for distribution of such executables. - - When a "work that uses the Library" uses material from a header file -that is part of the Library, the object code for the work may be a -derivative work of the Library even though the source code is not. -Whether this is true is especially significant if the work can be -linked without the Library, or if the work is itself a library. The -threshold for this to be true is not precisely defined by law. - - If such an object file uses only numerical parameters, data -structure layouts and accessors, and small macros and small inline -functions (ten lines or less in length), then the use of the object -file is unrestricted, regardless of whether it is legally a derivative -work. (Executables containing this object code plus portions of the -Library will still fall under Section 6.) - - Otherwise, if the work is a derivative of the Library, you may -distribute the object code for the work under the terms of Section 6. -Any executables containing that work also fall under Section 6, -whether or not they are linked directly with the Library itself. - - 6. As an exception to the Sections above, you may also combine or -link a "work that uses the Library" with the Library to produce a -work containing portions of the Library, and distribute that work -under terms of your choice, provided that the terms permit -modification of the work for the customer's own use and reverse -engineering for debugging such modifications. - - You must give prominent notice with each copy of the work that the -Library is used in it and that the Library and its use are covered by -this License. You must supply a copy of this License. If the work -during execution displays copyright notices, you must include the -copyright notice for the Library among them, as well as a reference -directing the user to the copy of this License. Also, you must do one -of these things: - - a) Accompany the work with the complete corresponding - machine-readable source code for the Library including whatever - changes were used in the work (which must be distributed under - Sections 1 and 2 above); and, if the work is an executable linked - with the Library, with the complete machine-readable "work that - uses the Library", as object code and/or source code, so that the - user can modify the Library and then relink to produce a modified - executable containing the modified Library. (It is understood - that the user who changes the contents of definitions files in the - Library will not necessarily be able to recompile the application - to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the - Library. A suitable mechanism is one that (1) uses at run time a - copy of the library already present on the user's computer system, - rather than copying library functions into the executable, and (2) - will operate properly with a modified version of the library, if - the user installs one, as long as the modified version is - interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at - least three years, to give the same user the materials - specified in Subsection 6a, above, for a charge no more - than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy - from a designated place, offer equivalent access to copy the above - specified materials from the same place. - - e) Verify that the user has already received a copy of these - materials or that you have already sent this user a copy. - - For an executable, the required form of the "work that uses the -Library" must include any data and utility programs needed for -reproducing the executable from it. However, as a special exception, -the materials to be distributed need not include anything that is -normally distributed (in either source or binary form) with the major -components (compiler, kernel, and so on) of the operating system on -which the executable runs, unless that component itself accompanies -the executable. - - It may happen that this requirement contradicts the license -restrictions of other proprietary libraries that do not normally -accompany the operating system. Such a contradiction means you cannot -use both them and the Library together in an executable that you -distribute. - - 7. You may place library facilities that are a work based on the -Library side-by-side in a single library together with other library -facilities not covered by this License, and distribute such a combined -library, provided that the separate distribution of the work based on -the Library and of the other library facilities is otherwise -permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work - based on the Library, uncombined with any other library - facilities. This must be distributed under the terms of the - Sections above. - - b) Give prominent notice with the combined library of the fact - that part of it is a work based on the Library, and explaining - where to find the accompanying uncombined form of the same work. - - 8. You may not copy, modify, sublicense, link with, or distribute -the Library except as expressly provided under this License. Any -attempt otherwise to copy, modify, sublicense, link with, or -distribute the Library is void, and will automatically terminate your -rights under this License. However, parties who have received copies, -or rights, from you under this License will not have their licenses -terminated so long as such parties remain in full compliance. - - 9. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Library or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Library (or any work based on the -Library), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Library or works based on it. - - 10. Each time you redistribute the Library (or any work based on the -Library), the recipient automatically receives a license from the -original licensor to copy, distribute, link with or modify the Library -subject to these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties with -this License. - - 11. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Library at all. For example, if a patent -license would not permit royalty-free redistribution of the Library by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any -particular circumstance, the balance of the section is intended to apply, -and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 12. If the distribution and/or use of the Library is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Library under this License may add -an explicit geographical distribution limitation excluding those countries, -so that distribution is permitted only in or among countries not thus -excluded. In such case, this License incorporates the limitation as if -written in the body of this License. - - 13. The Free Software Foundation may publish revised and/or new -versions of the Lesser General Public License from time to time. -Such new versions will be similar in spirit to the present version, -but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library -specifies a version number of this License which applies to it and -"any later version", you have the option of following the terms and -conditions either of that version or of any later version published by -the Free Software Foundation. If the Library does not specify a -license version number, you may choose any version ever published by -the Free Software Foundation. - - 14. If you wish to incorporate parts of the Library into other free -programs whose distribution conditions are incompatible with these, -write to the author to ask for permission. For software which is -copyrighted by the Free Software Foundation, write to the Free -Software Foundation; we sometimes make exceptions for this. Our -decision will be guided by the two goals of preserving the free status -of all derivatives of our free software and of promoting the sharing -and reuse of software generally. - - NO WARRANTY - - 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO -WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. -EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR -OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY -KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE -LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME -THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN -WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY -AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU -FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR -CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE -LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING -RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A -FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF -SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH -DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Libraries - - If you develop a new library, and you want it to be of the greatest -possible use to the public, we recommend making it free software that -everyone can redistribute and change. You can do so by permitting -redistribution under these terms (or, alternatively, under the terms of the -ordinary General Public License). - - To apply these terms, attach the following notices to the library. It is -safest to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least the -"copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the library, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the - library `Frob' (a library for tweaking knobs) written by James Random Hacker. - - , 1 April 1990 - Ty Coon, President of Vice - -That's all there is to it! diff --git a/third_party/exo-ass-native/licenses/harfbuzz/COPYING b/third_party/exo-ass-native/licenses/harfbuzz/COPYING deleted file mode 100644 index 1dd917e9f2..0000000000 --- a/third_party/exo-ass-native/licenses/harfbuzz/COPYING +++ /dev/null @@ -1,42 +0,0 @@ -HarfBuzz is licensed under the so-called "Old MIT" license. Details follow. -For parts of HarfBuzz that are licensed under different licenses see individual -files names COPYING in subdirectories where applicable. - -Copyright © 2010-2022 Google, Inc. -Copyright © 2015-2020 Ebrahim Byagowi -Copyright © 2019,2020 Facebook, Inc. -Copyright © 2012,2015 Mozilla Foundation -Copyright © 2011 Codethink Limited -Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies) -Copyright © 2009 Keith Stribley -Copyright © 2011 Martin Hosken and SIL International -Copyright © 2007 Chris Wilson -Copyright © 2005,2006,2020,2021,2022,2023 Behdad Esfahbod -Copyright © 2004,2007,2008,2009,2010,2013,2021,2022,2023 Red Hat, Inc. -Copyright © 1998-2005 David Turner and Werner Lemberg -Copyright © 2016 Igalia S.L. -Copyright © 2022 Matthias Clasen -Copyright © 2018,2021 Khaled Hosny -Copyright © 2018,2019,2020 Adobe, Inc -Copyright © 2013-2015 Alexei Podtelezhnikov - -For full copyright notices consult the individual files in the package. - - -Permission is hereby granted, without written agreement and without -license or royalty fees, to use, copy, modify, and distribute this -software and its documentation for any purpose, provided that the -above copyright notice and the following two paragraphs appear in -all copies of this software. - -IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR -DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES -ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN -IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH -DAMAGE. - -THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS -ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO -PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. diff --git a/third_party/exo-ass-native/licenses/libass/COPYING b/third_party/exo-ass-native/licenses/libass/COPYING deleted file mode 100644 index 3ff0e11457..0000000000 --- a/third_party/exo-ass-native/licenses/libass/COPYING +++ /dev/null @@ -1,15 +0,0 @@ -ISC License - -Copyright (C) 2006-2016 libass contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/third_party/exo-ass-native/licenses/libxml2/Copyright b/third_party/exo-ass-native/licenses/libxml2/Copyright deleted file mode 100644 index 8c0b7c15dd..0000000000 --- a/third_party/exo-ass-native/licenses/libxml2/Copyright +++ /dev/null @@ -1,24 +0,0 @@ -Except where otherwise noted in the source code (e.g. the files dict.c and -list.c, which are covered by a similar licence but with different Copyright -notices) all the files are: - - Copyright (C) 1998-2012 Daniel Veillard. All Rights Reserved. - Copyright (C) The Libxml2 Contributors. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is fur- -nished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FIT- -NESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/third_party/exo-ass-native/mask_copy.h b/third_party/exo-ass-native/mask_copy.h deleted file mode 100644 index d90f9e727c..0000000000 --- a/third_party/exo-ass-native/mask_copy.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -namespace exo_ass { -// libass guarantees only stride * (height - 1) + width accessible source bytes. -inline bool copy_mask(std::vector &output, const uint8_t *source, - int width, int height, int stride, size_t budget) { - if (!source || width <= 0 || height <= 0 || stride < width - || static_cast(width) > budget / static_cast(height) - || static_cast(height - 1) - > (std::numeric_limits::max() - width) / static_cast(stride)) - return false; - output.resize(static_cast(width) * height); - for (int row = 0; row < height; ++row) - std::memcpy(output.data() + static_cast(row) * width, - source + static_cast(row) * stride, width); - return true; -} -} // namespace exo_ass diff --git a/third_party/exo-ass-native/mask_copy_test.cpp b/third_party/exo-ass-native/mask_copy_test.cpp deleted file mode 100644 index d39726d2aa..0000000000 --- a/third_party/exo-ass-native/mask_copy_test.cpp +++ /dev/null @@ -1,29 +0,0 @@ -#include "mask_copy.h" -#include -#include -#include - -int main() { - // Allocate exactly the libass guarantee: ASan catches a final-row overread. - for (int width : {1, 3, 16, 255}) for (int height : {1, 2, 19}) - for (int padding : {0, 1, 31}) { - int stride = width + padding; - size_t length = static_cast(stride) * (height - 1) + width; - auto source = std::make_unique(length); - for (size_t i = 0; i < length; ++i) source[i] = static_cast(i); - std::vector output; - assert(exo_ass::copy_mask(output, source.get(), width, height, stride, 8192)); - assert(output.size() == static_cast(width) * height); - for (int y = 0; y < height; ++y) for (int x = 0; x < width; ++x) - assert(output[static_cast(y) * width + x] == source[static_cast(y) * stride + x]); - } - std::vector output = {42}; - uint8_t source[] = {1, 2, 3}; - assert(!exo_ass::copy_mask(output, nullptr, 1, 1, 1, 8)); - assert(!exo_ass::copy_mask(output, source, -1, 1, 1, 8)); - assert(!exo_ass::copy_mask(output, source, 1, 0, 1, 8)); - assert(!exo_ass::copy_mask(output, source, 3, 1, 2, 8)); - assert(!exo_ass::copy_mask(output, source, 3, 3, 3, 8)); - assert(!exo_ass::copy_mask(output, source, INT_MAX, INT_MAX, INT_MAX, 8 * 1024 * 1024)); - assert(output.size() == 1 && output[0] == 42); -} diff --git a/third_party/exo-ass-native/prebuilt/arm64-v8a/libexo_ass.so b/third_party/exo-ass-native/prebuilt/arm64-v8a/libexo_ass.so deleted file mode 100755 index 686c9f4831..0000000000 Binary files a/third_party/exo-ass-native/prebuilt/arm64-v8a/libexo_ass.so and /dev/null differ diff --git a/third_party/ijk-native-lock.json b/third_party/ijk-native-lock.json index cd2e9ab19d..c49e4e7c81 100644 --- a/third_party/ijk-native-lock.json +++ b/third_party/ijk-native-lock.json @@ -12,10 +12,13 @@ "ffmpeg_patches": [ "third_party/patches/ijk-ffmpeg4-ndk28.patch" ], - "ffmpeg4_config_revision": "96598d75", + "ffmpeg4_config_revision": "96598d75d381a4e8c3137795a7b490253a26ec80", "ffmpeg_repository": "https://github.com/ShikinChen/FFmpeg.git", "ffmpeg_tag": "ff4.0--ijk0.8.8--20210426--001", - "openssl_tag": "openssl-3.2" + "ffmpeg_commit": "e88b5afbf9ca543a098ede6dd6765ef0e629836d", + "openssl_repository": "https://github.com/openssl/openssl.git", + "openssl_branch": "openssl-3.2", + "openssl_commit": "14ddbcee237cb99b3921c352852b4d4fadbb8e6c" }, "toolchain": { "arm64-v8a": "28.2.13676358", diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar index b3e23117ab..8b6f88aa3b 100644 Binary files a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar and b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar differ diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.md5 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.md5 index 97a7251304..9ed36fe826 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.md5 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.md5 @@ -1 +1 @@ -9c8c0c81664eedd3c9ba69b27223d410 \ No newline at end of file +78e3a5ef59878ba5d16fe230581327b2 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha1 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha1 index 027ff4a6f2..ea40127a16 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha1 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha1 @@ -1 +1 @@ -a15346ad4258c89a5bdbb4c8306b2fafbd1f93c7 \ No newline at end of file +1e7aa72657bb5c99b0f75d03674d32044b8239cd \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha256 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha256 index 47a44c5605..aa8622366c 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha256 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha256 @@ -1 +1 @@ -dc9aefdcf38b0b8a2a55a467476c67ac51094fe6a32995bd5e61bce079140bcc \ No newline at end of file +31c38835a729f422ba25835733aa94ac23d8fe6445df2d725a71e46a11d86bfc \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha512 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha512 index dc83135c3b..42b3937ac2 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha512 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi-sources.jar.sha512 @@ -1 +1 @@ -6e2f2177df4aefd94670c66b3ef4d000ce58c580db87c06d0d1c267dbba71d300e3cd71d5500e07dfe22921ddf533128571560c6b299d01344c7e5ddf8bf70fb \ No newline at end of file +745d8f304af5d44c21b084e365f354dd25bcf23af65b14d079610651650ddab0489fecc9c72ef8633aecff71839fa5fb7944d8fd4527ac3c63e30d2c2951aa0f \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar index dbdd8850f8..c4b8934768 100644 Binary files a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar and b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar differ diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.md5 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.md5 index 12392432aa..203fe3c6e4 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.md5 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.md5 @@ -1 +1 @@ -c42f9739e1c4154cdca80d8fa5199f61 \ No newline at end of file +790b1e8cd57a82b0a648a3feba5cf700 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha1 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha1 index 3609218e0d..68b837f363 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha1 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha1 @@ -1 +1 @@ -65d3594e8b7df98b29f5ad924a1aa21385d3653e \ No newline at end of file +cbf650d7141d66da74e9298917753cb672af68e8 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha256 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha256 index 4be9f01047..74d8bbf8cb 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha256 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha256 @@ -1 +1 @@ -4826fe94891fb584951c813a8e67a63aafe0816d6adf836317ed895c7eee9cfd \ No newline at end of file +5a4815ab415c0650bc8adaa60e518f32df548ddcfafb43e02381281a96dac6e5 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha512 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha512 index 2d920e8188..b30215a1ff 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha512 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.aar.sha512 @@ -1 +1 @@ -1fc3033021affacd45491216b6a111fd779249a961001ab5148bb4949e8df0b35ef64393c6f509b0afc4fff3fba3163a6d38cb274df97a73e0f2862ecdf5f9c5 \ No newline at end of file +26933fe234b641c5538f8fc368ca442b4c46a616769f042b0d87d449c3518389b277e97fc032029a326c678cc62f75346a2ad6007e74fae9675426b0217ebc1d \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module index 9662a24d86..5c85a6b1c0 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module @@ -68,11 +68,11 @@ { "name": "media3-common-1.11.0-alpha01-fongmi.aar", "url": "media3-common-1.11.0-alpha01-fongmi.aar", - "size": 659744, - "sha512": "1fc3033021affacd45491216b6a111fd779249a961001ab5148bb4949e8df0b35ef64393c6f509b0afc4fff3fba3163a6d38cb274df97a73e0f2862ecdf5f9c5", - "sha256": "4826fe94891fb584951c813a8e67a63aafe0816d6adf836317ed895c7eee9cfd", - "sha1": "65d3594e8b7df98b29f5ad924a1aa21385d3653e", - "md5": "c42f9739e1c4154cdca80d8fa5199f61" + "size": 656215, + "sha512": "26933fe234b641c5538f8fc368ca442b4c46a616769f042b0d87d449c3518389b277e97fc032029a326c678cc62f75346a2ad6007e74fae9675426b0217ebc1d", + "sha256": "5a4815ab415c0650bc8adaa60e518f32df548ddcfafb43e02381281a96dac6e5", + "sha1": "cbf650d7141d66da74e9298917753cb672af68e8", + "md5": "790b1e8cd57a82b0a648a3feba5cf700" } ] }, @@ -433,11 +433,11 @@ { "name": "media3-common-1.11.0-alpha01-fongmi.aar", "url": "media3-common-1.11.0-alpha01-fongmi.aar", - "size": 659744, - "sha512": "1fc3033021affacd45491216b6a111fd779249a961001ab5148bb4949e8df0b35ef64393c6f509b0afc4fff3fba3163a6d38cb274df97a73e0f2862ecdf5f9c5", - "sha256": "4826fe94891fb584951c813a8e67a63aafe0816d6adf836317ed895c7eee9cfd", - "sha1": "65d3594e8b7df98b29f5ad924a1aa21385d3653e", - "md5": "c42f9739e1c4154cdca80d8fa5199f61" + "size": 656215, + "sha512": "26933fe234b641c5538f8fc368ca442b4c46a616769f042b0d87d449c3518389b277e97fc032029a326c678cc62f75346a2ad6007e74fae9675426b0217ebc1d", + "sha256": "5a4815ab415c0650bc8adaa60e518f32df548ddcfafb43e02381281a96dac6e5", + "sha1": "cbf650d7141d66da74e9298917753cb672af68e8", + "md5": "790b1e8cd57a82b0a648a3feba5cf700" } ] }, @@ -453,11 +453,11 @@ { "name": "media3-common-1.11.0-alpha01-fongmi-sources.jar", "url": "media3-common-1.11.0-alpha01-fongmi-sources.jar", - "size": 536221, - "sha512": "6e2f2177df4aefd94670c66b3ef4d000ce58c580db87c06d0d1c267dbba71d300e3cd71d5500e07dfe22921ddf533128571560c6b299d01344c7e5ddf8bf70fb", - "sha256": "dc9aefdcf38b0b8a2a55a467476c67ac51094fe6a32995bd5e61bce079140bcc", - "sha1": "a15346ad4258c89a5bdbb4c8306b2fafbd1f93c7", - "md5": "9c8c0c81664eedd3c9ba69b27223d410" + "size": 534370, + "sha512": "745d8f304af5d44c21b084e365f354dd25bcf23af65b14d079610651650ddab0489fecc9c72ef8633aecff71839fa5fb7944d8fd4527ac3c63e30d2c2951aa0f", + "sha256": "31c38835a729f422ba25835733aa94ac23d8fe6445df2d725a71e46a11d86bfc", + "sha1": "1e7aa72657bb5c99b0f75d03674d32044b8239cd", + "md5": "78e3a5ef59878ba5d16fe230581327b2" } ] } diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.md5 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.md5 index c340d62f9a..0854a5e128 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.md5 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.md5 @@ -1 +1 @@ -39fc54a1c78aaa896fe1b170738e500a \ No newline at end of file +2ca0e4395eee071f6c062595e716a5f1 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha1 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha1 index 97c9f59311..916f202dea 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha1 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha1 @@ -1 +1 @@ -99ca6414856e3f4c2505a45df94096e3c17fbe79 \ No newline at end of file +8ad9703ed5014ca3e86dd5c643a1c7140bbb151f \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha256 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha256 index f5af2d78e9..a1ff101079 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha256 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha256 @@ -1 +1 @@ -71749c9ba272b7017d5408e88565905d367fe30b2ad47687d1c5cb242a89b6e4 \ No newline at end of file +e1bc887749ab6b69b1f8d5b56a129f5e2d6618f31ebe3db2a841b352b9f24c87 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha512 b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha512 index 1f3a402481..6894a8c546 100644 --- a/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha512 +++ b/third_party/maven/androidx/media3/media3-common/1.11.0-alpha01-fongmi/media3-common-1.11.0-alpha01-fongmi.module.sha512 @@ -1 +1 @@ -e0d8845ff7b71e35fbe7faafd56970159d9f1128bacc75420e5ae486a770437c74441ae607588bc43531530bea3af052f796f52ed47e57d7f014030adcd07926 \ No newline at end of file +e6e11d1351c614a3d63dd9a900dbc85acb11deaa1e74610a2e0328f5f007478ac8744382a97762e46466c95b2a0c6ba8ae4f93ff1b3a1fc7d2229f8bbfd53927 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/maven-metadata.xml b/third_party/maven/androidx/media3/media3-common/maven-metadata.xml index 1054f66fe9..cdd944cc08 100644 --- a/third_party/maven/androidx/media3/media3-common/maven-metadata.xml +++ b/third_party/maven/androidx/media3/media3-common/maven-metadata.xml @@ -8,6 +8,6 @@ 1.11.0-alpha01-fongmi - 20260902130438 + 20260809152617 diff --git a/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.md5 b/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.md5 index 441d0de0fa..107d69e4cd 100644 --- a/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.md5 +++ b/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.md5 @@ -1 +1 @@ -c3e1a58f143f70c48f574db248d09f84 \ No newline at end of file +25765721a42531e4966107cd2ce662b9 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha1 b/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha1 index 2692851b33..785b0e4c85 100644 --- a/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha1 +++ b/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha1 @@ -1 +1 @@ -6d7dc28aaa6efd39964ed07a061ebb62fafee6fa \ No newline at end of file +75696bb38f2686228aa4a3c2d915ec6c7864e301 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha256 b/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha256 index fb31722634..49eb4f21f1 100644 --- a/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha256 +++ b/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha256 @@ -1 +1 @@ -119dbe13c2fb43cf83c3e520cb3ca10eeba096bdbff42ecc7f7512f5f81ab74a \ No newline at end of file +1432df861b472933168bbe48734c042408b69da7ae2119145591d7f5278d6cce \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha512 b/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha512 index 39c1f16ca7..586de7d98a 100644 --- a/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha512 +++ b/third_party/maven/androidx/media3/media3-common/maven-metadata.xml.sha512 @@ -1 +1 @@ -9189df876ede42f2796ccc9a388c8783966fe01257702b42f5ccaf12b6956b7a26e605d1a673ed49df791f3bfa23f6fcb1aafb2eed72559b391b35f10ca3e4f1 \ No newline at end of file +baefee010aed7eceab6f7daec14832a441cff885210e9de50457013bcada8b72c47fefbc6a1f3f0987eb3853d31b2434f17c7a62081a667bd86bd6124e1bf621 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar index 58ff15f49d..656987109a 100644 Binary files a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar and b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar differ diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.md5 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.md5 index 04dfc67206..6a56a32bd2 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.md5 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.md5 @@ -1 +1 @@ -d217b570ee694d05e4d5dba310908de3 \ No newline at end of file +7ab9bb46dba7720c3571ae51af85760a \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha1 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha1 index 67fde86f96..7c551e8775 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha1 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha1 @@ -1 +1 @@ -5bfe134e284bdbb24ce0322ae718f2035cec1b02 \ No newline at end of file +17a911e6516ee5057eeab7f8a24eab1dcbbe2ac1 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha256 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha256 index a0d830f2f1..d5bd0c74cd 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha256 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha256 @@ -1 +1 @@ -c681e7071d709b4826099388ae18d7d2713c2f55b768e4440d9c965734674da0 \ No newline at end of file +9aee6336465f59a29efcadd5c72b26fd8cdaa3ee4ee88a74ad99de003ddc43b6 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha512 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha512 index f03bc261f2..661322f8e2 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha512 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi-sources.jar.sha512 @@ -1 +1 @@ -09d7a451127de594579bed9ad80ef58e4dafc5965cc9a08e87213551f89f19c4f1ece8c1d9bee048028e284b60a821161b55002bf8727fe4c00c176f4a4557e4 \ No newline at end of file +be5fca3a8fe37b16aac1478593fbc9935653d4ec264db0afaa16517d374ed6248996813575076528a1adefd5330f8960a699e8725491ce26022c8d23d21d25d5 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar index 4bfcbfe91c..6c4981e9d2 100644 Binary files a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar and b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar differ diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.md5 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.md5 index e45a88cf03..c91259f1d3 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.md5 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.md5 @@ -1 +1 @@ -121402106ba85023fbc12520aa91b238 \ No newline at end of file +8cb5bdbde8e7c6b5c1d96182f29fc6b2 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha1 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha1 index 63b1be69e0..4151052466 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha1 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha1 @@ -1 +1 @@ -8e7be155fbeb2268527d76f700aa8fa22c3850de \ No newline at end of file +c63c93debbc6b33401e4a19d115800e12a8b7cb3 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha256 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha256 index a5405a1523..8dbc432f44 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha256 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha256 @@ -1 +1 @@ -db23c133556a03df178090eef482ce427ae33f8e2d639ee82bdb6911b79b01e3 \ No newline at end of file +32c54bcb6113499267fadd75385d1a69a04eec3fcc34edf11e9bf6012d81ed91 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha512 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha512 index 0139a8f6fd..d05ede8a31 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha512 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.aar.sha512 @@ -1 +1 @@ -2c2302547bff32392e289adfbb903391acf12edb33bb101aa7d28ed16e200461e25ea5a26ccc215fbf501ffa89d771e8c5c5ff4e5206a54e431cebe08de82e77 \ No newline at end of file +5e88a9c902574f57dc71e78dc5134dcb5e07e7877b0e5c61fd47e46fccd4142e88630beb4d83c2fc8f8eac41696fccc16c727b14882f6d606000ef3b968ae10e \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module index 51f3257106..a8a3c941a5 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module @@ -35,11 +35,11 @@ { "name": "media3-container-1.11.0-alpha01-fongmi.aar", "url": "media3-container-1.11.0-alpha01-fongmi.aar", - "size": 67465, - "sha512": "2c2302547bff32392e289adfbb903391acf12edb33bb101aa7d28ed16e200461e25ea5a26ccc215fbf501ffa89d771e8c5c5ff4e5206a54e431cebe08de82e77", - "sha256": "db23c133556a03df178090eef482ce427ae33f8e2d639ee82bdb6911b79b01e3", - "sha1": "8e7be155fbeb2268527d76f700aa8fa22c3850de", - "md5": "121402106ba85023fbc12520aa91b238" + "size": 67476, + "sha512": "5e88a9c902574f57dc71e78dc5134dcb5e07e7877b0e5c61fd47e46fccd4142e88630beb4d83c2fc8f8eac41696fccc16c727b14882f6d606000ef3b968ae10e", + "sha256": "32c54bcb6113499267fadd75385d1a69a04eec3fcc34edf11e9bf6012d81ed91", + "sha1": "c63c93debbc6b33401e4a19d115800e12a8b7cb3", + "md5": "8cb5bdbde8e7c6b5c1d96182f29fc6b2" } ] }, @@ -71,11 +71,11 @@ { "name": "media3-container-1.11.0-alpha01-fongmi.aar", "url": "media3-container-1.11.0-alpha01-fongmi.aar", - "size": 67465, - "sha512": "2c2302547bff32392e289adfbb903391acf12edb33bb101aa7d28ed16e200461e25ea5a26ccc215fbf501ffa89d771e8c5c5ff4e5206a54e431cebe08de82e77", - "sha256": "db23c133556a03df178090eef482ce427ae33f8e2d639ee82bdb6911b79b01e3", - "sha1": "8e7be155fbeb2268527d76f700aa8fa22c3850de", - "md5": "121402106ba85023fbc12520aa91b238" + "size": 67476, + "sha512": "5e88a9c902574f57dc71e78dc5134dcb5e07e7877b0e5c61fd47e46fccd4142e88630beb4d83c2fc8f8eac41696fccc16c727b14882f6d606000ef3b968ae10e", + "sha256": "32c54bcb6113499267fadd75385d1a69a04eec3fcc34edf11e9bf6012d81ed91", + "sha1": "c63c93debbc6b33401e4a19d115800e12a8b7cb3", + "md5": "8cb5bdbde8e7c6b5c1d96182f29fc6b2" } ] }, @@ -91,11 +91,11 @@ { "name": "media3-container-1.11.0-alpha01-fongmi-sources.jar", "url": "media3-container-1.11.0-alpha01-fongmi-sources.jar", - "size": 55543, - "sha512": "09d7a451127de594579bed9ad80ef58e4dafc5965cc9a08e87213551f89f19c4f1ece8c1d9bee048028e284b60a821161b55002bf8727fe4c00c176f4a4557e4", - "sha256": "c681e7071d709b4826099388ae18d7d2713c2f55b768e4440d9c965734674da0", - "sha1": "5bfe134e284bdbb24ce0322ae718f2035cec1b02", - "md5": "d217b570ee694d05e4d5dba310908de3" + "size": 55524, + "sha512": "be5fca3a8fe37b16aac1478593fbc9935653d4ec264db0afaa16517d374ed6248996813575076528a1adefd5330f8960a699e8725491ce26022c8d23d21d25d5", + "sha256": "9aee6336465f59a29efcadd5c72b26fd8cdaa3ee4ee88a74ad99de003ddc43b6", + "sha1": "17a911e6516ee5057eeab7f8a24eab1dcbbe2ac1", + "md5": "7ab9bb46dba7720c3571ae51af85760a" } ] } diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.md5 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.md5 index 0342be6914..96cfc2255d 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.md5 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.md5 @@ -1 +1 @@ -362f357b691a35fc8c75b3241b8e7ce9 \ No newline at end of file +8cc61351853405dd636d226f7321473c \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha1 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha1 index e31c61b964..94534e17e3 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha1 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha1 @@ -1 +1 @@ -dcdf551abf3f2de373c3d2365b6d47d2a6286601 \ No newline at end of file +c5aa224704b9bf73458b23aeaaf86998cc5a49ee \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha256 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha256 index b94007ed3a..78647de11a 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha256 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha256 @@ -1 +1 @@ -3cac8119aa5a0546e42703b3f163f4b4f650e3eadad31e863b1a2f8526b7efe6 \ No newline at end of file +cad4ff5204c1d7f99c00d96a98c22f34241d38b56c99967595e14dd26b05f8fe \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha512 b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha512 index 1a182b0e4c..8144fe9de4 100644 --- a/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha512 +++ b/third_party/maven/androidx/media3/media3-container/1.11.0-alpha01-fongmi/media3-container-1.11.0-alpha01-fongmi.module.sha512 @@ -1 +1 @@ -2cb7d1c662ffaf0d894b137d13d14ae22c41f6b11711875f1c99718e6eaa6d2f2a6f447b1d30db9999eae9c31ba0afe15dd14018ae01988aa7700358367fe539 \ No newline at end of file +72f979376e93605199255491c7a826907ede450c31cb6e61a925b0c181182472663148e67363fea12f394bce7d6fc77df468333499d4ba5aad76be9e0026a4d1 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/maven-metadata.xml b/third_party/maven/androidx/media3/media3-container/maven-metadata.xml index 3965afaf28..2744596a67 100644 --- a/third_party/maven/androidx/media3/media3-container/maven-metadata.xml +++ b/third_party/maven/androidx/media3/media3-container/maven-metadata.xml @@ -8,6 +8,6 @@ 1.11.0-alpha01-fongmi - 20260902130438 + 20260809152618 diff --git a/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.md5 b/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.md5 index e061aa5936..8966557a05 100644 --- a/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.md5 +++ b/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.md5 @@ -1 +1 @@ -195333ff0307f546718c6231124009e6 \ No newline at end of file +c54782e35ca8c4320cc389baa758e7cd \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha1 b/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha1 index 1492e820ff..3ac2eea239 100644 --- a/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha1 +++ b/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha1 @@ -1 +1 @@ -f26f351c1ddfa5501ea602c6a5aa84410f446631 \ No newline at end of file +71f5bcf232267df9d2e5e71e31d95e7ea7f00cce \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha256 b/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha256 index 504c15e787..0f65cfe3c7 100644 --- a/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha256 +++ b/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha256 @@ -1 +1 @@ -b6537861d33d3ad728b586eeae841a92124f6ad0bdf9f512c3e4a5d2b0947f5c \ No newline at end of file +9a1909ce6b652607b4a944025246bd7368e39066b6022fa9f6cbb0e456b5740e \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha512 b/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha512 index 4b95788c9d..ac25096730 100644 --- a/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha512 +++ b/third_party/maven/androidx/media3/media3-container/maven-metadata.xml.sha512 @@ -1 +1 @@ -03f33fddcc0721aea8a50d103b6028c46853a7cefe2f1bacc5f8229e3067da274b222fe30ea24f4beb02dd0490fb1c07aea6e67b24536b979b750559bc998452 \ No newline at end of file +fd7338bf00cce8dce9fafd86603547367505febbd89d62b560d49bb3b1367507b3b3c745dd67c9d4fd7604b45d5385754a29c121abbc46cd907af9580d38c375 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar index 8425d41dbc..f239974537 100644 Binary files a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar and b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar differ diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.md5 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.md5 index b472babdb3..36bd51122e 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.md5 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.md5 @@ -1 +1 @@ -bf6850c431828f518395237260b062d8 \ No newline at end of file +b5d67e20c63c66f5edc02aab0e1ef006 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha1 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha1 index 404e6ff5c0..07c34455e8 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha1 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha1 @@ -1 +1 @@ -02b048b70b27ab0b25a309ab2ebd392c14468a30 \ No newline at end of file +33bd526b0fd8073b9b2c386fd78909c4c2fdcf7c \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha256 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha256 index 270f4a1c5d..0280f773f2 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha256 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha256 @@ -1 +1 @@ -5462494d6ed12e64b5597070b86206727775c760ebe040ef6d3a3a51244431d4 \ No newline at end of file +e24afff26ea1d21338b9c4f835b56e6ed5c95b996c46b9306cffbae2c5632af9 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha512 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha512 index dafe3ee126..6155284285 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha512 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar.sha512 @@ -1 +1 @@ -be395bb090464b46cbad1c4f757979d66fe93ed40c67d35e556e1d678d59f796f0e8cd2e5b7cf9cd700a2fb1c1273af6c84967e4fa5a2f447b81239a339e2172 \ No newline at end of file +c71abdce85500bc6054953f0d0dcfbeceb229c643ece61b2cc0861abddd23cedbdeb9377fc3742c97ba48374f58516add3ca6a1494b72ea5dbacf3aeea35d398 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar index 53649a12f0..77f9f71e75 100644 Binary files a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar and b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar differ diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.md5 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.md5 index eaed3d9af9..bbef464837 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.md5 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.md5 @@ -1 +1 @@ -afadabcd8613891485bb2fc8e280ded6 \ No newline at end of file +e9c208658b15ba52de197165beb65cf9 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha1 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha1 index bffa90e8ec..f869b93406 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha1 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha1 @@ -1 +1 @@ -f587bc028366d7671ff7fab7f6c1781a9b786227 \ No newline at end of file +aa218294976c4bfc97e536149ff5c6f3fac22ed0 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha256 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha256 index fab08eb7d5..0e1d9b57d0 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha256 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha256 @@ -1 +1 @@ -de557f0b91512a1e0726155bd0cbb23879279ce05a819ab27a49bea069225546 \ No newline at end of file +2fc009c2b20303bf7c8263db4259a08d94cc05e1578a53e5ff68cf159f1860dc \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha512 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha512 index 26f439bdbe..13a2e01848 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha512 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar.sha512 @@ -1 +1 @@ -73c35274c37829f64fdc6b2a1b60c68c2beab369a28e0ba916e57552f415e2a2fbe351b38cd67e0783356c805d9142399f43081e17a34ccef9ce1aa0cbe382fd \ No newline at end of file +3309ab7ff490f69f99222cd3159afc6998b4da0432dbe9ceaf5cae2005d9b656b8c9e74172d071591711610887a3534eb9d32f7361b5b0eecc393e865176c4e2 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module index 5da1ce1183..d7504ea123 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module @@ -35,11 +35,11 @@ { "name": "media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar", "url": "media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar", - "size": 148014, - "sha512": "73c35274c37829f64fdc6b2a1b60c68c2beab369a28e0ba916e57552f415e2a2fbe351b38cd67e0783356c805d9142399f43081e17a34ccef9ce1aa0cbe382fd", - "sha256": "de557f0b91512a1e0726155bd0cbb23879279ce05a819ab27a49bea069225546", - "sha1": "f587bc028366d7671ff7fab7f6c1781a9b786227", - "md5": "afadabcd8613891485bb2fc8e280ded6" + "size": 148198, + "sha512": "3309ab7ff490f69f99222cd3159afc6998b4da0432dbe9ceaf5cae2005d9b656b8c9e74172d071591711610887a3534eb9d32f7361b5b0eecc393e865176c4e2", + "sha256": "2fc009c2b20303bf7c8263db4259a08d94cc05e1578a53e5ff68cf159f1860dc", + "sha1": "aa218294976c4bfc97e536149ff5c6f3fac22ed0", + "md5": "e9c208658b15ba52de197165beb65cf9" } ] }, @@ -71,11 +71,11 @@ { "name": "media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar", "url": "media3-exoplayer-dash-1.11.0-alpha01-fongmi.aar", - "size": 148014, - "sha512": "73c35274c37829f64fdc6b2a1b60c68c2beab369a28e0ba916e57552f415e2a2fbe351b38cd67e0783356c805d9142399f43081e17a34ccef9ce1aa0cbe382fd", - "sha256": "de557f0b91512a1e0726155bd0cbb23879279ce05a819ab27a49bea069225546", - "sha1": "f587bc028366d7671ff7fab7f6c1781a9b786227", - "md5": "afadabcd8613891485bb2fc8e280ded6" + "size": 148198, + "sha512": "3309ab7ff490f69f99222cd3159afc6998b4da0432dbe9ceaf5cae2005d9b656b8c9e74172d071591711610887a3534eb9d32f7361b5b0eecc393e865176c4e2", + "sha256": "2fc009c2b20303bf7c8263db4259a08d94cc05e1578a53e5ff68cf159f1860dc", + "sha1": "aa218294976c4bfc97e536149ff5c6f3fac22ed0", + "md5": "e9c208658b15ba52de197165beb65cf9" } ] }, @@ -91,11 +91,11 @@ { "name": "media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar", "url": "media3-exoplayer-dash-1.11.0-alpha01-fongmi-sources.jar", - "size": 100721, - "sha512": "be395bb090464b46cbad1c4f757979d66fe93ed40c67d35e556e1d678d59f796f0e8cd2e5b7cf9cd700a2fb1c1273af6c84967e4fa5a2f447b81239a339e2172", - "sha256": "5462494d6ed12e64b5597070b86206727775c760ebe040ef6d3a3a51244431d4", - "sha1": "02b048b70b27ab0b25a309ab2ebd392c14468a30", - "md5": "bf6850c431828f518395237260b062d8" + "size": 100485, + "sha512": "c71abdce85500bc6054953f0d0dcfbeceb229c643ece61b2cc0861abddd23cedbdeb9377fc3742c97ba48374f58516add3ca6a1494b72ea5dbacf3aeea35d398", + "sha256": "e24afff26ea1d21338b9c4f835b56e6ed5c95b996c46b9306cffbae2c5632af9", + "sha1": "33bd526b0fd8073b9b2c386fd78909c4c2fdcf7c", + "md5": "b5d67e20c63c66f5edc02aab0e1ef006" } ] } diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.md5 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.md5 index a932f2bf2d..1bc2a3eaae 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.md5 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.md5 @@ -1 +1 @@ -8b3231f622b5ecedc2369c11ad636042 \ No newline at end of file +729e891690a197c7a181c5bc61c73db0 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha1 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha1 index ce9d183477..17eda0c6b2 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha1 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha1 @@ -1 +1 @@ -11b274462e9c8c19f32314976a08d4eb25fbf2f4 \ No newline at end of file +e27f63fe533782468ef20b0f35daf4875685aeb1 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha256 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha256 index 133b07c3fa..94188505d6 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha256 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha256 @@ -1 +1 @@ -ff845d2f1c46ab65efbacf0ab5f38b708c615ed5215874171146c942894d7bce \ No newline at end of file +e1a3f25345f46c5bbdab28dd117b6b3e3151ccc7fbef173041d1e355f9322312 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha512 b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha512 index 608a1a5ac5..4d574f27cf 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha512 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/1.11.0-alpha01-fongmi/media3-exoplayer-dash-1.11.0-alpha01-fongmi.module.sha512 @@ -1 +1 @@ -eca8072e8eedc8dfc8f17a93393dc3f35bd30ffdbc16adf02da4b60f512fcf2d8cfa601305c4b7ff6f870e6e2f9afd62b1e4c46f1f42ee826539de67a7d44794 \ No newline at end of file +82c88b91ec5a772bb43255e95880bfc72595f74af3817a4a9d4321d48f2f4edf2594a6c4426cf9e6b026325a6fb32a1eef9bd8f9c10739b85f91c23ef555c371 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml b/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml index c9467a591c..5829d1a2ae 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml @@ -8,6 +8,6 @@ 1.11.0-alpha01-fongmi - 20260903180043 + 20260809152700 diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.md5 b/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.md5 index c4abde4960..12085bad1d 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.md5 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.md5 @@ -1 +1 @@ -9150aee1a3f93926824ca13aa08fa877 \ No newline at end of file +7ec373813cdb5547ac178a74b62673a7 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha1 b/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha1 index fa992205b6..1966560cfa 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha1 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha1 @@ -1 +1 @@ -63eb848127c52b5d5d6062b56a31fae749726445 \ No newline at end of file +7e9b3fdf5257dbe44707c36328a8366470831fd2 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha256 b/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha256 index 94ab623e02..0ebf7845a6 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha256 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha256 @@ -1 +1 @@ -7135ebb0574f174437b853001955f18079b3871650a6be4da196ee9e75937497 \ No newline at end of file +1e7343e90341aa6d78bea2cecb8e96cd5282d5c09148274d9e33a5aa2685716a \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha512 b/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha512 index 969c1e653c..4d3c4962ce 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha512 +++ b/third_party/maven/androidx/media3/media3-exoplayer-dash/maven-metadata.xml.sha512 @@ -1 +1 @@ -320903e5b63c3d54f7f548f65bd43aefecf8b2eb227f167e53a0698c5b8a386226243227d4d0bc30b36b9ffee8f3a76cbdc8fdf82f53634dacd7cd9ccb422473 \ No newline at end of file +b8c11ddfa581ee64a5bb95ac1f2e3fc442c391698663d625a07c8c6e997384b8398b80ae34cc816404af5393687df60b8082db23b50c9bfc84d42ba76de92daa \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar index 4a001a9dac..6802de4bd9 100644 Binary files a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar and b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar differ diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.md5 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.md5 index 407e0a69fc..5eed516b09 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.md5 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.md5 @@ -1 +1 @@ -c29190d974917674fd1494fdfeed1fc5 \ No newline at end of file +91530feabc5974359d52836a189a5f16 diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha1 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha1 index f3559f5ef8..2f9d45c660 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha1 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha1 @@ -1 +1 @@ -0bb5d0bf0266144c29b1cff8b549e3077ee2d915 \ No newline at end of file +5b7e931e9c27c6c6ef1b62dff37026685fa5e076 diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha256 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha256 index 939e4710bf..1fd86034d9 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha256 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha256 @@ -1 +1 @@ -4d158d63ab0a99688880d6acfdc73ed340f09fa9ac9dd1934fbaa0babfcad086 \ No newline at end of file +83f4f83b4f44e621d52002c161f63fbcb77be6856af4b1e4a4cb0982b04549e1 diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha512 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha512 index 1fccc31ede..b799e3e638 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha512 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar.sha512 @@ -1 +1 @@ -bb95c02fe00bbd4f0fea2c4bf46b4eea8d3b76d8ada51a7b66f891e58d35eabd38953f5f8ab687e3b218b850de927ec2cfa88feb0d70b3f758dd4430ea176471 \ No newline at end of file +075cd93a897634f00a163c93e085399a8975a2adb8a3266d239d07e90c9697c03849d47f9e551e313fb862cce0a3f10416955d00e09c9b59c197f3de543aa067 diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar index d59e0ab738..c006205879 100644 Binary files a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar and b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar differ diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.md5 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.md5 index dc28106bfd..ee22b86b0d 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.md5 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.md5 @@ -1 +1 @@ -5a9fac38d18661349d78df813f77b08b \ No newline at end of file +0850011bbcf5853700f69e7cac5539f8 diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha1 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha1 index e581839ec8..7fb5a46165 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha1 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha1 @@ -1 +1 @@ -af019e2df98a476e50b6c813f9cad097631208a5 \ No newline at end of file +0436ffdcf3b77bb1109fc24c9dae68b1427a8e83 diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha256 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha256 index 11ed9c3b44..3a0ed4d211 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha256 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha256 @@ -1 +1 @@ -47f90bd4fc52567738847ceb59c435ddeb6d35b987866d6899de87de3170bdf6 \ No newline at end of file +cfea29681799509923174cac7ce31b7e944e542db9df58fd56b7a86499a5c07e diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha512 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha512 index ef6ac401df..9fbaa468da 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha512 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.aar.sha512 @@ -1 +1 @@ -d30c7d6f6818ee6c6e4c841848d7389b326109ce34ef7a0d6c38d5a711b365dd20df4743589ab868f61455cda51b07d2e5309612586e9723a87f81c61d53ab3f \ No newline at end of file +6f47c9fdd2ee597d57c268f238cdeeb84d684169e36f2221b9737473ee19ccf1d5dbeb52502518e63c6b1920fa5bb2658e990dbea1cbb935b368562b70861912 diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module index f7cce92cc7..6291f166f0 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module @@ -70,11 +70,11 @@ { "name": "media3-exoplayer-1.11.0-alpha01-fongmi.aar", "url": "media3-exoplayer-1.11.0-alpha01-fongmi.aar", - "size": 1743603, - "sha512": "d30c7d6f6818ee6c6e4c841848d7389b326109ce34ef7a0d6c38d5a711b365dd20df4743589ab868f61455cda51b07d2e5309612586e9723a87f81c61d53ab3f", - "sha256": "47f90bd4fc52567738847ceb59c435ddeb6d35b987866d6899de87de3170bdf6", - "sha1": "af019e2df98a476e50b6c813f9cad097631208a5", - "md5": "5a9fac38d18661349d78df813f77b08b" + "size": 1739181, + "sha512": "2662e693801b53298cbe0fd649a1748208e9483ab28da0bc91bc925067af4ddbc6d1bfbf0bc5237303fa91f156a44c9310c7b62214786c800bf678ec6bc2a257", + "sha256": "11d572f59f0404878353d9c85e022e153e114ecbb4d43cf61c910cd9e8c2536d", + "sha1": "c37019f592fe60cb9d0cb18c12040bbb82a2263c", + "md5": "a738184547695deabc648e55b7c207b2" } ] }, @@ -155,11 +155,11 @@ { "name": "media3-exoplayer-1.11.0-alpha01-fongmi.aar", "url": "media3-exoplayer-1.11.0-alpha01-fongmi.aar", - "size": 1743603, - "sha512": "d30c7d6f6818ee6c6e4c841848d7389b326109ce34ef7a0d6c38d5a711b365dd20df4743589ab868f61455cda51b07d2e5309612586e9723a87f81c61d53ab3f", - "sha256": "47f90bd4fc52567738847ceb59c435ddeb6d35b987866d6899de87de3170bdf6", - "sha1": "af019e2df98a476e50b6c813f9cad097631208a5", - "md5": "5a9fac38d18661349d78df813f77b08b" + "size": 1739181, + "sha512": "2662e693801b53298cbe0fd649a1748208e9483ab28da0bc91bc925067af4ddbc6d1bfbf0bc5237303fa91f156a44c9310c7b62214786c800bf678ec6bc2a257", + "sha256": "11d572f59f0404878353d9c85e022e153e114ecbb4d43cf61c910cd9e8c2536d", + "sha1": "c37019f592fe60cb9d0cb18c12040bbb82a2263c", + "md5": "a738184547695deabc648e55b7c207b2" } ] }, @@ -175,11 +175,11 @@ { "name": "media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar", "url": "media3-exoplayer-1.11.0-alpha01-fongmi-sources.jar", - "size": 1255526, - "sha512": "bb95c02fe00bbd4f0fea2c4bf46b4eea8d3b76d8ada51a7b66f891e58d35eabd38953f5f8ab687e3b218b850de927ec2cfa88feb0d70b3f758dd4430ea176471", - "sha256": "4d158d63ab0a99688880d6acfdc73ed340f09fa9ac9dd1934fbaa0babfcad086", - "sha1": "0bb5d0bf0266144c29b1cff8b549e3077ee2d915", - "md5": "c29190d974917674fd1494fdfeed1fc5" + "size": 1253917, + "sha512": "6883079131cf97bf0d6ed5b8ebc5e08d8e507f02609aa62c0af5e62da2b1a9c3f883243e2bf182948af4f906fb5f2cdf55c5d8ea6b65e1e3e32a1469f173b578", + "sha256": "c2a74d177abff6e91de2b26d0680920a966672796a080572d6477a2e3c9aa6db", + "sha1": "acc179c1381092a419dff9568a5297416eba1fe9", + "md5": "a33bb2038a20dcb4f30a916f5672e85f" } ] } diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.md5 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.md5 index ef652457b9..2aa506aca8 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.md5 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.md5 @@ -1 +1 @@ -bc70d6bd9927cd42b38adb4773c6ad10 \ No newline at end of file +6700bf87b8028af9221e6b1af1622828 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha1 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha1 index ee1037e159..abf1068baa 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha1 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha1 @@ -1 +1 @@ -7a0fdce3079b50b895e7a1116d5cf5f3f559ba23 \ No newline at end of file +ed5c2774a2a67c018e0d2b9588e1c7c730673879 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha256 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha256 index 8b3d023f25..43182f6b3a 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha256 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha256 @@ -1 +1 @@ -8cc441292431cb47f31c5dba2120f3f7856c0df27424e9b6d2e576398b46348d \ No newline at end of file +f20106bb4c89f0b167dec34a2909daf427149800e14f11db130431e95523e95d \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha512 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha512 index 5eaec8e288..847304a200 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha512 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.module.sha512 @@ -1 +1 @@ -d7cf2f1beb46c12ed30dfeacbd88321774d0f546972715099ffeeea95ca0aaa06cefa8f77ac6b2d78c078111e50bf2058a2cb66ed41bcf07df209fbf3205c259 \ No newline at end of file +0f312da1187d3228f61f772db3e4b8c56c99ce0f6b2945d9f5825c8f0932a6a5fc6adb1cf90c20fe19befbfec37ecbec1ce90555ff53b5677f3235114b136c42 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.md5 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.md5 index 8c83e52500..05853d7a50 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.md5 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.md5 @@ -1 +1 @@ -8cbe57bc947c2159598aa03f3cedde7c \ No newline at end of file +d7f52bfbb57e853ed7c5726d832455ce \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha1 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha1 index 6f08ce4dff..c9f5c308f0 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha1 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha1 @@ -1 +1 @@ -e75e26c575790abc82401760a40f6dfc99ea1c03 \ No newline at end of file +2a93c05f8f59a2c6d3b48f7a111e94af3a09274d \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha256 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha256 index 682abd9f6a..e0fc0e2a40 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha256 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha256 @@ -1 +1 @@ -ca5be4f0911931b82df270917cb91d2f1d221a97bcecd11f0d78eeaa620308a5 \ No newline at end of file +d24224701e867501cf49b6e26bee4efa6a9678e24a91e48b4f86c012e6970501 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha512 b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha512 index 4f751d0b64..1ff69c4b80 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha512 +++ b/third_party/maven/androidx/media3/media3-exoplayer/1.11.0-alpha01-fongmi/media3-exoplayer-1.11.0-alpha01-fongmi.pom.sha512 @@ -1 +1 @@ -d7ffe1f41a7d4b262a042cbe6e342550bfe715fc076b0f3a6bc72cb3ddbc3037f2f99f7e54aca542479da99fa83359eba35e030cea8207a511a7c94c80614297 \ No newline at end of file +14536137ca0dbd82437eb9a3c4fc05ab19af9e4f82862d282604fb70d4d10b715cf2699f492fa0b82585e26f4764ba6385e1fc690bd1b2491d5b2824d6c47343 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml b/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml index 169db6761a..d2a707be98 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml +++ b/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml @@ -8,6 +8,6 @@ 1.11.0-alpha01-fongmi - 20260809152658 + 20260826171434 diff --git a/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.md5 b/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.md5 index 9925c22eed..0d404648da 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.md5 +++ b/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.md5 @@ -1 +1 @@ -b075a8e949ea05fdf2c36c6bfcdd4f2c \ No newline at end of file +bfd220053b74a026892afc33daeb1784 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha1 b/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha1 index 6b0a942550..d4aad4972b 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha1 +++ b/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha1 @@ -1 +1 @@ -0e83570ae3f1fcb4403cb252cc4731d50cda6e34 \ No newline at end of file +49bd34c570d08ecae00a3020d3f2d650971806e9 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha256 b/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha256 index b34eab7cff..21f243281f 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha256 +++ b/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha256 @@ -1 +1 @@ -39e9ca0914bf3e20e8b3328e7b165df76fdaef44d152772f753c2ad64afa83eb \ No newline at end of file +976d633ae3345be73a74251a90c134aedf180b8b0f0aaaf18ee6dccb04f4ceab \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha512 b/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha512 index 91acfd665c..b570372f39 100644 --- a/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha512 +++ b/third_party/maven/androidx/media3/media3-exoplayer/maven-metadata.xml.sha512 @@ -1 +1 @@ -be65ff91745066632081ca10b2ede5c295b65ad39db02b9e04e8d3cdd899d80d31632a0a1180e17f7867511a9e6ae933afa9aea11641f6427e6a20e2505450d3 \ No newline at end of file +d73ad6354defd5e35d8fea73c0a93ce85788ff7722b0d60246d6bf7ee3d40db95d71001a3518feb2f106921089ab35d7be4d059debc64a62c3036db2a73c6c32 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml deleted file mode 100644 index f872f41d21..0000000000 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - androidx.media3 - media3-extractor - - 1.11.0-alpha01-fongmi - 1.11.0-alpha01-fongmi - - 1.11.0-alpha01-fongmi - - 20260904054737 - - diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.md5 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.md5 deleted file mode 100644 index fbc75d326c..0000000000 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.md5 +++ /dev/null @@ -1 +0,0 @@ -605d27084ece550f858465d5371c101f \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.sha1 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.sha1 deleted file mode 100644 index ab32dceaae..0000000000 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.sha1 +++ /dev/null @@ -1 +0,0 @@ -619dac46ec61096076a1c8d74ee01698a2f12a38 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.sha256 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.sha256 deleted file mode 100644 index c05468717a..0000000000 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.sha256 +++ /dev/null @@ -1 +0,0 @@ -31251b5056455970845d8d7eb5a4483c393d832c66094501f9412f851781f289 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.sha512 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.sha512 deleted file mode 100644 index 2eadbae74b..0000000000 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/maven-metadata.xml.sha512 +++ /dev/null @@ -1 +0,0 @@ -abbadf2812914d8407384e77ac61afd686530d31ba4d1abbbe900db547b00e77e2608628851ef5542c1b8df05338d390a735573f519c630a43f7944c5a449a23 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar index d9d2a535fa..f486d3fbea 100644 Binary files a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar and b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar differ diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.md5 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.md5 index 1c744af8b8..2737364245 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.md5 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.md5 @@ -1 +1 @@ -cd461b3dee8695dc6a76a405769aa937 \ No newline at end of file +f832babd94641c6cc371e0f12b4c9f9c \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha1 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha1 index f1bd5866d9..6940acc8a4 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha1 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha1 @@ -1 +1 @@ -31129b715d4ab5470d0e2f324d96a13ea997968a \ No newline at end of file +721767dccdf192727aa6d88666044743875c53aa \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha256 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha256 index a3f34825a1..22aefbe2f9 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha256 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha256 @@ -1 +1 @@ -bc864243c03f29722dddf1bedf58f24e3d83189d3f90c883ec294350651e4559 \ No newline at end of file +9314a303f402e903dd075db5e9babf9be6474da539a4b76bb1ed765b95b6ef4e \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha512 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha512 index b1985e84e8..f9b067f677 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha512 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi-sources.jar.sha512 @@ -1 +1 @@ -86c196f1619e4ee34ef14d261b1db0e216876d371b92fa7adb0011049fa7632d7ec769c8d8b495a7fa09a54120baf5033bf297da6609c94ea0790ca6faa76b2e \ No newline at end of file +c03effbf8116bc5adebda3af3789c4dc734e9181ac4232755098e4f36356cca83a64008f6ba024150191ce7046bb1378e088fe23a69d83e8b7693f342fee3547 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar index d6a82bc66a..e138ad3d54 100644 Binary files a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar and b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar differ diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.md5 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.md5 index 1806ebd728..a5fc247a66 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.md5 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.md5 @@ -1 +1 @@ -0d9ea410a4de08cc7db97ad200ca687d \ No newline at end of file +f6ef0358e9764d3adf09bbd5192240d0 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha1 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha1 index e2c50ff4a0..040f37aa22 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha1 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha1 @@ -1 +1 @@ -b085dd8a952c6b2e449bd8d5e83b487524686269 \ No newline at end of file +cf2769361a8b4b97c77738f07920055dd45c9a30 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha256 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha256 index a499961f0a..1342103b44 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha256 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha256 @@ -1 +1 @@ -c1eb0ab5118469c128d153665839fd4ecd3be1c22558e741d1670e1bb2ca2725 \ No newline at end of file +59a090bad8efc32552cf5a7ec1d2fb66444168bfbc97bd45e50764527f80c768 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha512 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha512 index 2414554df2..963a9043c5 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha512 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.aar.sha512 @@ -1 +1 @@ -62ba6d7f7d8a335e299d3d9459f7517b649dd5da1a72eaf932607a089d60d8499c4c748b9b49541a3791c8d2e724466315d98ac0ff019bc3aadb47a1d3fe3064 \ No newline at end of file +733b82b29e7307e9df482fb32f81a8bf7b1711b61fa6b9d072223fb0a75aa2e74fa7c1df41240a7eeeff2da97dba7010d663e20bfdf7b1fb52e8386736dd27c0 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module index 72e4f15ccf..39e0928033 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module @@ -42,11 +42,11 @@ { "name": "media3-extractor-1.11.0-alpha01-fongmi.aar", "url": "media3-extractor-1.11.0-alpha01-fongmi.aar", - "size": 1023407, - "sha512": "62ba6d7f7d8a335e299d3d9459f7517b649dd5da1a72eaf932607a089d60d8499c4c748b9b49541a3791c8d2e724466315d98ac0ff019bc3aadb47a1d3fe3064", - "sha256": "c1eb0ab5118469c128d153665839fd4ecd3be1c22558e741d1670e1bb2ca2725", - "sha1": "b085dd8a952c6b2e449bd8d5e83b487524686269", - "md5": "0d9ea410a4de08cc7db97ad200ca687d" + "size": 1016886, + "sha512": "733b82b29e7307e9df482fb32f81a8bf7b1711b61fa6b9d072223fb0a75aa2e74fa7c1df41240a7eeeff2da97dba7010d663e20bfdf7b1fb52e8386736dd27c0", + "sha256": "59a090bad8efc32552cf5a7ec1d2fb66444168bfbc97bd45e50764527f80c768", + "sha1": "cf2769361a8b4b97c77738f07920055dd45c9a30", + "md5": "f6ef0358e9764d3adf09bbd5192240d0" } ] }, @@ -99,11 +99,11 @@ { "name": "media3-extractor-1.11.0-alpha01-fongmi.aar", "url": "media3-extractor-1.11.0-alpha01-fongmi.aar", - "size": 1023407, - "sha512": "62ba6d7f7d8a335e299d3d9459f7517b649dd5da1a72eaf932607a089d60d8499c4c748b9b49541a3791c8d2e724466315d98ac0ff019bc3aadb47a1d3fe3064", - "sha256": "c1eb0ab5118469c128d153665839fd4ecd3be1c22558e741d1670e1bb2ca2725", - "sha1": "b085dd8a952c6b2e449bd8d5e83b487524686269", - "md5": "0d9ea410a4de08cc7db97ad200ca687d" + "size": 1016886, + "sha512": "733b82b29e7307e9df482fb32f81a8bf7b1711b61fa6b9d072223fb0a75aa2e74fa7c1df41240a7eeeff2da97dba7010d663e20bfdf7b1fb52e8386736dd27c0", + "sha256": "59a090bad8efc32552cf5a7ec1d2fb66444168bfbc97bd45e50764527f80c768", + "sha1": "cf2769361a8b4b97c77738f07920055dd45c9a30", + "md5": "f6ef0358e9764d3adf09bbd5192240d0" } ] }, @@ -119,11 +119,11 @@ { "name": "media3-extractor-1.11.0-alpha01-fongmi-sources.jar", "url": "media3-extractor-1.11.0-alpha01-fongmi-sources.jar", - "size": 859033, - "sha512": "86c196f1619e4ee34ef14d261b1db0e216876d371b92fa7adb0011049fa7632d7ec769c8d8b495a7fa09a54120baf5033bf297da6609c94ea0790ca6faa76b2e", - "sha256": "bc864243c03f29722dddf1bedf58f24e3d83189d3f90c883ec294350651e4559", - "sha1": "31129b715d4ab5470d0e2f324d96a13ea997968a", - "md5": "cd461b3dee8695dc6a76a405769aa937" + "size": 854530, + "sha512": "c03effbf8116bc5adebda3af3789c4dc734e9181ac4232755098e4f36356cca83a64008f6ba024150191ce7046bb1378e088fe23a69d83e8b7693f342fee3547", + "sha256": "9314a303f402e903dd075db5e9babf9be6474da539a4b76bb1ed765b95b6ef4e", + "sha1": "721767dccdf192727aa6d88666044743875c53aa", + "md5": "f832babd94641c6cc371e0f12b4c9f9c" } ] } diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.md5 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.md5 index 103ac5b831..eed65991d6 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.md5 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.md5 @@ -1 +1 @@ -7b6fc4892c2e5ae4e088ed38b350d018 \ No newline at end of file +1c896cb28ad17490dcb83d7e39d7f969 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha1 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha1 index 3f53c99f49..0bd6cdcde2 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha1 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha1 @@ -1 +1 @@ -bc801fe836a281587462948d2dd8e0ec81fc8bc6 \ No newline at end of file +c24561308324e7ce68d9f0846035e1284e18d643 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha256 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha256 index b2379ac74a..c94caf4b68 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha256 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha256 @@ -1 +1 @@ -0d1f26cf4349705fbe6feacf37168b7280d55b3428aa78a4df1119d8a154bd0e \ No newline at end of file +d470cbc53d7aa03f3b5e036b02c3f78157109a5240963d52ea8f2fde2c5d7a7a \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha512 b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha512 index 0e0c9ffee8..1fdf4a0f1d 100644 --- a/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha512 +++ b/third_party/maven/androidx/media3/media3-extractor/1.11.0-alpha01-fongmi/media3-extractor-1.11.0-alpha01-fongmi.module.sha512 @@ -1 +1 @@ -8e5285f507b25fb534160b4f9ea2b4edd6731525e8932a8f0e796e0b5e5997f37af51c70c008a4e64d85962a3d0d36c57b80f7a75acf39ba1d01f465532daac8 \ No newline at end of file +1ad98b1c3168cf90f3ebc2d9d7aea9d1854446897f05d93bc813c03433a2075af0040a8793d6b88d073a17cba6a14b34fec73113fc6b227057c280fa0706f5de \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml b/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml index f872f41d21..d853b9cb79 100644 --- a/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml +++ b/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml @@ -8,6 +8,6 @@ 1.11.0-alpha01-fongmi - 20260904054737 + 20260814204935 diff --git a/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.md5 b/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.md5 index fbc75d326c..8a9cb502fc 100644 --- a/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.md5 +++ b/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.md5 @@ -1 +1 @@ -605d27084ece550f858465d5371c101f \ No newline at end of file +1e1d0c21e350530b75404ed11d7ec162 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha1 b/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha1 index ab32dceaae..7dbe8210a5 100644 --- a/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha1 +++ b/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha1 @@ -1 +1 @@ -619dac46ec61096076a1c8d74ee01698a2f12a38 \ No newline at end of file +78fa6303ad537d8d3d5844f11e7f8375110d1101 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha256 b/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha256 index c05468717a..120ed7d89b 100644 --- a/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha256 +++ b/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha256 @@ -1 +1 @@ -31251b5056455970845d8d7eb5a4483c393d832c66094501f9412f851781f289 \ No newline at end of file +97f761e2f4bfbe07a682e73ffa5b22495f7b076a41ffa7c85d1a6f1df3dadd22 \ No newline at end of file diff --git a/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha512 b/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha512 index 2eadbae74b..e08bd2d0e4 100644 --- a/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha512 +++ b/third_party/maven/androidx/media3/media3-extractor/maven-metadata.xml.sha512 @@ -1 +1 @@ -abbadf2812914d8407384e77ac61afd686530d31ba4d1abbbe900db547b00e77e2608628851ef5542c1b8df05338d390a735573f519c630a43f7944c5a449a23 \ No newline at end of file +3341e0f4024cc94ab7be004daf9fa93f9ca204accf863151d1d38c45fdb96f4ab6274aad9d51377e7d9f1461185aed18c06cc0d00ba573db3cc2cb9c0bc71073 \ No newline at end of file diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar deleted file mode 100644 index fb0b33d65d..0000000000 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar and /dev/null differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.md5 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.md5 deleted file mode 100644 index 0279a10607..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.md5 +++ /dev/null @@ -1 +0,0 @@ -e6d36f1ffc240c9a5a0961248be0e90b diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.sha1 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.sha1 deleted file mode 100644 index dccfd328aa..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -4dcf1b82052adcd579a12b69577979e9f7a2a70e diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.sha256 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.sha256 deleted file mode 100644 index 993599d84f..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.sha256 +++ /dev/null @@ -1 +0,0 @@ -2a08cc5f58718a3da3dc437784891680a0710c376b322a385cd6e5adcde01492 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.sha512 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.sha512 deleted file mode 100644 index 186bc1b95c..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar.sha512 +++ /dev/null @@ -1 +0,0 @@ -c1006d82fcabfe7cadfd6cd5ab4192efa5d5d5800bbada19b9b0258893be710c37a8d6d84c2833e25073a7fed7e704acfcd245ca9c282cd29d0b785c7c3eba49 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar deleted file mode 100644 index 73e07b2bc6..0000000000 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar and /dev/null differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.md5 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.md5 deleted file mode 100644 index 62d08a8ca8..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.md5 +++ /dev/null @@ -1 +0,0 @@ -83ca5ae75a8e3be82761e153ca26c4d5 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.sha1 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.sha1 deleted file mode 100644 index 94b38a685c..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.sha1 +++ /dev/null @@ -1 +0,0 @@ -6a8934462b59128fdb4dcbca9b68add46962532e diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.sha256 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.sha256 deleted file mode 100644 index dc0c7bb08c..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.sha256 +++ /dev/null @@ -1 +0,0 @@ -bfd9c635cd18613c728e6e4d9635da2f80716d20adb1927b360cf5818085fb16 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.sha512 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.sha512 deleted file mode 100644 index add348b8b4..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar.sha512 +++ /dev/null @@ -1 +0,0 @@ -e223f63b82fe6551b64dc052808db68382a64cea30d7eccb140cdb6c2f41eeb03cc1be1a9064465c4604459bd8c9bdf77673c18b749dd25af1a478e9f53ed587 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module deleted file mode 100644 index 3e6f49dc62..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module +++ /dev/null @@ -1,117 +0,0 @@ -{ - "formatVersion": "1.1", - "component": { - "group": "io.github.anilbeesetti", - "module": "nextlib-media3ext", - "version": "1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4", - "attributes": { - "org.gradle.status": "release" - } - }, - "createdBy": { - "gradle": { - "version": "9.4.1" - } - }, - "variants": [ - { - "name": "releaseVariantReleaseApiPublication", - "attributes": { - "org.gradle.category": "library", - "org.gradle.dependency.bundling": "external", - "org.gradle.libraryelements": "aar", - "org.gradle.usage": "java-api" - }, - "dependencies": [ - { - "group": "org.jetbrains.kotlin", - "module": "kotlin-stdlib", - "version": { - "requires": "2.2.10" - } - } - ], - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar", - "size": 6870988, - "sha512": "e223f63b82fe6551b64dc052808db68382a64cea30d7eccb140cdb6c2f41eeb03cc1be1a9064465c4604459bd8c9bdf77673c18b749dd25af1a478e9f53ed587", - "sha256": "bfd9c635cd18613c728e6e4d9635da2f80716d20adb1927b360cf5818085fb16", - "sha1": "6a8934462b59128fdb4dcbca9b68add46962532e", - "md5": "83ca5ae75a8e3be82761e153ca26c4d5" - } - ] - }, - { - "name": "releaseVariantReleaseRuntimePublication", - "attributes": { - "org.gradle.category": "library", - "org.gradle.dependency.bundling": "external", - "org.gradle.libraryelements": "aar", - "org.gradle.usage": "java-runtime" - }, - "dependencies": [ - { - "group": "androidx.media3", - "module": "media3-exoplayer", - "version": { - "requires": "1.10.0" - } - }, - { - "group": "com.google.errorprone", - "module": "error_prone_annotations", - "version": { - "requires": "2.48.0" - } - }, - { - "group": "androidx.annotation", - "module": "annotation", - "version": { - "requires": "1.9.1" - } - }, - { - "group": "org.jetbrains.kotlin", - "module": "kotlin-stdlib", - "version": { - "requires": "2.2.10" - } - } - ], - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.aar", - "size": 6870988, - "sha512": "e223f63b82fe6551b64dc052808db68382a64cea30d7eccb140cdb6c2f41eeb03cc1be1a9064465c4604459bd8c9bdf77673c18b749dd25af1a478e9f53ed587", - "sha256": "bfd9c635cd18613c728e6e4d9635da2f80716d20adb1927b360cf5818085fb16", - "sha1": "6a8934462b59128fdb4dcbca9b68add46962532e", - "md5": "83ca5ae75a8e3be82761e153ca26c4d5" - } - ] - }, - { - "name": "releaseVariantReleaseSourcePublication", - "attributes": { - "org.gradle.category": "documentation", - "org.gradle.dependency.bundling": "external", - "org.gradle.docstype": "sources", - "org.gradle.usage": "java-runtime" - }, - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4-sources.jar", - "size": 20596, - "sha512": "c1006d82fcabfe7cadfd6cd5ab4192efa5d5d5800bbada19b9b0258893be710c37a8d6d84c2833e25073a7fed7e704acfcd245ca9c282cd29d0b785c7c3eba49", - "sha256": "2a08cc5f58718a3da3dc437784891680a0710c376b322a385cd6e5adcde01492", - "sha1": "4dcf1b82052adcd579a12b69577979e9f7a2a70e", - "md5": "e6d36f1ffc240c9a5a0961248be0e90b" - } - ] - } - ] -} diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.md5 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.md5 deleted file mode 100644 index 88d1adbe63..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.md5 +++ /dev/null @@ -1 +0,0 @@ -5fbca96c75bf2c8e3f945f4cb017c846 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.sha1 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.sha1 deleted file mode 100644 index 5cb41c10aa..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.sha1 +++ /dev/null @@ -1 +0,0 @@ -e367bc6c686dddd66dceca4a20d51f9d160de5a0 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.sha256 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.sha256 deleted file mode 100644 index 11be994c14..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.sha256 +++ /dev/null @@ -1 +0,0 @@ -eafcf6b48dc1c9ab87b1bf7d29f103d1645d4575114f89a8fae7f4fabc2ea613 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.sha512 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.sha512 deleted file mode 100644 index 8af02addb5..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.module.sha512 +++ /dev/null @@ -1 +0,0 @@ -7bcad390349501de65c1228665246924211a9c1bef9827c05f2790375da51455039eab89a3f2359b5ec945d6a657b45271e8315adbd084dbc07667f2ef6a348c diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom deleted file mode 100644 index 8755fc609c..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - 4.0.0 - io.github.anilbeesetti - nextlib-media3ext - 1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4 - aar - NextLib Media3 Extensions - Android library providing ffmpeg extensions for Media3 - https://github.com/anilbeesetti/nextlib - - - GNU General Public License v3.0 - https://www.gnu.org/licenses/gpl-3.0.html - repo - - - - - anilbeesetti - Anil Kumar Beesetti - - - - https://github.com/anilbeesetti/nextlib - - - - org.jetbrains.kotlin - kotlin-stdlib - 2.2.10 - compile - - - androidx.media3 - media3-exoplayer - 1.10.0 - runtime - - - com.google.errorprone - error_prone_annotations - 2.48.0 - runtime - - - androidx.annotation - annotation - 1.9.1 - runtime - - - diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.md5 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.md5 deleted file mode 100644 index 2fc6ab7ea6..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.md5 +++ /dev/null @@ -1 +0,0 @@ -624953145511bb2f5bfabc45a5942dff diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.sha1 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.sha1 deleted file mode 100644 index 37e35933eb..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.sha1 +++ /dev/null @@ -1 +0,0 @@ -a50c59d3ce7a2bd9e1df7c9a594406e439064ea5 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.sha256 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.sha256 deleted file mode 100644 index ef244ad10e..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.sha256 +++ /dev/null @@ -1 +0,0 @@ -f2ffec8e1292ebcfcf8e4c93708d303361874f91085190ad531db95c1da96e91 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.sha512 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.sha512 deleted file mode 100644 index d3f8752573..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r4.pom.sha512 +++ /dev/null @@ -1 +0,0 @@ -624ff34ea8e8b9356243bce7c36d39c6ddb94af4372f0301edc8e5a3affce6013684b015e9aec540ad61b30811879110faf22e7e175054aa87c6650c9b764f4f diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar deleted file mode 100644 index fb0b33d65d..0000000000 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar and /dev/null differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.md5 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.md5 deleted file mode 100644 index 0279a10607..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.md5 +++ /dev/null @@ -1 +0,0 @@ -e6d36f1ffc240c9a5a0961248be0e90b diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.sha1 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.sha1 deleted file mode 100644 index dccfd328aa..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -4dcf1b82052adcd579a12b69577979e9f7a2a70e diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.sha256 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.sha256 deleted file mode 100644 index 993599d84f..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.sha256 +++ /dev/null @@ -1 +0,0 @@ -2a08cc5f58718a3da3dc437784891680a0710c376b322a385cd6e5adcde01492 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.sha512 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.sha512 deleted file mode 100644 index 186bc1b95c..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar.sha512 +++ /dev/null @@ -1 +0,0 @@ -c1006d82fcabfe7cadfd6cd5ab4192efa5d5d5800bbada19b9b0258893be710c37a8d6d84c2833e25073a7fed7e704acfcd245ca9c282cd29d0b785c7c3eba49 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar deleted file mode 100644 index d6c6758b6c..0000000000 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar and /dev/null differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.md5 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.md5 deleted file mode 100644 index ab36fe70a3..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.md5 +++ /dev/null @@ -1 +0,0 @@ -77f20c8c2402895ed582612b876a56f6 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.sha1 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.sha1 deleted file mode 100644 index b239ed69c5..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.sha1 +++ /dev/null @@ -1 +0,0 @@ -c8ef7351af094c97a9445938bf3aa70487fcb8ee diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.sha256 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.sha256 deleted file mode 100644 index 7f28b92257..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.sha256 +++ /dev/null @@ -1 +0,0 @@ -398a266d31ad756b66a05af8be077f77298436994408cf3644a5554ee73f4773 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.sha512 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.sha512 deleted file mode 100644 index f1edb937a8..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar.sha512 +++ /dev/null @@ -1 +0,0 @@ -0c0cd380ce42e0d1e03e968ce12ac0fe835aaa5e718e0e529a6ca1eddad1c48ef9f4daf6223e1d0feba102698633278724a02471f6f6c30db2784f8aa3b9d7f6 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module deleted file mode 100644 index a9d723f279..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module +++ /dev/null @@ -1,117 +0,0 @@ -{ - "formatVersion": "1.1", - "component": { - "group": "io.github.anilbeesetti", - "module": "nextlib-media3ext", - "version": "1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5", - "attributes": { - "org.gradle.status": "release" - } - }, - "createdBy": { - "gradle": { - "version": "9.4.1" - } - }, - "variants": [ - { - "name": "releaseVariantReleaseApiPublication", - "attributes": { - "org.gradle.category": "library", - "org.gradle.dependency.bundling": "external", - "org.gradle.libraryelements": "aar", - "org.gradle.usage": "java-api" - }, - "dependencies": [ - { - "group": "org.jetbrains.kotlin", - "module": "kotlin-stdlib", - "version": { - "requires": "2.2.10" - } - } - ], - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar", - "size": 7517095, - "sha512": "0c0cd380ce42e0d1e03e968ce12ac0fe835aaa5e718e0e529a6ca1eddad1c48ef9f4daf6223e1d0feba102698633278724a02471f6f6c30db2784f8aa3b9d7f6", - "sha256": "398a266d31ad756b66a05af8be077f77298436994408cf3644a5554ee73f4773", - "sha1": "c8ef7351af094c97a9445938bf3aa70487fcb8ee", - "md5": "77f20c8c2402895ed582612b876a56f6" - } - ] - }, - { - "name": "releaseVariantReleaseRuntimePublication", - "attributes": { - "org.gradle.category": "library", - "org.gradle.dependency.bundling": "external", - "org.gradle.libraryelements": "aar", - "org.gradle.usage": "java-runtime" - }, - "dependencies": [ - { - "group": "androidx.media3", - "module": "media3-exoplayer", - "version": { - "requires": "1.10.0" - } - }, - { - "group": "com.google.errorprone", - "module": "error_prone_annotations", - "version": { - "requires": "2.48.0" - } - }, - { - "group": "androidx.annotation", - "module": "annotation", - "version": { - "requires": "1.9.1" - } - }, - { - "group": "org.jetbrains.kotlin", - "module": "kotlin-stdlib", - "version": { - "requires": "2.2.10" - } - } - ], - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.aar", - "size": 7517095, - "sha512": "0c0cd380ce42e0d1e03e968ce12ac0fe835aaa5e718e0e529a6ca1eddad1c48ef9f4daf6223e1d0feba102698633278724a02471f6f6c30db2784f8aa3b9d7f6", - "sha256": "398a266d31ad756b66a05af8be077f77298436994408cf3644a5554ee73f4773", - "sha1": "c8ef7351af094c97a9445938bf3aa70487fcb8ee", - "md5": "77f20c8c2402895ed582612b876a56f6" - } - ] - }, - { - "name": "releaseVariantReleaseSourcePublication", - "attributes": { - "org.gradle.category": "documentation", - "org.gradle.dependency.bundling": "external", - "org.gradle.docstype": "sources", - "org.gradle.usage": "java-runtime" - }, - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5-sources.jar", - "size": 20596, - "sha512": "c1006d82fcabfe7cadfd6cd5ab4192efa5d5d5800bbada19b9b0258893be710c37a8d6d84c2833e25073a7fed7e704acfcd245ca9c282cd29d0b785c7c3eba49", - "sha256": "2a08cc5f58718a3da3dc437784891680a0710c376b322a385cd6e5adcde01492", - "sha1": "4dcf1b82052adcd579a12b69577979e9f7a2a70e", - "md5": "e6d36f1ffc240c9a5a0961248be0e90b" - } - ] - } - ] -} diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.md5 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.md5 deleted file mode 100644 index 6bc11ae999..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.md5 +++ /dev/null @@ -1 +0,0 @@ -936013897c3b3c8979468d0531cc90c5 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.sha1 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.sha1 deleted file mode 100644 index 7958815d1f..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.sha1 +++ /dev/null @@ -1 +0,0 @@ -e7437ba6535ff6648b690c8dcae35474187012d9 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.sha256 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.sha256 deleted file mode 100644 index 343af6598d..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.sha256 +++ /dev/null @@ -1 +0,0 @@ -3699841de8757ebf143fa289f39d4fb2c1125ccd12f773ffe98983eb3ae335c9 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.sha512 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.sha512 deleted file mode 100644 index 50868b9be3..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.module.sha512 +++ /dev/null @@ -1 +0,0 @@ -81409a255f920bb45e16f99c242003659bdbd8b0f7807d997d8f69785962b92fd1f9b3ffb7097275cd6309167f5713e724b4e04d88d7e546e2e7235e26334b81 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom deleted file mode 100644 index 22d9fca0ed..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - 4.0.0 - io.github.anilbeesetti - nextlib-media3ext - 1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5 - aar - NextLib Media3 Extensions - Android library providing ffmpeg extensions for Media3 - https://github.com/anilbeesetti/nextlib - - - GNU General Public License v3.0 - https://www.gnu.org/licenses/gpl-3.0.html - repo - - - - - anilbeesetti - Anil Kumar Beesetti - - - - https://github.com/anilbeesetti/nextlib - - - - org.jetbrains.kotlin - kotlin-stdlib - 2.2.10 - compile - - - androidx.media3 - media3-exoplayer - 1.10.0 - runtime - - - com.google.errorprone - error_prone_annotations - 2.48.0 - runtime - - - androidx.annotation - annotation - 1.9.1 - runtime - - - diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.md5 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.md5 deleted file mode 100644 index 5a382d8ce6..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.md5 +++ /dev/null @@ -1 +0,0 @@ -a84bf4db0697452536e8f7c264a73000 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.sha1 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.sha1 deleted file mode 100644 index 55466a1e79..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.sha1 +++ /dev/null @@ -1 +0,0 @@ -ca2762b3b4312313cb682fa76c303d942e2f4a3c diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.sha256 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.sha256 deleted file mode 100644 index 510cab63a3..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.sha256 +++ /dev/null @@ -1 +0,0 @@ -6ec32cf24854d9ca933a77a69f46b52abdd6645e62307d68fd8f154224a8cf62 diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.sha512 b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.sha512 deleted file mode 100644 index 8dd875014e..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5.pom.sha512 +++ /dev/null @@ -1 +0,0 @@ -de7271a5ca8bd22ba3bc7cea67987898ff48e8f6e01bb23b7653cbd352107d746161f99b55c53849e062d9a6418154452593a75c308506bab6237881e33a813c diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2-javadoc.jar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2-javadoc.jar deleted file mode 100644 index e5b3b80089..0000000000 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2-javadoc.jar and /dev/null differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2-sources.jar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2-sources.jar deleted file mode 100644 index f0ba3ff42e..0000000000 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2-sources.jar and /dev/null differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.aar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.aar deleted file mode 100644 index a406160673..0000000000 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.aar and /dev/null differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.module b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.module deleted file mode 100644 index 2dffaf89a2..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.module +++ /dev/null @@ -1,137 +0,0 @@ -{ - "formatVersion": "1.1", - "component": { - "group": "io.github.anilbeesetti", - "module": "nextlib-media3ext", - "version": "1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2", - "attributes": { - "org.gradle.status": "release" - } - }, - "createdBy": { - "gradle": { - "version": "9.5.1" - } - }, - "variants": [ - { - "name": "releaseVariantReleaseApiPublication", - "attributes": { - "org.gradle.category": "library", - "org.gradle.dependency.bundling": "external", - "org.gradle.libraryelements": "aar", - "org.gradle.usage": "java-api" - }, - "dependencies": [ - { - "group": "org.jetbrains.kotlin", - "module": "kotlin-stdlib", - "version": { - "requires": "2.2.10" - } - } - ], - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.aar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.aar", - "size": 6294461, - "sha512": "b6ec0691a578f878c723718934266653f30997f7b370d1e30a04098b1b5889c870e8da8e7484f921d2805fdda62c9833d369002b571021cebbd6692e8ba73f4f", - "sha256": "d22cb1885ef998f203b8b7811f6e8df4e42c3d3b3736825279bb1eb05d76a52f", - "sha1": "e3e72d755c66c925b173a58d8fda6f373b96f698", - "md5": "b643af3120de27bf40f0409679f277b3" - } - ] - }, - { - "name": "releaseVariantReleaseRuntimePublication", - "attributes": { - "org.gradle.category": "library", - "org.gradle.dependency.bundling": "external", - "org.gradle.libraryelements": "aar", - "org.gradle.usage": "java-runtime" - }, - "dependencies": [ - { - "group": "androidx.media3", - "module": "media3-exoplayer", - "version": { - "requires": "1.10.0" - } - }, - { - "group": "com.google.errorprone", - "module": "error_prone_annotations", - "version": { - "requires": "2.48.0" - } - }, - { - "group": "androidx.annotation", - "module": "annotation", - "version": { - "requires": "1.9.1" - } - }, - { - "group": "org.jetbrains.kotlin", - "module": "kotlin-stdlib", - "version": { - "requires": "2.2.10" - } - } - ], - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.aar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.aar", - "size": 6294461, - "sha512": "b6ec0691a578f878c723718934266653f30997f7b370d1e30a04098b1b5889c870e8da8e7484f921d2805fdda62c9833d369002b571021cebbd6692e8ba73f4f", - "sha256": "d22cb1885ef998f203b8b7811f6e8df4e42c3d3b3736825279bb1eb05d76a52f", - "sha1": "e3e72d755c66c925b173a58d8fda6f373b96f698", - "md5": "b643af3120de27bf40f0409679f277b3" - } - ] - }, - { - "name": "releaseVariantReleaseSourcePublication", - "attributes": { - "org.gradle.category": "documentation", - "org.gradle.dependency.bundling": "external", - "org.gradle.docstype": "sources", - "org.gradle.usage": "java-runtime" - }, - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2-sources.jar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2-sources.jar", - "size": 20442, - "sha512": "514eb43ded3390d1da1f2b0da1e1a22711252908a9806deda7c0463095aaf64f01ff5ea2bb49b6da3b7cf6c3859ac8f85df31da871412fc19527cfeb5e37bc63", - "sha256": "043fe28b6c6faeecf58e9168ac92c61b2ed9c0c3010da5a5430af3c59d7741a4", - "sha1": "89a1b13b03e9415182dda3c9c95816bc5cee4c0c", - "md5": "e63687a2d7048145ac6bceb2a80a37c4" - } - ] - }, - { - "name": "releaseVariantReleaseJavaDocPublication", - "attributes": { - "org.gradle.category": "documentation", - "org.gradle.dependency.bundling": "external", - "org.gradle.docstype": "javadoc", - "org.gradle.usage": "java-runtime" - }, - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2-javadoc.jar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2-javadoc.jar", - "size": 328836, - "sha512": "f7b4e8e40b6cdad5bab691969de0e4b8abcc7720fdb1df57b79dc4409a61c30aeb22d4a388698478129e7158affd4256fc702bd625a4d20330d78d313cf83e90", - "sha256": "fec4d75645d279c75dd18dee54d276cb1c65192566602f87b271a72faee3f186", - "sha1": "35ffd146804aab0a0ebaa275098978135b663973", - "md5": "ea725f8f9b30486f4180eb8576281a82" - } - ] - } - ] -} diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.pom b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.pom deleted file mode 100644 index b991dcf0f6..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2.pom +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - 4.0.0 - io.github.anilbeesetti - nextlib-media3ext - 1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2 - aar - NextLib Media3 Extensions - Android library providing ffmpeg extensions for Media3 - https://github.com/anilbeesetti/nextlib - - - GNU General Public License v3.0 - https://www.gnu.org/licenses/gpl-3.0.html - repo - - - - - anilbeesetti - Anil Kumar Beesetti - - - - https://github.com/anilbeesetti/nextlib - - - - org.jetbrains.kotlin - kotlin-stdlib - 2.2.10 - compile - - - androidx.media3 - media3-exoplayer - 1.10.0 - runtime - - - com.google.errorprone - error_prone_annotations - 2.48.0 - runtime - - - androidx.annotation - annotation - 1.9.1 - runtime - - - diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3-javadoc.jar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3-javadoc.jar deleted file mode 100644 index e5b3b80089..0000000000 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3-javadoc.jar and /dev/null differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3-sources.jar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3-sources.jar deleted file mode 100644 index 0e63b06b62..0000000000 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3-sources.jar and /dev/null differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.aar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.aar deleted file mode 100644 index 91886fb2bb..0000000000 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.aar and /dev/null differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.module b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.module deleted file mode 100644 index 49daddf28a..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.module +++ /dev/null @@ -1,137 +0,0 @@ -{ - "formatVersion": "1.1", - "component": { - "group": "io.github.anilbeesetti", - "module": "nextlib-media3ext", - "version": "1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3", - "attributes": { - "org.gradle.status": "release" - } - }, - "createdBy": { - "gradle": { - "version": "9.4.1" - } - }, - "variants": [ - { - "name": "releaseVariantReleaseApiPublication", - "attributes": { - "org.gradle.category": "library", - "org.gradle.dependency.bundling": "external", - "org.gradle.libraryelements": "aar", - "org.gradle.usage": "java-api" - }, - "dependencies": [ - { - "group": "org.jetbrains.kotlin", - "module": "kotlin-stdlib", - "version": { - "requires": "2.2.10" - } - } - ], - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.aar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.aar", - "size": 6323699, - "sha512": "a10ab5a3268442b2da33197337c9137cb5223e6168323f12a18fb0de8a68f72a7c5e3ab0b8e9157a804e9db74128147d334b688f54a6f8f10e0910185374de7e", - "sha256": "74f650f52bc41ec4d2ac51509b677d548f45cb7ac6a0c7833687455ad922c0a5", - "sha1": "e2b0fd1c9d1b0c3762e5a5c7af5e328b38a7089e", - "md5": "ae01a3c7e301582ec65e4375e676a817" - } - ] - }, - { - "name": "releaseVariantReleaseRuntimePublication", - "attributes": { - "org.gradle.category": "library", - "org.gradle.dependency.bundling": "external", - "org.gradle.libraryelements": "aar", - "org.gradle.usage": "java-runtime" - }, - "dependencies": [ - { - "group": "androidx.media3", - "module": "media3-exoplayer", - "version": { - "requires": "1.10.0" - } - }, - { - "group": "com.google.errorprone", - "module": "error_prone_annotations", - "version": { - "requires": "2.48.0" - } - }, - { - "group": "androidx.annotation", - "module": "annotation", - "version": { - "requires": "1.9.1" - } - }, - { - "group": "org.jetbrains.kotlin", - "module": "kotlin-stdlib", - "version": { - "requires": "2.2.10" - } - } - ], - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.aar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.aar", - "size": 6323699, - "sha512": "a10ab5a3268442b2da33197337c9137cb5223e6168323f12a18fb0de8a68f72a7c5e3ab0b8e9157a804e9db74128147d334b688f54a6f8f10e0910185374de7e", - "sha256": "74f650f52bc41ec4d2ac51509b677d548f45cb7ac6a0c7833687455ad922c0a5", - "sha1": "e2b0fd1c9d1b0c3762e5a5c7af5e328b38a7089e", - "md5": "ae01a3c7e301582ec65e4375e676a817" - } - ] - }, - { - "name": "releaseVariantReleaseSourcePublication", - "attributes": { - "org.gradle.category": "documentation", - "org.gradle.dependency.bundling": "external", - "org.gradle.docstype": "sources", - "org.gradle.usage": "java-runtime" - }, - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3-sources.jar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3-sources.jar", - "size": 20463, - "sha512": "b870ed690d15e2234fa34a84259305058b428aa167b644766f535540faf862851b94b8fd6fcbe5b6086977bbd7296ea3d0d4b39b469bccbbc22ce0bc795015a3", - "sha256": "4e25d861f7df1389dde65eec0c6c3df7b2ba76ee417bf9258999fa9289b4b6e0", - "sha1": "b48ba39866e5b83570a94f4b5037b9d1cb86dcf1", - "md5": "bf56f0de7bd20e906ff9343b42d23521" - } - ] - }, - { - "name": "releaseVariantReleaseJavaDocPublication", - "attributes": { - "org.gradle.category": "documentation", - "org.gradle.dependency.bundling": "external", - "org.gradle.docstype": "javadoc", - "org.gradle.usage": "java-runtime" - }, - "files": [ - { - "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3-javadoc.jar", - "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3-javadoc.jar", - "size": 328836, - "sha512": "f7b4e8e40b6cdad5bab691969de0e4b8abcc7720fdb1df57b79dc4409a61c30aeb22d4a388698478129e7158affd4256fc702bd625a4d20330d78d313cf83e90", - "sha256": "fec4d75645d279c75dd18dee54d276cb1c65192566602f87b271a72faee3f186", - "sha1": "35ffd146804aab0a0ebaa275098978135b663973", - "md5": "ea725f8f9b30486f4180eb8576281a82" - } - ] - } - ] -} diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.pom b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.pom deleted file mode 100644 index 9a5e4758e9..0000000000 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.pom +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - 4.0.0 - io.github.anilbeesetti - nextlib-media3ext - 1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3 - aar - NextLib Media3 Extensions - Android library providing ffmpeg extensions for Media3 - https://github.com/anilbeesetti/nextlib - - - GNU General Public License v3.0 - https://www.gnu.org/licenses/gpl-3.0.html - repo - - - - - anilbeesetti - Anil Kumar Beesetti - - - - https://github.com/anilbeesetti/nextlib - - - - org.jetbrains.kotlin - kotlin-stdlib - 2.2.10 - compile - - - androidx.media3 - media3-exoplayer - 1.10.0 - runtime - - - com.google.errorprone - error_prone_annotations - 2.48.0 - runtime - - - androidx.annotation - annotation - 1.9.1 - runtime - - - diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1-sources.jar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1-sources.jar index f0ba3ff42e..a0152e7117 100644 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1-sources.jar and b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1-sources.jar differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.aar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.aar index a7d3f12c53..76baddc6cf 100644 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.aar and b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.aar differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.module b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.module index ff8ff952a7..bf9529e0ac 100644 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.module +++ b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload-av3a-r1/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.module @@ -35,11 +35,11 @@ { "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.aar", "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.aar", - "size": 6288112, - "sha512": "b9e4111dc04935b643479193e839c7356d380f6a9b3e01ac7a05be0ca434be9a806915c4668f81f4cb0daad0a4d2cdbf95bbce8ef6905764f2dca16b057c1267", - "sha256": "7f6c1922a3e9b54f65b193fb415bb8d6c1f201719ed223711ab10d22f45501e9", - "sha1": "75115e7cdf9ab458967d5ec3ffaba5aaeddb8559", - "md5": "55c11f96d91dcd196d2f5be43729c69f" + "size": 6288216, + "sha512": "c3f3c5638e6dbbe757a208bffedfd081d0a48e10d50cf33cbc50a86d29d34aeb17632c2f387c2fbdf45f50763fa213cd3e15b69620309698ba3490943c5e7dcd", + "sha256": "10f74899b256c8f7620494aa53237826bb5d4910e9c31d61fa0255f1fcaf43e8", + "sha1": "c9c9052f76a24645c466548d3285cfcfa5314ede", + "md5": "96394bbb43d02c285eea736cb674c341" } ] }, @@ -85,11 +85,11 @@ { "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.aar", "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.aar", - "size": 6288112, - "sha512": "b9e4111dc04935b643479193e839c7356d380f6a9b3e01ac7a05be0ca434be9a806915c4668f81f4cb0daad0a4d2cdbf95bbce8ef6905764f2dca16b057c1267", - "sha256": "7f6c1922a3e9b54f65b193fb415bb8d6c1f201719ed223711ab10d22f45501e9", - "sha1": "75115e7cdf9ab458967d5ec3ffaba5aaeddb8559", - "md5": "55c11f96d91dcd196d2f5be43729c69f" + "size": 6288216, + "sha512": "c3f3c5638e6dbbe757a208bffedfd081d0a48e10d50cf33cbc50a86d29d34aeb17632c2f387c2fbdf45f50763fa213cd3e15b69620309698ba3490943c5e7dcd", + "sha256": "10f74899b256c8f7620494aa53237826bb5d4910e9c31d61fa0255f1fcaf43e8", + "sha1": "c9c9052f76a24645c466548d3285cfcfa5314ede", + "md5": "96394bbb43d02c285eea736cb674c341" } ] }, @@ -105,11 +105,11 @@ { "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1-sources.jar", "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1-sources.jar", - "size": 20442, - "sha512": "514eb43ded3390d1da1f2b0da1e1a22711252908a9806deda7c0463095aaf64f01ff5ea2bb49b6da3b7cf6c3859ac8f85df31da871412fc19527cfeb5e37bc63", - "sha256": "043fe28b6c6faeecf58e9168ac92c61b2ed9c0c3010da5a5430af3c59d7741a4", - "sha1": "89a1b13b03e9415182dda3c9c95816bc5cee4c0c", - "md5": "e63687a2d7048145ac6bceb2a80a37c4" + "size": 20500, + "sha512": "8bb7364184a7130174b64686afa7c8816e81f4bdea4f1103fac95d264b803077a629fa7979fe36ed32180c2c1eeb7caf9fd3ccccb89189116eea392f8dc809c3", + "sha256": "8924d59253ee31d36579d2043c7538fd8275e683d0ad07befacca9da12637fd3", + "sha1": "fff302eeffed3717e78421868c280b9e83f73f94", + "md5": "b31ae7c4cf416dcdd572b98a2c10d12e" } ] }, diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-sources.jar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-sources.jar index 3c559ef681..ee373e0e43 100644 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-sources.jar and b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-sources.jar differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.aar b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.aar index 67b0e0b20b..92fd5a5cdb 100644 Binary files a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.aar and b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.aar differ diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.module b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.module index 0f915f9a71..58806a271e 100644 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.module +++ b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/1.10.0-0.12.1-fongmi-softload/nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.module @@ -35,11 +35,11 @@ { "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.aar", "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.aar", - "size": 10635077, - "sha512": "aba78b9f295ae0ac116b200c52a2601216e0a86b4de5c1233387ced003f032996b8fa0ee111d82dedd3f69fb54199ba5229fc40c15c73179813c4ce6a9b56bc8", - "sha256": "d28e5af085e431d6b7bf29f36a008dae28f4ee11ffdb49a373f1a9108d365f7c", - "sha1": "a301fd62543567eaead3fa63e361a0118453822e", - "md5": "c44bac6f779b45534ea80b945bf99c06" + "size": 11252003, + "sha512": "bca52755cb4a7d13175652b109b5285b4d03f23b9c949872364d76048f666a958d53e413dbe3f4c22a3f3ee57e58274cbede283fc7f01bb72ddb388064c153c3", + "sha256": "b87547d701acea9d74e31e54cdf962da588ac3b0dea3b8c76f3974d7065ceea2", + "sha1": "460250791bb479db5166eee2b9ed8fbbb7641452", + "md5": "9e18a25ee223fbb40d69660b29a850b7" } ] }, @@ -85,11 +85,11 @@ { "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.aar", "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload.aar", - "size": 10635077, - "sha512": "aba78b9f295ae0ac116b200c52a2601216e0a86b4de5c1233387ced003f032996b8fa0ee111d82dedd3f69fb54199ba5229fc40c15c73179813c4ce6a9b56bc8", - "sha256": "d28e5af085e431d6b7bf29f36a008dae28f4ee11ffdb49a373f1a9108d365f7c", - "sha1": "a301fd62543567eaead3fa63e361a0118453822e", - "md5": "c44bac6f779b45534ea80b945bf99c06" + "size": 11252003, + "sha512": "bca52755cb4a7d13175652b109b5285b4d03f23b9c949872364d76048f666a958d53e413dbe3f4c22a3f3ee57e58274cbede283fc7f01bb72ddb388064c153c3", + "sha256": "b87547d701acea9d74e31e54cdf962da588ac3b0dea3b8c76f3974d7065ceea2", + "sha1": "460250791bb479db5166eee2b9ed8fbbb7641452", + "md5": "9e18a25ee223fbb40d69660b29a850b7" } ] }, @@ -105,11 +105,11 @@ { "name": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-sources.jar", "url": "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-sources.jar", - "size": 20170, - "sha512": "953db2db9176993c61500a0b3fb11eefbb4053085e5b15b88d6a40396f17bf8869a6b0f4e380a5511fb6583bd416049f02a5265f1f2d6c816ad43728deeed45a", - "sha256": "94c0f5f39d6527ec9afdd87de016725b12a313bc074e3df77a8f20a7e470dfe5", - "sha1": "a964e475f29dfe1919b9adeb0ccf232eb686c411", - "md5": "82efa743d3ddbaf4a700f55218e227f5" + "size": 20568, + "sha512": "6f26c2ea868c4a65184d252583ffde5fa07fbdebc510fcdf25db4fb23737e527369846dda9f41f69745b78eef192913ad146de2f6a6d98f49a36b67a013de569", + "sha256": "ad029c9d76e81c4d66c2a22e09f89c224ef93a62d975fce464373185652510c2", + "sha1": "97e057fbe5fb2b4a92df12e4e87314f504b520e1", + "md5": "985dbb95395756e0fe0371bbfb28d01c" } ] }, diff --git a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/maven-metadata-local.xml b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/maven-metadata-local.xml index 41b3bbfea5..78a23471ef 100644 --- a/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/maven-metadata-local.xml +++ b/third_party/maven/io/github/anilbeesetti/nextlib-media3ext/maven-metadata-local.xml @@ -3,11 +3,11 @@ io.github.anilbeesetti nextlib-media3ext - 1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3 - 1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3 + 1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r1 + 1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r1 - 1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3 + 1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r1 - 20260904142800 + 20260821234925 diff --git a/third_party/media-lock.json b/third_party/media-lock.json index 5bdde9b4d3..466cb91e9c 100644 --- a/third_party/media-lock.json +++ b/third_party/media-lock.json @@ -26,8 +26,8 @@ "sha256": "a6a8bbf95630e70938dacde7e36c97ed330c5c426644893090cfc3280dc6ed20" }, { - "path": "third_party/patches/media3-exo-pixel-eac3-joc-guard.patch", - "sha256": "3c5b6ca8294603a1dfa12404a236513497afd0a78cef938360304c8484f28791" + "path": "third_party/patches/media3-exo-eac3-joc-pixel-guard.patch", + "sha256": "f36790e77d65cd2638c14eb38f23dbdd3125a2aec09acb031fe6baaa06ab7c41" }, { "path": "third_party/patches/media3-exo-dts-14bit-frame-size.patch", @@ -56,62 +56,32 @@ { "path": "third_party/patches/media3-precache-hls-safety.patch", "sha256": "9b48e895bd3923159f880152a54160e7c937b2b0369d717da559a7ad98fd14f7" - }, - { - "path": "third_party/patches/media3-exo-av3a-mp4.patch", - "sha256": "b12123fe9dd09c83fc36e106bf7ebe64ab67404b165ceb6b0acbf45706d36874" - }, - { - "path": "third_party/patches/media3-exo-av3a-dash-channel-config.patch", - "sha256": "76a1a876c8d8493a54826ad54a789ed487e9d11fe324c701af58821b4539b987" - }, - { - "path": "third_party/patches/media3-exo-alac-wave.patch", - "sha256": "2b6de52af60d16f705c03963c3e40b872f899f1c1b17b21c48851f7ecfe571f3" - }, - { - "path": "third_party/patches/media3-exo-ass-observer.patch", - "sha256": "5ceff44d31b67157b84ef2868ed1957279056eca4f0e7aee0ae2809fb53ba3ec" - }, - { - "path": "third_party/patches/media3-playback-diagnostics.patch", - "sha256": "338ee1df1719d67eb7f679a4ff4f2070a7003ef9cc037ddae57e732e52a52b97" - }, - { - "path": "third_party/patches/media3-exo-avs3.patch", - "sha256": "d3e867c2e41cb917a1876aef333167a48e369b2174b087e233185fe5bd37dcf3" } ], "artifact_overrides": [ { "coordinate": "androidx.media3:media3-common:1.11.0-alpha01-fongmi", - "aar_sha256": "4826fe94891fb584951c813a8e67a63aafe0816d6adf836317ed895c7eee9cfd", - "sources_sha256": "dc9aefdcf38b0b8a2a55a467476c67ac51094fe6a32995bd5e61bce079140bcc", - "reason": "C-AVS3 MIME/MP4/TS/Matroska support; all other committed class bytes preserved, including ASS and diagnostics" + "aar_sha256": "5a4815ab415c0650bc8adaa60e518f32df548ddcfafb43e02381281a96dac6e5", + "sources_sha256": "31c38835a729f422ba25835733aa94ac23d8fe6445df2d725a71e46a11d86bfc", + "reason": "E4-J1 adds Cue collision/region fields and LetterSpacingSpan data contracts" }, { "coordinate": "androidx.media3:media3-container:1.11.0-alpha01-fongmi", - "aar_sha256": "db23c133556a03df178090eef482ce427ae33f8e2d639ee82bdb6911b79b01e3", - "sources_sha256": "c681e7071d709b4826099388ae18d7d2713c2f55b768e4440d9c965734674da0", - "reason": "C-AVS3 MIME/MP4/TS/Matroska support; all other committed class bytes preserved, including ASS and diagnostics" + "aar_sha256": "32c54bcb6113499267fadd75385d1a69a04eec3fcc34edf11e9bf6012d81ed91", + "sources_sha256": "9aee6336465f59a29efcadd5c72b26fd8cdaa3ee4ee88a74ad99de003ddc43b6", + "reason": "E2-1 adds Dolby Vision parser version/length safety" }, { "coordinate": "androidx.media3:media3-extractor:1.11.0-alpha01-fongmi", - "aar_sha256": "c1eb0ab5118469c128d153665839fd4ecd3be1c22558e741d1670e1bb2ca2725", - "sources_sha256": "bc864243c03f29722dddf1bedf58f24e3d83189d3f90c883ec294350651e4559", - "reason": "C-AVS3 MIME/MP4/TS/Matroska support; all other committed class bytes preserved, including ASS and diagnostics" + "aar_sha256": "59a090bad8efc32552cf5a7ec1d2fb66444168bfbc97bd45e50764527f80c768", + "sources_sha256": "9314a303f402e903dd075db5e9babf9be6474da539a4b76bb1ed765b95b6ef4e", + "reason": "E-SP2 chained SeekHead/deferred Cues plus E2-1/E3-1b/E4-1/E7-2 extractor behavior" }, { "coordinate": "androidx.media3:media3-exoplayer:1.11.0-alpha01-fongmi", - "aar_sha256": "47f90bd4fc52567738847ceb59c435ddeb6d35b987866d6899de87de3170bdf6", - "sources_sha256": "4d158d63ab0a99688880d6acfdc73ed340f09fa9ac9dd1934fbaa0babfcad086", - "reason": "Committed local baseline including ASS; AV-DIAG-01 optional owner hooks, all unrelated class bytes preserved" - }, - { - "coordinate": "androidx.media3:media3-exoplayer-dash:1.11.0-alpha01-fongmi", - "aar_sha256": "de557f0b91512a1e0726155bd0cbb23879279ce05a819ab27a49bea069225546", - "sources_sha256": "5462494d6ed12e64b5597070b86206727775c760ebe040ef6d3a3a51244431d4", - "reason": "E12 AVS3A P7 DASH AudioChannelConfiguration mapping" + "aar_sha256": "cfea29681799509923174cac7ce31b7e944e542db9df58fd56b7a86499a5c07e", + "sources_sha256": "83f4f83b4f44e621d52002c161f63fbcb77be6856af4b1e4a4cb0982b04549e1", + "reason": "E3-1a/E4-1 baseline plus E7-2 Blu-ray/DVD logical multi-extent routing and E-SP3-B HLS pre-cache request safety" }, { "coordinate": "androidx.media3:media3-datasource:1.11.0-alpha01-fongmi", @@ -150,63 +120,15 @@ } ] }, - "avs3_video": { - "high_decoder": { - "repo": "https://github.com/zzZ2001a/avs", - "commit": "0c7ac42edfac6d18b92b58a5ef43bca58526ca7a", - "subdirectory": "dependencies/hpm-HPM-15.0", - "archive": "third_party/avs3-hpm/vendor/hpm-15.0.tar.gz", - "sha256": "7cfd796f4052b491af0a53a3a82356b5886ab03fcde3eea1a2f5ac4060370afb", - "version": "HPM-15.0-webhtv-1", - "simd_adapter": { - "repo": "https://github.com/DLTcollab/sse2neon", - "commit": "3cf69760cc6fdc45a5d70f0d42a8f079489f9867", - "version": "1.8.0", - "header": "third_party/avs3-hpm/vendor/sse2neon.h", - "sha256": "07723c9f9457dd4316f1fde3dd4eb6f31dd67d9955f6c21f4e609ac1698be48a", - "license": "MIT" - }, - "license": "LicenseRef-AVS-HPM-standards-testing-and-promotion", - "profiles": [ - "0x32" - ], - "bit_depths": [ - 10 - ], - "linkage": "static, separately built for Exo and MPV" - }, - "decoder": { - "repo": "https://github.com/uavs3/uavs3d", - "commit": "0e20d2c291853f196c68922a264bcd8471d75b68", - "url": "https://codeload.github.com/uavs3/uavs3d/tar.gz/0e20d2c291853f196c68922a264bcd8471d75b68", - "sha256": "1c1eb778b6080bc01493180ea7ae671c6444ce3aa760b5d021ba882eb0f9e3a0", - "license": "BSD-3-Clause", - "profiles": [ - "0x20", - "0x22" - ], - "bit_depths": [ - 8, - 10 - ], - "linkage": "static, separately built for Exo and MPV" - }, - "ffmpeg_patch": { - "path": "third_party/patches/ffmpeg-avs3.patch", - "sha256": "26c1cfb7fc1bb26fd48ca6b2efc09ea837fd060bcc0e1f578ee97de870585544" - } - }, "nextlib": { "repo": "https://github.com/anilbeesetti/nextlib.git", "branch": "release/1.10.0-0.12.1", "commit": "6ff6cf9d0820382b3c233d018c52e4163b09d345", - "version": "1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5", + "version": "1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r1", "upstream_version": "1.10.0-0.12.1", "patches": [ "third_party/patches/nextlib-ffmpeg-soft-load-shedding.patch", - "third_party/patches/nextlib-av3a.patch", - "third_party/patches/nextlib-ape-support.patch", - "third_party/patches/nextlib-avs3.patch" + "third_party/patches/nextlib-av3a.patch" ], "ffmpeg_repo": "https://github.com/FongMi/FFmpeg.git", "ffmpeg_commit": "177f090e0503b7e013922ca903bde14b1c375f18", @@ -215,10 +137,6 @@ "abis": [ "arm64-v8a", "armeabi-v7a" - ], - "avs3_artifact": { - "aar_sha256": "398a266d31ad756b66a05af8be077f77298436994408cf3644a5554ee73f4773", - "sources_sha256": "2a08cc5f58718a3da3dc437784891680a0710c376b322a385cd6e5adcde01492" - } + ] } } diff --git a/third_party/mpv-native-build.md b/third_party/mpv-native-build.md index ee67ae81b6..68d279fa5d 100644 --- a/third_party/mpv-native-build.md +++ b/third_party/mpv-native-build.md @@ -2,18 +2,6 @@ 本文是 WebHTV 重新生成 `libmpv.so` 及其 FFmpeg 依赖的权威说明。 -2026-09-17 C-AVS3 MediaCodec:`ffmpeg-avs3-mediacodec.patch` 接在FFmpeg diagnostics补丁后,加入 `avs3_mediacodec` / `video/avs3`,将raw/av3c序列配置交给现有MediaCodec会话。显式硬件上下文与hardware-only查询均为必需,无NDK按MIME绕过筛选的兜底;设备不支持时不自动软解,软件后端保留。正式FFmpeg override显式启用并校验该decoder,日常Debug/Release直接打包已提交资产。仅更新两ARM codec,其他18个MPV库保持;依赖锁定revision、JNI/渲染/HPM与Exo制品不变。两ABI/16KB/导出、9项Java检查和手机JNI/NDK无硬件拒绝已通过;AVS3硬件实际出帧/profile兼容/性能仍需目标设备验收。精确制品、补丁哈希、原始结果与回滚见 [C-AVS3 MediaCodec](../docs/C-AVS3-video-decoding.md#mpv-avs3-mediacodec2026-09-17)。 - -2026-09-17 C-AVS3 高级档次:在原有 uavs3d 之外,加入固定 HPM 15.0 `0c7ac42edfac6d18b92b58a5ef43bca58526ca7a` 的 `0x32` 软件后端,由 `build_avs3_native.sh` 强制调用 `build_hpm_native.sh`,按 MPV 自有 NDK29/ABI 独立静态链接。HPM 内部符号统一隔离,ARM SIMD 使用固定 SSE2NEON;输入、分配、错误、时间戳与 flush 由适配层管理。只更新两 ABI codec,保留其他 MPV/FEL/字幕库;硬解模式不自动切软件。原 HPM 许可证限定标准开发、测试与推广,原文与 SSE2NEON MIT 许可随 nextlib AAR/APK 提供,本地构建成功不代表已取得对外商业分发授权。具体已验收范围、性能与未验收项以 [C-AVS3 当前记录](../docs/C-AVS3-video-decoding.md) 为准。 - -2026-09-17 P2-4第9.25节:FEL起播的GPU上下文选项改用mpv原有 `m_option_copy` 管理对象列表所有权,修复配置析构释放静态内存导致的native abort。仅修改FEL patch的 `vo_gpu_next.c` 块并增量重建两ARM ABI的libmpv,保留完整FEL、渲染回退和手动视频解码合同;其余18个MPV库(包括AVS3 codec/JNI)逐字节不变,依赖锁和公开导出不变。真实ta/option析构的ASan/UBSan先复现旧错误再通过修复;构建命令、最终产物与手机验证见[任务第9.25节](../docs/P2-4-mpv-android-fel.md)。普通Gradle构建直接包含修复后的assets。 - -2026-09-16 C-AVS3:固定 uavs3d `0e20d2c291853f196c68922a264bcd8471d75b68`,由共享 `build_avs3_native.sh` 按 MPV 自有 NDK/prefix 构建并静态链接到 codec;支持基准档次 0x20/0x22 的 8/10-bit,未实现 High profile 0x30/0x32。Android ARMv7 使用 softfp,pthread 来自 libc;FFmpeg 包装补丁处理初始化数据边界、错误传播及 flush。只更新两 ABI 的 `libmvcodec.so`,其余 MPV/渲染/FEL/字幕库保持基线字节;Exo 另建 `libavcodec.so`,两套命名空间不混用。来源、手机逐像素证据、限制和回滚见 [C-AVS3](../docs/C-AVS3-video-decoding.md)。日常 APK 构建直接包含提交的能力,不需要额外开关。 - -2026-09-16 P2-4第9.18节:为无ADB电视补充`WebHTV FEL wait sample`,仅FEL/视频INFO记录开启时每3秒采样一次descriptor bind/push的线程调度计数、CPU/墙钟及acquire fence的poll(0)状态,权限/计数缺失明确未知,采样开销独立记录。`WebHTV FEL descriptors: capability-v=1`独立记录GPU/驱动/API和push扩展支持/启用情况;不改变扩展启用、重建、位深、同步或线程。两ABI同锁libmpv、host与13项Java检查、TV64 APK验证通过,其余18库不变;来源、原始日志、哈希、判读限制及回滚见[任务第9.18节](../docs/P2-4-mpv-android-fel.md)。这是一份诊断候选,电视卡顿尚待新Web日志裁决。 - -2026-09-14 P2-4第9.17节续修:首次实际FEL硬件draw另设有界10s初始化,结束只排除占用时间、普通帧仍750ms,seek/轮询不续期。FEL stable按实际启用的KHR扩展/入口/YCbCr数量门控push descriptors,布局创建失败回普通set;每帧仍完整绑定当前图像并ONE_TIME录制,不重放旧命令,不改10bit/NLQ与同步。新增`WebHTV FEL renderer init`、`WebHTV FEL descriptors`(mode/reason/max-push)和`push-descriptors`耗时进入有界App日志。两ABI/产物及实际SHA-256以[任务第9.17节](../docs/P2-4-mpv-android-fel.md)为准;本单元仅重编两份libmpv、其余18库不变。host缓存/生命周期测试需给`test_mpv_fel_contract.sh`提供`ANDROID_NDK_HOME`或`VULKAN_HEADERS_INCLUDE`,只用Vulkan声明,不加载GPU驱动。电视实际扩展能力、画面正确性及实时性能仍须实测验收。 - ## 两种构建必须分开 日常 App 构建不会编译 MPV native。仓库已经提交以下目录中的 `.so`,Gradle 和 GitHub Actions 直接把它们作为 assets 打进 APK: @@ -39,14 +27,6 @@ scripts/build_mpv_native.sh ## 固定输入 -P9 2026-09-10 修复保留以下独立补丁(顺序由脚本锁定): - -- `mpv-discnav-poll.patch` 接在原盘输入补丁之后,在demux线程处理纯导航事件;菜单动画不再依赖暂停时的媒体包需求。 -- `ffmpeg-mediacodec-output-serialization.patch` 接在硬件音频补丁之后,将每个decoder context的输出释放、serial检查、flush与stop串行化,沿用原引用计数生命周期。 -- `mpv-mediacodec-embed-reset.patch` 接在输出时序诊断补丁之后,在VO reset时丢弃待提交图像/旧PTS,保留硬件直出与双Surface。 - -这些补丁不改变库版本或公开JNI API。重建必须覆盖两ABI的FFmpeg和MPV,再由`--stage-only --install`验证并安装整套产物;不能仅复制旧prefix声称已构建。已有同锁缓存可在完整补丁应用验证后,显式调用框架的`buildall.sh -n --arch ffmpeg`及`mpv`增量重编受影响组件,并保存实际编译与链接日志。JNI源/头未改时不重编`libplayer.so`。 - 所有上游仓库、commit、tar 包 SHA-256、NDK 和 Meson/Ninja 版本统一记录在: ```text @@ -65,7 +45,7 @@ third_party/mpv-native-lock.json | MediaCodec/Vulkan | FongMi 分支内建 AImageReader/AHardwareBuffer OpenGL/Vulkan 后端、sync-fd、HDR/Dolby Vision 和双 Surface OSD;不再叠加旧 `fd679c81` 或 transient patch | | Dolby Vision双层硬解 | `third_party/patches/mpv-android-dovi-el-surface.patch`:把 GPU 杜比元数据处理与独立增强层解码拆成两项能力;Android AImageReader 只提供单路生产者 Surface,因此 `gpu-next` 保留 DV5 映射、DV 来源识别与 HDR10 基础层回退,但不启动第二个无 Surface 的 `mediacodec-copy`;非 Android 输出仍可显式声明 EL 合成能力 | | AudioTrack音频直通 | 保留 FongMi MPV 的 passthrough carrier rate 修复:PCM 可跟随设备原生采样率,SPDIF/IEC61937 必须保留编码器声明的载波采样率;E-AC3、TrueHD、DTS-HD 使用 192 kHz,不能改写为常见的 48 kHz,否则 MPV 会判定格式不一致并回退 PCM。`mpv-audiotrack-truehd-channel-mask.patch` 保留 TrueHD 全 API 7.1 兼容规则,并在 Android 12+ 仅对实际 8-channel carrier 使用 7.1 mask;DTS-HD HRA/其它 2-channel carrier 仍使用 stereo。App 侧对 DTS-HD 同时验证 stereo/7.1,取 Media3、HDMI/ARC/eARC/USB 路由编码和 MPV IEC61937 载波探测的交集;任一所需载波不被系统接受时不启用该格式直通,由 MPV 回退 PCM 解码。 | -| MediaCodec直出 | `mpv-mediacodec-embed-optional-osd.patch` 允许关闭字幕时不创建额外 OSD Surface;`mpv-mediacodec-embed-timed-release.patch` 按播放 PTS 将缓冲帧提交给 MediaCodec,避免立即释放导致持续掉帧;`mpv-mediacodec-output-timing-diagnostics.patch` 只增加有界的提交/迟到/掉帧时序诊断。`ffmpeg-audio-mediacodec-hardware-first.patch` 让已有 AAC/MP3/AMR 音频 wrapper 只创建真实硬件 Codec,初始化失败后由 mpv 继续普通 FFmpeg decoder。 | +| MediaCodec直出 | `mpv-mediacodec-embed-optional-osd.patch` 允许关闭字幕时不创建额外 OSD Surface;`mpv-mediacodec-embed-timed-release.patch` 按播放 PTS 将缓冲帧提交给 MediaCodec,避免立即释放导致持续掉帧;`mpv-mediacodec-output-timing-diagnostics.patch` 只增加有界的提交/迟到/掉帧时序诊断。 | | Vulkan硬解稳定性与功耗 | `auto` 通过 `mpv-android-vulkan-smart-backend.patch` 恢复优先 direct AHardwareBuffer 采样,避免 HDR 默认执行全分辨率转换;direct 不支持时回退 queue-safe stable pool,再回退通用 conversion。显式 `stable` 保留给问题驱动,App 在自动模式发生视频输出错误或已识别首帧超时时会重建为 stable,并按设备环境记忆。stable 额外尝试两种 packed RGB10 storage 格式,最后才扩大到 RGBA16F。 | | Matroska代理Seek | `third_party/patches/mpv-matroska-segment-end.patch`,可Seek但HTTP总长度未知时使用MKV自身声明的Segment边界读取SeekHead/Cues | | FFmpeg | `FongMi/FFmpeg@177f090e0503b7e013922ca903bde14b1c375f18`(9.0.1 fongmi) | @@ -176,7 +156,7 @@ scripts/build_mpv_native.sh --abi arm64-v8a --jobs 8 --work-dir /tmp/webhtv-mpv- 2. 检查 NDK revision 和 LLVM 工具。 3. 在独立 Python venv 中安装固定版本 Meson/Ninja及 MbedTLS 生成工具依赖。 4. 下载构建框架和每个固定 commit,初始化 MbedTLS、FreeType、libplacebo 子模块,并校验所有发行 tar 包 SHA-256。 -5. 对固定 FFmpeg commit 应用 `third_party/patches/ffmpeg-webhtv-proxy-range.patch`,只接受App内部代理写入的精确Range起点标记,使缺少`Content-Range`的206响应仍能按请求偏移重连;它不会制造未知的资源总长度。同时应用 `third_party/patches/ffmpeg-mediacodec-port-starvation.patch`,对 MediaCodec 输入、输出端同时不可用的状态采用短时有界等待,并返回真实解码错误让 mpv 的硬解失败计数触发下一硬解或软解回退,避免把端口永久不可用误判成普通 `EAGAIN` 后无限重试。随后应用 `third_party/patches/ffmpeg-audio-mediacodec-hardware-first.patch`:API 29+ 要求 `isHardwareAccelerated()` 且排除 `isSoftwareOnly()`,旧系统排除已知软件 Codec 名称;没有真实硬件时让该 MediaCodec decoder 初始化失败并交回 mpv 软件后备,不在音频 buffer 热路径增加探测。 +5. 对固定 FFmpeg commit 应用 `third_party/patches/ffmpeg-webhtv-proxy-range.patch`,只接受App内部代理写入的精确Range起点标记,使缺少`Content-Range`的206响应仍能按请求偏移重连;它不会制造未知的资源总长度。同时应用 `third_party/patches/ffmpeg-mediacodec-port-starvation.patch`,对 MediaCodec 输入、输出端同时不可用的状态采用短时有界等待,并返回真实解码错误让 mpv 的硬解失败计数触发下一硬解或软解回退,避免把端口永久不可用误判成普通 `EAGAIN` 后无限重试。 6. 固定 MPV 到 FongMi 完整分支;该分支已经包含 AImageReader OpenGL/Vulkan、sync-fd、HDR/Dolby Vision、双 Surface OSD、直播状态、Android helper scheme,以及直通时保留 SPDIF/IEC61937 载波采样率的 AudioTrack 修复。WebHTV 按以下顺序应用 MPV 补丁: - `mpv-stream-cb-disc-controls.patch` - `mpv-android-dovi-el-surface.patch` @@ -256,51 +236,6 @@ P2-2 在现有 `mpv-dovi-profile7-hdr10-base-layer.patch` 内完成 Profile 7 HD 本阶段使用 NDK r29 对 `arm64-v8a` 与 `armeabi-v7a` 完整重建并安装 native assets;一次 `scripts/verify_mpv_native_assets.sh --require-elf` 通过,APK `app-mobile-arm64_v8a-debug.apk` 内十个 arm64 MPV 资产与工作区完全一致,两个 `libplayer.so` 保持字节不变。用户在 USB 连接的 vivo V2453A 上确认安装后的 DV7 及邻接播放验证通过。实现提交为 `ba47756d7e463abeb9377088b819a2520e150935`,恢复 tag 为 `recovery/P2-2-MPV-DV7-METADATA-CODECPAR/20260829065811-ba47756d7e46`。完整来源、哈希、验证和回滚记录见 [P2-2-mpv-dv7-metadata-codecpar.md](../docs/P2-2-mpv-dv7-metadata-codecpar.md)。 -## P2-4 Android FEL 双层重建(2026-09-12) - -独立补丁 `mpv-android-fel.patch` 在现有补丁序列末尾应用,不升级任何锁定依赖。`android-dovi-fel` 默认关闭;只有 App 手动选择「FEL 双层重建」、识别源 Profile 7 并使用 `gpu-next` 时才开启软件 EL 配对。原 `VO_CAP_GPU_DOVI_EL` Android gate 保留,新增的软件 EL 能力不能启动第二路 MediaCodec。 - -补丁适配 `FongMi/mpv@06ec6e1746e5cbdcd271e613fdb1f7f7ecd36042` 的 EL force_swdec 必要接线,不吸收其 Surface/HDR/OSD 重写。可靠性续修参考 mpv #18375 的预热/持帧问题,仅在新模式将 BL 待配对/预取限制为1/1、EL为8/4;两路独立有界队列,不能把 `hwdec-extra-frames` 当成 MediaCodec Surface 扩池。保持 PTS/NLQ,连续8次解码错误且无成功帧时结束失败解码,不改变禁止 BL 软回退的策略。现有 FFmpeg、libplacebo、原盘/音频补丁保持原样。 - -日志31进一步暴露配对之外的图像持有:FEL 的 Vulkan auto/direct 实例内部改用既有 stable 原始YUV暂存池,不写回用户后端设置,也不改变非FEL选择。暂存至少10-bit,不允许8-bit降级;GPU复制提交后以最多100ms的有界fence等待尝试及时归还AImage,超时仍保留图像所有权,不提前释放或CPU回读。`WebHTV FEL GPU staging`和`WebHTV FEL GPU pool`将实际后端、提交/完成、仍持有图像、位深与等待时间写入App调试日志;旧的direct/fence实现不改。该适配增加新FEL模式的GPU拷贝/显存需求,实际花屏、持续播放和性能仍需电视验证。 - -本阶段使用同锁温缓存,只重编两 ABI 的 `mpv`:**串行**执行 `buildall.sh -n --arch arm64 mpv` 与 `--arch armv7l mpv`(当前脚本会修改共享 `meson.build` 的 iconv 路径,不能并行),随后 `scripts/build_mpv_native.sh --abi all --stage-only --install`。可靠性续修还修改 canonical JNI 的 NODE 回调,必须 `scripts/build_mpv_player_jni.sh --abi all --install` 重建 `libplayer.so`,使 Java 和二进制成套交付。NODE 序列化有深度、节点及字节限制,轨道/章节通过订阅快照传递,缺值不回退逐项 JNI 查询;不改现有串行 mutation/Surface/shutdown 队列。 - -不得跳过实际编译而仅复制旧 prefix。`scripts/test_mpv_fel_contract.sh` 执行 NODE 与 FEL 策略的 host ASan/UBSan 检查及生产 patch 静态契约;`scripts/verify_mpv_native_assets.sh --require-elf` 额外要求队列/失败与 JNI NODE 标记。它们不代表真机重建、画质、性能或恢复页生命周期验收通过。 - -日志31续修未再改JNI,因此复用本单元已经编译和验证的两份`libplayer.so`,只需重编两份`libmpv.so`后重新打APK;不能为未改变的FFmpeg/libplacebo或JNI重复全链构建。 - -日志32续修切断FEL只读状态查询的native循环等待:`hwdec-current`通过wrapper缓存读取,包含真实探测可用状态和独立名称副本;不得调用decoder dispatch锁等待硬解端口。缓存先于async producer的帧发布更新,seek/reinit使旧状态失效;仅FEL的固定container FPS也不取decoder锁。修改型控制仍同步,普通模式不变。`WebHTV FEL status snapshot`输出查询次数、发布序号和可用状态,便于在不能ADB的电视上辨别新路径;host回归直接编译生产wrapper函数,以禁止decoder等待的stub验证读路径,并检查旧模式/重置型控制仍同步。此轮同样仅重编mpv,不改JNI、FFmpeg、libplacebo。 - -日志33确认上述状态快照已生效但连续播放仍失败,不能把它当作完整根因。新增`WebHTV FEL pipeline diagnostics v=1`:每个VO实例用always-lock-free原子值记录core/BL/EL/VO当前步骤和阶段起点,另外记录收发计数及VO请求帧预算;只在原fatal错误内格式化`WebHTV FEL pipeline:`,不逐帧输出、不同步查询native、不新增线程,不改变解码/渲染/超时策略。该消息复用App的fatal限流豁免,电视没有ADB也可从调试日志取证。host C测试直接编译生产header,覆盖禁用、时钟回绕、缓冲截断及并发;两ABI都必须通过lock-free编译断言和新标记校验。 - -日志35暴露VO丢帧接线缺口:核心持有未来帧等待第二帧,VO已空闲,但丢帧分支跳过了原本在`draw_frame`中的Surface暂存。FEL+MediaCodec+gpu-next现在通过内部`VOCTRL_PREPARE_FEL_FRAME`在丢弃显示时仍准备当前/未来帧,映射在VO锁外执行,复用既有GPU缓存,不做EL合成或呈现;暂时未就绪请求保留帧重绘,永久失败报告backend error。核心双帧时序、插帧设置及正常/非FEL路径不变。`WebHTV FEL dropped-frame staging`和fatal快照中的`surface-drops/drop-prepared/drop-retries`用于电视验证,fatal与pipeline放在同一物理行保证限流豁免。真实`render_frame()`/暂存函数的host有限缓冲测试证明旧代码停滞、新代码可归还输出;这仍不是电视持续播放验收。 - -日志36后曾用`dovi_split=bl_rpu`隔离EL;日志38证明全部6帧暂存归还后仍停止,不能把该版本称已修复。当前FEL候选只为Profile 7 FEL的MediaCodec BL使用已有`dovi_split=bl`,硬件输入不含RPU/EL,私有decoder context和packet移除DOVI/HEVC EL配置;共享demux/codec及独立软件EL/RPU不变。现有PTS配对函数从EL继承原始DV映射,GPU仍执行FEL重建。独立packet ref承接BSF所有权,支持EAGAIN重试/seek清理,错误失败关闭,旧模式不扫描码流;不增加公开符号、依赖或JNI变更,仅两ABI mpv增量重建。host测试涵盖真实BSF格式/所有权/输入载荷、软件EL逐帧RPU/PTS/NLQ和实际继承函数(第二参数传GIJoe Profile 7样片路径;需本机FFmpeg开发库和pkg-config)。`WebHTV FEL BL input isolation: pure-bl`、`RPU-source=EL`、配对RPU来源/缺失统计和fatal同行`BL-input={...}`供无ADB电视取证。此候选仍需电视实播,不能据host/编译通过宣称已稳定。 - -日志39/40否决pure-BL候选的可靠性与实时性能。生产者交接续修在独立BL worker发布硬件帧前,通过已有非阻塞VO暂存接口等待GPU完成且AImage归还;共享AVBuffer原子标记保证源归还先于发布及下一次decode,2ms轮询、750ms总界限,不新增App/core同步等待。缓存命中继续推进fence,FEL mapper保持raw-YUV存储以兼容稍后从EL继承RPU,不增加第二次像素拷贝或降位深。`WebHTV FEL producer handoff`和`WebHTV FEL decoder cost`记录交接及BL/EL耗时;非FEL不进入新增计时路径。`fel_producer_handoff_test.c`直接编译生产函数体验证有限输出进展与所有权顺序,仍不能替代电视验收。 - -日志41的751ms超时暴露“mapper已返回纹理但源未完成→确认后重入→ready快速路径不清请求槽”。VO先确认自己的请求,映射成功但源未归还时保留同槽重试。FEL stable若支持SYNC_FD导出,则用独立source-release semaphore随GPU copy提交,导出fd交`AImage_deleteAsync`,使CPU不必逐帧等待GPU;libplacebo的render-ready semaphore不被导出消费,原VkFence继续保护GPU input/output/command资源。原子状态区分安全交还源和GPU完成。无能力、信号量创建或fd导出失败时保留有界CPU等待;失败后禁止再次signal未消费的source semaphore。不改变默认模式、位深、EL/RPU、缓存上限和超时预算。 - -新`WebHTV FEL source release`、`map cost`、`async-returns`及producer的`gpu-complete`供无ADB电视取证。定向回归编译实际VO/submit/export/finish/producer函数,涵盖120帧CPU归还、120帧异步归还、延迟完成、fd=-1/失败/复用、独立两信号和准入隔离;它们不是电视画质/实时性能验收。 - -日志42三次首帧751ms失败否决上一候选:冷启动GPU资源/compute pipeline创建也被算进逐帧750ms。当前修正用同一共享lease的单调phase位标记真实冷初始化,仅此阶段单独有界10秒,普通排队/交接仍750ms,不由“第几帧”或轮询次数续期。BL待交接帧由wrapper持有,每次process检查一次后返回已有dispatch(2ms可中断等待),reset/stop取消自己的VO lease,generation防止旧回写。FEL失败只发送一次EOF且停止feed,避免零样本权重的信号队列空转。`WebHTV FEL GPU init`区分outputs/pipeline/总初始化耗时,`handoff timeout: phase=`与`wait=async`进入App调试日志。 - -日志29三次进入播放但稳态约10–12fps,不能认为此前交接修复完成实时性能需求。当前增量加入仅FEL/INFO启用的冷/热map分段墙钟、线程CPU、既有copy fence完成后的无WAIT GPU timestamp、既有libplacebo pass均值,以及render/flush/submit/swap和实际decoder线程信息;不支持/失败时只停用计时,不改变像素或生命周期。App纯性能记录按严格来源/级别识别并独立限流,只写一次现有调试日志存储,不重复排队主线程/漂亮Logcat,错误路径不变。 - -当前状态、实际资产 SHA-256、验证结果与回滚以 [P2-4-mpv-android-fel.md](../docs/P2-4-mpv-android-fel.md) 第9.14节为准。早先NODE可靠性单元替换过两 ABI 的 `libmpv.so` 和 `libplayer.so`;本轮以`1620bac1566727f4067eda631647a11652082e74`为基线,**仅重编/替换两份libmpv,其余18库(含JNI)逐字节不变**。host计时/所有权契约、23项Java定向测试、双ABI/ELF/导出、两个debug APK各10库/签名/封装结构通过。GPU区间可能含依赖等待,pass缓存不是整帧墙钟,线程数也不等于CPU利用率;目标电视画质、可靠性、性能与生命周期仍须新日志验证,不以构建通过标记任务完成。 - -## AV-DIAG-01 诊断窄补丁(2026-09-15) - -本单元不改变`mpv-native-lock.json`版本及既有DV/字幕/音频/网络补丁,追加: - -- `third_party/patches/ffmpeg-mediacodec-diagnostics.patch`:实际查名访问/拒绝/profile比较、查询失败stage、Java提前返回/NDK by-MIME与真实create/configure/start;SHA256 `bf3ad167b8811f661e2c47f33f6ec41e2887125727caa115f3553e814a1710e9`。 -- `third_party/patches/mpv-playback-diagnostics.patch`:原AudioTrack内的配置/重建、原始write结果和单位、已有head/timestamp缓存、低频actual route;跨输出生命周期独立编号;SHA256 `0a62ef78d2cdce3a8e3d843319b364d644a70bda13291452640d86a12c2a136c`。INFO关闭时不做新增写入统计/时间采样。 - -当前锁定输入:framework `99a60ad2141d5ace94453590903c2c6b9a0a2443`、MPV `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`、FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`、libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`,NDK29/API24。两ARM ABI从同一缓存补丁链增量构建,随后`bash scripts/build_mpv_native.sh --abi all --stage-only --install`;不reset已应用补丁的缓存,不重编未改变contract的`libplayer.so`。 - -`verify_mpv_native_assets.sh --require-elf`已通过,包含新增lookup/write marker和原命名空间/依赖门禁;arm64 libmpv SHA256 `8213150b467bc2dd9501bbd8e484ac8db04f537ebc8276133a845c18fda1b01a`,armv7 `2a0e1f749b8d57377da086a5c150db588b90cf65b656eddce36e6a3ba194709d`。完整实现、产物/验证日志、设备验收边界及原子回滚仅记录在[AV-DIAG-01](../docs/AV-DIAG-01-playback-diagnostics.md)的14.12–14.14;没有将构建通过记为设备实播通过。 - ## 提交前验证 至少构建一个快速 Release: diff --git a/third_party/mpv-native-lock.json b/third_party/mpv-native-lock.json index d9102c2f74..92d5b384c4 100644 --- a/third_party/mpv-native-lock.json +++ b/third_party/mpv-native-lock.json @@ -148,37 +148,6 @@ "describe_tag": "v0.41.0", "tag_repo": "https://github.com/mpv-player/mpv.git", "history_depth": 1600 - }, - "uavs3d": { - "repo": "https://github.com/uavs3/uavs3d", - "commit": "0e20d2c291853f196c68922a264bcd8471d75b68", - "url": "https://codeload.github.com/uavs3/uavs3d/tar.gz/0e20d2c291853f196c68922a264bcd8471d75b68", - "sha256": "1c1eb778b6080bc01493180ea7ae671c6444ce3aa760b5d021ba882eb0f9e3a0", - "version": "1.2.0-0e20d2c2-10bit" } - }, - "avs3_high_decoder": { - "repo": "https://github.com/zzZ2001a/avs", - "commit": "0c7ac42edfac6d18b92b58a5ef43bca58526ca7a", - "subdirectory": "dependencies/hpm-HPM-15.0", - "archive": "third_party/avs3-hpm/vendor/hpm-15.0.tar.gz", - "sha256": "7cfd796f4052b491af0a53a3a82356b5886ab03fcde3eea1a2f5ac4060370afb", - "version": "HPM-15.0-webhtv-1", - "simd_adapter": { - "repo": "https://github.com/DLTcollab/sse2neon", - "commit": "3cf69760cc6fdc45a5d70f0d42a8f079489f9867", - "version": "1.8.0", - "header": "third_party/avs3-hpm/vendor/sse2neon.h", - "sha256": "07723c9f9457dd4316f1fde3dd4eb6f31dd67d9955f6c21f4e609ac1698be48a", - "license": "MIT" - }, - "license": "LicenseRef-AVS-HPM-standards-testing-and-promotion", - "profiles": [ - "0x32" - ], - "bit_depths": [ - 10 - ], - "linkage": "static, separately built for Exo and MPV" } } diff --git a/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader.comp b/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader.comp new file mode 100644 index 0000000000..85e377143e --- /dev/null +++ b/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader.comp @@ -0,0 +1,17 @@ +#version 450 + +layout(local_size_x = 16, local_size_y = 16) in; + +layout(set = 0, binding = 0) uniform sampler2D source_image; +layout(set = 0, binding = 1) writeonly uniform image2D target_image; + +void main() +{ + ivec2 position = ivec2(gl_GlobalInvocationID.xy); + ivec2 size = imageSize(target_image); + if (any(greaterThanEqual(position, size))) + return; + + vec2 uv = (vec2(position) + vec2(0.5)) / vec2(size); + imageStore(target_image, position, texture(source_image, uv)); +} diff --git a/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_comp.h b/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_comp.h new file mode 100644 index 0000000000..287c528864 --- /dev/null +++ b/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_comp.h @@ -0,0 +1,74 @@ +// Generated from hwdec_aimagereader.comp with: +// glslc -fshader-stage=compute --target-env=vulkan1.1 -O -mfmt=c +static const uint32_t aimagereader_comp_spv[] = { + 0x07230203,0x00010300,0x000d000a,0x0000003e, + 0x00000000,0x00020011,0x00000001,0x00020011, + 0x00000032,0x00020011,0x00000038,0x0006000b, + 0x00000001,0x4c534c47,0x6474732e,0x3035342e, + 0x00000000,0x0003000e,0x00000000,0x00000001, + 0x0006000f,0x00000005,0x00000004,0x6e69616d, + 0x00000000,0x0000000d,0x00060010,0x00000004, + 0x00000011,0x00000010,0x00000010,0x00000001, + 0x00040047,0x0000000d,0x0000000b,0x0000001c, + 0x00040047,0x00000016,0x00000022,0x00000000, + 0x00040047,0x00000016,0x00000021,0x00000001, + 0x00030047,0x00000016,0x00000019,0x00040047, + 0x00000032,0x00000022,0x00000000,0x00040047, + 0x00000032,0x00000021,0x00000000,0x00040047, + 0x0000003a,0x0000000b,0x00000019,0x00020013, + 0x00000002,0x00030021,0x00000003,0x00000002, + 0x00040015,0x00000006,0x00000020,0x00000001, + 0x00040017,0x00000007,0x00000006,0x00000002, + 0x00040015,0x0000000a,0x00000020,0x00000000, + 0x00040017,0x0000000b,0x0000000a,0x00000003, + 0x00040020,0x0000000c,0x00000001,0x0000000b, + 0x0004003b,0x0000000c,0x0000000d,0x00000001, + 0x00040017,0x0000000e,0x0000000a,0x00000002, + 0x00030016,0x00000013,0x00000020,0x00090019, + 0x00000014,0x00000013,0x00000001,0x00000000, + 0x00000000,0x00000000,0x00000002,0x00000000, + 0x00040020,0x00000015,0x00000000,0x00000014, + 0x0004003b,0x00000015,0x00000016,0x00000000, + 0x00020014,0x0000001b,0x00040017,0x0000001c, + 0x0000001b,0x00000002,0x00040017,0x00000022, + 0x00000013,0x00000002,0x0004002b,0x00000013, + 0x00000027,0x3f000000,0x0005002c,0x00000022, + 0x00000028,0x00000027,0x00000027,0x00090019, + 0x0000002f,0x00000013,0x00000001,0x00000000, + 0x00000000,0x00000000,0x00000001,0x00000000, + 0x0003001b,0x00000030,0x0000002f,0x00040020, + 0x00000031,0x00000000,0x00000030,0x0004003b, + 0x00000031,0x00000032,0x00000000,0x00040017, + 0x00000035,0x00000013,0x00000004,0x0004002b, + 0x00000013,0x00000036,0x00000000,0x0004002b, + 0x0000000a,0x00000038,0x00000010,0x0004002b, + 0x0000000a,0x00000039,0x00000001,0x0006002c, + 0x0000000b,0x0000003a,0x00000038,0x00000038, + 0x00000039,0x0004002b,0x0000000a,0x0000003d, + 0x00000000,0x00050036,0x00000002,0x00000004, + 0x00000000,0x00000003,0x000200f8,0x00000005, + 0x000300f7,0x0000003b,0x00000000,0x000300fb, + 0x0000003d,0x0000003c,0x000200f8,0x0000003c, + 0x0004003d,0x0000000b,0x0000000f,0x0000000d, + 0x0007004f,0x0000000e,0x00000010,0x0000000f, + 0x0000000f,0x00000000,0x00000001,0x0004007c, + 0x00000007,0x00000011,0x00000010,0x0004003d, + 0x00000014,0x00000017,0x00000016,0x00040068, + 0x00000007,0x00000018,0x00000017,0x000500af, + 0x0000001c,0x0000001d,0x00000011,0x00000018, + 0x0004009a,0x0000001b,0x0000001e,0x0000001d, + 0x000300f7,0x00000020,0x00000000,0x000400fa, + 0x0000001e,0x0000001f,0x00000020,0x000200f8, + 0x0000001f,0x000200f9,0x0000003b,0x000200f8, + 0x00000020,0x0004006f,0x00000022,0x00000026, + 0x00000011,0x00050081,0x00000022,0x00000029, + 0x00000026,0x00000028,0x0004006f,0x00000022, + 0x0000002b,0x00000018,0x00050088,0x00000022, + 0x0000002c,0x00000029,0x0000002b,0x0004003d, + 0x00000014,0x0000002d,0x00000016,0x0004003d, + 0x00000030,0x00000033,0x00000032,0x00070058, + 0x00000035,0x00000037,0x00000033,0x0000002c, + 0x00000002,0x00000036,0x00040063,0x0000002d, + 0x00000011,0x00000037,0x000200f9,0x0000003b, + 0x000200f8,0x0000003b,0x000100fd,0x00010038, +}; diff --git a/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_vk.c b/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_vk.c new file mode 100644 index 0000000000..b400bf20ce --- /dev/null +++ b/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_vk.c @@ -0,0 +1,1538 @@ +/* + * Android AImageReader / Vulkan interop. + * + * MediaCodec surface output is normally backed by an opaque AHardwareBuffer. + * Import it into Vulkan and convert it with the driver's YCbCr conversion + * sampler into a regular RGB image that libplacebo can consume. This keeps the + * whole path on the GPU and avoids mediacodec-copy's CPU surface download. + */ + +#include "config.h" + +#include +#include +#include +#include + +#include +#include +#include + +#include "common/common.h" +#include "common/msg.h" +#include "video/out/gpu/context.h" +#include "video/out/gpu/hwdec.h" +#include "video/out/placebo/ra_pl.h" +#include "video/out/vulkan/context.h" + +#include "hwdec_aimagereader_comp.h" +#include "hwdec_aimagereader_vk.h" + +#define OUTPUT_COUNT 3 +#define INPUT_CACHE_SIZE 8 +#define EXTERNAL_FORMAT_DESCRIPTOR_COUNT 4 + +struct vk_input { + AHardwareBuffer *buffer; + VkImage image; + VkDeviceMemory memory; + VkImageView view; + uint64_t last_used; + int users; + bool initialized; +}; + +struct vk_output { + VkImage image; + VkDeviceMemory memory; + VkImageView view; + pl_tex pltex; + struct ra_tex *ratex; + VkDescriptorSet descriptor; + VkCommandBuffer command; + VkSemaphore available; + VkSemaphore acquire; + VkSemaphore ready; + VkFence fence; + VkQueryPool query_pool; + AImage *source_image; + struct mp_image *source_frame; + struct vk_input *input; + bool pending; + bool written; + bool released; + bool timing_pending; +}; + +struct aimagereader_vk { + struct mp_log *log; + struct ra_hwdec_mapper *mapper; + struct aimagereader_vk_api api; + + pl_gpu gpu; + pl_vulkan vk; + VkDevice device; + VkQueue queue; + uint32_t queue_family; + PFN_vkGetAndroidHardwareBufferPropertiesANDROID GetAHBProperties; + PFN_vkGetPhysicalDeviceImageFormatProperties2 GetImageFormatProperties2; + PFN_vkImportSemaphoreFdKHR ImportSemaphoreFdKHR; + float timestamp_period; + + VkCommandPool command_pool; + VkFormat output_format; + int width; + int height; + int output_index; + bool resources_ready; + + uint32_t source_ahb_format; + VkFormat source_format; + uint64_t external_format; + VkAndroidHardwareBufferFormatPropertiesANDROID source_props; + VkSamplerYcbcrConversion conversion; + VkSampler sampler; + VkDescriptorSetLayout descriptor_layout; + VkDescriptorPool descriptor_pool; + VkPipelineLayout pipeline_layout; + VkPipeline pipeline; + + struct vk_input inputs[INPUT_CACHE_SIZE]; + int num_inputs; + uint64_t input_serial; + struct vk_output outputs[OUTPUT_COUNT]; +}; + +static bool vk_success(struct aimagereader_vk *p, VkResult result, + const char *operation) +{ + if (result == VK_SUCCESS) + return true; + mp_err(p->log, "%s failed: %d\n", operation, result); + return false; +} + +static bool has_extension(pl_vulkan vk, const char *name) +{ + for (int n = 0; n < vk->num_extensions; n++) { + if (strcmp(vk->extensions[n], name) == 0) + return true; + } + return false; +} + +static bool can_import_sync_fd(pl_vulkan vk) +{ + PFN_vkGetPhysicalDeviceExternalSemaphoreProperties get_properties = + (PFN_vkGetPhysicalDeviceExternalSemaphoreProperties) + vk->get_proc_addr( + vk->instance, "vkGetPhysicalDeviceExternalSemaphoreProperties"); + if (!get_properties) { + get_properties = (PFN_vkGetPhysicalDeviceExternalSemaphoreProperties) + vk->get_proc_addr( + vk->instance, + "vkGetPhysicalDeviceExternalSemaphorePropertiesKHR"); + } + if (!get_properties) + return false; + + VkPhysicalDeviceExternalSemaphoreInfo info = { + .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_SEMAPHORE_INFO, + .handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT, + }; + VkExternalSemaphoreProperties properties = { + .sType = VK_STRUCTURE_TYPE_EXTERNAL_SEMAPHORE_PROPERTIES, + }; + get_properties(vk->phys_device, &info, &properties); + return properties.externalSemaphoreFeatures & + VK_EXTERNAL_SEMAPHORE_FEATURE_IMPORTABLE_BIT; +} + +static bool has_ycbcr_conversion(pl_vulkan vk) +{ + const VkStructureType ycbcr_type = + VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SAMPLER_YCBCR_CONVERSION_FEATURES; + const VkBaseOutStructure *feature = vk->features->pNext; + while (feature) { + if (feature->sType == + VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_1_FEATURES) { + const VkPhysicalDeviceVulkan11Features *vk11 = + (const void *)feature; + if (vk11->samplerYcbcrConversion) + return true; + } + if (feature->sType == ycbcr_type) { + const VkPhysicalDeviceSamplerYcbcrConversionFeatures *ycbcr = + (const void *)feature; + if (ycbcr->samplerYcbcrConversion) + return true; + } + feature = feature->pNext; + } + return false; +} + +static PFN_vkGetPhysicalDeviceImageFormatProperties2 +get_image_format_properties2(pl_vulkan vk) +{ + PFN_vkGetPhysicalDeviceImageFormatProperties2 get_properties = + (PFN_vkGetPhysicalDeviceImageFormatProperties2) + vk->get_proc_addr( + vk->instance, "vkGetPhysicalDeviceImageFormatProperties2"); + if (!get_properties) { + get_properties = + (PFN_vkGetPhysicalDeviceImageFormatProperties2) + vk->get_proc_addr( + vk->instance, + "vkGetPhysicalDeviceImageFormatProperties2KHR"); + } + return get_properties; +} + +bool aimagereader_vk_available(struct ra_ctx *ra_ctx, struct mp_log *log) +{ + struct mpvk_ctx *ctx = ra_vk_ctx_get(ra_ctx); + pl_gpu gpu = ra_pl_get(ra_ctx->ra); + pl_vulkan vk = gpu ? pl_vulkan_get(gpu) : NULL; + if (!ctx || !vk || !vk->get_proc_addr || !vk->features || + !vk->queue_compute.count || + !vk->features->features.shaderStorageImageWriteWithoutFormat || + !has_ycbcr_conversion(vk)) + return false; + + const char *ahb_extension = + VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME; + const char *foreign_extension = + VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME; + if (!has_extension(vk, ahb_extension) || + !has_extension(vk, foreign_extension)) { + mp_verbose(log, "Vulkan device lacks Android hardware-buffer interop\n"); + return false; + } + + PFN_vkGetDeviceProcAddr get_device_proc = + (PFN_vkGetDeviceProcAddr)vk->get_proc_addr(vk->instance, + "vkGetDeviceProcAddr"); + return get_device_proc && + get_device_proc(vk->device, + "vkGetAndroidHardwareBufferPropertiesANDROID") && + get_image_format_properties2(vk); +} + +static uint32_t find_memory_type(struct aimagereader_vk *p, + uint32_t type_bits, + VkMemoryPropertyFlags preferred) +{ + VkPhysicalDeviceMemoryProperties props; + vkGetPhysicalDeviceMemoryProperties(p->vk->phys_device, &props); + + for (uint32_t n = 0; n < props.memoryTypeCount; n++) { + if ((type_bits & (1u << n)) && + (props.memoryTypes[n].propertyFlags & preferred) == preferred) + return n; + } + for (uint32_t n = 0; n < props.memoryTypeCount; n++) { + if (type_bits & (1u << n)) + return n; + } + return UINT32_MAX; +} + +static void destroy_input(struct aimagereader_vk *p, struct vk_input *input) +{ + if (input->view) + vkDestroyImageView(p->device, input->view, NULL); + if (input->image) + vkDestroyImage(p->device, input->image, NULL); + if (input->memory) + vkFreeMemory(p->device, input->memory, NULL); + *input = (struct vk_input){0}; +} + +static bool finish_output(struct aimagereader_vk *p, struct vk_output *output, + bool wait) +{ + if (!output->pending) + return true; + + VkResult result = wait + ? vkWaitForFences(p->device, 1, &output->fence, VK_TRUE, UINT64_MAX) + : vkGetFenceStatus(p->device, output->fence); + if (result == VK_NOT_READY) + return false; + if (!vk_success(p, result, "waiting for AHardwareBuffer conversion")) + return false; + + if (output->source_image) { + p->api.AImage_delete(output->source_image); + output->source_image = NULL; + } + if (output->timing_pending) { + uint64_t timestamps[2] = {0}; + VkResult timing = vkGetQueryPoolResults( + p->device, output->query_pool, 0, 2, sizeof(timestamps), timestamps, + sizeof(timestamps[0]), VK_QUERY_RESULT_64_BIT); + if (timing == VK_SUCCESS && timestamps[1] >= timestamps[0]) { + double elapsed = (timestamps[1] - timestamps[0]) + * (double)p->timestamp_period; + p->mapper->external_gpu_time_ns = elapsed >= (double)INT64_MAX + ? INT64_MAX : (uint64_t)(elapsed + 0.5); + p->mapper->external_gpu_time_serial++; + } + output->timing_pending = false; + } + if (output->input) { + if (output->input->users > 0) + output->input->users--; + output->input = NULL; + } + output->pending = false; + return vk_success(p, vkResetFences(p->device, 1, &output->fence), + "resetting conversion fence"); +} + +static void destroy_output(struct aimagereader_vk *p, + struct vk_output *output) +{ + finish_output(p, output, true); + mp_image_unrefp(&output->source_frame); + + if (output->ratex) + ra_tex_free(p->mapper->ra, &output->ratex); + if (output->view) + vkDestroyImageView(p->device, output->view, NULL); + if (output->image) + vkDestroyImage(p->device, output->image, NULL); + if (output->memory) + vkFreeMemory(p->device, output->memory, NULL); + if (output->available) + vkDestroySemaphore(p->device, output->available, NULL); + if (output->acquire) + vkDestroySemaphore(p->device, output->acquire, NULL); + if (output->ready) + vkDestroySemaphore(p->device, output->ready, NULL); + if (output->fence) + vkDestroyFence(p->device, output->fence, NULL); + if (output->query_pool) + vkDestroyQueryPool(p->device, output->query_pool, NULL); + *output = (struct vk_output){0}; +} + +static void destroy_conversion_resources(struct aimagereader_vk *p) +{ + for (int n = 0; n < OUTPUT_COUNT; n++) + finish_output(p, &p->outputs[n], true); + + if (p->descriptor_pool) + vkDestroyDescriptorPool(p->device, p->descriptor_pool, NULL); + p->descriptor_pool = VK_NULL_HANDLE; + + VkCommandBuffer commands[OUTPUT_COUNT]; + uint32_t num_commands = 0; + for (int n = 0; n < OUTPUT_COUNT; n++) { + struct vk_output *output = &p->outputs[n]; + output->descriptor = VK_NULL_HANDLE; + if (output->command) + commands[num_commands++] = output->command; + output->command = VK_NULL_HANDLE; + } + if (num_commands) { + vkFreeCommandBuffers(p->device, p->command_pool, num_commands, + commands); + } + + p->mapper->tex[0] = NULL; + for (int n = 0; n < OUTPUT_COUNT; n++) + destroy_output(p, &p->outputs[n]); + + for (int n = 0; n < p->num_inputs; n++) + destroy_input(p, &p->inputs[n]); + p->num_inputs = 0; + + if (p->pipeline) + vkDestroyPipeline(p->device, p->pipeline, NULL); + if (p->pipeline_layout) + vkDestroyPipelineLayout(p->device, p->pipeline_layout, NULL); + if (p->descriptor_layout) + vkDestroyDescriptorSetLayout(p->device, p->descriptor_layout, NULL); + if (p->sampler) + vkDestroySampler(p->device, p->sampler, NULL); + if (p->conversion) + vkDestroySamplerYcbcrConversion(p->device, p->conversion, NULL); + + p->pipeline = VK_NULL_HANDLE; + p->pipeline_layout = VK_NULL_HANDLE; + p->descriptor_layout = VK_NULL_HANDLE; + p->sampler = VK_NULL_HANDLE; + p->conversion = VK_NULL_HANDLE; + p->output_format = VK_FORMAT_UNDEFINED; + p->width = 0; + p->height = 0; + p->output_index = -1; + p->source_ahb_format = 0; + p->source_format = VK_FORMAT_UNDEFINED; + p->external_format = 0; + p->source_props = + (VkAndroidHardwareBufferFormatPropertiesANDROID){0}; + p->resources_ready = false; +} + +static int ycbcr_format_depth(VkFormat format) +{ + switch (format) { + case VK_FORMAT_G8B8G8R8_422_UNORM: + case VK_FORMAT_B8G8R8G8_422_UNORM: + case VK_FORMAT_G8_B8_R8_3PLANE_420_UNORM: + case VK_FORMAT_G8_B8R8_2PLANE_420_UNORM: + case VK_FORMAT_G8_B8_R8_3PLANE_422_UNORM: + case VK_FORMAT_G8_B8R8_2PLANE_422_UNORM: + case VK_FORMAT_G8_B8_R8_3PLANE_444_UNORM: + return 8; + case VK_FORMAT_G10X6B10X6G10X6R10X6_422_UNORM_4PACK16: + case VK_FORMAT_B10X6G10X6R10X6G10X6_422_UNORM_4PACK16: + case VK_FORMAT_G10X6_B10X6_R10X6_3PLANE_420_UNORM_3PACK16: + case VK_FORMAT_G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16: + case VK_FORMAT_G10X6_B10X6_R10X6_3PLANE_422_UNORM_3PACK16: + case VK_FORMAT_G10X6_B10X6R10X6_2PLANE_422_UNORM_3PACK16: + case VK_FORMAT_G10X6_B10X6_R10X6_3PLANE_444_UNORM_3PACK16: + return 10; + case VK_FORMAT_G12X4B12X4G12X4R12X4_422_UNORM_4PACK16: + case VK_FORMAT_B12X4G12X4R12X4G12X4_422_UNORM_4PACK16: + case VK_FORMAT_G12X4_B12X4_R12X4_3PLANE_420_UNORM_3PACK16: + case VK_FORMAT_G12X4_B12X4R12X4_2PLANE_420_UNORM_3PACK16: + case VK_FORMAT_G12X4_B12X4_R12X4_3PLANE_422_UNORM_3PACK16: + case VK_FORMAT_G12X4_B12X4R12X4_2PLANE_422_UNORM_3PACK16: + case VK_FORMAT_G12X4_B12X4_R12X4_3PLANE_444_UNORM_3PACK16: + return 12; + case VK_FORMAT_G16B16G16R16_422_UNORM: + case VK_FORMAT_B16G16R16G16_422_UNORM: + case VK_FORMAT_G16_B16_R16_3PLANE_420_UNORM: + case VK_FORMAT_G16_B16R16_2PLANE_420_UNORM: + case VK_FORMAT_G16_B16_R16_3PLANE_422_UNORM: + case VK_FORMAT_G16_B16R16_2PLANE_422_UNORM: + case VK_FORMAT_G16_B16_R16_3PLANE_444_UNORM: + return 16; + default: + return 0; + } +} + +static bool source_is_high_depth( + struct aimagereader_vk *p, const AHardwareBuffer_Desc *desc, + const VkAndroidHardwareBufferFormatPropertiesANDROID *props) +{ + const struct pl_bit_encoding *bits = + &p->mapper->src_params.repr.bits; + if (bits->color_depth > 8 || bits->sample_depth > 8 || + ycbcr_format_depth(props->format) > 8) { + return true; + } + + switch (desc->format) { + case AHARDWAREBUFFER_FORMAT_R16G16B16A16_FLOAT: + case AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM: + case AHARDWAREBUFFER_FORMAT_YCbCr_P010: + return true; + default: + return false; + } +} + +static VkFormat choose_output_format( + struct aimagereader_vk *p, const AHardwareBuffer_Desc *desc, + const VkAndroidHardwareBufferFormatPropertiesANDROID *props) +{ + bool high_depth = source_is_high_depth(p, desc, props); + const VkFormat high_depth_candidates[] = { + VK_FORMAT_A2B10G10R10_UNORM_PACK32, + VK_FORMAT_R16G16B16A16_SFLOAT, + VK_FORMAT_R8G8B8A8_UNORM, + }; + const VkFormat low_depth_candidates[] = { + VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_A2B10G10R10_UNORM_PACK32, + VK_FORMAT_R16G16B16A16_SFLOAT, + }; + const VkFormat *candidates = + high_depth ? high_depth_candidates : low_depth_candidates; + int num_candidates = MP_ARRAY_SIZE(high_depth_candidates); + const VkFormatFeatureFlags needed = + VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT | + VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT; + + for (int n = 0; n < num_candidates; n++) { + VkFormatProperties format_props; + vkGetPhysicalDeviceFormatProperties(p->vk->phys_device, + candidates[n], &format_props); + if ((format_props.optimalTilingFeatures & needed) == needed) + return candidates[n]; + } + return VK_FORMAT_UNDEFINED; +} + +static int collect_queue_families(struct aimagereader_vk *p, + uint32_t families[3]) +{ + const struct pl_vulkan_queue queues[] = { + p->vk->queue_graphics, + p->vk->queue_compute, + p->vk->queue_transfer, + }; + int count = 0; + for (int n = 0; n < (int)MP_ARRAY_SIZE(queues); n++) { + if (!queues[n].count) + continue; + bool duplicate = false; + for (int i = 0; i < count; i++) + duplicate |= families[i] == queues[n].index; + if (!duplicate) + families[count++] = queues[n].index; + } + return count; +} + +static bool create_output_image(struct aimagereader_vk *p, + struct vk_output *output, + const uint32_t *queue_families, + int num_queue_families) +{ + const VkImageUsageFlags usage = + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT; + VkImageCreateInfo image_info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .imageType = VK_IMAGE_TYPE_2D, + .format = p->output_format, + .extent = {p->width, p->height, 1}, + .mipLevels = 1, + .arrayLayers = 1, + .samples = VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_OPTIMAL, + .usage = usage, + .sharingMode = num_queue_families > 1 + ? VK_SHARING_MODE_CONCURRENT + : VK_SHARING_MODE_EXCLUSIVE, + .queueFamilyIndexCount = + num_queue_families > 1 ? num_queue_families : 0, + .pQueueFamilyIndices = + num_queue_families > 1 ? queue_families : NULL, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, + }; + if (!vk_success(p, vkCreateImage(p->device, &image_info, NULL, + &output->image), + "creating conversion output image")) + return false; + + VkMemoryRequirements requirements; + vkGetImageMemoryRequirements(p->device, output->image, &requirements); + uint32_t memory_type = find_memory_type( + p, requirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + if (memory_type == UINT32_MAX) { + mp_err(p->log, "No memory type for conversion output image\n"); + return false; + } + + VkMemoryAllocateInfo alloc_info = { + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = requirements.size, + .memoryTypeIndex = memory_type, + }; + if (!vk_success(p, vkAllocateMemory(p->device, &alloc_info, NULL, + &output->memory), + "allocating conversion output memory") || + !vk_success(p, vkBindImageMemory(p->device, output->image, + output->memory, 0), + "binding conversion output memory")) + return false; + + VkImageViewCreateInfo view_info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, + .image = output->image, + .viewType = VK_IMAGE_VIEW_TYPE_2D, + .format = p->output_format, + .subresourceRange = { + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .levelCount = 1, + .layerCount = 1, + }, + }; + if (!vk_success(p, vkCreateImageView(p->device, &view_info, NULL, + &output->view), + "creating conversion output view")) + return false; + + output->pltex = pl_vulkan_wrap(p->gpu, pl_vulkan_wrap_params( + .image = output->image, + .width = p->width, + .height = p->height, + .format = p->output_format, + .usage = usage, + )); + if (!output->pltex) { + mp_err(p->log, "libplacebo cannot wrap conversion output format %d\n", + p->output_format); + return false; + } + + output->ratex = talloc_ptrtype(NULL, output->ratex); + if (!mppl_wrap_tex(p->mapper->ra, output->pltex, output->ratex)) { + pl_tex_destroy(p->gpu, &output->pltex); + TA_FREEP(&output->ratex); + return false; + } + + VkSemaphoreCreateInfo semaphore_info = { + .sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, + }; + VkFenceCreateInfo fence_info = { + .sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO, + }; + if (!vk_success(p, vkCreateSemaphore(p->device, &semaphore_info, NULL, + &output->available), + "creating conversion availability semaphore") || + !vk_success(p, vkCreateSemaphore(p->device, &semaphore_info, NULL, + &output->acquire), + "creating AImage acquire semaphore") || + !vk_success(p, vkCreateSemaphore(p->device, &semaphore_info, NULL, + &output->ready), + "creating conversion completion semaphore") || + !vk_success(p, vkCreateFence(p->device, &fence_info, NULL, + &output->fence), + "creating conversion fence")) + return false; + + VkQueryPoolCreateInfo query_info = { + .sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO, + .queryType = VK_QUERY_TYPE_TIMESTAMP, + .queryCount = 2, + }; + if (!vk_success(p, vkCreateQueryPool(p->device, &query_info, NULL, + &output->query_pool), + "creating conversion timestamp pool")) + return false; + + return true; +} + +static bool create_outputs( + struct aimagereader_vk *p, const AHardwareBuffer_Desc *desc, + const VkAndroidHardwareBufferFormatPropertiesANDROID *props) +{ + p->width = desc->width; + p->height = desc->height; + p->source_ahb_format = desc->format; + p->output_format = choose_output_format(p, desc, props); + if (p->output_format == VK_FORMAT_UNDEFINED) { + mp_err(p->log, "No sampleable storage image format for AHardwareBuffer " + "conversion\n"); + return false; + } + + uint32_t queue_families[3]; + int num_queue_families = collect_queue_families(p, queue_families); + for (int n = 0; n < OUTPUT_COUNT; n++) { + if (!create_output_image(p, &p->outputs[n], queue_families, + num_queue_families)) + return false; + } + + VkCommandBufferAllocateInfo alloc_info = { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = p->command_pool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = OUTPUT_COUNT, + }; + VkCommandBuffer commands[OUTPUT_COUNT]; + if (!vk_success(p, vkAllocateCommandBuffers(p->device, &alloc_info, + commands), + "allocating conversion command buffers")) + return false; + for (int n = 0; n < OUTPUT_COUNT; n++) + p->outputs[n].command = commands[n]; + + return true; +} + +static bool same_source_format( + struct aimagereader_vk *p, const AHardwareBuffer_Desc *desc, + const VkAndroidHardwareBufferFormatPropertiesANDROID *props) +{ + return p->source_ahb_format == desc->format && + p->source_format == props->format && + p->external_format == props->externalFormat && + p->source_props.formatFeatures == props->formatFeatures && + p->source_props.suggestedYcbcrModel == + props->suggestedYcbcrModel && + p->source_props.suggestedYcbcrRange == + props->suggestedYcbcrRange && + p->source_props.suggestedXChromaOffset == + props->suggestedXChromaOffset && + p->source_props.suggestedYChromaOffset == + props->suggestedYChromaOffset && + memcmp(&p->source_props.samplerYcbcrConversionComponents, + &props->samplerYcbcrConversionComponents, + sizeof(props->samplerYcbcrConversionComponents)) == 0; +} + +static uint32_t sampler_descriptor_count( + struct aimagereader_vk *p, + const VkAndroidHardwareBufferFormatPropertiesANDROID *props, + bool needs_conversion) +{ + if (!needs_conversion) + return 1; + + // Vulkan has no query for opaque Android external formats. Four is the + // conservative fallback also used by Android's ANGLE Vulkan backend. + if (props->externalFormat) + return EXTERNAL_FORMAT_DESCRIPTOR_COUNT; + + VkPhysicalDeviceImageFormatInfo2 format_info = { + .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_FORMAT_INFO_2, + .format = props->format, + .type = VK_IMAGE_TYPE_2D, + .tiling = VK_IMAGE_TILING_OPTIMAL, + .usage = VK_IMAGE_USAGE_SAMPLED_BIT, + }; + VkSamplerYcbcrConversionImageFormatProperties ycbcr_props = { + .sType = + VK_STRUCTURE_TYPE_SAMPLER_YCBCR_CONVERSION_IMAGE_FORMAT_PROPERTIES, + }; + VkImageFormatProperties2 image_props = { + .sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2, + .pNext = &ycbcr_props, + }; + if (!vk_success(p, p->GetImageFormatProperties2( + p->vk->phys_device, &format_info, &image_props), + "querying YCbCr sampler descriptor count")) { + return 0; + } + + return ycbcr_props.combinedImageSamplerDescriptorCount; +} + +static bool create_pipeline( + struct aimagereader_vk *p, + const VkAndroidHardwareBufferFormatPropertiesANDROID *props) +{ + p->source_format = props->format; + p->external_format = props->externalFormat; + p->source_props = *props; + p->source_props.pNext = NULL; + + bool needs_conversion = props->externalFormat || + ycbcr_format_depth(props->format); + uint32_t sampler_descriptors = + sampler_descriptor_count(p, props, needs_conversion); + if (!sampler_descriptors) + return false; + + VkFilter chroma_filter = VK_FILTER_NEAREST; + VkFilter sample_filter = VK_FILTER_NEAREST; + bool linear_chroma = + props->formatFeatures & + VK_FORMAT_FEATURE_SAMPLED_IMAGE_YCBCR_CONVERSION_LINEAR_FILTER_BIT; + bool linear_sample = + props->formatFeatures & + VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT; + bool separate_filter = + props->formatFeatures & + VK_FORMAT_FEATURE_SAMPLED_IMAGE_YCBCR_CONVERSION_SEPARATE_RECONSTRUCTION_FILTER_BIT; + if (needs_conversion && linear_chroma) { + chroma_filter = VK_FILTER_LINEAR; + if (linear_sample) { + sample_filter = VK_FILTER_LINEAR; + } else if (!separate_filter) { + chroma_filter = VK_FILTER_NEAREST; + } + } + VkExternalFormatANDROID external_format = { + .sType = VK_STRUCTURE_TYPE_EXTERNAL_FORMAT_ANDROID, + .externalFormat = props->externalFormat, + }; + if (needs_conversion) { + VkSamplerYcbcrConversionCreateInfo conversion_info = { + .sType = + VK_STRUCTURE_TYPE_SAMPLER_YCBCR_CONVERSION_CREATE_INFO, + .pNext = props->externalFormat ? &external_format : NULL, + .format = props->externalFormat + ? VK_FORMAT_UNDEFINED + : props->format, + .ycbcrModel = props->suggestedYcbcrModel, + .ycbcrRange = props->suggestedYcbcrRange, + .components = props->samplerYcbcrConversionComponents, + .xChromaOffset = props->suggestedXChromaOffset, + .yChromaOffset = props->suggestedYChromaOffset, + .chromaFilter = chroma_filter, + }; + if (!vk_success(p, vkCreateSamplerYcbcrConversion( + p->device, &conversion_info, NULL, &p->conversion), + "creating Android YCbCr conversion")) + return false; + } + + VkSamplerYcbcrConversionInfo conversion_info = { + .sType = VK_STRUCTURE_TYPE_SAMPLER_YCBCR_CONVERSION_INFO, + .conversion = p->conversion, + }; + VkSamplerCreateInfo sampler_info = { + .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, + .pNext = p->conversion ? &conversion_info : NULL, + .magFilter = sample_filter, + .minFilter = sample_filter, + .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST, + .addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + .addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + .addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + .maxLod = 0.0f, + }; + if (!vk_success(p, vkCreateSampler(p->device, &sampler_info, NULL, + &p->sampler), + "creating Android hardware-buffer sampler")) + return false; + + VkDescriptorSetLayoutBinding bindings[] = { + { + .binding = 0, + .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + .descriptorCount = 1, + .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT, + .pImmutableSamplers = &p->sampler, + }, { + .binding = 1, + .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, + .descriptorCount = 1, + .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT, + }, + }; + VkDescriptorSetLayoutCreateInfo descriptor_layout_info = { + .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO, + .bindingCount = MP_ARRAY_SIZE(bindings), + .pBindings = bindings, + }; + if (!vk_success(p, vkCreateDescriptorSetLayout( + p->device, &descriptor_layout_info, NULL, + &p->descriptor_layout), + "creating conversion descriptor layout")) + return false; + + VkPipelineLayoutCreateInfo pipeline_layout_info = { + .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, + .setLayoutCount = 1, + .pSetLayouts = &p->descriptor_layout, + }; + if (!vk_success(p, vkCreatePipelineLayout( + p->device, &pipeline_layout_info, NULL, + &p->pipeline_layout), + "creating conversion pipeline layout")) + return false; + + VkShaderModuleCreateInfo shader_info = { + .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, + .codeSize = sizeof(aimagereader_comp_spv), + .pCode = aimagereader_comp_spv, + }; + VkShaderModule shader = VK_NULL_HANDLE; + if (!vk_success(p, vkCreateShaderModule(p->device, &shader_info, NULL, + &shader), + "creating conversion shader")) + return false; + + VkComputePipelineCreateInfo pipeline_info = { + .sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO, + .stage = { + .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, + .stage = VK_SHADER_STAGE_COMPUTE_BIT, + .module = shader, + .pName = "main", + }, + .layout = p->pipeline_layout, + }; + VkResult result = vkCreateComputePipelines( + p->device, VK_NULL_HANDLE, 1, &pipeline_info, NULL, &p->pipeline); + vkDestroyShaderModule(p->device, shader, NULL); + if (!vk_success(p, result, "creating AHardwareBuffer conversion pipeline")) + return false; + + VkDescriptorPoolSize pool_sizes[] = { + { + .type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + .descriptorCount = OUTPUT_COUNT * sampler_descriptors, + }, { + .type = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, + .descriptorCount = OUTPUT_COUNT, + }, + }; + VkDescriptorPoolCreateInfo pool_info = { + .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO, + .maxSets = OUTPUT_COUNT, + .poolSizeCount = MP_ARRAY_SIZE(pool_sizes), + .pPoolSizes = pool_sizes, + }; + if (!vk_success(p, vkCreateDescriptorPool(p->device, &pool_info, NULL, + &p->descriptor_pool), + "creating conversion descriptor pool")) + return false; + + VkDescriptorSetLayout layouts[OUTPUT_COUNT]; + for (int n = 0; n < OUTPUT_COUNT; n++) + layouts[n] = p->descriptor_layout; + VkDescriptorSet descriptors[OUTPUT_COUNT]; + VkDescriptorSetAllocateInfo descriptor_info = { + .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO, + .descriptorPool = p->descriptor_pool, + .descriptorSetCount = OUTPUT_COUNT, + .pSetLayouts = layouts, + }; + if (!vk_success(p, vkAllocateDescriptorSets( + p->device, &descriptor_info, descriptors), + "allocating conversion descriptors")) + return false; + + for (int n = 0; n < OUTPUT_COUNT; n++) { + struct vk_output *output = &p->outputs[n]; + output->descriptor = descriptors[n]; + VkDescriptorImageInfo target = { + .imageView = output->view, + .imageLayout = VK_IMAGE_LAYOUT_GENERAL, + }; + VkWriteDescriptorSet write = { + .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, + .dstSet = output->descriptor, + .dstBinding = 1, + .descriptorCount = 1, + .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, + .pImageInfo = &target, + }; + vkUpdateDescriptorSets(p->device, 1, &write, 0, NULL); + } + + mp_info(p->log, "Using Vulkan AHardwareBuffer GPU conversion " + "(source format %d, external format 0x%" PRIx64 + ", output format %d, sampler descriptors %u, " + "chroma filter %s)\n", + p->source_format, p->external_format, p->output_format, + sampler_descriptors, + chroma_filter == VK_FILTER_LINEAR ? "linear" : "nearest"); + return true; +} + +static struct vk_input *find_input(struct aimagereader_vk *p, + AHardwareBuffer *buffer) +{ + for (int n = 0; n < p->num_inputs; n++) { + if (p->inputs[n].buffer == buffer) + return &p->inputs[n]; + } + return NULL; +} + +static struct vk_input *select_input_slot(struct aimagereader_vk *p) +{ + for (int n = 0; n < p->num_inputs; n++) { + if (!p->inputs[n].buffer) + return &p->inputs[n]; + } + if (p->num_inputs < INPUT_CACHE_SIZE) + return &p->inputs[p->num_inputs]; + + struct vk_input *oldest = NULL; + for (int n = 0; n < p->num_inputs; n++) { + struct vk_input *input = &p->inputs[n]; + if (!input->users && + (!oldest || input->last_used < oldest->last_used)) + oldest = input; + } + if (!oldest) { + mp_err(p->log, "All AHardwareBuffer import slots are in use\n"); + return NULL; + } + + destroy_input(p, oldest); + return oldest; +} + +static struct vk_input *create_input(struct aimagereader_vk *p, + AHardwareBuffer *buffer, + const AHardwareBuffer_Desc *desc) +{ + if (!desc->width || !desc->height || desc->layers != 1 || + !(desc->usage & AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE)) { + mp_err(p->log, "Unsupported Android hardware-buffer description " + "(%ux%u, layers %u, usage 0x%" PRIx64 ")\n", + desc->width, desc->height, desc->layers, desc->usage); + return NULL; + } + + VkAndroidHardwareBufferFormatPropertiesANDROID format_props = { + .sType = + VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_FORMAT_PROPERTIES_ANDROID, + }; + VkAndroidHardwareBufferPropertiesANDROID buffer_props = { + .sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_PROPERTIES_ANDROID, + .pNext = &format_props, + }; + if (!vk_success(p, p->GetAHBProperties(p->device, buffer, &buffer_props), + "querying Android hardware-buffer properties")) + return NULL; + if (format_props.format == VK_FORMAT_UNDEFINED && + !format_props.externalFormat) { + mp_err(p->log, "Android hardware buffer has no Vulkan format\n"); + return NULL; + } + + if (!p->resources_ready) { + if (!create_outputs(p, desc, &format_props) || + !create_pipeline(p, &format_props)) { + destroy_conversion_resources(p); + return NULL; + } + p->resources_ready = true; + } else if (p->width != (int)desc->width || + p->height != (int)desc->height || + !same_source_format(p, desc, &format_props)) { + mp_err(p->log, "Android hardware-buffer format changed without " + "reconfiguring the mapper\n"); + return NULL; + } + + struct vk_input *input = select_input_slot(p); + if (!input) + return NULL; + + VkExternalFormatANDROID external_format = { + .sType = VK_STRUCTURE_TYPE_EXTERNAL_FORMAT_ANDROID, + .externalFormat = format_props.externalFormat, + }; + VkExternalMemoryImageCreateInfo external_memory = { + .sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO, + .pNext = format_props.externalFormat ? &external_format : NULL, + .handleTypes = + VK_EXTERNAL_MEMORY_HANDLE_TYPE_ANDROID_HARDWARE_BUFFER_BIT_ANDROID, + }; + VkImageCreateInfo image_info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .pNext = &external_memory, + .imageType = VK_IMAGE_TYPE_2D, + .format = format_props.externalFormat + ? VK_FORMAT_UNDEFINED + : format_props.format, + .extent = {desc->width, desc->height, 1}, + .mipLevels = 1, + .arrayLayers = 1, + .samples = VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_OPTIMAL, + .usage = VK_IMAGE_USAGE_SAMPLED_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, + }; + if (!vk_success(p, vkCreateImage(p->device, &image_info, NULL, + &input->image), + "creating imported Android hardware-buffer image")) + goto error; + + uint32_t memory_type = + find_memory_type(p, buffer_props.memoryTypeBits, 0); + if (memory_type == UINT32_MAX) { + mp_err(p->log, "No memory type for Android hardware buffer\n"); + goto error; + } + + VkImportAndroidHardwareBufferInfoANDROID import_info = { + .sType = + VK_STRUCTURE_TYPE_IMPORT_ANDROID_HARDWARE_BUFFER_INFO_ANDROID, + .buffer = buffer, + }; + VkMemoryDedicatedAllocateInfo dedicated_info = { + .sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO, + .pNext = &import_info, + .image = input->image, + }; + VkMemoryAllocateInfo alloc_info = { + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .pNext = &dedicated_info, + .allocationSize = buffer_props.allocationSize, + .memoryTypeIndex = memory_type, + }; + if (!vk_success(p, vkAllocateMemory(p->device, &alloc_info, NULL, + &input->memory), + "importing Android hardware-buffer memory") || + !vk_success(p, vkBindImageMemory(p->device, input->image, + input->memory, 0), + "binding Android hardware-buffer memory")) + goto error; + + VkSamplerYcbcrConversionInfo conversion_info = { + .sType = VK_STRUCTURE_TYPE_SAMPLER_YCBCR_CONVERSION_INFO, + .conversion = p->conversion, + }; + VkImageViewCreateInfo view_info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, + .pNext = p->conversion ? &conversion_info : NULL, + .image = input->image, + .viewType = VK_IMAGE_VIEW_TYPE_2D, + .format = format_props.externalFormat + ? VK_FORMAT_UNDEFINED + : format_props.format, + .components = { + VK_COMPONENT_SWIZZLE_IDENTITY, + VK_COMPONENT_SWIZZLE_IDENTITY, + VK_COMPONENT_SWIZZLE_IDENTITY, + VK_COMPONENT_SWIZZLE_IDENTITY, + }, + .subresourceRange = { + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .levelCount = 1, + .layerCount = 1, + }, + }; + if (!vk_success(p, vkCreateImageView(p->device, &view_info, NULL, + &input->view), + "creating Android hardware-buffer image view")) + goto error; + + input->buffer = buffer; + if (input == &p->inputs[p->num_inputs]) + p->num_inputs++; + return input; + +error: + destroy_input(p, input); + return NULL; +} + +static void release_output_after_error(struct aimagereader_vk *p, + struct vk_output *output, + bool waited) +{ + if (!output->released) + return; + + pl_vulkan_release_ex(p->gpu, pl_vulkan_release_params( + .tex = output->pltex, + .layout = VK_IMAGE_LAYOUT_GENERAL, + .qf = VK_QUEUE_FAMILY_IGNORED, + .semaphore = waited ? (pl_vulkan_sem){.sem = output->available} + : (pl_vulkan_sem){0}, + )); +} + +static bool reset_acquire_semaphore(struct aimagereader_vk *p, + struct vk_output *output) +{ + if (output->acquire) + vkDestroySemaphore(p->device, output->acquire, NULL); + output->acquire = VK_NULL_HANDLE; + + VkSemaphoreCreateInfo semaphore_info = { + .sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, + }; + return vk_success(p, vkCreateSemaphore(p->device, &semaphore_info, NULL, + &output->acquire), + "resetting AImage acquire semaphore"); +} + +static bool import_acquire_fence(struct aimagereader_vk *p, + struct vk_output *output, int fd) +{ + if (fd < 0) + return true; + if (!output->acquire && !reset_acquire_semaphore(p, output)) + return false; + + VkImportSemaphoreFdInfoKHR import_info = { + .sType = VK_STRUCTURE_TYPE_IMPORT_SEMAPHORE_FD_INFO_KHR, + .semaphore = output->acquire, + .flags = VK_SEMAPHORE_IMPORT_TEMPORARY_BIT, + .handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT, + .fd = fd, + }; + VkResult result = p->ImportSemaphoreFdKHR + ? p->ImportSemaphoreFdKHR(p->device, &import_info) + : VK_ERROR_EXTENSION_NOT_PRESENT; + return vk_success(p, result, "importing AImage acquire fence"); +} + +static bool record_conversion(struct aimagereader_vk *p, + struct vk_output *output, + struct vk_input *input) +{ + VkCommandBufferBeginInfo begin_info = { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, + }; + if (!vk_success(p, vkResetCommandBuffer(output->command, 0), + "resetting conversion command buffer") || + !vk_success(p, vkBeginCommandBuffer(output->command, &begin_info), + "beginning conversion command buffer")) + return false; + + vkCmdResetQueryPool(output->command, output->query_pool, 0, 2); + vkCmdWriteTimestamp(output->command, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + output->query_pool, 0); + + VkImageMemoryBarrier acquire[] = { + { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = 0, + .dstAccessMask = VK_ACCESS_SHADER_READ_BIT, + .oldLayout = input->initialized + ? VK_IMAGE_LAYOUT_GENERAL + : VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_FOREIGN_EXT, + .dstQueueFamilyIndex = p->queue_family, + .image = input->image, + .subresourceRange = { + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .levelCount = 1, + .layerCount = 1, + }, + }, { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = 0, + .dstAccessMask = VK_ACCESS_SHADER_WRITE_BIT, + .oldLayout = output->written + ? VK_IMAGE_LAYOUT_GENERAL + : VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = output->image, + .subresourceRange = { + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .levelCount = 1, + .layerCount = 1, + }, + }, + }; + vkCmdPipelineBarrier(output->command, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, + 0, NULL, 0, NULL, MP_ARRAY_SIZE(acquire), acquire); + + vkCmdBindPipeline(output->command, VK_PIPELINE_BIND_POINT_COMPUTE, + p->pipeline); + vkCmdBindDescriptorSets(output->command, VK_PIPELINE_BIND_POINT_COMPUTE, + p->pipeline_layout, 0, 1, &output->descriptor, + 0, NULL); + vkCmdDispatch(output->command, MP_ALIGN_UP(p->width, 16) / 16, + MP_ALIGN_UP(p->height, 16) / 16, 1); + + VkImageMemoryBarrier release[] = { + { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_SHADER_READ_BIT, + .dstAccessMask = 0, + .oldLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = p->queue_family, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_FOREIGN_EXT, + .image = input->image, + .subresourceRange = { + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .levelCount = 1, + .layerCount = 1, + }, + }, { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT, + .dstAccessMask = 0, + .oldLayout = VK_IMAGE_LAYOUT_GENERAL, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = output->image, + .subresourceRange = { + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .levelCount = 1, + .layerCount = 1, + }, + }, + }; + vkCmdPipelineBarrier(output->command, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, + 0, NULL, 0, NULL, MP_ARRAY_SIZE(release), release); + vkCmdWriteTimestamp(output->command, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, + output->query_pool, 1); + + return vk_success(p, vkEndCommandBuffer(output->command), + "ending conversion command buffer"); +} + +static bool submit_conversion(struct aimagereader_vk *p, + struct vk_output *output, + bool wait_for_output, + bool wait_for_acquire) +{ + VkSemaphore wait_semaphores[2]; + VkPipelineStageFlags wait_stages[2]; + uint32_t wait_count = 0; + if (wait_for_output) { + wait_semaphores[wait_count] = output->available; + wait_stages[wait_count++] = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT; + } + if (wait_for_acquire) { + wait_semaphores[wait_count] = output->acquire; + wait_stages[wait_count++] = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT; + } + + VkSubmitInfo submit_info = { + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .waitSemaphoreCount = wait_count, + .pWaitSemaphores = wait_count ? wait_semaphores : NULL, + .pWaitDstStageMask = wait_count ? wait_stages : NULL, + .commandBufferCount = 1, + .pCommandBuffers = &output->command, + .signalSemaphoreCount = 1, + .pSignalSemaphores = &output->ready, + }; + + p->vk->lock_queue(p->vk, p->queue_family, 0); + VkResult result = vkQueueSubmit(p->queue, 1, &submit_info, output->fence); + p->vk->unlock_queue(p->vk, p->queue_family, 0); + return vk_success(p, result, "submitting AHardwareBuffer conversion"); +} + +struct aimagereader_vk *aimagereader_vk_create( + struct ra_hwdec_mapper *mapper, const struct aimagereader_vk_api *api) +{ + struct aimagereader_vk *p = talloc_zero(NULL, struct aimagereader_vk); + p->log = mapper->log; + p->mapper = mapper; + p->api = *api; + + struct mpvk_ctx *ctx = ra_vk_ctx_get(mapper->owner->ra_ctx); + p->gpu = ra_pl_get(mapper->ra); + p->vk = p->gpu ? pl_vulkan_get(p->gpu) : NULL; + if (!ctx || !p->vk || !p->vk->get_proc_addr || !p->vk->features || + !p->vk->queue_compute.count || + !p->vk->features->features.shaderStorageImageWriteWithoutFormat || + !has_ycbcr_conversion(p->vk) || + !has_extension( + p->vk, + VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME) || + !has_extension(p->vk, VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME)) + goto error; + + p->device = p->vk->device; + p->queue_family = p->vk->queue_compute.index; + p->output_index = -1; + vkGetDeviceQueue(p->device, p->queue_family, 0, &p->queue); + + p->GetImageFormatProperties2 = get_image_format_properties2(p->vk); + VkPhysicalDeviceProperties device_properties; + vkGetPhysicalDeviceProperties(p->vk->phys_device, &device_properties); + p->timestamp_period = device_properties.limits.timestampPeriod; + + PFN_vkGetDeviceProcAddr get_device_proc = + (PFN_vkGetDeviceProcAddr)p->vk->get_proc_addr( + p->vk->instance, "vkGetDeviceProcAddr"); + if (get_device_proc) { + p->GetAHBProperties = + (PFN_vkGetAndroidHardwareBufferPropertiesANDROID) + get_device_proc( + p->device, + "vkGetAndroidHardwareBufferPropertiesANDROID"); + if (has_extension(p->vk, "VK_KHR_external_semaphore_fd") && + can_import_sync_fd(p->vk)) { + p->ImportSemaphoreFdKHR = + (PFN_vkImportSemaphoreFdKHR)get_device_proc( + p->device, "vkImportSemaphoreFdKHR"); + } + } + if (!p->queue || !p->GetAHBProperties || !p->GetImageFormatProperties2) + goto error; + + VkCommandPoolCreateInfo pool_info = { + .sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, + .flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, + .queueFamilyIndex = p->queue_family, + }; + if (!vk_success(p, vkCreateCommandPool(p->device, &pool_info, NULL, + &p->command_pool), + "creating AHardwareBuffer conversion command pool")) + goto error; + + mapper->dst_params = mapper->src_params; + mapper->dst_params.imgfmt = IMGFMT_RGB0; + mapper->dst_params.hw_subfmt = 0; + mapper->dst_params_ready = false; + mapper->dst_params_preserve_repr = false; + mapper->dst_params_map_coordinates = false; + mapper->dst_num_components = 0; + memset(mapper->dst_component_mapping, 0, + sizeof(mapper->dst_component_mapping)); + MP_INFO(mapper, "Using WebHTV low-power Vulkan AHardwareBuffer GPU conversion\n"); + if (p->ImportSemaphoreFdKHR) { + MP_VERBOSE(mapper, "Using Vulkan sync_fd for AImage acquire fences\n"); + } else { + MP_VERBOSE(mapper, + "Vulkan sync_fd unavailable; using bounded CPU fence waits\n"); + } + return p; + +error: + aimagereader_vk_destroy(&p); + return NULL; +} + +void aimagereader_vk_destroy(struct aimagereader_vk **state) +{ + struct aimagereader_vk *p = *state; + if (!p) + return; + + if (p->gpu) + pl_gpu_finish(p->gpu); + destroy_conversion_resources(p); + if (p->command_pool) + vkDestroyCommandPool(p->device, p->command_pool, NULL); + + talloc_free(p); + *state = NULL; +} + +bool aimagereader_vk_can_import_acquire_fence(struct aimagereader_vk *p) +{ + return p && p->ImportSemaphoreFdKHR; +} + +void aimagereader_vk_buffer_removed( + struct aimagereader_vk *p, AHardwareBuffer *buffer) +{ + struct vk_input *input = find_input(p, buffer); + if (!input || input->users) + return; + destroy_input(p, input); +} + +bool aimagereader_vk_reuse(struct aimagereader_vk *p, + struct mp_image *frame) +{ + for (int n = 0; n < OUTPUT_COUNT; n++) { + struct vk_output *output = &p->outputs[n]; + if (output->source_frame && + output->source_frame->planes[3] == frame->planes[3]) { + p->mapper->tex[0] = output->ratex; + return true; + } + } + return false; +} + +bool aimagereader_vk_retain_last(struct aimagereader_vk *p, + struct mp_image *frame) +{ + return aimagereader_vk_reuse(p, frame); +} + +static int map_internal(struct aimagereader_vk *p, AImage *image, + AHardwareBuffer *buffer, int acquire_fence_fd, + struct mp_image *frame) +{ + struct vk_output *output = NULL; + struct mp_image *frame_ref = NULL; + bool wait_for_output = false; + bool acquire_imported = false; + + for (int n = 0; n < OUTPUT_COUNT; n++) + finish_output(p, &p->outputs[n], false); + + AHardwareBuffer_Desc desc; + p->api.AHardwareBuffer_describe(buffer, &desc); + struct vk_input *input = find_input(p, buffer); + if (!input) + input = create_input(p, buffer, &desc); + if (!input) + goto error; + + p->output_index = (p->output_index + 1) % OUTPUT_COUNT; + output = &p->outputs[p->output_index]; + if (!finish_output(p, output, true)) + goto error; + + wait_for_output = output->released; + if (wait_for_output) { + if (!pl_vulkan_hold_ex(p->gpu, pl_vulkan_hold_params( + .tex = output->pltex, + .layout = VK_IMAGE_LAYOUT_GENERAL, + .qf = VK_QUEUE_FAMILY_IGNORED, + .semaphore = (pl_vulkan_sem){.sem = output->available}, + ))) { + mp_err(p->log, "Failed reclaiming conversion output texture\n"); + goto error; + } + } + mp_image_unrefp(&output->source_frame); + + VkDescriptorImageInfo source = { + .imageView = input->view, + .imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + }; + VkWriteDescriptorSet write = { + .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, + .dstSet = output->descriptor, + .dstBinding = 0, + .descriptorCount = 1, + .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + .pImageInfo = &source, + }; + vkUpdateDescriptorSets(p->device, 1, &write, 0, NULL); + + frame_ref = mp_image_new_ref(frame); + if (!frame_ref || !record_conversion(p, output, input)) + goto error; + + acquire_imported = acquire_fence_fd >= 0; + if (acquire_imported) { + if (!import_acquire_fence(p, output, acquire_fence_fd)) + goto error; + acquire_fence_fd = -1; + } + if (!submit_conversion(p, output, wait_for_output, acquire_imported)) { + if (acquire_imported) + reset_acquire_semaphore(p, output); + goto error; + } + + output->source_image = image; + output->source_frame = frame_ref; + output->input = input; + output->pending = true; + output->timing_pending = true; + output->written = true; + output->released = true; + input->users++; + input->last_used = ++p->input_serial; + input->initialized = true; + + pl_vulkan_release_ex(p->gpu, pl_vulkan_release_params( + .tex = output->pltex, + .layout = VK_IMAGE_LAYOUT_GENERAL, + .qf = VK_QUEUE_FAMILY_IGNORED, + .semaphore = (pl_vulkan_sem){.sem = output->ready}, + )); + p->mapper->tex[0] = output->ratex; + return 0; + +error: + if (acquire_fence_fd >= 0) + close(acquire_fence_fd); + mp_image_unrefp(&frame_ref); + if (output) + release_output_after_error(p, output, wait_for_output); + return -1; +} + +int aimagereader_vk_map( + struct aimagereader_vk *p, AImage *image, + AHardwareBuffer *buffer, const AImageCropRect *crop, int32_t data_space, + struct mp_image *frame, int *acquire_fence) +{ + (void)crop; + (void)data_space; + int fence = *acquire_fence; + *acquire_fence = -1; + return map_internal(p, image, buffer, fence, frame); +} diff --git a/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_vk.h b/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_vk.h new file mode 100644 index 0000000000..ee1d684ec4 --- /dev/null +++ b/third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_vk.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +struct mp_log; +struct mp_image; +struct ra_ctx; +struct ra_hwdec_mapper; + +struct aimagereader_vk_api { + void (*AImage_delete)(AImage *image); + void (*AHardwareBuffer_describe)(const AHardwareBuffer *buffer, + AHardwareBuffer_Desc *desc); +}; + +struct aimagereader_vk; + +bool aimagereader_vk_available(struct ra_ctx *ra_ctx, struct mp_log *log); +struct aimagereader_vk *aimagereader_vk_create( + struct ra_hwdec_mapper *mapper, const struct aimagereader_vk_api *api); +void aimagereader_vk_destroy(struct aimagereader_vk **state); +void aimagereader_vk_buffer_removed(struct aimagereader_vk *state, + AHardwareBuffer *buffer); +bool aimagereader_vk_can_import_acquire_fence(struct aimagereader_vk *state); + +// Reuse a mapped texture when libplacebo acquires the same source frame again, +// for example while redrawing OSD on top of a queued video frame. +bool aimagereader_vk_reuse(struct aimagereader_vk *state, + struct mp_image *frame); +bool aimagereader_vk_retain_last(struct aimagereader_vk *state, + struct mp_image *frame); + +// Takes ownership of image on success. Sets acquire_fence to -1 when consumed. +int aimagereader_vk_map(struct aimagereader_vk *state, AImage *image, + AHardwareBuffer *buffer, + const AImageCropRect *crop, int32_t data_space, + struct mp_image *frame, int *acquire_fence); diff --git a/third_party/mpv-native-overrides/depinfo.sh b/third_party/mpv-native-overrides/depinfo.sh index 3a7a35184a..df8c452946 100755 --- a/third_party/mpv-native-overrides/depinfo.sh +++ b/third_party/mpv-native-overrides/depinfo.sh @@ -38,8 +38,7 @@ dep_dav1d=() dep_libxml2=() dep_freetype2=() dep_libaribcaption=(freetype2) -dep_uavs3d=() -dep_ffmpeg=(mbedtls dav1d libxml2 libaribcaption uavs3d) +dep_ffmpeg=(mbedtls dav1d libxml2 libaribcaption) dep_fontconfig=(libxml2 freetype2) dep_fribidi=() dep_harfbuzz=() diff --git a/third_party/mpv-native-overrides/ffmpeg.sh b/third_party/mpv-native-overrides/ffmpeg.sh deleted file mode 100644 index 36312e91d1..0000000000 --- a/third_party/mpv-native-overrides/ffmpeg.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh -. ../../include/depinfo.sh -. ../../include/cmake-android.sh - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf _build$ndk_suffix _build_arcdav3a$ndk_suffix - exit 0 -else - exit 255 -fi - -mkdir -p _build$ndk_suffix -cd _build$ndk_suffix - -cpu=armv7-a -[[ "$ndk_triple" == "aarch64"* ]] && cpu=armv8-a -[[ "$ndk_triple" == "x86_64"* ]] && cpu=generic -[[ "$ndk_triple" == "i686"* ]] && cpu="i686 --disable-asm" - -cpuflags= -[[ "$ndk_triple" == "arm"* ]] && cpuflags="$cpuflags -mfpu=neon -mcpu=cortex-a8" - -audio_filters=(acompressor alimiter equalizer pan silenceremove stereotools volume) -audio_filter_args=() -for filter in "${audio_filters[@]}"; do - audio_filter_args+=(--enable-filter="$filter") -done - -if ! grep -q -- "--enable-libarcdav3a" ../configure; then - echo "FFmpeg source does not contain libarcdav3a support. Update the pinned FFmpeg branch." >&2 - exit 1 -fi - -av3a_source="../dependency/avs3a" -av3a_build="../_build_arcdav3a$ndk_suffix" -if [ ! -f "$av3a_source/CMakeLists.txt" ]; then - echo "FFmpeg source does not contain dependency/avs3a. Update the pinned FFmpeg branch." >&2 - exit 1 -fi -android_cmake_setup "$av3a_source" "$av3a_build" -DBUILD_SHARED_LIBS=OFF -android_cmake_build "$av3a_build" -android_cmake_install "$av3a_build" - -args=( - --target-os=android --enable-cross-compile - --cross-prefix=$ndk_triple- --cc=$CC --pkg-config=pkg-config --nm=llvm-nm - --arch=${ndk_triple%%-*} --cpu=$cpu - --extra-cflags="-I$prefix_dir/include $cpuflags" --extra-ldflags="-L$prefix_dir/lib" - - --enable-{jni,mediacodec,mbedtls,libdav1d,libxml2,libaribcaption,libarcdav3a,libuavs3d} --disable-vulkan - --enable-decoder=avs3_mediacodec - --disable-static --enable-shared --enable-{gpl,version3} - - # disable unneeded parts - --disable-{stripping,doc,programs} - # to keep the build lean we disable some feature quite aggressively: - # - muxers, encoders: mpv-android does not have any way to use these - # - devices: no practical use on Android - --disable-{muxers,encoders,devices} - # useful to taking screenshots - --enable-encoder=mjpeg,png - # required by TV audio settings through mpv's lavfi audio filter chain - "${audio_filter_args[@]}" - # useful for the `dump-cache` command and mpv audio passthrough - --enable-muxer=mov,matroska,mpegts,spdif -) -../configure "${args[@]}" - -if ! grep -Fqx '#define CONFIG_AVS3_MEDIACODEC_DECODER 1' config_components.h; then - echo "Required AVS3 MediaCodec decoder is missing from the FFmpeg build." >&2 - exit 1 -fi - -make -j$cores -make DESTDIR="$prefix_dir" install diff --git a/third_party/mpv-native-overrides/uavs3d.sh b/third_party/mpv-native-overrides/uavs3d.sh deleted file mode 100644 index 19d11920b0..0000000000 --- a/third_party/mpv-native-overrides/uavs3d.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -case "$1" in - build) ;; - clean) exit 0 ;; - *) exit 255 ;; -esac - -case "$ndk_triple" in - aarch64*) avs3_abi=arm64-v8a ;; - arm*) avs3_abi=armeabi-v7a ;; - *) echo "Unsupported AVS3 ABI: $ndk_triple" >&2; exit 1 ;; -esac - -bash "${WEBHTV_ROOT:?WebHTV build root is required}/scripts/build_avs3_native.sh" \ - --abi "$avs3_abi" --ndk "$DIR/sdk/android-ndk-$v_ndk" \ - --prefix "$prefix_dir" --work-dir "$PWD/_build$ndk_suffix" --jobs "$cores" diff --git a/third_party/mpv-player-jni/patches/libbluray-hdmv-input.patch b/third_party/mpv-player-jni/patches/libbluray-hdmv-input.patch deleted file mode 100644 index 3545711ff2..0000000000 --- a/third_party/mpv-player-jni/patches/libbluray-hdmv-input.patch +++ /dev/null @@ -1,768 +0,0 @@ ---- a/src/libbluray/decoders/graphics_controller.c -+++ b/src/libbluray/decoders/graphics_controller.c -@@ -39,6 +39,8 @@ - - #include "../register.h" - #include "../keys.h" -+#include "../hdmv/mobj_data.h" -+#include "../hdmv/hdmv_insn.h" - - #ifdef _WIN32 - /* mingw: PRId64 seems to expands to %d without stdio.h ... */ -@@ -63,6 +65,16 @@ - int effect_running; /* single-loop animation not yet complete */ - } BOG_DATA; - -+/* WebHTV menu visit state. IDs only: never retain display-set pointers. */ -+#define MENU_HISTORY_MAX 8 -+typedef struct { -+ uint16_t page, child, entry; -+ uint8_t page_version, child_version; -+ unsigned num_bogs; -+ uint16_t enabled[MAX_NUM_BOGS]; -+} MENU_PARENT; -+/* End WebHTV menu visit state. */ -+ - struct graphics_controller_s { - - BD_REGISTERS *regs; -@@ -82,6 +94,20 @@ - unsigned pg_dirty; - unsigned popup_visible; - unsigned valid_mouse_position; -+ uint16_t mouse_button_id; -+ uint16_t mouse_x, mouse_y; -+ unsigned mouse_parent_position; -+ MENU_PARENT menu_history[MENU_HISTORY_MAX]; -+ unsigned menu_history_count; -+ unsigned menu_entry_pending; -+ uint16_t menu_entry_page, menu_entry_button; -+ uint64_t menu_entry_deadline; -+ unsigned pointer_route_pending; -+ uint16_t pointer_target_page; -+ uint16_t pointer_target_button; -+ uint16_t pointer_origin_button; -+ unsigned pointer_route_steps; -+ uint64_t pointer_route_deadline; - unsigned auto_action_triggered; - BOG_DATA bog_data[MAX_NUM_BOGS]; - BOG_DATA *saved_bog_data; -@@ -193,7 +219,7 @@ - enum { BTN_NORMAL, BTN_SELECTED, BTN_ACTIVATED }; - - static BD_PG_OBJECT *_find_object_for_button(PG_DISPLAY_SET *s, -- BD_IG_BUTTON *button, int state, -+ const BD_IG_BUTTON *button, int state, - BOG_DATA *bog_data) - { - BD_PG_OBJECT *object = NULL; -@@ -393,8 +419,11 @@ - return 1; - } - -+static void _clear_menu_history(GRAPHICS_CONTROLLER *gc); -+ - static int _restore_page_state(GRAPHICS_CONTROLLER *gc) - { -+ _clear_menu_history(gc); - gc->in_effects = NULL; - gc->out_effects = NULL; - -@@ -695,11 +724,80 @@ - gc->auto_action_triggered = 0; - } - -+/* WebHTV observed menu transitions. Only an actual input can create a parent. */ -+static void _clear_menu_history(GRAPHICS_CONTROLLER *gc) -+{ -+ gc->menu_history_count = 0; -+ gc->menu_entry_pending = 0; -+ gc->mouse_parent_position = 0; -+ gc->valid_mouse_position = 0; -+ gc->pointer_route_pending = 0; -+} -+ -+static void _remember_menu_entry(GRAPHICS_CONTROLLER *gc, BD_IG_PAGE *page, -+ const BD_IG_BUTTON *button) -+{ -+ gc->menu_entry_pending = 0; -+ if (!button || button->auto_action_flag || !button->num_nav_cmds || -+ !button->nav_cmds || !gc->ig_drawn || page->num_bogs > MAX_NUM_BOGS || -+ !_find_object_for_button(gc->igs, button, BTN_SELECTED, NULL)) return; -+ gc->menu_entry_page = page->id; -+ gc->menu_entry_button = button->id; -+ gc->menu_entry_deadline = bd_get_scr() + 180000; -+ gc->menu_entry_pending = 1; -+} -+ -+static void _observe_menu_page_change(GRAPHICS_CONTROLLER *gc, uint16_t page_id) -+{ -+ unsigned current = bd_psr_read(gc->regs, PSR_MENU_PAGE_ID); -+ if (current == page_id) return; -+ gc->mouse_parent_position = 0; -+ for (unsigned i = 0; i < gc->menu_history_count; i++) { -+ if (gc->menu_history[i].page == page_id) { -+ gc->menu_history_count = i; /* authored return: pop, never reverse the edge */ -+ gc->menu_entry_pending = 0; -+ return; -+ } -+ } -+ BD_IG_PAGE *page = _find_page(&gc->igs->ics->interactive_composition, current); -+ BD_IG_PAGE *child = _find_page(&gc->igs->ics->interactive_composition, page_id); -+ unsigned bog = 0; -+ BD_IG_BUTTON *entry = page ? _find_button_page(page, gc->menu_entry_button, &bog) : NULL; -+ if (!gc->menu_entry_pending || current != gc->menu_entry_page || -+ bd_get_scr() > gc->menu_entry_deadline || !page || !child || !entry || -+ page->num_bogs > MAX_NUM_BOGS || bog >= MAX_NUM_BOGS || -+ gc->bog_data[bog].enabled_button != entry->id) { -+ gc->menu_history_count = 0; /* unrelated/automatic transition, not a guessed parent */ -+ gc->menu_entry_pending = 0; -+ return; -+ } -+ if (gc->menu_history_count && -+ gc->menu_history[gc->menu_history_count - 1].child != current) gc->menu_history_count = 0; -+ if (gc->menu_history_count == MENU_HISTORY_MAX) { -+ memmove(gc->menu_history, gc->menu_history + 1, -+ (MENU_HISTORY_MAX - 1) * sizeof(gc->menu_history[0])); -+ gc->menu_history_count--; -+ } -+ MENU_PARENT *visit = &gc->menu_history[gc->menu_history_count++]; -+ visit->page = page->id; -+ visit->child = child->id; -+ visit->entry = entry->id; -+ visit->page_version = page->version; -+ visit->child_version = child->version; -+ visit->num_bogs = page->num_bogs; -+ for (unsigned i = 0; i < page->num_bogs; i++) visit->enabled[i] = gc->bog_data[i].enabled_button; -+ gc->menu_entry_pending = 0; -+ GC_TRACE("_user_input menu-visit: parent=%u child=%u entry=%u depth=%u\n", -+ visit->page, visit->child, visit->entry, gc->menu_history_count); -+} -+/* End WebHTV observed menu transitions. */ -+ - static void _select_page(GRAPHICS_CONTROLLER *gc, uint16_t page_id, int out_effects) - { - unsigned cur_page_id = bd_psr_read(gc->regs, PSR_MENU_PAGE_ID); - BD_IG_PAGE *page = NULL; - -+ _observe_menu_page_change(gc, page_id); - bd_psr_write(gc->regs, PSR_MENU_PAGE_ID, page_id); - - _reset_page_state(gc); -@@ -739,6 +837,7 @@ - - gc->popup_visible = 0; - gc->valid_mouse_position = 0; -+ _clear_menu_history(gc); - gc->page_uo_mask = uo_mask_get_empty(); - - graphics_processor_free(&gc->igp); -@@ -873,6 +972,7 @@ - return 0; - } - -+ _clear_menu_history(gc); /* a newly decoded IG set owns a new button context */ - if (!gc->igs || !gc->igs->complete) { - bd_mutex_unlock(&gc->mutex); - return 0; -@@ -1523,7 +1623,346 @@ - #define VK_IS_NUMERIC(vk) (/*vk >= BD_VK_0 &&*/ vk <= BD_VK_9) - #define VK_IS_CURSOR(vk) (vk >= BD_VK_UP && vk <= BD_VK_RIGHT) - #define VK_TO_NUMBER(vk) ((vk) - BD_VK_0) -+ -+static int _button_has_no_action(const BD_IG_BUTTON *button) -+{ -+ if (!button || (button->num_nav_cmds && !button->nav_cmds)) return 0; -+ for (unsigned i = 0; i < button->num_nav_cmds; i++) { -+ const HDMV_INSN *insn = &button->nav_cmds[i].insn; -+ if (insn->grp != INSN_GROUP_BRANCH || insn->sub_grp != BRANCH_GOTO || -+ insn->branch_opt != INSN_NOP) return 0; -+ } -+ return 1; -+} -+ -+static void _trace_input_button(const BD_IG_PAGE *page, const BD_IG_BUTTON *button, -+ const char *phase) -+{ -+ if (button) { -+ GC_TRACE("_user_input %s: page=%u default=%u button=%u cmds=%u auto=%u " -+ "pos=%u,%u up=%u down=%u left=%u right=%u objects=%u/%u/%u\n", -+ phase, page->id, page->default_selected_button_id_ref, button->id, -+ button->num_nav_cmds, button->auto_action_flag, button->x_pos, button->y_pos, -+ button->upper_button_id_ref, button->lower_button_id_ref, -+ button->left_button_id_ref, button->right_button_id_ref, -+ button->normal_start_object_id_ref, button->selected_start_object_id_ref, -+ button->activated_start_object_id_ref); -+ for (unsigned i = 0; button->nav_cmds && i < button->num_nav_cmds; i++) { -+ if (i >= 8 && i + 2 < button->num_nav_cmds) continue; -+ const MOBJ_CMD *cmd = &button->nav_cmds[i]; -+ GC_TRACE("_user_input command: button=%u index=%u group=%u sub=%u branch=%u set=%u cmp=%u imm=%u/%u dst=%08x src=%08x\n", -+ button->id, i, cmd->insn.grp, cmd->insn.sub_grp, cmd->insn.branch_opt, cmd->insn.set_opt, -+ cmd->insn.cmp_opt, cmd->insn.imm_op1, cmd->insn.imm_op2, cmd->dst, cmd->src); -+ } -+ } -+} -+ -+/* WebHTV authored-menu routing. Never synthesize a page/register write. */ -+static const MENU_PARENT *_visited_parent(GRAPHICS_CONTROLLER *gc, BD_IG_PAGE *page) -+{ -+ if (!gc->menu_history_count) return NULL; -+ const MENU_PARENT *visit = &gc->menu_history[gc->menu_history_count - 1]; -+ BD_IG_PAGE *parent = _find_page(&gc->igs->ics->interactive_composition, visit->page); -+ if (!parent || visit->child != page->id || visit->child_version != page->version || -+ visit->page_version != parent->version || visit->num_bogs != parent->num_bogs || -+ visit->num_bogs > MAX_NUM_BOGS) return NULL; -+ return visit; -+} -+ -+static BD_IG_BUTTON *_visited_button_at(GRAPHICS_CONTROLLER *gc, BD_IG_PAGE *parent, -+ const MENU_PARENT *visit) -+{ -+ BD_IG_BUTTON *hit = NULL; -+ for (unsigned i = 0; i < visit->num_bogs; i++) { -+ BD_IG_BUTTON *button = _find_button_bog(&parent->bog[i], visit->enabled[i]); -+ if (!button || button->auto_action_flag || _button_has_no_action(button)) continue; -+ BD_PG_OBJECT *object = _find_object_for_button(gc->igs, button, BTN_SELECTED, NULL); -+ if (!object || gc->mouse_x < button->x_pos || gc->mouse_y < button->y_pos || -+ gc->mouse_x >= button->x_pos + object->width || -+ gc->mouse_y >= button->y_pos + object->height) continue; -+ if (hit) return NULL; /* overlapping parent targets must not be guessed */ -+ hit = button; -+ } -+ return hit; -+} -+ -+static int _inert_pointer_button(const BD_IG_BUTTON *button) -+{ -+ if (!_button_has_no_action(button) || button->auto_action_flag) return 0; -+ uint16_t refs[] = {button->upper_button_id_ref, button->lower_button_id_ref, -+ button->left_button_id_ref, button->right_button_id_ref}; -+ for (unsigned i = 0; i < 4; i++) { -+ if (refs[i] != button->id && refs[i] != 0xffff) return 0; -+ } -+ return 1; -+} -+ -+static BD_IG_BUTTON *_matching_parent_button(BD_IG_PAGE *page, const BD_IG_BUTTON *copy) -+{ -+ if (!_inert_pointer_button(copy) || copy->selected_start_object_id_ref == 0xffff) return NULL; -+ for (unsigned i = 0; i < page->num_bogs; i++) { -+ BD_IG_BUTTON *button = _find_button_bog(&page->bog[i], page->bog[i].default_valid_button_id_ref); -+ if (button && button->num_nav_cmds && !button->auto_action_flag && -+ button->x_pos == copy->x_pos && button->y_pos == copy->y_pos && -+ button->selected_start_object_id_ref == copy->selected_start_object_id_ref) -+ return button; -+ } -+ return NULL; -+} -+ -+/* Some discs flatten the parent toolbar into an inert, cropped bitmap. Match -+ * multiple parent button centers, not their padded image edges. Both the actual -+ * pointer and target center must lie in the backdrop; ambiguous hits fail. */ -+static unsigned _parent_buttons_in_background(GRAPHICS_CONTROLLER *gc, BD_IG_PAGE *parent, -+ const BD_IG_BUTTON *background, BD_IG_BUTTON **target) -+{ -+ *target = NULL; -+ if (!_button_has_no_action(background) || background->auto_action_flag) return 0; -+ BD_PG_OBJECT *area = _find_object_for_button(gc->igs, background, BTN_SELECTED, NULL); -+ if (!area) return 0; -+ unsigned count = 0, hits = 0; -+ for (unsigned i = 0; i < parent->num_bogs; i++) { -+ BD_IG_BUTTON *button = _find_button_bog(&parent->bog[i], parent->bog[i].default_valid_button_id_ref); -+ if (!button || !button->nav_cmds || _button_has_no_action(button) || button->auto_action_flag) continue; -+ BD_PG_OBJECT *object = _find_object_for_button(gc->igs, button, BTN_SELECTED, NULL); -+ if (!object || !object->width || !object->height) continue; -+ unsigned cx = button->x_pos + object->width / 2; -+ unsigned cy = button->y_pos + object->height / 2; -+ if (cx < background->x_pos || cy < background->y_pos || -+ cx >= background->x_pos + area->width || cy >= background->y_pos + area->height) continue; -+ count++; -+ if (gc->mouse_x >= button->x_pos && gc->mouse_y >= button->y_pos && -+ gc->mouse_x < button->x_pos + object->width && gc->mouse_y < button->y_pos + object->height) { -+ hits++; -+ *target = button; -+ } -+ } -+ if (count < 2 || hits != 1) *target = NULL; -+ return count; -+} -+ -+/* Resolve only constant GPR preparation inside this button's own straight-line -+ * program. Preserve SetButtonPage flag bits exactly as the real HDMV VM does. */ -+static int _menu_constant_operand(const MOBJ_CMD *commands, unsigned end, uint32_t operand, -+ int immediate, uint32_t *value) -+{ -+ if (immediate) { *value = operand; return 1; } -+ while (end) { -+ const MOBJ_CMD *cmd = &commands[--end]; -+ if (cmd->insn.grp == INSN_GROUP_SET && cmd->insn.sub_grp == SET_SET && -+ cmd->insn.set_opt == INSN_MOVE && cmd->dst == (operand & 0xfff)) { -+ *value = (operand & 0xc0000000) | (cmd->src & 0x3fffffff); -+ return 1; -+ } -+ } -+ return 0; -+} -+ -+static int _authored_return_page(const BD_IG_BUTTON *button) -+{ -+ if (!button || !button->auto_action_flag || !button->num_nav_cmds || button->num_nav_cmds > 16 || !button->nav_cmds || -+ button->normal_start_object_id_ref != 0xffff || -+ button->selected_start_object_id_ref != 0xffff || -+ button->activated_start_object_id_ref != 0xffff) return -1; -+ int page = -1; -+ for (unsigned i = 0; i < button->num_nav_cmds; i++) { -+ const MOBJ_CMD *cmd = &button->nav_cmds[i]; -+ const HDMV_INSN *insn = &cmd->insn; -+ if (insn->grp == INSN_GROUP_BRANCH && insn->sub_grp == BRANCH_GOTO && insn->branch_opt == INSN_NOP) continue; -+ if (page >= 0 || insn->grp != INSN_GROUP_SET || insn->op_cnt != 2) return -1; -+ if (insn->sub_grp == SET_SET && insn->set_opt == INSN_MOVE && -+ !insn->imm_op1 && insn->imm_op2 && cmd->dst <= 0xfff) continue; -+ uint32_t dst, src; -+ if (insn->sub_grp != SET_SETSYSTEM || insn->set_opt != INSN_SET_BUTTON_PAGE || -+ !_menu_constant_operand(button->nav_cmds, i, cmd->dst, insn->imm_op1, &dst) || -+ !_menu_constant_operand(button->nav_cmds, i, cmd->src, insn->imm_op2, &src) || !(src & 0x80000000)) return -1; -+ /* Reject unrelated register writes, even if the page itself is known. */ -+ for (unsigned j = 0; j < i; j++) { -+ const MOBJ_CMD *move = &button->nav_cmds[j]; -+ if (move->insn.grp == INSN_GROUP_SET && -+ (insn->imm_op1 || move->dst != (cmd->dst & 0xfff)) && -+ (insn->imm_op2 || move->dst != (cmd->src & 0xfff))) return -1; -+ } -+ page = src & 0xff; -+ } -+ return page; -+} - -+static int _menu_only_auto(const BD_IG_BUTTON *button, unsigned page_id) -+{ -+ if (!button->auto_action_flag || !button->num_nav_cmds || !button->nav_cmds || -+ button->normal_start_object_id_ref != 0xffff || -+ button->selected_start_object_id_ref != 0xffff || -+ button->activated_start_object_id_ref != 0xffff) return 0; -+ int selects = 0; -+ for (unsigned i = 0; i < button->num_nav_cmds; i++) { -+ const MOBJ_CMD *cmd = &button->nav_cmds[i]; -+ if (cmd->insn.grp == INSN_GROUP_CMP) continue; -+ if (cmd->insn.grp != INSN_GROUP_SET || cmd->insn.sub_grp != SET_SETSYSTEM || !cmd->insn.imm_op1) return 0; -+ if (cmd->insn.set_opt == INSN_ENABLE_BUTTON || cmd->insn.set_opt == INSN_DISABLE_BUTTON) continue; -+ if (cmd->insn.set_opt != INSN_SET_BUTTON_PAGE || !cmd->insn.imm_op2 || -+ !(cmd->dst & 0x80000000) || ((cmd->src & 0x80000000) && (cmd->src & 0xff) != page_id)) return 0; -+ selects = 1; -+ } -+ return selects; -+} -+ -+typedef struct { -+ BD_IG_BUTTON *button; -+ BD_IG_BUTTON *first_auto; -+} MENU_ROUTE_NODE; -+ -+static int _queue_menu_node(MENU_ROUTE_NODE queue[MAX_NUM_BOGS], unsigned *tail, -+ BD_IG_BUTTON *button, BD_IG_BUTTON *first_auto) -+{ -+ if (!button) return 1; -+ for (unsigned i = 0; i < *tail; i++) if (queue[i].button == button) return 1; -+ if (*tail == MAX_NUM_BOGS) return 0; -+ queue[(*tail)++] = (MENU_ROUTE_NODE){button, first_auto}; -+ return 1; -+} -+ -+static BD_IG_BUTTON *_find_authored_return(GRAPHICS_CONTROLLER *gc, BD_IG_PAGE *page, -+ const BD_IG_BUTTON *copy, BD_IG_PAGE **parent, -+ BD_IG_BUTTON **target) -+{ -+ MENU_ROUTE_NODE queue[MAX_NUM_BOGS]; -+ unsigned head = 0, tail = 0, first = 0; -+ BD_IG_BUTTON *found = NULL; -+ *parent = NULL; -+ *target = NULL; -+ if (!page->num_bogs || page->num_bogs > MAX_NUM_BOGS) return NULL; -+ const MENU_PARENT *visit = _visited_parent(gc, page); -+ unsigned selected = bd_psr_read(gc->regs, PSR_SELECTED_BUTTON_ID); -+ BD_IG_BUTTON *start = _find_button_page(page, selected, &first); -+ if (!start || _inert_pointer_button(start) || gc->bog_data[first].enabled_button != selected) { -+ selected = page->default_selected_button_id_ref; -+ start = _find_button_page(page, selected, &first); -+ } -+ if (!start || _inert_pointer_button(start) || gc->bog_data[first].enabled_button != selected) return NULL; -+ _queue_menu_node(queue, &tail, start, NULL); -+ while (head < tail) { -+ MENU_ROUTE_NODE node = queue[head++]; -+ BD_IG_BUTTON *button = node.button; -+ int page_id = _authored_return_page(button); -+ BD_IG_PAGE *candidate = page_id >= 0 ? _find_page(&gc->igs->ics->interactive_composition, page_id) : NULL; -+ if (candidate && candidate != page && candidate->num_bogs <= MAX_NUM_BOGS) { -+ BD_IG_BUTTON *match = copy ? _matching_parent_button(candidate, copy) : NULL; -+ if (copy && !match) _parent_buttons_in_background(gc, candidate, copy, &match); -+ unsigned copies = 0; -+ if (!copy) { -+ for (unsigned i = 0; i < page->num_bogs; i++) { -+ BD_IG_BUTTON *b = _find_button_bog(&page->bog[i], gc->bog_data[i].enabled_button); -+ copies += _matching_parent_button(candidate, b) != NULL; -+ BD_IG_BUTTON *unused; -+ if (_parent_buttons_in_background(gc, candidate, b, &unused) >= 2) copies += 2; -+ } -+ } -+ if (!copy && visit && candidate->id == visit->page) { -+ if (gc->mouse_parent_position) match = _visited_button_at(gc, candidate, visit); -+ else copies = 2; /* an observed entry plus authored return proves parenthood */ -+ } -+ if (gc->mouse_parent_position && (!visit || candidate->id != visit->page || !match)) continue; -+ if (match || copies >= 2) { -+ if (found && *parent != candidate) return NULL; /* ambiguous parent */ -+ if (!found) { found = node.first_auto ? node.first_auto : button; *parent = candidate; *target = match; } -+ } -+ } -+ /* Menu-only auto nodes may choose an enabled variant using PSRs. -+ * Explore their possible same-page destinations, but execute only the -+ * first auto node, then re-evaluate the graph after the real VM ends. */ -+ if (button->auto_action_flag) { -+ if (_menu_only_auto(button, page->id)) { -+ for (unsigned i = 0; i < button->num_nav_cmds; i++) { -+ const MOBJ_CMD *cmd = &button->nav_cmds[i]; -+ if (cmd->insn.grp == INSN_GROUP_SET && cmd->insn.set_opt == INSN_SET_BUTTON_PAGE && -+ !_queue_menu_node(queue, &tail, _find_button_page(page, cmd->dst & 0xffff, NULL), -+ node.first_auto ? node.first_auto : button)) return NULL; -+ } -+ } -+ continue; -+ } -+ uint16_t refs[] = {button->upper_button_id_ref, button->lower_button_id_ref, -+ button->left_button_id_ref, button->right_button_id_ref}; -+ for (unsigned i = 0; i < 4; i++) { -+ unsigned next = 0; -+ BD_IG_BUTTON *b = _find_button_page(page, refs[i], &next); -+ if (b && next < page->num_bogs && (node.first_auto || gc->bog_data[next].enabled_button == refs[i]) && -+ !_queue_menu_node(queue, &tail, b, node.first_auto)) return NULL; -+ } -+ } -+ return found; -+} -+ -+static int _request_authored_return(GRAPHICS_CONTROLLER *gc, BD_IG_PAGE *page, -+ const BD_IG_BUTTON *copy, GC_NAV_CMDS *cmds) -+{ -+ BD_IG_PAGE *parent; -+ BD_IG_BUTTON *target; -+ BD_IG_BUTTON *gateway = _find_authored_return(gc, page, copy, &parent, &target); -+ if (!gateway || !cmds) return 0; -+ GC_TRACE("_user_input authored-return: page=%u gateway=%u parent=%u target=%d\n", -+ page->id, gateway->id, parent->id, target ? target->id : -1); -+ gc->pointer_route_pending = 1; -+ gc->pointer_target_page = parent->id; -+ gc->pointer_target_button = target ? target->id : 0xffff; -+ const MENU_PARENT *visit = _visited_parent(gc, page); -+ gc->pointer_origin_button = visit && visit->page == parent->id ? visit->entry : 0xffff; -+ gc->pointer_route_steps = 1; -+ gc->pointer_route_deadline = bd_get_scr() + 180000; -+ _select_button(gc, gateway->id); -+ _render_page(gc, 0xffff, cmds); -+ return 1; -+} -+ -+static int _complete_pointer_route(GRAPHICS_CONTROLLER *gc, GC_NAV_CMDS *cmds) -+{ -+ if (!gc->pointer_route_pending || !cmds || cmds->num_nav_cmds) return 0; -+ if (bd_get_scr() > gc->pointer_route_deadline) { -+ GC_TRACE("_user_input pointer route expired\n"); -+ gc->pointer_route_pending = 0; -+ return 0; -+ } -+ if (gc->button_effect_running || gc->in_effects || gc->out_effects) return 0; -+ unsigned page_id = bd_psr_read(gc->regs, PSR_MENU_PAGE_ID); -+ if (page_id != gc->pointer_target_page) { -+ BD_IG_PAGE *current = _find_page(&gc->igs->ics->interactive_composition, page_id), *parent; -+ BD_IG_BUTTON *unused; -+ BD_IG_BUTTON *next = current ? _find_authored_return(gc, current, NULL, &parent, &unused) : NULL; -+ if (!next || parent->id != gc->pointer_target_page || gc->pointer_route_steps++ >= 16) { -+ GC_TRACE("_user_input pointer route cancelled: no verified continuation\n"); -+ gc->pointer_route_pending = 0; -+ return 0; -+ } -+ GC_TRACE("_user_input pointer route: continue page=%u auto=%u step=%u\n", page_id, next->id, gc->pointer_route_steps); -+ _select_button(gc, next->id); -+ _render_page(gc, 0xffff, cmds); -+ return 1; -+ } -+ gc->pointer_route_pending = 0; -+ if (gc->pointer_target_button == 0xffff) return 1; -+ BD_IG_PAGE *page = _find_page(&gc->igs->ics->interactive_composition, gc->pointer_target_page); -+ unsigned bog = 0; -+ BD_IG_BUTTON *button = page ? _find_button_page(page, gc->pointer_target_button, &bog) : NULL; -+ if (!button || gc->bog_data[bog].enabled_button != button->id || -+ button->auto_action_flag || !button->num_nav_cmds) return 0; -+ if (gc->pointer_origin_button != 0xffff ? gc->pointer_origin_button == button->id : -+ bd_psr_read(gc->regs, PSR_SELECTED_BUTTON_ID) == button->id) { -+ GC_TRACE("_user_input pointer route: close at parent=%u button=%u\n", page->id, button->id); -+ return 1; -+ } -+ GC_TRACE("_user_input pointer route: activate parent=%u button=%u cmds=%u\n", -+ page->id, button->id, button->num_nav_cmds); -+ cmds->num_nav_cmds = button->num_nav_cmds; -+ cmds->nav_cmds = button->nav_cmds; -+ cmds->sound_id_ref = button->activated_sound_id_ref; -+ _select_button(gc, button->id); -+ _render_page(gc, button->id, cmds); -+ _remember_menu_entry(gc, page, button); -+ return 1; -+} -+/* End WebHTV authored-menu routing. */ -+ - static int _user_input(GRAPHICS_CONTROLLER *gc, uint32_t key, GC_NAV_CMDS *cmds) - { - PG_DISPLAY_SET *s = gc->igs; -@@ -1533,6 +1972,10 @@ - unsigned new_btn_id = cur_btn_id; - unsigned ii; - int activated_btn_id = -1; -+ -+ gc->pointer_route_pending = 0; -+ gc->menu_entry_pending = 0; -+ if (key != BD_VK_MOUSE_ACTIVATE) gc->mouse_parent_position = 0; - - if (s->ics->interactive_composition.ui_model == IG_UI_MODEL_POPUP && !gc->popup_visible) { - GC_TRACE("_user_input(): popup menu not visible\n"); -@@ -1545,14 +1988,14 @@ - - if (!gc->ig_drawn) { - GC_ERROR("_user_input(): menu not visible\n"); -- return 0; -+ return key == BD_VK_MENU_BACK ? -1 : 0; - } - - _reset_user_timeout(gc); - -- if (gc->button_effect_running) { -+ if (gc->button_effect_running || (key == BD_VK_MENU_BACK && (gc->in_effects || gc->out_effects))) { - GC_ERROR("_user_input(): button_effect_running\n"); -- return 0; -+ return key == BD_VK_MENU_BACK ? -1 : 0; - } - - GC_TRACE("_user_input(%d)\n", key); -@@ -1564,11 +2007,20 @@ - return -1; - } - -+ if (key == BD_VK_MENU_BACK) { -+ BD_IG_BUTTON *selected = _find_button_page(page, cur_btn_id, NULL); -+ if (selected && selected->auto_action_flag) return -1; -+ return _request_authored_return(gc, page, NULL, cmds); -+ } -+ - if (key == BD_VK_MOUSE_ACTIVATE) { - if (!gc->valid_mouse_position) { - GC_TRACE("_user_input(): BD_VK_MOUSE_ACTIVATE outside of valid buttons\n"); - return -1; - } -+ if (gc->mouse_parent_position) return _request_authored_return(gc, page, NULL, cmds); -+ BD_IG_BUTTON *hit = _find_button_page(page, gc->mouse_button_id, NULL); -+ if (_button_has_no_action(hit) && !hit->auto_action_flag) return _request_authored_return(gc, page, hit, cmds); - key = BD_VK_ENTER; - } - -@@ -1590,6 +2042,7 @@ - /* cursor keys */ - else if (VK_IS_CURSOR(key) || key == BD_VK_ENTER) { - if (button->id == cur_btn_id) { -+ _trace_input_button(page, button, "selected"); - switch(key) { - case BD_VK_UP: - new_btn_id = button->upper_button_id_ref; -@@ -1632,7 +2085,13 @@ - - _select_button(gc, new_btn_id); - -+ int before_cmds = cmds ? cmds->num_nav_cmds : -1; - _render_page(gc, activated_btn_id, cmds); -+ if (activated_btn_id >= 0 && cmds && cmds->num_nav_cmds) -+ _remember_menu_entry(gc, page, _find_button_page(page, activated_btn_id, NULL)); -+ GC_TRACE("_user_input result: page=%u key=%u from=%u to=%u activated=%d cmds=%d->%d\n", -+ page_id, key, cur_btn_id, new_btn_id, activated_btn_id, -+ before_cmds, cmds ? cmds->num_nav_cmds : -1); - - /* found one*/ - return 1; -@@ -1795,6 +2254,8 @@ - unsigned ii; - - gc->valid_mouse_position = 0; -+ gc->pointer_route_pending = 0; -+ gc->mouse_parent_position = 0; - - if (!gc->ig_drawn) { - GC_TRACE("_mouse_move(): menu not visible\n"); -@@ -1834,7 +2295,26 @@ - - /* mouse is over button */ - gc->valid_mouse_position = 1; -+ gc->mouse_button_id = button->id; -+ gc->mouse_x = x; -+ gc->mouse_y = y; -+ _trace_input_button(page, button, "mouse-hit"); -+ if (_button_has_no_action(button)) { -+ GC_TRACE("_user_input graph: current=%u bogs=%u\n", cur_btn_id, page->num_bogs); -+ for (unsigned jj = 0; jj < page->num_bogs && jj < 64; jj++) { -+ _trace_input_button(page, _find_button_bog(&page->bog[jj], -+ gc->bog_data[jj].enabled_button), "graph"); -+ } -+ } - -+ /* Decorative copies must not trap keyboard selection in a self-loop. */ -+ if (_inert_pointer_button(button)) return 1; -+ if (_button_has_no_action(button) && !button->auto_action_flag) { -+ BD_IG_PAGE *parent; -+ BD_IG_BUTTON *target; -+ if (_find_authored_return(gc, page, button, &parent, &target)) return 1; -+ } -+ - /* is button already selected? */ - if (button->id == cur_btn_id) { - return 1; -@@ -1857,6 +2337,18 @@ - _reset_user_timeout(gc); - } - -+ if (!gc->valid_mouse_position && !gc->in_effects && !gc->out_effects && _visited_parent(gc, page)) { -+ BD_IG_PAGE *parent; -+ BD_IG_BUTTON *target; -+ gc->mouse_x = x; -+ gc->mouse_y = y; -+ gc->mouse_parent_position = 1; -+ if (_find_authored_return(gc, page, NULL, &parent, &target) && target) { -+ gc->valid_mouse_position = 1; -+ GC_TRACE("_user_input parent-hit: page=%u parent=%u target=%u x=%u y=%u\n", -+ page->id, parent->id, target->id, x, y); -+ } else gc->mouse_parent_position = 0; -+ } - return gc->valid_mouse_position; - } - -@@ -2020,6 +2512,7 @@ - /* fall thru */ - - case GC_CTRL_POPUP: -+ _clear_menu_history(gc); - if (gc->igs->ics->interactive_composition.ui_model != IG_UI_MODEL_POPUP) { - /* not pop-up menu */ - break; -@@ -2040,6 +2533,7 @@ - break; - - case GC_CTRL_INIT_MENU: -+ _clear_menu_history(gc); - _select_page(gc, 0, 0); - _render_page(gc, 0xffff, cmds); - break; -@@ -2075,6 +2569,8 @@ - #endif - } - -+ if ((ctrl == GC_CTRL_NOP || ctrl == GC_CTRL_IG_END) && _complete_pointer_route(gc, cmds)) result = 1; -+ - if (cmds) { - if (gc->igs->ics->interactive_composition.ui_model == IG_UI_MODEL_POPUP) { - cmds->status |= GC_STATUS_POPUP; -@@ -2082,7 +2578,7 @@ - if (gc->ig_drawn) { - cmds->status |= GC_STATUS_MENU_OPEN; - } -- if (gc->in_effects || gc->out_effects || gc->button_animation_running || gc->user_timeout) { -+ if (gc->in_effects || gc->out_effects || gc->button_animation_running || gc->user_timeout || gc->pointer_route_pending) { - /* do not trigger if unopened pop-up menu has animations */ - if (gc->ig_open) { - cmds->status |= GC_STATUS_ANIMATE; ---- a/src/libbluray/bluray.c -+++ b/src/libbluray/bluray.c -@@ -34,6 +34,7 @@ - #include "util/logging.h" - #include "util/strutl.h" - #include "util/mutex.h" -+#include "util/time.h" - #include "bdnav/bdid_parse.h" - #include "bdnav/navigation.h" - #include "bdnav/index_parse.h" -@@ -149,6 +150,8 @@ - HDMV_VM *hdmv_vm; - uint8_t hdmv_suspended; - uint8_t hdmv_num_invalid_pl; -+ uint8_t user_menu_root_pending; -+ uint64_t user_menu_root_deadline; - - /* BD-J */ - BDJAVA *bdjava; -@@ -946,10 +949,17 @@ - GC_NAV_CMDS cmds = {-1, NULL, -1, 0, 0, EMPTY_UO_MASK}; - - result = gc_run(bd->graphics_controller, msg, param, &cmds); -+ -+ if (msg == GC_CTRL_VK_KEY || msg == GC_CTRL_MOUSE_MOVE || cmds.num_nav_cmds > 0) { -+ BD_DEBUG(DBG_GC, "HDMV input GC: msg=%d param=%u result=%d cmds=%d status=%u\n", -+ msg, param, result, cmds.num_nav_cmds, cmds.status); -+ } - - if (cmds.num_nav_cmds > 0) { -- hdmv_vm_set_object(bd->hdmv_vm, cmds.num_nav_cmds, cmds.nav_cmds); -+ int vm_result = hdmv_vm_set_object(bd->hdmv_vm, cmds.num_nav_cmds, cmds.nav_cmds); - bd->hdmv_suspended = !hdmv_vm_running(bd->hdmv_vm); -+ BD_DEBUG(DBG_GC, "HDMV input VM: cmds=%d result=%d running=%d\n", -+ cmds.num_nav_cmds, vm_result, !bd->hdmv_suspended); - } - - if (cmds.status != bd->gc_status) { -@@ -3685,7 +3695,14 @@ - } - } - -- return _play_title(bd, BLURAY_TITLE_TOP_MENU); -+ int result = _play_title(bd, BLURAY_TITLE_TOP_MENU); -+ if (result && bd->title_type == title_hdmv) { -+ /* An explicit root request may restore the disc's last child page. -+ * Let its initialization run, then follow its authored parent route. */ -+ bd->user_menu_root_pending = 1; -+ bd->user_menu_root_deadline = bd_get_scr() + 900000; -+ } -+ return result; - } - - int bd_menu_call(BLURAY *bd, int64_t pts) -@@ -3843,6 +3860,18 @@ - - if (bd->gc_status & GC_STATUS_ANIMATE) { - _run_gc(bd, GC_CTRL_NOP, 0); -+ } -+ -+ if (bd->user_menu_root_pending && bd->hdmv_suspended) { -+ if (bd_get_scr() > bd->user_menu_root_deadline) { -+ bd->user_menu_root_pending = 0; -+ } else if ((bd->gc_status & GC_STATUS_MENU_OPEN) && -+ bd_psr_read(bd->regs, PSR_TITLE_NUMBER) == BLURAY_TITLE_TOP_MENU) { -+ int result = _run_gc(bd, GC_CTRL_VK_KEY, BD_VK_MENU_BACK); -+ if (result >= 0) bd->user_menu_root_pending = 0; -+ BD_DEBUG(DBG_GC, "HDMV explicit root: parent-result=%d pending=%u\n", -+ result, bd->user_menu_root_pending); -+ } - } - } - -@@ -4068,6 +4097,7 @@ - - bd_mutex_lock(&bd->mutex); - -+ bd->user_menu_root_pending = 0; /* a newer real input supersedes root normalization */ - _set_scr(bd, pts); - if (_get_key_uo_index(bd, BD_VK_KEY(key), &uo_mask_idx)) { - uo_is_masked = _is_uo_masked(bd, uo_mask_idx); ---- a/src/libbluray/keys.h -+++ b/src/libbluray/keys.h -@@ -57,6 +57,9 @@ - /** Mouse click. Translated to BD_VK_ENTER if mouse is over a valid button. */ - BD_VK_MOUSE_ACTIVATE = 17, - -+ /* WebHTV: follow an authored HDMV parent-menu route when available. */ -+ BD_VK_MENU_BACK = 18, -+ - BD_VK_RED = 403, /**< Color key "Red" */ - BD_VK_GREEN = 404, /**< Color key "Green" */ - BD_VL_YELLOW = 405, /**< Color key "Yellow" */ diff --git a/third_party/mpv-player-jni/patches/mpv-audiotrack-underrun.patch b/third_party/mpv-player-jni/patches/mpv-audiotrack-underrun.patch deleted file mode 100644 index 3faf6032bd..0000000000 --- a/third_party/mpv-player-jni/patches/mpv-audiotrack-underrun.patch +++ /dev/null @@ -1,17 +0,0 @@ -diff --git a/audio/out/ao_audiotrack.c b/audio/out/ao_audiotrack.c ---- a/audio/out/ao_audiotrack.c -+++ b/audio/out/ao_audiotrack.c -@@ -733,6 +733,13 @@ static MP_THREAD_VOID ao_thread(void *arg) - } - } else { - int samples = ao_read_data(ao, &p->chunk, read_samples, ts, NULL, false, false); -+ if (samples <= 0) { -+ // A zero-byte WRITE_BLOCKING does not block. Release the -+ // mutex while waiting for data or start/uninit to wake us. -+ // Match the compressed path's bounded underrun wait. -+ mp_cond_timedwait(&p->wakeup, &p->lock, MP_TIME_MS_TO_NS(20)); -+ continue; -+ } - int ret = AudioTrack_write(ao, NULL, samples * ao->sstride); - if (ret >= 0) { - p->written_frames += ret / ao->sstride; diff --git a/third_party/mpv-player-jni/patches/mpv-discnav-input.patch b/third_party/mpv-player-jni/patches/mpv-discnav-input.patch deleted file mode 100644 index 7099aa0039..0000000000 --- a/third_party/mpv-player-jni/patches/mpv-discnav-input.patch +++ /dev/null @@ -1,541 +0,0 @@ -diff --git a/player/discnav.c b/player/discnav.c ---- a/player/discnav.c -+++ b/player/discnav.c -@@ -119,30 +119,41 @@ - return NULL; - } - --bool disc_nav_mouse_pos_to_src(struct MPContext *mpctx, int src_w, int src_h, -- int *out_x, int *out_y) -+bool disc_nav_window_pos_to_src(struct MPContext *mpctx, double wx, double wy, -+ int src_w, int src_h, int *out_x, int *out_y) - { - struct vo *vo = mpctx->video_out; - if (!vo || !vo->config_ok || src_w <= 0 || src_h <= 0) - return false; -- int wx, wy, hover; -- mp_input_get_mouse_pos(mpctx->input, &wx, &wy, &hover); -- struct mp_rect src, dst; -- struct mp_osd_res osd; // mandatory out param; ignored -- vo_get_src_dst_rects(vo, &src, &dst, &osd); -- int dw = dst.x1 - dst.x0; -- int dh = dst.y1 - dst.y0; -+ -+ // Invert the same published OSD rectangle used by push_bd_overlay(). -+ // Negative margins describe zoom/crop; the visible video rectangle alone -+ // would incorrectly map that cropped portion to the entire menu plane. -+ // Unlike vo_get_src_dst_rects(), this snapshot is safe on the core thread. -+ struct mp_osd_res res = osd_get_vo_res(mpctx->osd); -+ int dw = res.w - res.ml - res.mr; -+ int dh = res.h - res.mt - res.mb; - if (dw <= 0 || dh <= 0) - return false; -- double fx = (wx - dst.x0) / (double)dw; -- double fy = (wy - dst.y0) / (double)dh; -- if (fx < 0 || fx > 1 || fy < 0 || fy > 1) -+ if (!(wx >= 0 && wx < res.w && wy >= 0 && wy < res.h)) - return false; -+ double fx = (wx - res.ml) / dw; -+ double fy = (wy - res.mt) / dh; -+ if (!(fx >= 0 && fx < 1 && fy >= 0 && fy < 1)) -+ return false; - *out_x = MPMIN((int)(fx * src_w), src_w - 1); - *out_y = MPMIN((int)(fy * src_h), src_h - 1); - return true; - } - -+bool disc_nav_mouse_pos_to_src(struct MPContext *mpctx, int src_w, int src_h, -+ int *out_x, int *out_y) -+{ -+ int wx, wy, hover; -+ mp_input_get_mouse_pos(mpctx->input, &wx, &wy, &hover); -+ return disc_nav_window_pos_to_src(mpctx, wx, wy, src_w, src_h, out_x, out_y); -+} -+ - // Push the current menu/highlight state to the dvd_subtitle decoders. - static void push_dvd_overlay(struct MPContext *mpctx, struct stream_nav_state *nav) - { -diff --git a/player/core.h b/player/core.h ---- a/player/core.h -+++ b/player/core.h -@@ -682,6 +682,8 @@ - void disc_nav_reset(struct MPContext *mpctx); - void disc_nav_destroy(struct MPContext *mpctx); - struct stream *disc_nav_get_stream(struct MPContext *mpctx); -+bool disc_nav_window_pos_to_src(struct MPContext *mpctx, double wx, double wy, -+ int src_w, int src_h, int *out_x, int *out_y); - bool disc_nav_mouse_pos_to_src(struct MPContext *mpctx, int src_w, int src_h, - int *out_x, int *out_y); - -diff --git a/player/command.c b/player/command.c ---- a/player/command.c -+++ b/player/command.c -@@ -7528,13 +7528,24 @@ - if (action == STREAM_NAV_MOUSE_MOVE || action == STREAM_NAV_MOUSE_CLICK) { - double nx = cmd->args[1].v.d; - double ny = cmd->args[2].v.d; -- if (nx >= 0 && ny >= 0) { -- if (pre.src_w <= 0 || pre.src_h <= 0 || nx > 1 || ny > 1) { -+ if (cmd->args[3].v.i == 1) { -+ // Carry the pointer in this command: an earlier "mouse" command -+ // only queues an input event and may not have been consumed yet. -+ if (!disc_nav_window_pos_to_src(mpctx, nx, ny, pre.src_w, pre.src_h, -+ &nc.x, &nc.y)) -+ { - cmd->success = false; - return; - } -- nc.x = (int)(nx * pre.src_w); -- nc.y = (int)(ny * pre.src_h); -+ } else if (nx != -1 || ny != -1) { -+ if (pre.src_w <= 0 || pre.src_h <= 0 || -+ !(nx >= 0 && nx <= 1 && ny >= 0 && ny <= 1)) -+ { -+ cmd->success = false; -+ return; -+ } -+ nc.x = MPMIN((int)(nx * pre.src_w), pre.src_w - 1); -+ nc.y = MPMIN((int)(ny * pre.src_h), pre.src_h - 1); - } else if (!disc_nav_mouse_pos_to_src(mpctx, pre.src_w, pre.src_h, - &nc.x, &nc.y)) - { -@@ -7545,7 +7556,9 @@ - - if (stream_control(s, STREAM_CTRL_NAV_CMD, &nc) < 1) { - cmd->success = false; -- } else if (mpctx->demuxer && stream_nav_action_activates(action)) { -+ } else if (mpctx->demuxer) { -+ // Hover/cursor input can execute authored auto-actions too. The -+ // demux thread pumps them independently of any cached media packet. - demux_drive_nav(mpctx->demuxer); - } - -@@ -8122,7 +8135,9 @@ - {"mouse-move", STREAM_NAV_MOUSE_MOVE}, - {"mouse-click", STREAM_NAV_MOUSE_CLICK})}, - {"x", OPT_DOUBLE(v.d), OPTDEF_DOUBLE(-1)}, -- {"y", OPT_DOUBLE(v.d), OPTDEF_DOUBLE(-1)} }, -+ {"y", OPT_DOUBLE(v.d), OPTDEF_DOUBLE(-1)}, -+ {"coordinates", OPT_CHOICE(v.i, -+ {"normalized", 0}, {"window", 1}), OPTDEF_INT(0)} }, - }, - - { "flush-status-line", cmd_flush_status_line, { {"clear", OPT_BOOL(v.b)} } }, -diff --git a/stream/stream_bluray.c b/stream/stream_bluray.c ---- a/stream/stream_bluray.c -+++ b/stream/stream_bluray.c -@@ -130,10 +130,15 @@ - bool overlay_ig_visible; // IG plane was FLUSHED with non-empty content - bool menu_event_active; // BD_EVENT_MENU == 1 - bool popup_supported; // BD_EVENT_POPUP == 1 -+ bool input_pending; // guarded by overlay_lock; pumped by demux -+ int64_t next_nav_poll_ns; // menu animation must also run over cached packets -+ uint32_t uo_mask; // last public BD_EVENT_UO_MASK_CHANGED - uint32_t nav_change_id; // bumped on FLUSH/HIDE/MENU/POPUP events - uint32_t discontinuity_id; // bumped on actions that may hop (SELECT...) -- bool data_delivered; // any byte returned from fill_buffer yet -+ bool data_delivered; // demux writes; input reads under overlay_lock - bool still_active; // holding an indefinite still. -+ bool trace_navigation; // debug log configuration, immutable after open -+ uint32_t trace_request; // guarded by overlay_lock - }; - - extern const stream_info_t stream_info_ffmpeg; -@@ -141,6 +146,113 @@ - extern const stream_info_t stream_info_bdmv_dir; - - static int bluray_stream_open_internal(stream_t *s); -+ -+// The libbluray logger is process-global and has no opaque owner. Never retain -+// a stream there: only a synchronous API call may lend it a thread-local owner. -+static thread_local struct { -+ stream_t *stream; -+ const char *phase; -+ uint32_t request; -+ unsigned lines; -+ int64_t window_ns; -+ unsigned window_lines; -+} bd_trace; -+ -+static bool bluray_trace_line(const char *text, bool input) -+{ -+ if (!text) -+ return false; -+ if (strncmp(text, "hdmv_vm.c:", 10) == 0) -+ return true; -+ if (strncmp(text, "graphics_controller.c:", 22) == 0) { -+ if (strstr(text, "skipping already rendered") || -+ strstr(text, "not triggered (ANIMATING)")) -+ return false; -+ return strstr(text, "_set_button_page") || strstr(text, "_user_input") || -+ strstr(text, "_mouse_move") || strstr(text, "auto-activate") || -+ strstr(text, " activated") || strstr(text, "unknown page") || -+ (input && (strstr(text, "render button #") || -+ strstr(text, "rendering page #"))); -+ } -+ return strncmp(text, "bluray.c:", 9) == 0 && -+ (strstr(text, "bd_menu_call") || strstr(text, "bd_user_input") || -+ strstr(text, "bd_play_title") || strstr(text, "HDMV")); -+} -+ -+static void bluray_trace_log(const char *text) -+{ -+ if (!bd_trace.stream || !bluray_trace_line(text, !strcmp(bd_trace.phase, "input"))) -+ return; -+ int64_t now = mp_time_ns(); -+ if (now - bd_trace.window_ns >= MP_TIME_S_TO_NS(1)) { -+ bd_trace.window_ns = now; -+ bd_trace.window_lines = 0; -+ } -+ if (bd_trace.lines >= 96 || bd_trace.window_lines >= 600) { -+ if (bd_trace.lines == 96 || bd_trace.window_lines == 600) { -+ MP_INFO(bd_trace.stream, "bdnav-debug seq=%u phase=%s trace capped\n", -+ bd_trace.request, bd_trace.phase); -+ bd_trace.lines++; -+ bd_trace.window_lines++; -+ } -+ return; -+ } -+ bd_trace.lines++; -+ bd_trace.window_lines++; -+ MP_INFO(bd_trace.stream, "bdnav-debug seq=%u phase=%s t_ms=%lld %.900s", -+ bd_trace.request, bd_trace.phase, (long long)(now / 1000000), text); -+} -+ -+static void bluray_trace_init(void) -+{ -+ bd_set_debug_handler(bluray_trace_log); -+ bd_set_debug_mask(DBG_CRIT | DBG_HDMV | DBG_GC); -+} -+ -+static void bluray_trace_begin(stream_t *s, const char *phase) -+{ -+ struct bluray_priv_s *b = s->priv; -+ if (!b->trace_navigation) -+ return; -+ mp_mutex_lock(&b->overlay_lock); -+ bd_trace.request = b->trace_request; -+ mp_mutex_unlock(&b->overlay_lock); -+ bd_trace.stream = s; -+ bd_trace.phase = phase; -+ bd_trace.lines = 0; -+} -+ -+static void bluray_trace_end(void) -+{ -+ bd_trace.stream = NULL; -+} -+ -+static void bluray_trace_state(stream_t *s, const char *phase, int result, -+ int64_t started_ns) -+{ -+ struct bluray_priv_s *b = s->priv; -+ if (!b->trace_navigation) -+ return; -+ mp_mutex_lock(&b->overlay_lock); -+ MP_INFO(s, "bdnav-debug seq=%u phase=%s result=%d elapsed_ms=%lld " -+ "menu=%d overlay=%d popup=%d still=%d uo=%u change=%u disc=%u " -+ "plane=%dx%d delivered=%d\n", -+ b->trace_request, phase, result, -+ (long long)((mp_time_ns() - started_ns) / 1000000), -+ b->menu_event_active, b->overlay_ig_visible, b->popup_supported, -+ b->still_active, b->uo_mask, b->nav_change_id, b->discontinuity_id, -+ b->plane_w, b->plane_h, b->data_delivered); -+ mp_mutex_unlock(&b->overlay_lock); -+} -+ -+static int bluray_read_ext(stream_t *s, void *buf, int len, BD_EVENT *event) -+{ -+ struct bluray_priv_s *b = s->priv; -+ bluray_trace_begin(s, len ? "read" : "poll"); -+ int result = bd_read_ext(b->bd, buf, len, event); -+ bluray_trace_end(); -+ return result; -+} - - // Lazy (re-)allocation for the IG-plane working/publish buffers. - static bool bd_ensure_plane(struct bluray_priv_s *priv, int w, int h) -@@ -418,7 +530,25 @@ - struct bluray_priv_s *b = s->priv; - if (b->hdmv_mode) - MP_VERBOSE(s, "bdnav: event %d param %u\n", ev->event, ev->param); -+ if (b->hdmv_mode && b->trace_navigation && -+ (ev->event == BD_EVENT_TITLE || ev->event == BD_EVENT_PLAYLIST || -+ ev->event == BD_EVENT_PLAYITEM || ev->event == BD_EVENT_END_OF_TITLE || -+ ev->event == BD_EVENT_MENU || ev->event == BD_EVENT_POPUP || -+ ev->event == BD_EVENT_STILL || ev->event == BD_EVENT_STILL_TIME || -+ ev->event == BD_EVENT_UO_MASK_CHANGED)) { -+ mp_mutex_lock(&b->overlay_lock); -+ MP_INFO(s, "bdnav-debug seq=%u event=%u param=%u\n", -+ b->trace_request, ev->event, ev->param); -+ mp_mutex_unlock(&b->overlay_lock); -+ } - switch (ev->event) { -+ case BD_EVENT_UO_MASK_CHANGED: -+ if (b->hdmv_mode) { -+ mp_mutex_lock(&b->overlay_lock); -+ b->uo_mask = ev->param; -+ mp_mutex_unlock(&b->overlay_lock); -+ } -+ break; - case BD_EVENT_MENU: - // ev->param: 1 if the disc is currently in an HDMV menu, 0 otherwise. - if (b->hdmv_mode) { -@@ -516,13 +646,12 @@ - - static int bluray_poll_hdmv_events(stream_t *s) - { -- struct bluray_priv_s *b = s->priv; - // User input queues VM commands, not just BD_EVENTs. A zero-byte read - // executes those commands even while the current clip holds a still. - for (int i = 0; i < 200; i++) { - BD_EVENT event = {0}; - if (mp_cancel_test(s->cancel) || -- bd_read_ext(b->bd, NULL, 0, &event) < 0) -+ bluray_read_ext(s, NULL, 0, &event) < 0) - return -1; - if (event.event == BD_EVENT_NONE) - return 0; -@@ -560,7 +689,7 @@ - // parses with the correct codec context. - for (int i = 0; i < 200; i++) { - uint32_t disc_before = b->discontinuity_id; -- int n = bd_read_ext(b->bd, (uint8_t *)buf + total, len - total, &event); -+ int n = bluray_read_ext(s, (uint8_t *)buf + total, len - total, &event); - if (n < 0) { - MP_VERBOSE(s, "bdnav: bd_read_ext err iter=%d\n", i); - return -1; -@@ -582,8 +711,11 @@ - return 0; - mp_sleep_ns(MP_TIME_MS_TO_NS(5)); - } -- if (total > 0) -+ if (total > 0 && !b->data_delivered) { -+ mp_mutex_lock(&b->overlay_lock); - b->data_delivered = true; -+ mp_mutex_unlock(&b->overlay_lock); -+ } - if (total == 0) - MP_VERBOSE(s, "bdnav: fill returned 0 (events=%d)\n", events_seen); - return total; -@@ -592,6 +724,37 @@ - while (bd_get_event(b->bd, &event)) - handle_event(s, &event); - return bd_read(b->bd, buf, len); -+} -+ -+static int bluray_call_user_menu(stream_t *s) -+{ -+ struct bluray_priv_s *b = s->priv; -+ int64_t call_started = mp_time_ns(); -+ int result = bd_menu_call(b->bd, -1); -+ bluray_trace_state(s, "menu-normal", result, call_started); -+ if (result > 0) -+ return result; -+ -+ mp_mutex_lock(&b->overlay_lock); -+ bool restricted = b->uo_mask & BLURAY_UO_MENU_CALL; -+ bool started = b->data_delivered; -+ mp_mutex_unlock(&b->overlay_lock); -+ // Only an explicit user request may override an authored menu-call mask. -+ // Never do this while opening the disc: FIRST PLAY must run first. -+ if (!restricted || !started || -+ !bd_set_player_setting(b->bd, BLURAY_PLAYER_SETTING_UO_RESTRICTION_LEVEL, -+ BLURAY_PLAYER_SETTING_UO_RESTRICTION_DISABLED)) -+ return result; -+ -+ call_started = mp_time_ns(); -+ result = bd_menu_call(b->bd, -1); -+ bluray_trace_state(s, "menu-override", result, call_started); -+ // This instance otherwise uses libbluray's default RELAXED policy. Do not -+ // leave title search, seeking or subsequent input globally unrestricted. -+ bd_set_player_setting(b->bd, BLURAY_PLAYER_SETTING_UO_RESTRICTION_LEVEL, -+ BLURAY_PLAYER_SETTING_UO_RESTRICTION_RELAXED); -+ MP_INFO(s, "discnav explicit menu override result=%d\n", result); -+ return result; - } - - static int bluray_stream_control(stream_t *s, int cmd, void *arg) -@@ -599,6 +762,34 @@ - struct bluray_priv_s *b = s->priv; - - switch (cmd) { -+ case STREAM_CTRL_NAV_POLL: { -+ if (!b->hdmv_mode) -+ return STREAM_UNSUPPORTED; -+ mp_mutex_lock(&b->overlay_lock); -+ bool pending = b->input_pending; -+ int64_t now = mp_time_ns(); -+ bool animate = (b->menu_event_active || b->overlay_ig_visible) && now >= b->next_nav_poll_ns; -+ uint32_t before = b->nav_change_id; -+ b->input_pending = false; -+ if (pending || animate) -+ b->next_nav_poll_ns = now + MP_TIME_MS_TO_NS(33); -+ mp_mutex_unlock(&b->overlay_lock); -+ if (pending || animate) { -+ int64_t started = mp_time_ns(); -+ int result = bluray_poll_hdmv_events(s); -+ if (pending) -+ bluray_trace_state(s, "pump-complete", result, started); -+ if (result < 0) -+ return STREAM_ERROR; -+ mp_mutex_lock(&b->overlay_lock); -+ uint32_t after = b->nav_change_id; -+ mp_mutex_unlock(&b->overlay_lock); -+ if (pending) -+ MP_INFO(s, "discnav pump change=%u->%u disc=%u\n", before, -+ after, b->discontinuity_id); -+ } -+ return STREAM_OK; -+ } - case STREAM_CTRL_GET_NUM_CHAPTERS: { - const BLURAY_TITLE_INFO *ti = b->title_info; - if (!ti) -@@ -740,6 +931,16 @@ - return STREAM_UNSUPPORTED; - struct stream_nav_cmd *nav = arg; - uint32_t key = BD_VK_NONE; -+ int64_t input_started = mp_time_ns(); -+ int hit = -1, root_result = -1, input_result = -1; -+ bool accepted = false; -+ mp_mutex_lock(&b->overlay_lock); -+ bool popup_supported = b->popup_supported; -+ uint32_t uo_mask = b->uo_mask; -+ b->trace_request++; -+ mp_mutex_unlock(&b->overlay_lock); -+ bluray_trace_begin(s, "input"); -+ bluray_trace_state(s, "input-begin", nav->action, input_started); - switch (nav->action) { - case STREAM_NAV_UP: - key = BD_VK_UP; -@@ -759,30 +960,74 @@ - case STREAM_NAV_MENU_ROOT: - case STREAM_NAV_MENU_TITLE: - // BD doesn't distinguish "title menu", both map to disc root. -- bd_menu_call(b->bd, -1); -+ input_result = bd_user_input(b->bd, -1, BD_VK_MENU_BACK); -+ accepted = input_result > 0; -+ if (!accepted) { -+ root_result = bluray_call_user_menu(s); -+ accepted = root_result > 0; -+ } -+ if (!accepted && popup_supported) -+ key = BD_VK_POPUP; - break; - case STREAM_NAV_MENU_POPUP: -- key = BD_VK_POPUP; -- break; - case STREAM_NAV_PREV_MENU: -- // No dedicated "previous menu" key; popup-toggle is the closest -- // equivalent and behaves like "dismiss current menu" on most -- // discs when already in popup. -- key = BD_VK_POPUP; -+ // HDMV has no BACK key. Toggle a real popup, otherwise request -+ // the top menu; a non-popup page cannot consume BD_VK_POPUP. -+ if (popup_supported) { -+ input_result = bd_user_input(b->bd, -1, BD_VK_POPUP); -+ accepted = input_result >= 0; -+ } -+ if (!accepted) { -+ input_result = bd_user_input(b->bd, -1, BD_VK_MENU_BACK); -+ accepted = input_result > 0; -+ } -+ if (!accepted) { -+ root_result = bluray_call_user_menu(s); -+ accepted = root_result > 0; -+ } - break; - case STREAM_NAV_MOUSE_MOVE: -- bd_mouse_select(b->bd, -1, nav->x, nav->y); -- break; - case STREAM_NAV_MOUSE_CLICK: -- bd_mouse_select(b->bd, -1, nav->x, nav->y); -- key = BD_VK_MOUSE_ACTIVATE; -+ if (nav->x < 0 || nav->y < 0 || -+ nav->x > UINT16_MAX || nav->y > UINT16_MAX) -+ break; -+ int64_t mouse_started = mp_time_ns(); -+ hit = bd_mouse_select(b->bd, -1, nav->x, nav->y); -+ bluray_trace_state(s, "mouse-select", hit, mouse_started); -+ accepted = hit > 0; -+ // 0 means no button at this position, not a successful selection. -+ // Do not activate a stale selection after a missed/failed hit. -+ if (accepted && nav->action == STREAM_NAV_MOUSE_CLICK) -+ key = BD_VK_MOUSE_ACTIVATE; - break; -+ default: -+ bluray_trace_end(); -+ return STREAM_UNSUPPORTED; -+ } -+ if (key != BD_VK_NONE) { -+ int64_t key_started = mp_time_ns(); -+ input_result = bd_user_input(b->bd, -1, key); -+ bluray_trace_state(s, "user-input", input_result, key_started); -+ // Unlike mouse selection and menu_call, 0 is a valid key result. -+ // It does not promise a state change (e.g. a boundary/effect/UO). -+ accepted = input_result >= 0; - } -- if (key != BD_VK_NONE) -- bd_user_input(b->bd, -1, key); -+ if (accepted) { -+ mp_mutex_lock(&b->overlay_lock); -+ b->input_pending = true; -+ mp_mutex_unlock(&b->overlay_lock); -+ } -+ if (nav->action != STREAM_NAV_MOUSE_MOVE) { -+ MP_INFO(s, "discnav action=%d x=%d y=%d hit=%d root=%d key=%u " -+ "input=%d accepted=%d popup=%d uo=%u\n", -+ nav->action, nav->x, nav->y, hit, root_result, key, -+ input_result, accepted, popup_supported, uo_mask); -+ } - // The next demux read runs pending VM commands. Only actual disc - // events may change the generation; a page/button change is no seek. -- return STREAM_OK; -+ bluray_trace_state(s, "input-end", accepted, input_started); -+ bluray_trace_end(); -+ return accepted ? STREAM_OK : STREAM_ERROR; - } - case STREAM_CTRL_GET_NAV_STATE: { - struct stream_nav_state *st = arg; -@@ -928,8 +1173,11 @@ - b->opts = opts_cache->opts; - - int ret = 0; -- if (!mp_msg_test(s->log, MSGL_DEBUG)) -- bd_set_debug_mask(0); -+ b->trace_navigation = mp_msg_test(s->log, MSGL_DEBUG); -+ if (b->trace_navigation) { -+ static mp_once trace_once = MP_STATIC_ONCE_INITIALIZER; -+ mp_exec_once(&trace_once, bluray_trace_init); -+ } - - if (b->cfg_stream_url && b->cfg_stream_url[0]) { - ret = open_bluray_from_stream(s, b->cfg_stream_url); -diff --git a/stream/stream.h b/stream/stream.h ---- a/stream/stream.h -+++ b/stream/stream.h -@@ -97,6 +97,7 @@ - STREAM_CTRL_NAV_CMD, // struct stream_nav_cmd* - STREAM_CTRL_GET_NAV_STATE, // struct stream_nav_state* - STREAM_CTRL_GET_NAV_OVERLAY, // struct stream_nav_overlay_req* -+ STREAM_CTRL_NAV_POLL, // demux-thread-only pending navigation pump - }; - - // Fetch a BGRA snapshot of the disc-menu overlay (used for Blu-ray HDMV). -diff --git a/demux/demux_disc.c b/demux/demux_disc.c ---- a/demux/demux_disc.c -+++ b/demux/demux_disc.c -@@ -415,6 +415,13 @@ - { - struct priv *p = demuxer->priv; - -+ // Reading one slave packet is not an event pump: libavformat and the -+ // stream can already have buffered packets. Execute pending menu input -+ // before inspecting the generation or consuming any of those packets. -+ if (p->is_bd && p->nav_active && -+ stream_control(demuxer->stream, STREAM_CTRL_NAV_POLL, NULL) == STREAM_ERROR) -+ return false; -+ - struct stream_nav_state nav = {0}; - if (stream_control(demuxer->stream, STREAM_CTRL_GET_NAV_STATE, &nav) >= 1) { - if (nav.nav_active != p->nav_active) { diff --git a/third_party/mpv-player-jni/patches/mpv-discnav-poll.patch b/third_party/mpv-player-jni/patches/mpv-discnav-poll.patch deleted file mode 100644 index cad24ccee8..0000000000 --- a/third_party/mpv-player-jni/patches/mpv-discnav-poll.patch +++ /dev/null @@ -1,141 +0,0 @@ ---- a/demux/demux.c -+++ b/demux/demux.c -@@ -220,6 +220,7 @@ - bool hyst_active; - bool nav_active; // disc navigation (DVD/BD menus) is active - bool nav_pump; // one-shot request to force a single read -+ bool nav_poll; // event-only request, independent of packet demand - size_t max_bytes; - size_t max_bytes_bw; - bool seekable_cache; -@@ -1302,6 +1303,16 @@ - mp_mutex_lock(&in->lock); - in->nav_pump = true; - in->reading = true; -+ mp_cond_signal(&in->wakeup); -+ mp_mutex_unlock(&in->lock); -+} -+ -+void demux_poll_nav(struct demuxer *demuxer) -+{ -+ struct demux_internal *in = demuxer->in; -+ mp_assert(demuxer == in->d_user); -+ mp_mutex_lock(&in->lock); -+ in->nav_poll = true; - mp_cond_signal(&in->wakeup); - mp_mutex_unlock(&in->lock); - } -@@ -2687,6 +2698,19 @@ - - // In case the seekable cache was disabled. - free_empty_cached_ranges(in); -+} -+ -+// Run on the demux owner, but do not read media or change queue demand. A -+// paused menu must finish authored animation even when all queues are full. -+static bool poll_nav(struct demux_internal *in) -+{ -+ if (!in->nav_poll || in->blocked || demux_cancel_test(in->d_thread)) -+ return false; -+ in->nav_poll = false; -+ mp_mutex_unlock(&in->lock); -+ stream_control(in->d_thread->stream, STREAM_CTRL_NAV_POLL, NULL); -+ mp_mutex_lock(&in->lock); -+ return true; - } - - // Make demuxing progress. Return whether progress was made. -@@ -2716,6 +2740,8 @@ - execute_seek(in); - return true; - } -+ if (poll_nav(in)) -+ return true; - if (read_packet(in)) - return true; // read_packet unlocked, so recheck conditions - if (mp_time_ns() >= in->next_cache_update) { -@@ -3273,6 +3299,8 @@ - mp_mutex_lock(&in->lock); - if (in->nav_active != active) { - in->nav_active = active; -+ if (active) -+ MP_VERBOSE(demuxer, "WebHTV event-only disc navigation polling enabled\n"); - mp_cond_signal(&in->wakeup); - } - mp_mutex_unlock(&in->lock); ---- a/demux/demux.h -+++ b/demux/demux.h -@@ -350,6 +350,7 @@ - void demux_set_wakeup_cb(struct demuxer *demuxer, void (*cb)(void *ctx), void *ctx); - void demux_start_prefetch(struct demuxer *demuxer); - void demux_drive_nav(struct demuxer *demuxer); -+void demux_poll_nav(struct demuxer *demuxer); - - bool demux_cancel_test(struct demuxer *demuxer); - bool demux_read_interrupted(struct demuxer *demuxer); ---- a/player/command.c -+++ b/player/command.c -@@ -7562,6 +7562,10 @@ - demux_drive_nav(mpctx->demuxer); - } - -+ // A rejected click can mean an unfinished button animation. Keep its -+ // event loop alive without pretending the click succeeded or reading ahead. -+ if (mpctx->demuxer) -+ demux_poll_nav(mpctx->demuxer); - mp_wakeup_core(mpctx); - } - ---- a/player/discnav.c -+++ b/player/discnav.c -@@ -427,6 +427,24 @@ - st->menu_selected_track = NULL; - st->menu_saved_track = NULL; - } -+} -+ -+static void poll_disc_nav(struct MPContext *mpctx, struct stream *s, -+ const struct stream_nav_state *nav) -+{ -+ if (!mpctx->demuxer || !nav->nav_active || -+ (strcmp(s->info->name, "bd") != 0 && -+ strcmp(s->info->name, "bdmv/bluray") != 0)) -+ return; -+ bool idle = nav->still_active || -+ (mpctx->video_status == STATUS_EOF && -+ mpctx->audio_status == STATUS_EOF); -+ if (idle) -+ demux_drive_nav(mpctx->demuxer); -+ if (nav->menu_active) -+ demux_poll_nav(mpctx->demuxer); -+ if (idle || nav->menu_active) -+ mp_set_timeout(mpctx, 0.033); - } - - void disc_nav_update(struct MPContext *mpctx) -@@ -443,21 +461,10 @@ - mpctx->disc_nav_still_frame, still); - mpctx->disc_nav_still_frame = still; - -- // The BD-J VM progresses only on reads, pump them while playback idles -- // at EOF or a held still, per libbluray's requirement. Our core wakes up -- // at 1s intervals even at pause, but that's not enough for fluid button -- // navigation. So, we always drive the VM at least at 20Hz. -- if (have && mpctx->demuxer) { -- bool bd_idle = (strcmp(s->info->name, "bd") == 0 || -- strcmp(s->info->name, "bdmv/bluray") == 0) && -- (still || (mpctx->video_status == STATUS_EOF && -- mpctx->audio_status == STATUS_EOF)); -- if (bd_idle || (nav.menu_active && mpctx->paused)) { -- if (bd_idle) -- demux_drive_nav(mpctx->demuxer); -- mp_set_timeout(mpctx, 0.05); -- } -- } -+ // Poll HDMV events on the demux thread without demanding media packets. -+ // Merely waking the core while paused leaves button effects stuck forever. -+ if (have) -+ poll_disc_nav(mpctx, s, &nav); - - if (!have) { - if (st->overlay_visible) { diff --git a/third_party/mpv-player-jni/src/event.cpp b/third_party/mpv-player-jni/src/event.cpp index 89fcf1bc58..e99a30f8cb 100644 --- a/third_party/mpv-player-jni/src/event.cpp +++ b/third_party/mpv-player-jni/src/event.cpp @@ -6,7 +6,6 @@ #include "jni_utils.h" #include "log.h" #include "request.h" -#include "node_json.h" static void sendPropertyUpdateToJava(JNIEnv *env, mpv_event_property *prop) { @@ -32,16 +31,6 @@ static void sendPropertyUpdateToJava(JNIEnv *env, mpv_event_property *prop) jvalue = utf8_to_jstring(env, *(const char**)prop->data); env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_SS, jprop, jvalue); break; - case MPV_FORMAT_NODE: { - std::string json; - if (mpv_node_json::encode(static_cast(prop->data), &json)) - jvalue = utf8_to_jstring(env, json.c_str()); - else - ALOGE("bounded NODE snapshot rejected for %s", prop->name); - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventPropertyNode_SS, - jprop, jvalue); - break; - } default: ALOGV("sendPropertyUpdateToJava: Unknown property update format received in callback: %d!", prop->format); break; diff --git a/third_party/mpv-player-jni/src/iso_dvd.cpp b/third_party/mpv-player-jni/src/iso_dvd.cpp index 8f4263645b..862801363d 100644 --- a/third_party/mpv-player-jni/src/iso_dvd.cpp +++ b/third_party/mpv-player-jni/src/iso_dvd.cpp @@ -32,7 +32,7 @@ jmethodID manager_read; jmethodID manager_close; jmethodID manager_prepare_track_metadata; -enum class IsoKind { NONE, RAW, DVD, BLURAY }; +enum class IsoKind { NONE, DVD, BLURAY }; struct DvdStream { int64_t session_id = -1; @@ -45,7 +45,6 @@ struct DvdStream { uint64_t bluray_duration_ticks = 0; uint32_t bluray_playlist = 0; uint32_t bluray_title_count = 0; - bool owns_java_session = true; std::atomic cancelled{false}; std::mutex lock; std::unordered_map track_languages; @@ -171,18 +170,6 @@ int64_t stream_read(void *opaque, char *buffer, uint64_t requested) { auto *stream = static_cast(opaque); if (!stream || !buffer || stream->cancelled) return -1; std::lock_guard guard(stream->lock); - if (stream->kind == IsoKind::RAW) { - if (stream->iso_offset >= stream->output_size || requested == 0) return 0; - int wanted = requested > INT32_MAX ? INT32_MAX : static_cast(requested); - int64_t remaining = stream->output_size - stream->iso_offset; - if (wanted > remaining) wanted = static_cast(remaining); - int read = java_read(stream, stream->iso_offset, buffer, wanted); - if (read > 0) { - stream->iso_offset += read; - stream->output_offset += read; - } - return read; - } if (stream->kind == IsoKind::BLURAY) { int wanted = requested > INT32_MAX ? INT32_MAX : static_cast(requested); int read = bd_read(stream->bluray, reinterpret_cast(buffer), wanted); @@ -209,13 +196,6 @@ int64_t stream_seek(void *opaque, int64_t offset) { auto *stream = static_cast(opaque); if (!stream || offset < 0 || stream->cancelled) return MPV_ERROR_GENERIC; std::lock_guard guard(stream->lock); - if (stream->kind == IsoKind::RAW) { - if (stream->output_size >= 0 && offset > stream->output_size) - return MPV_ERROR_GENERIC; - stream->iso_offset = offset; - stream->output_offset = offset; - return offset; - } if (stream->kind == IsoKind::BLURAY) { int64_t result = bd_seek(stream->bluray, static_cast(offset)); if (result < 0) return MPV_ERROR_GENERIC; @@ -322,10 +302,7 @@ void stream_close(void *opaque) { } if (stream->nav) dvdnav_close(stream->nav); if (stream->bluray) bd_close(stream->bluray); - // A raw ISO callback is owned by MPV's nested Blu-ray stream. Keep the - // Java session alive across Blu-ray->DVD probing and let MpvPlayer close - // it with the public ISO URI when playback ends. - if (stream->owns_java_session) close_java_session(stream->session_id); + close_java_session(stream->session_id); delete stream; } @@ -423,27 +400,17 @@ int stream_open(void *, char *uri, mpv_stream_cb_info *info) { auto *stream = new DvdStream(); stream->session_id = id; JNIEnv *env = env_for_thread(); - jlong length = env ? env->CallStaticLongMethod(manager_class, manager_length, id) : -1; - if (!env || length <= 0) { + if (!env || env->CallStaticLongMethod(manager_class, manager_length, id) <= 0) { delete stream; return MPV_ERROR_LOADING_FAILED; } - const char *raw_marker = strstr(uri, "/raw"); - if (raw_marker && raw_marker[4] == '\0') { - stream->kind = IsoKind::RAW; - stream->owns_java_session = false; - stream->output_size = length; - ALOGV("iso-native opened raw Blu-ray ISO callback session=%lld size=%lld", - static_cast(id), static_cast(stream->output_size)); - } else { - // Probe Blu-ray first. libdvdread may scan a very large UDF image - // sequentially while looking for VIDEO_TS, which makes BD ISO startup - // appear hung and wastes remote bandwidth. - if (!open_bluray(stream) && !open_dvd(stream)) { - close_java_session(id); - delete stream; - return MPV_ERROR_LOADING_FAILED; - } + // Probe Blu-ray first. libdvdread may scan a very large UDF image + // sequentially while looking for VIDEO_TS, which makes BD ISO startup + // appear hung and wastes remote bandwidth. + if (!open_bluray(stream) && !open_dvd(stream)) { + close_java_session(id); + delete stream; + return MPV_ERROR_LOADING_FAILED; } { std::lock_guard guard(streams_lock); diff --git a/third_party/mpv-player-jni/src/jni_utils.cpp b/third_party/mpv-player-jni/src/jni_utils.cpp index 4178521811..6906817b72 100644 --- a/third_party/mpv-player-jni/src/jni_utils.cpp +++ b/third_party/mpv-player-jni/src/jni_utils.cpp @@ -213,9 +213,6 @@ bool init_methods_cache(JNIEnv *env) cache_static_method( env, &mpv_MPVLib_eventProperty_SS, mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Ljava/lang/String;)V") && - cache_static_method( - env, &mpv_MPVLib_eventPropertyNode_SS, mpv_MPVLib, - "eventPropertyNode", "(Ljava/lang/String;Ljava/lang/String;)V") && cache_static_method(env, &mpv_MPVLib_event, mpv_MPVLib, "event", "(I)V") && cache_static_method(env, &mpv_MPVLib_eventCommandReply_Ji, mpv_MPVLib, diff --git a/third_party/mpv-player-jni/src/jni_utils.h b/third_party/mpv-player-jni/src/jni_utils.h index 56a4feb1d7..6fa2c64a6b 100644 --- a/third_party/mpv-player-jni/src/jni_utils.h +++ b/third_party/mpv-player-jni/src/jni_utils.h @@ -30,7 +30,6 @@ UTIL_EXTERN jmethodID mpv_MPVLib_eventProperty_S, mpv_MPVLib_eventProperty_Sl, mpv_MPVLib_eventProperty_Sd, mpv_MPVLib_eventProperty_SS, - mpv_MPVLib_eventPropertyNode_SS, mpv_MPVLib_event, mpv_MPVLib_eventCommandReply_Ji, mpv_MPVLib_eventEndFile_iiS, diff --git a/third_party/mpv-player-jni/src/node_json.h b/third_party/mpv-player-jni/src/node_json.h deleted file mode 100644 index 1738d901f5..0000000000 --- a/third_party/mpv-player-jni/src/node_json.h +++ /dev/null @@ -1,101 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -// Copy a NODE while mpv_wait_event still owns it. Never expose mpv pointers to -// Java, and bound both traversal and allocation for untrusted track metadata. -namespace mpv_node_json { -constexpr size_t MAX_BYTES = 512 * 1024; -constexpr int MAX_DEPTH = 12; -constexpr int MAX_NODES = 16384; - -class Writer { - std::string text; - int remaining = MAX_NODES; - - bool append(const char *s, size_t n) { - if (n > MAX_BYTES - text.size()) return false; - text.append(s, n); - return true; - } - - bool quote(const char *s) { - if (!s || !append("\"", 1)) return false; - for (size_t i = 0; s[i]; ++i) { - unsigned char c = static_cast(s[i]); - if (c == '"' || c == '\\') { - char escaped[] = {'\\', static_cast(c)}; - if (!append(escaped, 2)) return false; - } else if (c < 0x20) { - char escaped[7]; - std::snprintf(escaped, sizeof(escaped), "\\u%04x", c); - if (!append(escaped, 6)) return false; - } else if (!append(s + i, 1)) { - return false; - } - } - return append("\"", 1); - } - - bool node(const mpv_node &value, int depth) { - if (depth > MAX_DEPTH || --remaining < 0) return false; - char number[64]; - switch (value.format) { - case MPV_FORMAT_NONE: return append("null", 4); - case MPV_FORMAT_STRING: return quote(value.u.string); - case MPV_FORMAT_FLAG: - return value.u.flag ? append("true", 4) : append("false", 5); - case MPV_FORMAT_INT64: { - int n = std::snprintf(number, sizeof(number), "%lld", - static_cast(value.u.int64)); - return n > 0 && n < static_cast(sizeof(number)) && append(number, n); - } - case MPV_FORMAT_DOUBLE: { - if (!std::isfinite(value.u.double_)) return append("null", 4); - // snprintf is locale-dependent; avoid emitting a comma decimal. - int n = std::snprintf(number, sizeof(number), "%.17g", value.u.double_); - for (int i = 0; i < n && i < static_cast(sizeof(number)); ++i) - if (number[i] == ',') number[i] = '.'; - return n > 0 && n < static_cast(sizeof(number)) && append(number, n); - } - case MPV_FORMAT_NODE_ARRAY: - case MPV_FORMAT_NODE_MAP: { - const mpv_node_list *list = value.u.list; - bool map = value.format == MPV_FORMAT_NODE_MAP; - if (!list || list->num < 0 || list->num > remaining || - (list->num && (!list->values || (map && !list->keys)))) return false; - if (!append(map ? "{" : "[", 1)) return false; - for (int i = 0; i < list->num; ++i) { - if (i && !append(",", 1)) return false; - if (map && (!quote(list->keys[i]) || !append(":", 1))) return false; - if (!node(list->values[i], depth + 1)) return false; - } - return append(map ? "}" : "]", 1); - } - default: return false; - } - } - -public: - bool write(const mpv_node *value, std::string *result) { - if (!result) return false; - result->clear(); - if (!value || !node(*value, 0)) return false; - result->swap(text); - return true; - } -}; - -inline bool encode(const mpv_node *value, std::string *result) { - try { - return Writer().write(value, result); - } catch (const std::bad_alloc &) { - if (result) result->clear(); - return false; - } -} -} diff --git a/third_party/mpv-player-jni/tests/audiotrack_underrun_test.c b/third_party/mpv-player-jni/tests/audiotrack_underrun_test.c deleted file mode 100644 index 24b2345523..0000000000 --- a/third_party/mpv-player-jni/tests/audiotrack_underrun_test.c +++ /dev/null @@ -1,89 +0,0 @@ -#include -#include -#include -#include -#include - -typedef void JNIEnv; -struct priv { bool thread_terminate, compressed; void *audiotrack, *chunk; int lock, wakeup, chunksize; unsigned written_frames; }; -struct ao { struct priv *priv; int sstride, samplerate; }; -struct mp_aframe { int unused; }; -static const struct { int PLAYSTATE_PAUSED, PLAYSTATE_PLAYING; } AudioTrack = {0, 1}; -static const struct { int ERROR_DEAD_OBJECT; } AudioManager = {-6}; -static unsigned reads, writes, zero_writes, waits, empty_reads, short_writes, recreates; -static int play_state, write_result; -static bool stop_on_wait; -static struct ao *active; -#define MP_THREAD_VOID void -#define MP_THREAD_RETURN() return -#define MP_JNI_GET_ENV(ao) NULL -#define MP_JNI_CALL_INT(...) play_state -#define MP_TIME_S_TO_NS(s) ((int64_t)((s) * 1000000000.0)) -#define MP_TIME_MS_TO_NS(ms) ((int64_t)(ms) * 1000000) -#define MP_WARN(...) ((void)0) -#define MP_ERR(...) ((void)0) -static void mp_thread_set_name(const char *name) {} -static void mp_mutex_lock(int *lock) { assert(!*lock); *lock = 1; } -static void mp_mutex_unlock(int *lock) { assert(*lock); *lock = 0; } -static int64_t mp_time_ns(void) { return 0; } -static void mp_cond_timedwait(int *cond, int *lock, int64_t time) { - assert(*lock); - assert(time == MP_TIME_MS_TO_NS(play_state == AudioTrack.PLAYSTATE_PLAYING ? 20 : 300)); - waits++; - if (stop_on_wait) active->priv->thread_terminate = true; - else play_state = AudioTrack.PLAYSTATE_PLAYING; -} -static double AudioTrack_getLatency(struct ao *ao) { return 0; } -static int ao_read_data(struct ao *ao, void **data, int samples, int64_t ts, bool *eof, bool pad, bool blocking) { - assert(!pad && !blocking); - assert(++reads < 20); // A missing empty-read wait must not hang the test. - return reads <= empty_reads ? 0 : 8; -} -static int AudioTrack_write(struct ao *ao, const void *source, int len) { - writes++; - zero_writes += len == 0; - ao->priv->thread_terminate = true; - return write_result == INT_MAX ? len : write_result; -} -static int AudioTrack_Recreate(struct ao *ao) { recreates++; return 0; } -static struct mp_aframe *ao_read_frame(struct ao *ao) { return NULL; } -static uint8_t **mp_aframe_get_data_ro(struct mp_aframe *frame) { return NULL; } -static size_t mp_aframe_get_data_size(struct mp_aframe *frame) { return 0; } -static unsigned mp_aframe_get_size(struct mp_aframe *frame) { return 0; } -static void ao_request_reload(struct ao *ao) {} -static void talloc_free(void *data) {} - -#include "ao_thread_under_test.h" - -static unsigned run(unsigned empty, bool terminate_on_wait, bool paused, bool compressed, int result) { - reads = writes = zero_writes = waits = short_writes = recreates = 0; - empty_reads = empty; - stop_on_wait = terminate_on_wait; - play_state = paused ? AudioTrack.PLAYSTATE_PAUSED : AudioTrack.PLAYSTATE_PLAYING; - write_result = result; - struct priv p = {.audiotrack = (void *)1, .chunksize = 4096, .compressed = compressed}; - struct ao ao = {.priv = &p, .sstride = 24, .samplerate = 48000}; - active = &ao; - ao_thread(&ao); - assert(p.lock == 0); - assert(zero_writes == 0); - return p.written_frames; -} - -int main(void) { - assert(run(5, false, false, false, INT_MAX) == 8); - assert(reads == 6 && waits == 5 && writes == 1); - assert(run(1, true, false, false, INT_MAX) == 0); - assert(reads == 1 && waits == 1 && writes == 0); - assert(run(0, false, true, false, INT_MAX) == 8); - assert(waits == 1 && writes == 1); - assert(run(0, false, false, false, INT_MAX) == 8); - assert(waits == 0 && writes == 1); - assert(run(0, false, false, false, 4 * 24) == 4); - assert(waits == 0 && writes == 1); - assert(run(0, false, false, false, AudioManager.ERROR_DEAD_OBJECT) == 0); - assert(recreates == 1 && waits == 0); - assert(run(0, true, false, true, INT_MAX) == 0); - assert(waits == 1 && writes == 0); - puts("PASS: PCM empty-read wait, recovery, stop, pause/start, normal/short write, dead-object and compressed wait (7 cases)"); -} diff --git a/third_party/mpv-player-jni/tests/bluray_menu_routing_test.c b/third_party/mpv-player-jni/tests/bluray_menu_routing_test.c deleted file mode 100644 index 75d8c2a821..0000000000 --- a/third_party/mpv-player-jni/tests/bluray_menu_routing_test.c +++ /dev/null @@ -1,436 +0,0 @@ -#include -#include -#include -#include -#include "libbluray/decoders/graphics_controller.h" -#include "libbluray/decoders/ig.h" -#include "libbluray/decoders/textst.h" -#include "libbluray/decoders/graphics_processor.h" -#include "libbluray/hdmv/mobj_data.h" -#include "libbluray/hdmv/hdmv_insn.h" -#include "libbluray/keys.h" - -#define GC_TRACE(...) do { if (0) fprintf(stderr, __VA_ARGS__); } while (0) -#define GC_ERROR(...) GC_TRACE(__VA_ARGS__) -enum { PSR_SELECTED_BUTTON_ID, PSR_MENU_PAGE_ID, BTN_SELECTED }; -#include "menu_visit_types.h" -struct graphics_controller_s { - uint32_t *regs; - PG_DISPLAY_SET *igs; - struct { unsigned enabled_button; } bog_data[MAX_NUM_BOGS]; - unsigned ig_open, ig_drawn, valid_mouse_position, mouse_button_id, popup_visible; - uint16_t mouse_x, mouse_y; - unsigned mouse_parent_position, menu_history_count, menu_entry_pending; - uint16_t menu_entry_page, menu_entry_button, pointer_origin_button; - uint64_t menu_entry_deadline; - MENU_PARENT menu_history[MENU_HISTORY_MAX]; - unsigned button_effect_running, pointer_route_pending, pointer_target_page; - unsigned pointer_target_button, pointer_route_steps; - uint64_t pointer_route_deadline; - void *in_effects, *out_effects; -}; -static uint64_t now; -static uint64_t bd_get_scr(void) { return now; } -static uint32_t bd_psr_read(uint32_t *regs, unsigned index) { return regs[index]; } -static BD_IG_PAGE *_find_page(BD_IG_INTERACTIVE_COMPOSITION *ic, unsigned id) -{ - for (unsigned i = 0; i < ic->num_pages; i++) if (ic->page[i].id == id) return &ic->page[i]; - return NULL; -} -static BD_IG_BUTTON *_find_button_bog(BD_IG_BOG *bog, unsigned id) -{ - for (unsigned i = 0; i < bog->num_buttons; i++) if (bog->button[i].id == id) return &bog->button[i]; - return NULL; -} -static BD_IG_BUTTON *_find_button_page(BD_IG_PAGE *page, unsigned id, unsigned *bog) -{ - for (unsigned i = 0; i < page->num_bogs; i++) { - BD_IG_BUTTON *button = _find_button_bog(&page->bog[i], id); - if (button) { if (bog) *bog = i; return button; } - } - return NULL; -} -static void _select_button(GRAPHICS_CONTROLLER *gc, uint32_t id) { gc->regs[PSR_SELECTED_BUTTON_ID] = id; } -static int _render_page(GRAPHICS_CONTROLLER *gc, unsigned activated, GC_NAV_CMDS *cmds) -{ - BD_IG_PAGE *page = _find_page(&gc->igs->ics->interactive_composition, gc->regs[PSR_MENU_PAGE_ID]); - BD_IG_BUTTON *selected = _find_button_page(page, gc->regs[PSR_SELECTED_BUTTON_ID], NULL); - if (selected && selected->auto_action_flag && activated != selected->id && cmds) { - cmds->num_nav_cmds = selected->num_nav_cmds; - cmds->nav_cmds = selected->nav_cmds; - } - return 1; -} -static void _reset_user_timeout(GRAPHICS_CONTROLLER *gc) { (void)gc; } -static BD_PG_OBJECT *_find_object_for_button(PG_DISPLAY_SET *set, const BD_IG_BUTTON *button, int state, void *unused) -{ - (void)set; (void)state; (void)unused; - static BD_PG_OBJECT object = {.width = 50, .height = 50}; - static BD_PG_OBJECT background = {.width = 400, .height = 80}; - if (button->selected_start_object_id_ref == 83) return &background; - return button->selected_start_object_id_ref == 0xffff ? NULL : &object; -} -#include "menu_history_under_test.h" -#include "menu_route_under_test.h" - -struct fixture { - GRAPHICS_CONTROLLER gc; - uint32_t regs[2]; - PG_DISPLAY_SET set; - BD_IG_INTERACTIVE ig; - BD_IG_PAGE pages[MENU_HISTORY_MAX + 2]; - BD_IG_BOG bogs[MENU_HISTORY_MAX + 2][8]; - BD_IG_BUTTON buttons[MENU_HISTORY_MAX + 2][8]; - MOBJ_CMD action, back, other_back, transport[2], register_back[4], nop; -}; -static MOBJ_CMD set_page(unsigned page, unsigned button) -{ - return (MOBJ_CMD){.insn = {.grp = INSN_GROUP_SET, .sub_grp = SET_SETSYSTEM, - .set_opt = INSN_SET_BUTTON_PAGE, .op_cnt = 2, .imm_op1 = 1, .imm_op2 = 1}, - .dst = 0x80000000 | button, .src = 0x80000000 | page}; -} -static BD_IG_BUTTON make_button(unsigned id, unsigned x, unsigned y, unsigned object, MOBJ_CMD *cmd, int automatic) -{ - return (BD_IG_BUTTON){.id = id, .x_pos = x, .y_pos = y, .auto_action_flag = automatic, - .upper_button_id_ref = id, .lower_button_id_ref = id, .left_button_id_ref = id, .right_button_id_ref = id, - .normal_start_object_id_ref = object, .selected_start_object_id_ref = object, .activated_start_object_id_ref = object, - .num_nav_cmds = cmd ? 1 : 0, .nav_cmds = cmd}; -} -static void enter_page(struct fixture *f, unsigned page, unsigned selected) -{ - _observe_menu_page_change(&f->gc, f->pages[page].id); - f->regs[PSR_MENU_PAGE_ID] = f->pages[page].id; - f->regs[PSR_SELECTED_BUTTON_ID] = selected; - for (unsigned i = 0; i < f->pages[page].num_bogs; i++) - f->gc.bog_data[i].enabled_button = f->pages[page].bog[i].default_valid_button_id_ref; -} -static void init(struct fixture *f) -{ - memset(f, 0, sizeof(*f)); - now = 90000; - f->action = set_page(77, 8); - f->back = set_page(41, 100); - f->other_back = set_page(42, 100); - f->buttons[0][0] = make_button(100, 100, 100, 11, &f->action, 0); - f->buttons[0][1] = make_button(101, 250, 100, 21, &f->action, 0); - f->buttons[1][0] = make_button(8, 10, 40, 101, &f->action, 0); - f->buttons[1][0].lower_button_id_ref = 99; - f->buttons[1][1] = make_button(60, 100, 100, 11, NULL, 0); - f->buttons[1][2] = make_button(61, 250, 100, 21, NULL, 0); - f->buttons[1][3] = make_button(99, 0, 0, 0xffff, &f->back, 1); - f->buttons[2][0] = f->buttons[0][0]; - f->buttons[2][1] = f->buttons[0][1]; - for (unsigned p = 0; p < 3; p++) { - f->pages[p] = (BD_IG_PAGE){.id = p == 1 ? 77 : 41 + (p == 2), .num_bogs = p == 1 ? 4 : 2, - .default_selected_button_id_ref = p == 1 ? 8 : 100, .bog = f->bogs[p]}; - for (unsigned i = 0; i < 8; i++) f->bogs[p][i] = (BD_IG_BOG){ - .num_buttons = 1, .button = &f->buttons[p][i], .default_valid_button_id_ref = f->buttons[p][i].id}; - } - f->ig.interactive_composition = (BD_IG_INTERACTIVE_COMPOSITION){.num_pages = 3, .page = f->pages}; - f->set.ics = &f->ig; - f->gc = (GRAPHICS_CONTROLLER){.regs = f->regs, .igs = &f->set, .ig_open = 1, .ig_drawn = 1}; - enter_page(f, 1, 8); -} -static BD_IG_BUTTON *find(struct fixture *f, const BD_IG_BUTTON *copy) -{ - BD_IG_PAGE *parent; - BD_IG_BUTTON *target; - return _find_authored_return(&f->gc, &f->pages[1], copy, &parent, &target); -} -static void add_transport(struct fixture *f) -{ - f->transport[0] = (MOBJ_CMD){.insn = {.grp = INSN_GROUP_SET, .sub_grp = SET_SETSYSTEM, - .set_opt = INSN_ENABLE_BUTTON, .imm_op1 = 1, .op_cnt = 1}, .dst = 30}; - f->transport[1] = set_page(77, 30); - f->transport[1].src = 0; /* current page, selected button only */ - f->buttons[1][4] = make_button(17, 0, 0, 0xffff, f->transport, 1); - f->buttons[1][4].num_nav_cmds = 2; - f->buttons[1][5] = make_button(30, 10, 80, 102, &f->action, 0); - f->buttons[1][5].lower_button_id_ref = 99; - f->buttons[1][0].lower_button_id_ref = 17; - f->pages[1].num_bogs = 6; - for (unsigned i = 4; i < 6; i++) f->bogs[1][i].default_valid_button_id_ref = f->buttons[1][i].id; - enter_page(f, 1, 8); -} -static void add_background_return(struct fixture *f) -{ - /* Same structure as the House disc, deliberately different page/GPR IDs. */ - f->register_back[0] = (MOBJ_CMD){.insn = {.grp = INSN_GROUP_SET, .sub_grp = SET_SET, - .set_opt = INSN_MOVE, .op_cnt = 2, .imm_op2 = 1}, .dst = 17, .src = 100}; - f->register_back[1] = f->register_back[0]; - f->register_back[1].dst = 29; - f->register_back[1].src = 41; - f->register_back[2] = set_page(29, 17); - f->register_back[2].insn.imm_op1 = f->register_back[2].insn.imm_op2 = 0; - f->buttons[1][3].nav_cmds = f->register_back; - f->buttons[1][3].num_nav_cmds = 4; - f->buttons[1][1] = make_button(7, 80, 90, 83, &f->nop, 0); - f->buttons[1][1].upper_button_id_ref = f->buttons[1][1].lower_button_id_ref = 8; - f->buttons[1][1].left_button_id_ref = f->buttons[1][1].right_button_id_ref = 8; - f->bogs[1][1].default_valid_button_id_ref = 7; - f->bogs[1][2].default_valid_button_id_ref = 0xffff; /* no individual toolbar copies */ - f->pages[1].default_selected_button_id_ref = 0xffff; - enter_page(f, 1, 8); -} -static void test_background_return(void) -{ - struct fixture f; - GC_NAV_CMDS cmds = {0}; - init(&f); add_background_return(&f); - assert(_authored_return_page(&f.buttons[1][3]) == 41); - assert(find(&f, NULL) == &f.buttons[1][3]); /* Back does not need a pointer */ - assert(_mouse_move(&f.gc, 260, 110, &cmds) == 1 && f.regs[PSR_SELECTED_BUTTON_ID] == 8); - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1 && cmds.nav_cmds == f.register_back); - assert(f.gc.pointer_target_button == 101); - enter_page(&f, 0, 100); cmds = (GC_NAV_CMDS){0}; - assert(_complete_pointer_route(&f.gc, &cmds) && cmds.nav_cmds == &f.action); - init(&f); add_background_return(&f); cmds = (GC_NAV_CMDS){0}; - assert(_mouse_move(&f.gc, 110, 110, &cmds) == 1); - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1); - enter_page(&f, 0, 100); cmds = (GC_NAV_CMDS){0}; - assert(_complete_pointer_route(&f.gc, &cmds) && !cmds.num_nav_cmds); /* same icon closes */ - init(&f); add_background_return(&f); f.regs[PSR_SELECTED_BUTTON_ID] = 7; - assert(find(&f, NULL) == &f.buttons[1][3]); /* previously trapped background focus */ - init(&f); add_background_return(&f); cmds = (GC_NAV_CMDS){0}; - assert(_mouse_move(&f.gc, 200, 110, &cmds) == 1); /* gap between the parent icons */ - assert(!_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) && !cmds.num_nav_cmds); - init(&f); add_background_return(&f); cmds = (GC_NAV_CMDS){0}; - f.buttons[0][1].x_pos = f.buttons[0][0].x_pos; - assert(_mouse_move(&f.gc, 110, 110, &cmds) == 1); - assert(!_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds)); /* ambiguous hit */ - init(&f); add_background_return(&f); f.buttons[1][1].x_pos = 150; - assert(!find(&f, NULL)); /* one contained parent button center is insufficient */ - init(&f); add_background_return(&f); cmds = (GC_NAV_CMDS){0}; - f.buttons[0][0].y_pos = f.buttons[0][1].y_pos = 125; /* edges extend below the cropped backdrop */ - assert(_mouse_move(&f.gc, 260, 150, &cmds) == 1 && f.regs[PSR_SELECTED_BUTTON_ID] == 8); - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1 && f.gc.pointer_target_button == 101); - init(&f); add_background_return(&f); f.buttons[0][0].y_pos = f.buttons[0][1].y_pos = 165; - assert(!find(&f, NULL)); /* a slight edge overlap without matching centers is not a toolbar */ - init(&f); add_background_return(&f); f.gc.bog_data[3].enabled_button = 0xffff; - assert(!find(&f, NULL)); - init(&f); add_background_return(&f); f.register_back[2].src = 0x80000000 | 30; - assert(!find(&f, NULL)); /* unknown GPR, never read speculative VM state */ - init(&f); add_background_return(&f); f.register_back[0].insn.imm_op2 = 0; - assert(!find(&f, NULL)); /* nonconstant assignment */ - init(&f); add_background_return(&f); f.register_back[0].dst = 18; - assert(!find(&f, NULL)); /* missing button operand and unrelated write */ - init(&f); add_background_return(&f); f.register_back[3] = f.register_back[0]; - assert(!find(&f, NULL)); /* only NOP may follow the terminating menu command */ - init(&f); add_background_return(&f); f.register_back[0].insn.grp = INSN_GROUP_BRANCH; - f.register_back[0].insn.sub_grp = BRANCH_JUMP; f.register_back[0].insn.branch_opt = INSN_JUMP_TITLE; - assert(!find(&f, NULL)); -} - -static void open_without_child_toolbar(struct fixture *f) -{ - GC_NAV_CMDS cmds = {0}; - init(f); - /* The visible toolbar may be video/normal-only art, not current-page hitboxes. */ - f->buttons[1][1].selected_start_object_id_ref = 0xffff; - f->buttons[1][2].selected_start_object_id_ref = 0xffff; - enter_page(f, 0, 100); - assert(_user_input(&f->gc, BD_VK_ENTER, &cmds) == 1 && f->gc.menu_entry_pending); - enter_page(f, 1, 8); - assert(f->gc.menu_history_count == 1 && !f->gc.menu_entry_pending); - assert(f->gc.menu_history[0].page == 41 && f->gc.menu_history[0].child == 77); -} - -static void test_observed_parent(void) -{ - struct fixture f; - GC_NAV_CMDS cmds = {0}; - open_without_child_toolbar(&f); - assert(_mouse_move(&f.gc, 260, 110, &cmds) == 1 && f.gc.mouse_parent_position); - assert(f.regs[PSR_SELECTED_BUTTON_ID] == 8); /* hovering a parent never steals child focus */ - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1 && cmds.nav_cmds == &f.back); - assert(f.gc.pointer_origin_button == 100 && f.gc.pointer_target_button == 101); - enter_page(&f, 0, 101); cmds = (GC_NAV_CMDS){0}; - assert(f.gc.menu_history_count == 0); - assert(_complete_pointer_route(&f.gc, &cmds) == 1 && cmds.nav_cmds == &f.action); - assert(f.gc.menu_entry_pending); /* sibling selection can establish its next child */ - enter_page(&f, 1, 8); - assert(f.gc.menu_history_count == 1 && f.gc.menu_history[0].entry == 101); - - open_without_child_toolbar(&f); cmds = (GC_NAV_CMDS){0}; - assert(_mouse_move(&f.gc, 110, 110, &cmds) == 1); - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1); - enter_page(&f, 0, 101); cmds = (GC_NAV_CMDS){0}; - assert(_complete_pointer_route(&f.gc, &cmds) == 1 && !cmds.num_nav_cmds); /* origin closes even if return selected another button */ - - open_without_child_toolbar(&f); cmds = (GC_NAV_CMDS){0}; - assert(_user_input(&f.gc, BD_VK_MENU_BACK, &cmds) == 1 && cmds.nav_cmds == &f.back); - enter_page(&f, 0, 100); cmds = (GC_NAV_CMDS){0}; - assert(_complete_pointer_route(&f.gc, &cmds) && !cmds.num_nav_cmds); - - open_without_child_toolbar(&f); cmds = (GC_NAV_CMDS){0}; - f.buttons[1][0].x_pos = 250; f.buttons[1][0].y_pos = 100; - assert(_mouse_move(&f.gc, 260, 110, &cmds) == 1 && !f.gc.mouse_parent_position); - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1 && cmds.nav_cmds == &f.action); /* actual child wins */ - - open_without_child_toolbar(&f); cmds = (GC_NAV_CMDS){0}; - assert(!_mouse_move(&f.gc, 200, 110, &cmds)); /* gap */ - assert(!_mouse_move(&f.gc, 1900, 1000, &cmds)); /* outside */ - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) < 0 && !cmds.num_nav_cmds); - f.buttons[0][0].x_pos = f.buttons[0][1].x_pos; - assert(!_mouse_move(&f.gc, 260, 110, &cmds)); /* ambiguous parent overlap */ - - open_without_child_toolbar(&f); cmds = (GC_NAV_CMDS){0}; - f.gc.menu_history[0].enabled[1] = 0xffff; - assert(!_mouse_move(&f.gc, 260, 110, &cmds)); /* not enabled when observed */ - open_without_child_toolbar(&f); cmds = (GC_NAV_CMDS){0}; - assert(_mouse_move(&f.gc, 260, 110, &cmds) == 1); - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1); - enter_page(&f, 0, 100); f.gc.bog_data[1].enabled_button = 0xffff; cmds = (GC_NAV_CMDS){0}; - assert(!_complete_pointer_route(&f.gc, &cmds) && !cmds.num_nav_cmds); /* revalidate after actual return */ - - open_without_child_toolbar(&f); cmds = (GC_NAV_CMDS){0}; - _clear_menu_history(&f.gc); - assert(!find(&f, NULL) && !_mouse_move(&f.gc, 260, 110, &cmds)); /* unvisited/reset context */ - assert(!f.gc.valid_mouse_position && !f.gc.pointer_route_pending); - open_without_child_toolbar(&f); f.pages[0].version++; cmds = (GC_NAV_CMDS){0}; - assert(!_mouse_move(&f.gc, 260, 110, &cmds)); - open_without_child_toolbar(&f); f.pages[1].version++; cmds = (GC_NAV_CMDS){0}; - assert(!_mouse_move(&f.gc, 260, 110, &cmds)); - open_without_child_toolbar(&f); f.pages[0].num_bogs = MAX_NUM_BOGS + 1; - assert(!_visited_parent(&f.gc, &f.pages[1])); - open_without_child_toolbar(&f); enter_page(&f, 2, 100); - assert(!f.gc.menu_history_count); /* automatic/unrelated transition is not an ancestor */ - - open_without_child_toolbar(&f); cmds = (GC_NAV_CMDS){0}; - f.gc.bog_data[3].enabled_button = 0xffff; - assert(!_mouse_move(&f.gc, 260, 110, &cmds)); /* no reachable authored return */ - open_without_child_toolbar(&f); f.back.insn.imm_op2 = 0; cmds = (GC_NAV_CMDS){0}; - assert(!_mouse_move(&f.gc, 260, 110, &cmds)); /* unknown VM page is not synthesized */ - open_without_child_toolbar(&f); f.gc.button_effect_running = 1; cmds = (GC_NAV_CMDS){0}; - assert(_mouse_move(&f.gc, 260, 110, &cmds) < 0); - f.gc.button_effect_running = 0; f.gc.in_effects = &f; - assert(!_mouse_move(&f.gc, 260, 110, &cmds)); - f.gc.in_effects = NULL; - assert(_mouse_move(&f.gc, 260, 110, &cmds) == 1); - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1); - _user_input(&f.gc, BD_VK_LEFT, &cmds); - assert(!f.gc.pointer_route_pending && !f.gc.mouse_parent_position); - - open_without_child_toolbar(&f); cmds = (GC_NAV_CMDS){0}; - assert(_mouse_move(&f.gc, 260, 110, &cmds) == 1); - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1); - now += 180001; cmds = (GC_NAV_CMDS){0}; - assert(!_complete_pointer_route(&f.gc, &cmds) && !f.gc.pointer_route_pending); - - init(&f); enter_page(&f, 0, 100); cmds = (GC_NAV_CMDS){0}; - assert(_user_input(&f.gc, BD_VK_ENTER, &cmds) == 1); - now += 180001; enter_page(&f, 1, 8); - assert(!f.gc.menu_history_count); /* late automatic jump cannot inherit old click */ - - open_without_child_toolbar(&f); cmds = (GC_NAV_CMDS){0}; - assert(_user_input(&f.gc, BD_VK_ENTER, &cmds) == 1); - enter_page(&f, 2, 100); - assert(f.gc.menu_history_count == 2 && f.gc.menu_history[1].page == 77); - enter_page(&f, 1, 8); - assert(f.gc.menu_history_count == 1); - enter_page(&f, 0, 100); - assert(f.gc.menu_history_count == 0); - - init(&f); - f.ig.interactive_composition.num_pages = MENU_HISTORY_MAX + 2; - for (unsigned p = 3; p < MENU_HISTORY_MAX + 2; p++) { - f.pages[p] = f.pages[2]; f.pages[p].id = 40 + p; f.pages[p].bog = f.bogs[p]; - for (unsigned i = 0; i < 2; i++) { - f.buttons[p][i] = f.buttons[2][i]; f.bogs[p][i] = f.bogs[2][i]; - f.bogs[p][i].button = &f.buttons[p][i]; - } - } - enter_page(&f, 0, 100); - for (unsigned p = 1; p < MENU_HISTORY_MAX + 2; p++) { - cmds = (GC_NAV_CMDS){0}; - assert(_user_input(&f.gc, BD_VK_ENTER, &cmds) == 1); - enter_page(&f, p, p == 1 ? 8 : 100); - assert(f.gc.menu_history_count == (p < MENU_HISTORY_MAX ? p : MENU_HISTORY_MAX)); - } - assert(f.gc.menu_history[0].page == 77); /* oldest entry evicted at capacity */ - enter_page(&f, 1, 8); - assert(!f.gc.menu_history_count); - - /* Same-page auto transport used by subtitle/audio choices must still reach Back. */ - open_without_child_toolbar(&f); add_transport(&f); cmds = (GC_NAV_CMDS){0}; - assert(_mouse_move(&f.gc, 260, 110, &cmds) == 1); - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1 && cmds.nav_cmds == f.transport); - enter_page(&f, 1, 30); cmds = (GC_NAV_CMDS){0}; - assert(_complete_pointer_route(&f.gc, &cmds) == 1 && cmds.nav_cmds == &f.back); - enter_page(&f, 0, 100); cmds = (GC_NAV_CMDS){0}; - assert(_complete_pointer_route(&f.gc, &cmds) == 1 && cmds.nav_cmds == &f.action); - puts("PASS: observed parent miss-hit / origin toggle & sibling reactivation / current-page precedence / enabled snapshots / context & version invalidation / bounded navigation / transport / animation / cancellation / timeout"); -} -int main(void) -{ - test_observed_parent(); - test_background_return(); - struct fixture f; - GC_NAV_CMDS cmds = {0}; - init(&f); - MOBJ_CMD nop[2] = {0}; - BD_IG_BUTTON no_action = make_button(7, 0, 792, 83, nop, 0); - assert(_button_has_no_action(&no_action)); - no_action.num_nav_cmds = 2; - assert(_button_has_no_action(&no_action)); - nop[1] = f.action; - assert(!_button_has_no_action(&no_action)); - no_action.nav_cmds = NULL; - assert(!_button_has_no_action(&no_action)); - assert(!_button_has_no_action(NULL)); - assert(_inert_pointer_button(&f.buttons[1][2])); - assert(find(&f, &f.buttons[1][2]) == &f.buttons[1][3]); - assert(_mouse_move(&f.gc, 260, 110, &cmds) == 1 && f.regs[PSR_SELECTED_BUTTON_ID] == 8); - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1 && cmds.nav_cmds == &f.back); - assert(f.gc.pointer_route_pending && f.gc.pointer_target_button == 101); - enter_page(&f, 0, 100); - assert(!_complete_pointer_route(&f.gc, &cmds)); /* existing VM commands are never overwritten */ - cmds = (GC_NAV_CMDS){0}; - assert(_complete_pointer_route(&f.gc, &cmds) && cmds.nav_cmds == &f.action && f.regs[PSR_SELECTED_BUTTON_ID] == 101); - assert(!f.gc.pointer_route_pending); - - init(&f); cmds = (GC_NAV_CMDS){0}; - assert(_mouse_move(&f.gc, 110, 110, &cmds) == 1); - assert(_user_input(&f.gc, BD_VK_MOUSE_ACTIVATE, &cmds) == 1); - enter_page(&f, 0, 100); cmds = (GC_NAV_CMDS){0}; - assert(_complete_pointer_route(&f.gc, &cmds) && !cmds.num_nav_cmds); /* same entry closes */ - - init(&f); add_transport(&f); cmds = (GC_NAV_CMDS){0}; - assert(find(&f, NULL) == &f.buttons[1][4]); - assert(_user_input(&f.gc, BD_VK_MENU_BACK, &cmds) == 1 && cmds.nav_cmds == f.transport); - enter_page(&f, 1, 30); cmds = (GC_NAV_CMDS){0}; - assert(_complete_pointer_route(&f.gc, &cmds) && cmds.nav_cmds == &f.back); - enter_page(&f, 0, 100); cmds = (GC_NAV_CMDS){0}; - assert(_complete_pointer_route(&f.gc, &cmds) && !cmds.num_nav_cmds); - - init(&f); add_transport(&f); - f.transport[0].insn.set_opt = INSN_SET_STREAM; - assert(!find(&f, NULL)); /* never cross an audio/playlist/register side effect */ - init(&f); f.gc.bog_data[3].enabled_button = 0xffff; - assert(!find(&f, NULL)); - init(&f); f.buttons[1][3].selected_start_object_id_ref = 55; - assert(!find(&f, NULL)); /* a visible next-page auto is not Back */ - init(&f); f.back.insn.imm_op2 = 0; - assert(!find(&f, NULL)); /* unknown register target */ - init(&f); f.buttons[1][4] = make_button(199, 0, 0, 0xffff, &f.other_back, 1); - f.pages[1].num_bogs = 5; f.bogs[1][4].default_valid_button_id_ref = 199; - f.buttons[1][0].right_button_id_ref = 199; enter_page(&f, 1, 8); - assert(!find(&f, NULL)); /* conflicting parents are not guessed */ - init(&f); f.regs[PSR_SELECTED_BUTTON_ID] = 61; - assert(find(&f, NULL) == &f.buttons[1][3]); /* recover an old trapped selection */ - init(&f); cmds = (GC_NAV_CMDS){0}; - assert(_user_input(&f.gc, BD_VK_MENU_BACK, &cmds) == 1); - now += 180001; cmds = (GC_NAV_CMDS){0}; - assert(!_complete_pointer_route(&f.gc, &cmds) && !f.gc.pointer_route_pending); - init(&f); cmds = (GC_NAV_CMDS){0}; - assert(_user_input(&f.gc, BD_VK_MENU_BACK, &cmds) == 1); - enter_page(&f, 1, 8); cmds = (GC_NAV_CMDS){0}; - _user_input(&f.gc, BD_VK_LEFT, &cmds); - assert(!f.gc.pointer_route_pending); - f.gc.pointer_route_pending = 1; - assert(_mouse_move(&f.gc, 1900, 1000, &cmds) == 0 && !f.gc.pointer_route_pending); - init(&f); f.gc.button_effect_running = 1; - assert(_user_input(&f.gc, BD_VK_MENU_BACK, &cmds) == -1); - puts("PASS: authored parent graph / inert & merged-background pointer selection / constant GPR return / sibling activation / toggle close / multi-step VM transport / disabled & visible auto rejection / ambiguous & unknown register rejection / timeout / newer-input cancellation / effect deferral"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/disc_navigation_input_test.c b/third_party/mpv-player-jni/tests/disc_navigation_input_test.c deleted file mode 100644 index 9a5d6c1228..0000000000 --- a/third_party/mpv-player-jni/tests/disc_navigation_input_test.c +++ /dev/null @@ -1,372 +0,0 @@ -#include -#include -#include -#include -#include - -#define MPMIN(a, b) ((a) < (b) ? (a) : (b)) -#define MP_TIME_MS_TO_NS(ms) ((ms) * 1000000LL) -#define MP_INFO(ctx, ...) do { if (false) fprintf(stderr, __VA_ARGS__); } while (0) -enum stream_nav_action { - STREAM_NAV_UP, STREAM_NAV_DOWN, STREAM_NAV_LEFT, STREAM_NAV_RIGHT, - STREAM_NAV_SELECT, STREAM_NAV_MENU_ROOT, STREAM_NAV_MENU_TITLE, - STREAM_NAV_MENU_POPUP, STREAM_NAV_PREV_MENU, STREAM_NAV_MOUSE_MOVE, - STREAM_NAV_MOUSE_CLICK, -}; -enum { STREAM_CTRL_GET_NAV_STATE, STREAM_CTRL_NAV_CMD, STREAM_CTRL_NAV_POLL }; -enum { STREAM_ERROR = -1, STREAM_UNSUPPORTED = -2, STREAM_OK = 1 }; -enum { BD_VK_NONE, BD_VK_UP, BD_VK_DOWN, BD_VK_LEFT, BD_VK_RIGHT, - BD_VK_ENTER, BD_VK_POPUP, BD_VK_MOUSE_ACTIVATE, BD_VK_MENU_BACK }; -enum { BLURAY_UO_MENU_CALL = 1, - BLURAY_PLAYER_SETTING_UO_RESTRICTION_LEVEL = 0x102, - BLURAY_PLAYER_SETTING_UO_RESTRICTION_DISABLED = 0, - BLURAY_PLAYER_SETTING_UO_RESTRICTION_RELAXED = 5 }; -struct mp_osd_res { int w, h, ml, mr, mt, mb; }; -struct vo { bool config_ok; }; -struct input_ctx { int x, y, reads; }; -struct stream_nav_state { int src_w, src_h; }; -struct stream_nav_cmd { int action, x, y; }; -struct bluray_priv_s { - bool hdmv_mode, popup_supported, input_pending, data_delivered; - bool menu_event_active, overlay_ig_visible; - int64_t next_nav_poll_ns; - uint32_t uo_mask, nav_change_id, discontinuity_id, trace_request; - int overlay_lock; - void *bd; -}; -struct stream { struct bluray_priv_s *priv; struct stream_nav_state state; }; -typedef struct stream stream_t; -struct priv { bool is_bd, nav_active; }; -struct demuxer { struct stream *stream; int drives; struct priv *priv; int event_polls; }; -struct demux_packet { int cached; }; -struct MPContext { - struct vo *video_out; - struct input_ctx *input; - struct mp_osd_res *osd; - struct demuxer *demuxer; - int wakeups; -}; -struct mp_cmd_arg { union { double d; int i; } v; }; -struct mp_cmd_ctx { - struct MPContext *mpctx; - struct mp_cmd_arg args[4]; - bool success; -}; - -static int select_result, key_result, popup_result, root_result; -static int back_result, back_calls; -static int select_calls, key_calls, popup_calls, root_calls, click_x, click_y; -static uint32_t last_key; -static struct bluray_priv_s bluray; -static int polls; -static bool poll_failed, page_action_pending; -static int restriction_level, setting_calls, unrestricted_root_result; -static bool setting_failed; -static int64_t fake_time; -static int64_t mp_time_ns(void) { return fake_time; } -static void bluray_trace_begin(stream_t *s, const char *phase) { (void)s; (void)phase; } -static void bluray_trace_end(void) {} -static void bluray_trace_state(stream_t *s, const char *phase, int result, int64_t started) -{ (void)s; (void)phase; (void)result; (void)started; } -static void mp_mutex_lock(int *lock) { assert(!*lock); *lock = 1; } -static void mp_mutex_unlock(int *lock) { assert(*lock); *lock = 0; } -static int bd_mouse_select(void *bd, int64_t pts, uint16_t x, uint16_t y) -{ - (void)bd; (void)pts; - assert(!bluray.overlay_lock); - select_calls++; - click_x = x; - click_y = y; - return select_result; -} -static int bd_user_input(void *bd, int64_t pts, uint32_t key) -{ - (void)bd; (void)pts; - assert(!bluray.overlay_lock); - key_calls++; - last_key = key; - if (key == BD_VK_MENU_BACK) { - back_calls++; - return back_result; - } - if (key == BD_VK_POPUP) { - popup_calls++; - return popup_result; - } - return key_result; -} -static int bd_menu_call(void *bd, int64_t pts) -{ - (void)bd; (void)pts; - assert(!bluray.overlay_lock); - root_calls++; - return restriction_level == BLURAY_PLAYER_SETTING_UO_RESTRICTION_DISABLED - ? unrestricted_root_result : root_result; -} -static int bd_set_player_setting(void *bd, uint32_t setting, uint32_t value) -{ - (void)bd; - assert(!bluray.overlay_lock); - assert(setting == BLURAY_PLAYER_SETTING_UO_RESTRICTION_LEVEL); - setting_calls++; - if (setting_failed) return 0; - restriction_level = value; - return 1; -} -#include "disc_menu_call_under_test.h" -static int bluray_poll_hdmv_events(struct stream *s) -{ - assert(!s->priv->overlay_lock); - polls++; - if (poll_failed) return -1; - if (page_action_pending) { - s->priv->nav_change_id++; - page_action_pending = false; - } - return 0; -} -static int bluray_control(struct stream *s, int command, void *arg) -{ - struct bluray_priv_s *b = s->priv; - switch (command) { -#include "disc_pump_under_test.h" -#include "disc_bluray_input_under_test.h" - default: return STREAM_UNSUPPORTED; - } -} -static int stream_control(struct stream *s, int command, void *arg) -{ - if (command == STREAM_CTRL_GET_NAV_STATE) { - *(struct stream_nav_state *)arg = s->state; - return STREAM_OK; - } - return bluray_control(s, command, arg); -} -static struct stream *disc_nav_get_stream(struct MPContext *mpctx) -{ - return mpctx->demuxer ? mpctx->demuxer->stream : NULL; -} -static void demux_drive_nav(struct demuxer *demux) { demux->drives++; } -static void demux_poll_nav(struct demuxer *demux) { demux->event_polls++; } -static void mp_wakeup_core(struct MPContext *mpctx) { mpctx->wakeups++; } -static void mp_input_get_mouse_pos(struct input_ctx *input, int *x, int *y, int *hover) -{ - input->reads++; - *x = input->x; *y = input->y; *hover = 1; -} -static struct mp_osd_res osd_get_vo_res(struct mp_osd_res *osd) { return *osd; } -#include "disc_action_under_test.h" -#include "disc_pointer_under_test.h" -#include "disc_command_under_test.h" - -// Compile the actual disc-demux prologue before its cached packet read. -#include "disc_demux_input_under_test.h" - static struct demux_packet cached = {1}; - *out_pkt = &cached; - return true; -} - -static void reset_results(void) -{ - select_result = key_result = popup_result = root_result = 1; - select_calls = key_calls = popup_calls = root_calls = 0; - back_result = back_calls = 0; - fake_time = 0; - last_key = BD_VK_NONE; - polls = 0; - poll_failed = page_action_pending = false; - restriction_level = BLURAY_PLAYER_SETTING_UO_RESTRICTION_RELAXED; - setting_calls = 0; - setting_failed = false; - unrestricted_root_result = 1; - bluray = (struct bluray_priv_s){.hdmv_mode = true}; -} -static bool run(struct MPContext *mpctx, int action, double x, double y, int window) -{ - struct mp_cmd_ctx cmd = {.mpctx = mpctx, .success = true}; - cmd.args[0].v.i = action; - cmd.args[1].v.d = x; - cmd.args[2].v.d = y; - cmd.args[3].v.i = window; - cmd_discnav(&cmd); - return cmd.success; -} -static void expect_point(struct MPContext *mpctx, double x, double y, int sx, int sy) -{ - assert(run(mpctx, STREAM_NAV_MOUSE_CLICK, x, y, 1)); - assert(click_x == sx && click_y == sy); - assert(last_key == BD_VK_MOUSE_ACTIVATE); -} - -int main(void) -{ - reset_results(); - struct vo vo = {.config_ok = true}; - // The old input queue still holds a different point, outside the video. - struct input_ctx input = {.x = 3, .y = 4}; - struct mp_osd_res osd = {.w = 1200, .h = 800, .mt = 100, .mb = 100}; - struct stream stream = {.priv = &bluray, .state = {1920, 1080}}; - struct demuxer demux = {.stream = &stream}; - struct MPContext mpctx = {&vo, &input, &osd, &demux, 0}; - - // Atomic window points never consult the not-yet-consumed input queue. - expect_point(&mpctx, 900, 550, 1440, 810); - expect_point(&mpctx, 300, 250, 480, 270); - assert(input.reads == 0 && demux.drives == 2); - // Legacy normalized coordinates and no-coordinate input remain available. - assert(run(&mpctx, STREAM_NAV_MOUSE_CLICK, 0.5, 0.5, 0)); - assert(click_x == 960 && click_y == 540 && input.reads == 0); - assert(run(&mpctx, STREAM_NAV_MOUSE_CLICK, 1, 1, 0)); - assert(click_x == 1919 && click_y == 1079); - input.x = 300; input.y = 250; - assert(run(&mpctx, STREAM_NAV_MOUSE_CLICK, -1, -1, 0)); - assert(click_x == 480 && click_y == 270 && input.reads == 1); - assert(!run(&mpctx, STREAM_NAV_MOUSE_CLICK, NAN, 0.5, 0)); - assert(!run(&mpctx, STREAM_NAV_MOUSE_CLICK, 1.1, 0.5, 0)); - assert(!run(&mpctx, STREAM_NAV_MOUSE_CLICK, -1, 0.5, 0)); - - int calls = select_calls; - const double outside[][2] = {{0, 99}, {0, 700}, {-1, 400}, {1200, 400}, - {0, 800}, {NAN, 400}, {INFINITY, 400}}; - for (unsigned i = 0; i < sizeof(outside) / sizeof(outside[0]); i++) - assert(!run(&mpctx, STREAM_NAV_MOUSE_CLICK, outside[i][0], outside[i][1], 1)); - assert(select_calls == calls); - expect_point(&mpctx, 0, 100, 0, 0); - expect_point(&mpctx, 1199, 699, 1918, 1078); - // Zoom/crop: the left edge is the quarter point of the full menu plane. - osd = (struct mp_osd_res){.w = 960, .h = 540, .ml = -480, .mr = -480, - .mt = -270, .mb = -270}; - expect_point(&mpctx, 0, 0, 480, 270); - expect_point(&mpctx, 480, 270, 960, 540); - // Pillarbox plus non-square scaling / a smaller published OSD surface. - osd = (struct mp_osd_res){.w = 1280, .h = 720, .ml = 160, .mr = 160}; - expect_point(&mpctx, 640, 360, 960, 540); - assert(!run(&mpctx, STREAM_NAV_MOUSE_CLICK, 159, 360, 1)); - osd = (struct mp_osd_res){.w = 640, .h = 360}; - expect_point(&mpctx, 320, 180, 960, 540); - vo.config_ok = false; - assert(!run(&mpctx, STREAM_NAV_MOUSE_CLICK, 320, 180, 1)); - vo.config_ok = true; - stream.state.src_w = 0; - assert(!run(&mpctx, STREAM_NAV_MOUSE_CLICK, 320, 180, 1)); - stream.state.src_w = 1920; - - // A missed hit or libbluray error must never activate the previous button. - reset_results(); - for (int hit = -1; hit <= 0; hit++) { - select_result = hit; - assert(!run(&mpctx, STREAM_NAV_MOUSE_CLICK, 320, 180, 1)); - assert(key_calls == 0); - } - select_result = 1; - key_result = -1; - int drives = demux.drives; - int event_polls = demux.event_polls; - assert(!run(&mpctx, STREAM_NAV_MOUSE_CLICK, 320, 180, 1)); - assert(demux.drives == drives); - assert(demux.event_polls == event_polls + 1); - key_result = 0; // API success without an activation change is not an error. - assert(run(&mpctx, STREAM_NAV_SELECT, -1, -1, 0)); - assert(demux.drives == drives + 1); - int keys = key_calls; - assert(run(&mpctx, STREAM_NAV_MOUSE_MOVE, 320, 180, 1)); - assert(key_calls == keys && demux.drives == drives + 2); - - // Slave packets already cached must not prevent the VM from advancing. - struct priv disc = {.is_bd = true, .nav_active = true}; - demux.priv = &disc; - struct demux_packet *packet = NULL; - page_action_pending = true; - assert(bluray.input_pending); - assert(d_read_packet(&demux, &packet) && packet->cached); - assert(polls == 1 && !bluray.input_pending && !page_action_pending); - assert(bluray.nav_change_id == 1 && bluray.discontinuity_id == 0); - assert(d_read_packet(&demux, &packet)); - assert(polls == 1); // No extra VM work on unchanged media reads. - assert(run(&mpctx, STREAM_NAV_RIGHT, -1, -1, 0)); - assert(bluray.input_pending); // Cursor auto-actions also require a pump. - poll_failed = true; - assert(!d_read_packet(&demux, &packet)); - poll_failed = false; - bluray.input_pending = true; - disc.is_bd = false; - assert(d_read_packet(&demux, &packet) && bluray.input_pending); - disc.is_bd = true; - - // Page animations/auto-actions must keep advancing over cached media, but - // not at packet frequency and not for ordinary non-menu playback. - reset_results(); - bluray.menu_event_active = true; - assert(d_read_packet(&demux, &packet) && polls == 1); - for (int i = 0; i < 100; i++) assert(d_read_packet(&demux, &packet)); - assert(polls == 1); - fake_time = MP_TIME_MS_TO_NS(33); - assert(d_read_packet(&demux, &packet) && polls == 2); - bluray.menu_event_active = false; - fake_time += MP_TIME_MS_TO_NS(1000); - assert(d_read_packet(&demux, &packet) && polls == 2); - - // A verified authored Back route closes the child without restarting ROOT. - reset_results(); - back_result = 1; - assert(run(&mpctx, STREAM_NAV_MENU_ROOT, -1, -1, 0)); - assert(run(&mpctx, STREAM_NAV_PREV_MENU, -1, -1, 0)); - assert(back_calls == 2 && root_calls == 0 && popup_calls == 0); - - // Root succeeds: do not accidentally toggle a popup after the menu call. - reset_results(); - bluray.popup_supported = true; - assert(run(&mpctx, STREAM_NAV_MENU_ROOT, -1, -1, 0)); - assert(root_calls == 1 && popup_calls == 0); - root_result = 0; - assert(run(&mpctx, STREAM_NAV_MENU_ROOT, -1, -1, 0)); - assert(root_calls == 2 && popup_calls == 1); - popup_result = -1; - assert(!run(&mpctx, STREAM_NAV_MENU_ROOT, -1, -1, 0)); - bluray.popup_supported = false; - keys = key_calls; - assert(!run(&mpctx, STREAM_NAV_MENU_ROOT, -1, -1, 0)); - assert(key_calls == keys + 1 && last_key == BD_VK_MENU_BACK); - - // Back on a non-popup top-menu subpage requests root, not unsupported popup. - reset_results(); - assert(run(&mpctx, STREAM_NAV_PREV_MENU, -1, -1, 0)); - assert(root_calls == 1 && popup_calls == 0); - bluray.popup_supported = true; - assert(run(&mpctx, STREAM_NAV_PREV_MENU, -1, -1, 0)); - assert(root_calls == 1 && popup_calls == 1); - popup_result = -1; - assert(run(&mpctx, STREAM_NAV_PREV_MENU, -1, -1, 0)); - assert(root_calls == 2 && popup_calls == 2); - root_result = 0; - assert(!run(&mpctx, STREAM_NAV_PREV_MENU, -1, -1, 0)); - // A user menu request can interrupt an authored UO-restricted clip only - // after FIRST PLAY has run and supplied media. Always restore the policy. - reset_results(); - root_result = 0; - bluray.uo_mask = BLURAY_UO_MENU_CALL; - assert(!run(&mpctx, STREAM_NAV_MENU_ROOT, -1, -1, 0)); - assert(root_calls == 1 && setting_calls == 0); // startup is not bypassed - bluray.data_delivered = true; - assert(run(&mpctx, STREAM_NAV_MENU_ROOT, -1, -1, 0)); - assert(root_calls == 3 && setting_calls == 2); - assert(restriction_level == BLURAY_PLAYER_SETTING_UO_RESTRICTION_RELAXED); - unrestricted_root_result = 0; - assert(!run(&mpctx, STREAM_NAV_PREV_MENU, -1, -1, 0)); - assert(root_calls == 5 && setting_calls == 4); - assert(restriction_level == BLURAY_PLAYER_SETTING_UO_RESTRICTION_RELAXED); - bluray.uo_mask = 0; // unrelated failure must not change restrictions - assert(!run(&mpctx, STREAM_NAV_MENU_TITLE, -1, -1, 0)); - assert(root_calls == 6 && setting_calls == 4); - bluray.uo_mask = BLURAY_UO_MENU_CALL; - setting_failed = true; - assert(!run(&mpctx, STREAM_NAV_MENU_ROOT, -1, -1, 0)); - assert(root_calls == 7 && setting_calls == 5); - assert(restriction_level == BLURAY_PLAYER_SETTING_UO_RESTRICTION_RELAXED); - bluray.hdmv_mode = false; - assert(!run(&mpctx, STREAM_NAV_MENU_ROOT, -1, -1, 0)); - demux.stream = NULL; - assert(!run(&mpctx, STREAM_NAV_SELECT, -1, -1, 0)); - puts("PASS: atomic pointer / OSD mapping / hit gating / native results / cached-packet VM pump / root-popup-back fallback / explicit menu UO restore"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/disc_navigation_liveness_test.c b/third_party/mpv-player-jni/tests/disc_navigation_liveness_test.c deleted file mode 100644 index 7c524b5129..0000000000 --- a/third_party/mpv-player-jni/tests/disc_navigation_liveness_test.c +++ /dev/null @@ -1,111 +0,0 @@ -#include -#include -#include -#include - -enum { STREAM_CTRL_NAV_POLL, STATUS_EOF = 4 }; -#define mp_assert assert -struct stream_info { const char *name; }; -struct stream { struct stream_info *info; int polls, effects_left; }; -struct stream_nav_state { bool nav_active, menu_active, still_active; }; -struct demux_internal; -struct demuxer { - struct demux_internal *in; - struct stream *stream; - bool cancelled; -}; -struct demux_internal { - struct demuxer *d_user, *d_thread; - bool nav_poll, nav_pump, reading, blocked; - int lock, wakeup; -}; -struct MPContext { - struct demuxer *demuxer; - int video_status, audio_status; - bool paused; - double timeout; -}; -static void mp_mutex_lock(int *lock) { assert(!*lock); *lock = 1; } -static void mp_mutex_unlock(int *lock) { assert(*lock); *lock = 0; } -static void mp_cond_signal(int *condition) { (*condition)++; } -static bool demux_cancel_test(struct demuxer *demux) { return demux->cancelled; } -static void mp_set_timeout(struct MPContext *ctx, double delay) { ctx->timeout = delay; } -static int stream_control(struct stream *s, int command, void *arg) -{ - assert(command == STREAM_CTRL_NAV_POLL && arg == NULL); - s->polls++; - if (s->effects_left) s->effects_left--; - return 1; -} -#include "disc_requests_under_test.h" -#include "disc_poll_under_test.h" -#include "disc_tick_under_test.h" - -static bool dispatch(struct demux_internal *in) -{ - mp_mutex_lock(&in->lock); - bool worked = poll_nav(in); - assert(in->lock == 1); - mp_mutex_unlock(&in->lock); - return worked; -} - -int main(void) -{ - struct stream_info info = {"bd"}; - struct stream stream = {.info = &info, .effects_left = 3}; - struct demux_internal in = {0}; - struct demuxer user = {.in = &in, .stream = &stream}; - struct demuxer owner = {.in = &in, .stream = &stream}; - in.d_user = &user; - in.d_thread = &owner; - struct MPContext ctx = {.demuxer = &user, .paused = true}; - struct stream_nav_state nav = {.nav_active = true, .menu_active = true}; - - // Full packet queues / paused consumers do not request any media reads. - for (int frame = 0; frame < 3; frame++) { - poll_disc_nav(&ctx, &stream, &nav); - assert(ctx.timeout > 0 && ctx.timeout <= 0.05); - assert(in.nav_poll && !in.reading && !in.nav_pump && ctx.paused); - assert(dispatch(&in)); - assert(!dispatch(&in)); // No self-wakeup / busy-loop after servicing. - } - assert(stream.effects_left == 0 && stream.polls == 3); - - // Repeated requests coalesce; blocked/closing owners never enter libbluray. - demux_poll_nav(&user); - demux_poll_nav(&user); - in.blocked = true; - assert(!dispatch(&in) && stream.polls == 3 && in.nav_poll); - in.blocked = false; - owner.cancelled = true; - assert(!dispatch(&in) && stream.polls == 3); - owner.cancelled = false; - assert(dispatch(&in) && stream.polls == 4 && !in.reading); - - nav.menu_active = false; - ctx.timeout = 0; - poll_disc_nav(&ctx, &stream, &nav); - assert(!in.nav_poll && !in.nav_pump && ctx.timeout == 0); - nav.menu_active = true; - nav.nav_active = false; // Longest-title/BD-J fallback is unchanged. - poll_disc_nav(&ctx, &stream, &nav); - assert(!in.nav_poll && ctx.timeout == 0); - nav.nav_active = true; - info.name = "dvdnav"; - poll_disc_nav(&ctx, &stream, &nav); - assert(!in.nav_poll && ctx.timeout == 0); - - info.name = "bdmv/bluray"; - nav.menu_active = false; - nav.still_active = true; - poll_disc_nav(&ctx, &stream, &nav); - assert(in.nav_pump && in.reading && ctx.timeout > 0); // Existing still path. - in.nav_pump = in.reading = false; - nav.still_active = false; - ctx.video_status = ctx.audio_status = STATUS_EOF; - poll_disc_nav(&ctx, &stream, &nav); - assert(in.nav_pump && in.reading); - puts("PASS: paused/full-queue menu animation progresses without media reads; coalescing/cancel/idle preserved"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/disc_navigation_options_test.c b/third_party/mpv-player-jni/tests/disc_navigation_options_test.c deleted file mode 100644 index 7d299ee9d8..0000000000 --- a/third_party/mpv-player-jni/tests/disc_navigation_options_test.c +++ /dev/null @@ -1,87 +0,0 @@ -#include -#include -#include -#include - -struct stream_info { const char *name; }; -struct stream { struct stream_info *info; }; -struct stream_nav_state { bool nav_active; }; -struct m_obj_settings { const char *name; }; -struct mp_vo_opts { struct m_obj_settings *video_driver_list; }; -struct MPOpts { struct mp_vo_opts *vo; }; -struct MPContext { struct MPOpts *opts; void *mconfig; }; - -#define STREAM_CTRL_GET_NAV_STATE 1 -#define M_SETOPT_BACKUP 16 -#define MP_VERBOSE(...) ((void)0) -#define bstr0(value) (value) - -static struct stream_info info = {.name = "bd"}; -static struct stream disc = {.info = &info}; -static struct stream *active_stream = &disc; -static bool navigation_active = true; -static int query_result = 1, option_calls; -static struct stream *disc_nav_get_stream(struct MPContext *mpctx) -{ - (void)mpctx; - return active_stream; -} -static int stream_control(struct stream *s, int command, void *arg) -{ - assert(s == &disc && command == STREAM_CTRL_GET_NAV_STATE); - ((struct stream_nav_state *)arg)->nav_active = navigation_active; - return query_result; -} -static int m_config_set_option_cli(void *config, const char *name, - const char *value, int flags) -{ - (void)config; - assert(strcmp(name, "hwdec-software-fallback") == 0); - assert(strcmp(value, "yes") == 0); - assert(flags == M_SETOPT_BACKUP); - option_calls++; - return 0; -} - -#include "disc_options_under_test.h" - -static void expect_calls(struct MPContext *mpctx, int count) -{ - option_calls = 0; - load_disc_navigation_options(mpctx); - assert(option_calls == count); -} - -int main(void) -{ - struct m_obj_settings output = {.name = "gpu-next"}; - struct mp_vo_opts vo = {.video_driver_list = &output}; - struct MPOpts opts = {.vo = &vo}; - struct MPContext mpctx = {.opts = &opts}; - - expect_calls(&mpctx, 1); - output.name = "gpu"; - info.name = "bdmv/bluray"; - expect_calls(&mpctx, 1); - navigation_active = false; // BD-J/no-menu longest-title fallback. - expect_calls(&mpctx, 0); - navigation_active = true; - query_result = 0; - expect_calls(&mpctx, 0); - query_result = 1; - info.name = "dvdnav"; - expect_calls(&mpctx, 0); - info.name = "bd"; - output.name = "mediacodec_embed"; - expect_calls(&mpctx, 0); - output.name = "libmpv"; - expect_calls(&mpctx, 0); - output.name = NULL; - expect_calls(&mpctx, 0); - vo.video_driver_list = NULL; - expect_calls(&mpctx, 0); - active_stream = NULL; - expect_calls(&mpctx, 0); - puts("PASS: HDMV GPU fallback is file-local and precedes decoder init; other paths unchanged"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/disc_navigation_progress_test.c b/third_party/mpv-player-jni/tests/disc_navigation_progress_test.c deleted file mode 100644 index c7ddeff81e..0000000000 --- a/third_party/mpv-player-jni/tests/disc_navigation_progress_test.c +++ /dev/null @@ -1,125 +0,0 @@ -#include -#include -#include -#include - -enum { BD_EVENT_NONE, BD_EVENT_PLAYLIST }; -enum { STREAM_VIDEO, STREAM_AUDIO, STREAM_SUB }; -typedef struct { int event; } BD_EVENT; -struct bluray_priv_s { - void *bd; - bool hdmv_mode, still_active, data_delivered; - int overlay_lock; - uint32_t discontinuity_id; -}; -typedef struct { struct bluray_priv_s *priv; void *cancel; } stream_t; -struct sh_stream { int demuxer_id, type; }; -struct priv { bool is_bd; }; -static bool pending_command, cancelled, io_error; -static int poll_reads, data_reads, legacy_reads; -static void mp_mutex_lock(int *lock) { assert(!*lock); *lock = 1; } -static void mp_mutex_unlock(int *lock) { assert(*lock); *lock = 0; } - -#define MP_VERBOSE(ctx, ...) do { if (false) fprintf(stderr, __VA_ARGS__); } while (0) -#define MP_TIME_MS_TO_NS(ms) (ms) -static void mp_sleep_ns(int ns) { (void)ns; } -static bool mp_cancel_test(void *cancel) { (void)cancel; return cancelled; } -static int bd_read_ext(void *bd, void *buffer, int len, BD_EVENT *event) -{ - (void)bd; (void)buffer; - if (io_error) return -1; - event->event = BD_EVENT_NONE; - if (!len) { - poll_reads++; - if (pending_command) { - pending_command = false; - event->event = BD_EVENT_PLAYLIST; - } - return 0; - } - data_reads++; - return len; -} -static int bluray_read_ext(stream_t *s, void *buffer, int len, BD_EVENT *event) -{ - return bd_read_ext(s->priv->bd, buffer, len, event); -} -static int bd_get_event(void *bd, BD_EVENT *event) -{ - (void)bd; (void)event; - return 0; // Commands are not events until bd_read_ext runs the VM. -} -static int bd_read(void *bd, void *buffer, int len) -{ - (void)bd; (void)buffer; - legacy_reads++; - return len; -} -static void handle_event(stream_t *s, BD_EVENT *event) -{ - if (event->event == BD_EVENT_PLAYLIST) { - s->priv->still_active = false; - s->priv->discontinuity_id++; - } -} - -#include "disc_progress_under_test.h" -#include "disc_pid_under_test.h" - -static bool ignored(bool native_bd, int type, int id) -{ - struct priv state = {.is_bd = native_bd}; - struct priv *p = &state; - struct sh_stream input = {.demuxer_id = id, .type = type}; - struct sh_stream *src = &input; - return DISC_STREAM_IGNORED; -} - -int main(void) -{ - struct bluray_priv_s b = {.hdmv_mode = true, .still_active = true, - .data_delivered = true, .discontinuity_id = 4}; - stream_t s = {.priv = &b}; - char buffer[192]; - pending_command = true; - - // A selected still-menu button must execute, preserving the clip boundary. - assert(bluray_stream_fill_buffer(&s, buffer, sizeof(buffer)) == 0); - assert(!pending_command && !b.still_active); - assert(b.discontinuity_id == 5 && poll_reads == 2 && data_reads == 0); - assert(bluray_stream_fill_buffer(&s, buffer, sizeof(buffer)) == 192); - assert(data_reads == 1); - - // Waiting on a still without a command must not skip it or fabricate a hop. - b.still_active = true; - assert(bluray_stream_fill_buffer(&s, buffer, sizeof(buffer)) == 0); - assert(b.still_active && b.discontinuity_id == 5 && data_reads == 1); - - // First-play commands may directly produce the initial clip. - b.data_delivered = false; - pending_command = true; - assert(bluray_stream_fill_buffer(&s, buffer, sizeof(buffer)) == 192); - assert(!b.still_active && b.discontinuity_id == 6); - - cancelled = true; - assert(bluray_stream_fill_buffer(&s, buffer, sizeof(buffer)) == -1); - cancelled = false; - io_error = true; - assert(bluray_stream_fill_buffer(&s, buffer, sizeof(buffer)) == -1); - io_error = false; - b.hdmv_mode = false; - assert(bluray_stream_fill_buffer(&s, buffer, sizeof(buffer)) == 192); - assert(legacy_reads == 1); - - for (int type = STREAM_VIDEO; type <= STREAM_SUB; type++) { - assert(ignored(false, type, 0x1fff)); - assert(ignored(true, type, 0x1fff)); - } - assert(!ignored(false, STREAM_AUDIO, 0x1100)); - assert(!ignored(true, STREAM_AUDIO, 0x1100)); - assert(!ignored(true, STREAM_VIDEO, 0x1011)); - assert(!ignored(true, STREAM_SUB, 0x1200)); - assert(!ignored(false, STREAM_AUDIO, 0x80)); // DVD remains unchanged. - puts("PASS: still-menu command progresses, clip boundary held, NULL_PID rejected on native/callback paths"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/disc_navigation_start_test.c b/third_party/mpv-player-jni/tests/disc_navigation_start_test.c deleted file mode 100644 index f749dd989a..0000000000 --- a/third_party/mpv-player-jni/tests/disc_navigation_start_test.c +++ /dev/null @@ -1,77 +0,0 @@ -#include -#include -#include - -enum { BLURAY_MENU_TITLE = -1, BLURAY_DEFAULT_TITLE = -2, - BLURAY_TITLE_TOP_MENU = 0 }; -struct bluray_priv_s { - int cfg_title, current_title, overlay_lock; - bool hdmv_mode; - void *bd; -}; -typedef struct { struct bluray_priv_s *priv; } stream_t; -static bool play_ok, first_play_pending, overlay_registered; -static int play_calls, top_menu_calls, title_selections; -#define MP_VERBOSE(ctx, ...) do { if (false) fprintf(stderr, __VA_ARGS__); } while (0) -static void mp_mutex_init(int *lock) { *lock = 1; } -static void mp_mutex_destroy(int *lock) { assert(*lock == 1); *lock = 0; } -static void bd_yuv_overlay_cb(void) {} -static void bd_register_overlay_proc(void *bd, void *context, void (*cb)(void)) -{ - (void)bd; (void)context; - overlay_registered = cb != NULL; -} -static int bd_play(void *bd) -{ - (void)bd; - play_calls++; - first_play_pending = play_ok; - return play_ok; -} -int bd_play_title(void *bd, int title) -{ - (void)bd; - assert(title == BLURAY_TITLE_TOP_MENU); - top_menu_calls++; - first_play_pending = false; // Discards the not-yet-executed FIRST PLAY. - return 1; -} -static int bd_get_current_title(void *bd) { (void)bd; return 0; } -static int bd_get_main_title(void *bd) { (void)bd; return 7; } -static void select_initial_title(stream_t *s, int title) -{ - s->priv->current_title = title; - title_selections++; -} -static void start_navigation(stream_t *s) -{ - struct bluray_priv_s *b = s->priv; -#include "disc_start_under_test.h" -} - -int main(void) -{ - struct bluray_priv_s b = {.cfg_title = BLURAY_MENU_TITLE}; - stream_t s = {.priv = &b}; - play_ok = true; - start_navigation(&s); - assert(b.hdmv_mode && overlay_registered && b.overlay_lock == 1); - assert(first_play_pending && play_calls == 1 && top_menu_calls == 0); - assert(title_selections == 0); - - // Failed boot still quietly falls back to the ordinary main title. - play_ok = false; - b = (struct bluray_priv_s){.cfg_title = BLURAY_MENU_TITLE}; - start_navigation(&s); - assert(!b.hdmv_mode && !overlay_registered && b.overlay_lock == 0); - assert(b.cfg_title == BLURAY_DEFAULT_TITLE && b.current_title == 7); - assert(play_calls == 2 && title_selections == 1); - - // Menu-disabled / BD-J fallback must not boot a VM or register overlays. - b = (struct bluray_priv_s){.cfg_title = BLURAY_DEFAULT_TITLE}; - start_navigation(&s); - assert(!b.hdmv_mode && !overlay_registered && b.current_title == 7); - assert(play_calls == 2 && top_menu_calls == 0 && title_selections == 2); - puts("PASS: FIRST PLAY survives initialization; failed/disabled menus retain ordinary-title fallback"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/disc_navigation_trace_test.c b/third_party/mpv-player-jni/tests/disc_navigation_trace_test.c deleted file mode 100644 index ad3faf4579..0000000000 --- a/third_party/mpv-player-jni/tests/disc_navigation_trace_test.c +++ /dev/null @@ -1,112 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include - -#define thread_local _Thread_local -#define MP_TIME_S_TO_NS(s) ((s) * 1000000000LL) -enum { DBG_CRIT = 1, DBG_HDMV = 2, DBG_GC = 4 }; -typedef struct { int event; } BD_EVENT; -struct bluray_priv_s { - bool trace_navigation, menu_event_active, overlay_ig_visible, popup_supported; - bool still_active, data_delivered; - uint32_t trace_request, uo_mask, nav_change_id, discontinuity_id; - int overlay_lock, plane_w, plane_h; - void *bd; -}; -typedef struct { struct bluray_priv_s *priv; } stream_t; -static unsigned emitted; -static char last_log[1500]; -static int64_t now = 1000000000LL; -static void (*handler)(const char *); -static unsigned mask; -static int64_t mp_time_ns(void) { return now; } -static void mp_mutex_lock(int *lock) { assert(!*lock); *lock = 1; } -static void mp_mutex_unlock(int *lock) { assert(*lock); *lock = 0; } -static void log_message(stream_t *s, const char *format, ...) -{ - assert(s); - va_list ap; - va_start(ap, format); - vsnprintf(last_log, sizeof(last_log), format, ap); - va_end(ap); - emitted++; -} -#define MP_INFO(s, ...) log_message(s, __VA_ARGS__) -static void bd_set_debug_handler(void (*fn)(const char *)) { handler = fn; } -static void bd_set_debug_mask(unsigned value) { mask = value; } -static int bd_read_ext(void *bd, void *buf, int len, BD_EVENT *event) -{ - (void)bd; (void)buf; - handler("hdmv_vm.c:997: 0000: SET_BUTTON_PAGE 0, 2\n"); - event->event = 7; - return len; -} -#include "disc_trace_under_test.h" - -static void *unowned_thread(void *unused) -{ - (void)unused; - handler("hdmv_vm.c:997: must not inherit another thread's owner\n"); - return NULL; -} - -int main(void) -{ - bluray_trace_init(); - assert(handler && mask == (DBG_CRIT | DBG_HDMV | DBG_GC)); - assert(bluray_trace_line("hdmv_vm.c:997: SET_BUTTON_PAGE\n", false)); - assert(bluray_trace_line("graphics_controller.c:1558: _user_input(17)\n", false)); - assert(bluray_trace_line("graphics_controller.c:1530: _user_input mouse-hit: button=29 cmds=0\n", true)); - assert(bluray_trace_line("bluray.c:956: HDMV input GC: result=1 cmds=0\n", true)); - assert(bluray_trace_line("bluray.c:965: HDMV input VM: cmds=2 result=0 running=1\n", false)); - assert(bluray_trace_line("graphics_controller.c:1312: render button #29\n", true)); - assert(!bluray_trace_line("graphics_controller.c:1312: render button #29\n", false)); - assert(!bluray_trace_line("graphics_controller.c:1282: skipping already rendered\n", true)); - assert(!bluray_trace_line("graphics_controller.c:1499: auto-activate not triggered (ANIMATING)\n", true)); - assert(!bluray_trace_line("file.c:99: https://private-media-address\n", true)); - assert(!bluray_trace_line(NULL, true)); - handler("hdmv_vm.c:997: no owner\n"); - assert(emitted == 0); - struct bluray_priv_s b = {.trace_navigation = true, .trace_request = 17}; - stream_t s = {.priv = &b}; - bluray_trace_begin(&s, "input"); - handler("graphics_controller.c:1312: render button #29\n"); - assert(emitted == 1 && strstr(last_log, "seq=17 phase=input")); - pthread_t thread; - assert(!pthread_create(&thread, NULL, unowned_thread, NULL)); - assert(!pthread_join(thread, NULL)); - assert(emitted == 1); - for (int i = 0; i < 200; i++) handler("hdmv_vm.c:997: test\n"); - assert(emitted == 97 && strstr(last_log, "trace capped")); - bluray_trace_end(); - assert(bd_trace.stream == NULL); - handler("hdmv_vm.c:997: after close\n"); - assert(emitted == 97); - now += MP_TIME_S_TO_NS(1); - unsigned before = emitted; - for (int input = 0; input < 8; input++) { - bluray_trace_begin(&s, "input"); - for (int i = 0; i < 90; i++) handler("hdmv_vm.c:997: test\n"); - bluray_trace_end(); - } - assert(emitted - before == 601); // per-thread one-second budget + one notice - now += MP_TIME_S_TO_NS(1); - BD_EVENT event = {0}; - assert(bluray_read_ext(&s, NULL, 0, &event) == 0 && event.event == 7); - assert(bd_trace.stream == NULL && strstr(last_log, "phase=poll")); - bluray_trace_state(&s, "input-end", 1, now); - assert(strstr(last_log, "result=1 elapsed_ms=0")); - b.trace_navigation = false; - before = emitted; - bluray_trace_begin(&s, "input"); - handler("hdmv_vm.c:997: disabled\n"); - bluray_trace_state(&s, "input-end", 1, now); - bluray_trace_end(); - assert(before == emitted); - puts("PASS: navigation trace filtering / disabled mode / per-call and per-second caps / thread-local ownership / cleared read context"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/disc_protocol_test.c b/third_party/mpv-player-jni/tests/disc_protocol_test.c deleted file mode 100644 index 434434e4ca..0000000000 --- a/third_party/mpv-player-jni/tests/disc_protocol_test.c +++ /dev/null @@ -1,50 +0,0 @@ -#include - -#include -#include - -static int probe_open(void *context, char *uri, mpv_stream_cb_info *info) -{ - (void)context; - (void)uri; - (void)info; - return MPV_ERROR_LOADING_FAILED; -} - -static int expect_registration(mpv_handle *handle, const char *label, - const char *protocol, - mpv_stream_cb_open_ro_fn callback, int expected) -{ - int actual = mpv_stream_cb_add_ro(handle, protocol, NULL, callback); - printf("%s: protocol=%s actual=%d expected=%d %s\n", label, protocol, - actual, expected, actual == expected ? "PASS" : "FAIL"); - return actual != expected; -} - -int main(void) -{ - mpv_handle *handle = mpv_create(); - if (!handle) { - fprintf(stderr, "mpv_create failed\n"); - return 2; - } - - int failures = 0; - failures += expect_registration(handle, "ISO callback", "webhtv-dvdiso", - probe_open, 0); - failures += expect_registration(handle, "Duplicate callback", "webhtv-dvdiso", - probe_open, MPV_ERROR_INVALID_PARAMETER); - failures += expect_registration(handle, "Other custom callback", "webhtv-probe", - probe_open, 0); - failures += expect_registration(handle, "Built-in file stays reserved", "file", - probe_open, MPV_ERROR_INVALID_PARAMETER); - failures += expect_registration(handle, "Built-in HTTPS stays reserved", "https", - probe_open, MPV_ERROR_INVALID_PARAMETER); - failures += expect_registration(handle, "Missing callback", "webhtv-null", - NULL, MPV_ERROR_INVALID_PARAMETER); - - mpv_destroy(handle); - printf("Native protocol regression: %s (%d failures)\n", - failures ? "FAIL" : "PASS", failures); - return failures ? 1 : 0; -} diff --git a/third_party/mpv-player-jni/tests/disc_reopen_test.c b/third_party/mpv-player-jni/tests/disc_reopen_test.c deleted file mode 100644 index 469685d690..0000000000 --- a/third_party/mpv-player-jni/tests/disc_reopen_test.c +++ /dev/null @@ -1,104 +0,0 @@ -#include -#include -#include - -// Compile the real reopen_slave() against a deterministic event-only stream. -// In particular, EOF between playlists must not leave a freed slave behind. -struct chapter { void *metadata; }; -struct demuxer { - void *priv, *stream, *cancel, *global; - int stream_origin, depth, num_chapters; - struct chapter *chapters; -}; -struct priv { - struct demuxer *slave; - bool is_cdda; - int slave_to_outer_count; - void **slave_to_outer; -}; -struct demuxer_params { - const char *force_format; - void *external_stream; - int stream_flags, depth; -}; - -static struct demuxer slave; -static int free_count, double_frees, peek_bytes; -static bool slave_live, open_ok; - -#define MP_VERBOSE(...) ((void)0) -#define MP_WARN(...) ((void)0) -#define STREAM_CTRL_GET_TIME_LENGTH 1 - -static void demux_free(struct demuxer *value) -{ - if (!value) return; - free_count++; - if (!slave_live) double_frees++; - slave_live = false; -} -static void stream_rebase_position(void *stream) { (void)stream; } -static int stream_read_peek(void *stream, void *buffer, unsigned size) -{ - (void)stream; (void)buffer; (void)size; - return peek_bytes; -} -static struct demuxer *demux_open_url(const char *url, struct demuxer_params *p, - void *cancel, void *global) -{ - (void)url; (void)p; (void)cancel; (void)global; - slave_live = open_ok; - return open_ok ? &slave : NULL; -} -static void sync_streams(struct demuxer *d) { (void)d; } -static int stream_control(void *stream, int command, void *arg) -{ - (void)stream; (void)command; (void)arg; - return 0; -} -static void demux_set_duration(struct demuxer *d, double v) { (void)d; (void)v; } -static void talloc_free(void *p) { (void)p; } -static void add_stream_chapters(struct demuxer *d) { (void)d; } -static void sync_initial_edition(struct demuxer *d) { (void)d; } -static void demux_lists_changed(struct demuxer *d) { (void)d; } - -#include "disc_reopen_under_test.h" - -#define CHECK(condition) do { \ - if (!(condition)) { \ - fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #condition); \ - return 1; \ - } \ -} while (0) - -int main(void) -{ - struct priv p = {.slave = &slave}; - struct demuxer parent = {.priv = &p}; - slave_live = true; - - // Event-only reads may repeat, and closing after either must be safe. - for (int n = 0; n < 3; n++) { - CHECK(!reopen_slave(&parent)); - CHECK(p.slave == NULL); - CHECK(free_count == 1 && double_frees == 0); - } - demux_free(p.slave); - CHECK(free_count == 1 && double_frees == 0); - - // A failed format probe has the same ownership contract. - peek_bytes = 192; - CHECK(!reopen_slave(&parent)); - CHECK(p.slave == NULL && double_frees == 0); - - // Later data can recover; a subsequent jump must release exactly once. - open_ok = true; - CHECK(reopen_slave(&parent)); - CHECK(p.slave == &slave && slave_live); - CHECK(reopen_slave(&parent)); - CHECK(free_count == 2 && double_frees == 0); - demux_free(p.slave); - CHECK(free_count == 3 && double_frees == 0); - puts("disc reopen regression: PASS (event-only retries, close, failed probe, recovery)"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_async_producer_test.c b/third_party/mpv-player-jni/tests/fel_async_producer_test.c deleted file mode 100644 index 9335853df3..0000000000 --- a/third_party/mpv-player-jni/tests/fel_async_producer_test.c +++ /dev/null @@ -1,231 +0,0 @@ -// Compile the actual wrapper process, pending-frame handoff, reset helper and -// decoder dispatch loop. Platform/codec callbacks are deterministic fakes; -// unlike a GPU-only model this exercises the real frame ownership boundary. -#include "filters/f_android_fel.h" -#include -#include -#include -#include -#include -#include - -#define MPMAX(a, b) ((a) > (b) ? (a) : (b)) -#define MP_TIME_MS_TO_NS(ms) ((ms) * 1000000LL) -#define MP_NOPTS_VALUE (-1e20) -#define MP_INFO(...) ((void)0) -#define MP_ERR(...) ((void)0) -#define mp_assert assert -#define MP_THREAD_VOID void -#define MP_THREAD_RETURN() return -enum { STREAM_VIDEO = 1, STREAM_AUDIO, MP_FRAME_VIDEO, MP_FRAME_EOF }; -enum { IMGFMT_MEDIACODEC = 1, IMGFMT_SW }; -enum { VO_ERROR = -1, VO_NOTIMPL = -3, VO_FALSE = 0, VO_TRUE = 1 }; -struct mp_image { int imgfmt; double pts; AVBufferRef *android_fel_staging; }; -struct mp_frame { int type; struct mp_image *data; }; -#define MP_NO_FRAME ((struct mp_frame){0}) -#define MP_EOF_FRAME ((struct mp_frame){.type = MP_FRAME_EOF}) -#define MAKE_FRAME(t, p) ((struct mp_frame){t, (void *)(p)}) -enum { MP_FRAME_PACKET = 5 }; -struct mp_pin { int unused; }; -struct mp_filter { void *priv; struct mp_pin *pins[2], *ppins[1]; }; -struct mp_codec_params { int unused; }; -struct mp_decoder { struct mp_filter *f; }; -struct demux_packet { bool segmented; struct mp_codec_params *codec; double start, end; }; -struct vo { int unused; }; -struct priv { - bool android_fel, software_el, fel_stage_failed, request_terminate_dec_thread; - void *queue, *dec_dispatch, *opt_cache; - struct { struct vo *dr_vo; } stream_info; - struct { int type; } *header; - struct mp_filter *decf, *dec_root_filter; - struct mp_decoder *decoder; - struct mp_codec_params *codec; - struct mp_frame fel_stage_frame, decoded_coverart, *reverse_queue, packet; - int64_t fel_stage_started, fel_stage_ns, fel_stage_max_ns; - unsigned long long fel_staged, fel_stage_retries; - double fel_stage_log_at, fel_queue_log_at; - int fel_queue_peak, coverart_returned, num_reverse_queue, play_dir; - int cache_lock, attempt_framedrops, packets_without_output, dropped_frames; - bool attached_picture, preroll_discard, reverse_queue_complete; - size_t reverse_queue_byte_size; - struct demux_packet *new_segment; - double start, end; - int public; -}; - -static void read_frame(struct priv *); -static int stage_fel_before_publish(struct priv *, struct mp_frame); -static void reset_fel_staging(struct priv *); -static void decf_process(struct mp_filter *); -static void dec_thread(void *); -static int64_t now_ns, ready_ns; -static int reads, feeds, prepares, conversions, failures, eof_writes, video_writes; -static int allocations, cancellations, dispatch_ticks, async_marks, controls_at_tick; -static bool force_error, ordinary; -static struct priv producer; -static struct mp_filter wrapper, root, decoder_filter; -static struct mp_decoder decoder; -static struct vo vo; -static struct mp_image *output, *request; - -static int64_t mp_time_ns(void) { return now_ns; } -static double mp_time_sec(void) { return now_ns / 1e9; } -static void mp_mutex_lock(int *lock) { assert(!*lock); *lock = 1; } -static void mp_mutex_unlock(int *lock) { assert(*lock); *lock = 0; } -static bool mp_pin_in_needs_data(struct mp_pin *pin) { (void)pin; return true; } -static void mp_frame_unref(struct mp_frame *frame) -{ - if (frame->type == MP_FRAME_VIDEO) { - assert(frame->data && frame->data != request); - av_buffer_unref(&frame->data->android_fel_staging); - free(frame->data); - allocations--; - } - *frame = MP_NO_FRAME; -} -static struct mp_frame mp_frame_ref(struct mp_frame frame) -{ (void)frame; abort(); } -static double mp_frame_get_pts(struct mp_frame frame) { return frame.data->pts; } -static void mp_pin_in_write(struct mp_pin *pin, struct mp_frame frame) -{ - (void)pin; - if (frame.type == MP_FRAME_EOF) { eof_writes++; return; } - assert(frame.type == MP_FRAME_VIDEO && !output); - assert(ordinary || (frame.data->android_fel_staging && - mp_android_fel_staging_ready(frame.data->android_fel_staging->data))); - output = frame.data; - video_writes++; -} -static struct mp_frame mp_pin_out_read(struct mp_pin *pin) -{ - (void)pin; - assert(!producer.fel_stage_frame.type && !request); - reads++; - struct mp_image *img = calloc(1, sizeof(*img)); - assert(img); - allocations++; - img->imgfmt = ordinary ? IMGFMT_SW : IMGFMT_MEDIACODEC; - img->pts = reads / 24.0; - return (struct mp_frame){MP_FRAME_VIDEO, img}; -} -static int vo_prepare_fel_frame(struct vo *v, struct mp_image *image) -{ - assert(v == &vo && (!request || request == image)); - prepares++; - if (force_error) { request = NULL; return VO_ERROR; } - if (!image->android_fel_staging) image->android_fel_staging = av_buffer_allocz(1); - assert(image->android_fel_staging); - request = image; - if (now_ns < ready_ns) return VO_FALSE; - mp_android_fel_staging_complete(image->android_fel_staging->data); - request = NULL; - return VO_TRUE; -} -static void vo_cancel_fel_frame(struct vo *v, struct mp_image *image) -{ - assert(v == &vo && image == request); - request = NULL; - cancellations++; -} -static void process_output_frame(struct priv *p, struct mp_frame frame) -{ (void)p; assert(frame.type == MP_FRAME_VIDEO); conversions++; } -static bool process_decoded_frame(struct priv *p, struct mp_frame *frame) -{ (void)p; (void)frame; return false; } -static void enqueue_backward_frame(struct priv *p, struct mp_frame frame) -{ (void)p; (void)frame; abort(); } -static void reset_decoder(struct priv *p) { (void)p; abort(); } -static bool decoder_wrapper_reinit(int *p) { (void)p; abort(); } -static void mp_filter_internal_mark_progress(struct mp_filter *f) { (void)f; } -static void mp_filter_internal_mark_failed(struct mp_filter *f) { (void)f; failures++; } -static bool m_config_cache_update(void *cache) { (void)cache; return false; } -static void update_queue_config(struct priv *p) { (void)p; } -static void feed_packet(struct priv *p) { (void)p; feeds++; } -static int update_cached_values(struct priv *p) { (void)p; return 0; } -static int mp_async_queue_get_frames(void *queue) { (void)queue; return eof_writes; } -static void mp_thread_set_name(const char *name) { assert(name); } -static void mp_filter_graph_run(struct mp_filter *f) -{ assert(f == &root); decf_process(&wrapper); } -static void mp_filter_mark_async_progress(struct mp_filter *f) -{ assert(f == &wrapper); async_marks++; } -static void mp_dispatch_queue_process(void *dispatch, double wait) -{ - assert(dispatch == &producer); - dispatch_ticks++; - if (producer.fel_stage_frame.type) { - assert(wait == .002 && !eof_writes && !video_writes); - now_ns += MP_TIME_MS_TO_NS(2); - if (controls_at_tick == dispatch_ticks) { - reset_fel_staging(&producer); // reset/stop runs at dispatch boundary - producer.request_terminate_dec_thread = true; - } - } else { - assert(isinf(wait)); // old paths and terminal state do not start polling - producer.request_terminate_dec_thread = true; - } -} -static void reset(void) -{ - assert(!allocations && !request && !output); - now_ns = MP_TIME_MS_TO_NS(1000); - ready_ns = now_ns + MP_TIME_MS_TO_NS(2130); - reads = feeds = prepares = conversions = failures = eof_writes = video_writes = 0; - cancellations = dispatch_ticks = async_marks = controls_at_tick = 0; - ordinary = force_error = false; - static struct { int type; } header = {STREAM_VIDEO}; - wrapper = (struct mp_filter){.priv = &producer}; - decoder = (struct mp_decoder){.f = &decoder_filter}; - producer = (struct priv){.android_fel = true, .queue = &producer, - .dec_dispatch = &producer, .decf = &wrapper, .dec_root_filter = &root, - .decoder = &decoder, .stream_info.dr_vo = &vo, .play_dir = 1}; - producer.header = (void *)&header; -} -static void free_output(void) -{ - struct mp_frame frame = {MP_FRAME_VIDEO, output}; - output = NULL; - mp_frame_unref(&frame); -} -int main(void) -{ - reset(); - int64_t started = now_ns; - decf_process(&wrapper); - assert(now_ns == started && producer.fel_stage_frame.type == MP_FRAME_VIDEO); - assert(reads == 1 && feeds == 1 && conversions == 1 && !eof_writes); - dec_thread(&producer); // actual thread loop, simulated 2.13-second cold init - assert(video_writes == 1 && !failures && !eof_writes); - assert(reads == 1 && feeds == 1 && conversions == 1 && producer.fel_staged == 1); - assert(now_ns == ready_ns && dispatch_ticks > 1000 && async_marks > 1000); - free_output(); - - reset(); - controls_at_tick = 10; - dec_thread(&producer); - assert(cancellations == 1 && !producer.fel_stage_frame.type && !allocations); - assert(now_ns < ready_ns && !failures && !eof_writes && !video_writes); - reset_fel_staging(&producer); - assert(cancellations == 1); // no double cancel/free - - reset(); - force_error = true; - decf_process(&wrapper); - assert(producer.fel_stage_failed && failures == 1 && eof_writes == 1); - assert(!allocations && reads == 1 && feeds == 1); - for (int n = 0; n < 30000; n++) { - decf_process(&wrapper); - read_frame(&producer); - } - assert(eof_writes == 1 && reads == 1 && feeds == 1 && prepares == 1); - dec_thread(&producer); - assert(dispatch_ticks == 1 && async_marks == 0); - - reset(); - ordinary = true; - producer.android_fel = false; - dec_thread(&producer); - assert(video_writes == 1 && prepares == 0 && dispatch_ticks == 1 && !async_marks); - assert(!producer.fel_stage_frame.type && !producer.fel_stage_started); - free_output(); - puts("PASS: actual wrapper/dispatch: cold-init pending ownership, no extra decode/PTS fix, responsive cancellation, 30000 post-failure requests emit one EOF, default isolation"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_bind_probe_test.c b/third_party/mpv-player-jni/tests/fel_bind_probe_test.c deleted file mode 100644 index 8a7b41ee0e..0000000000 --- a/third_party/mpv-player-jni/tests/fel_bind_probe_test.c +++ /dev/null @@ -1,321 +0,0 @@ -/* Compiles the actual diagnostic implementation with strict Vulkan spies. */ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "filters/f_android_fel.h" -#include "video/out/fel_bind_probe.h" - -#define MP_ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0])) -#define MPMAX(a, b) ((a) > (b) ? (a) : (b)) -#define MPMIN(a, b) ((a) < (b) ? (a) : (b)) -#define FEL_LATENCY_SAMPLES 128 -#define H(type, n) ((type)(uintptr_t)(n)) -struct fel_latency_window { uint64_t ns[FEL_LATENCY_SAMPLES]; unsigned count, next; }; -static void fel_latency_add(struct fel_latency_window *, uint64_t); -static int fel_latency_compare(const void *, const void *); -static void fel_latency_summary(const struct fel_latency_window *, uint64_t[3]); -struct vo { - struct { void (*wakeup_cb)(void *); void *wakeup_ctx; bool android_dovi_fel; } extra; - struct mp_fel_bind_probe fel_bind_probe; - bool config_ok; -}; -typedef struct MPContext { - struct { bool pause; } *opts; - bool paused_for_cache, paused; - struct vo *video_out; - void *vo_chain; - int video_status; -} MPContext; -struct m_property { const char *name; }; -enum { M_PROPERTY_SET, M_PROPERTY_GET, M_PROPERTY_OK = 1, - M_PROPERTY_ERROR = -1, M_PROPERTY_UNAVAILABLE = -2, STATUS_PLAYING = 4 }; -static bool get_internal_paused(struct MPContext *); -static void handle_fel_bind_probe(struct MPContext *); -static int mp_property_android_fel_bind_probe(void *, struct m_property *, int, void *); -static int64_t now_ns, cpu_ns; -static int notifications, wakes; -static int64_t mp_time_ns(void) { return now_ns += 1000; } -static int64_t mp_fel_thread_cpu_ns(void) { return cpu_ns += 1000; } -static void mp_sleep_ns(int64_t n) { now_ns += n; } -static void mp_notify_property(MPContext *ctx, const char *name) { - (void)ctx; assert(!strcmp(name, "android-fel-bind-probe")); notifications++; -} -static void mp_wakeup_core(MPContext *ctx) { (void)ctx; wakes++; } -static void update_internal_pause_state(MPContext *ctx) { ctx->paused = get_internal_paused(ctx); } -static void mp_set_timeout(MPContext *ctx, double t) { (void)ctx; assert(t == 0.05); } -static int m_property_strdup_ro(int action, void *arg, const char *s) { - assert(action == M_PROPERTY_GET); snprintf(arg, 64, "%s", s); return M_PROPERTY_OK; -} -struct pl_vulkan_queue { uint32_t index, count; }; -typedef struct fake_vulkan *pl_vulkan; -struct fake_vulkan { - VkPhysicalDevice phys_device; - struct pl_vulkan_queue queue_graphics, queue_compute, queue_transfer; - void (*lock_queue)(pl_vulkan, uint32_t, uint32_t); - void (*unlock_queue)(pl_vulkan, uint32_t, uint32_t); -}; -struct vk_input { VkImageView view; }; -struct test_ra_ctx { struct vo *vo; }; -struct test_owner { struct test_ra_ctx *ra_ctx; }; -struct test_mapper { struct test_owner *owner; }; -struct mp_image { struct { unsigned char *data; } *android_fel_staging; }; -struct aimagereader_vk_stable { - void *log, *gpu; - struct test_mapper *mapper; - pl_vulkan vk; - VkDevice device; - uint32_t queue_family, sampler_descriptors; - VkSampler sampler; - VkFormat output_format, source_format; - uint64_t external_format; - int width, height; - VkFence fel_probe_drains[8]; - unsigned fel_probe_drain_count; - bool fel_probe_drain_failed; -}; -enum { R_IMAGE, R_MEMORY, R_VIEW, R_COMMAND_POOL, R_POOL, R_LAYOUT, R_PIPELINE_LAYOUT, R_FENCE, R_COUNT }; -static unsigned made[R_COUNT], freed[R_COUNT], next_handle, tags[4096]; -static unsigned resets, begins, ends, binds, submits, writes, cancel_at_bind; -static bool command_live, queue_locked, never_signal, device_lost, ack_pause, fail_memory; -static VkDeviceSize target_bytes; -static int acquire_state, fail_reset; -static MPContext *current_core; -static struct mp_fel_bind_probe *current_probe; -static char logs[12000]; -static unsigned make(unsigned kind) { made[kind]++; return ++next_handle; } -static void mp_info(void *log, const char *format, ...) { - (void)log; va_list args; va_start(args, format); - size_t n = strlen(logs); vsnprintf(logs + n, sizeof(logs) - n, format, args); va_end(args); -} -static void pl_gpu_flush(void *gpu) { (void)gpu; assert(current_core->paused); } -static uint32_t find_memory_type(struct aimagereader_vk_stable *p, uint32_t bits, VkMemoryPropertyFlags flags) { - (void)p; assert(bits == 1 && flags == VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); return 0; -} -static int fel_probe_acquire_fence(int fd) { (void)fd; return acquire_state; } -static void lock_queue(pl_vulkan vk, uint32_t family, uint32_t idx) { - (void)vk; (void)family; (void)idx; assert(!queue_locked); queue_locked = true; -} -static void unlock_queue(pl_vulkan vk, uint32_t family, uint32_t idx) { - (void)vk; (void)family; (void)idx; assert(queue_locked); queue_locked = false; -} - -VkResult vkCreateFence(VkDevice d, const VkFenceCreateInfo *i, const VkAllocationCallbacks *a, VkFence *v) { - (void)d; (void)a; assert(!i->flags); *v = H(VkFence, make(R_FENCE)); return VK_SUCCESS; -} -void vkDestroyFence(VkDevice d, VkFence v, const VkAllocationCallbacks *a) { - (void)d; (void)v; (void)a; freed[R_FENCE]++; -} -VkResult vkGetFenceStatus(VkDevice d, VkFence v) { - (void)d; (void)v; return device_lost ? VK_ERROR_DEVICE_LOST : never_signal ? VK_NOT_READY : VK_SUCCESS; -} -VkResult vkWaitForFences(VkDevice d, uint32_t n, const VkFence *v, VkBool32 all, uint64_t timeout) { - (void)d; (void)v; assert(n == 1 && all && timeout == UINT64_MAX); return VK_SUCCESS; -} -void vkGetDeviceQueue(VkDevice d, uint32_t family, uint32_t idx, VkQueue *v) { - (void)d; *v = H(VkQueue, 100 + family + idx); -} -VkResult vkQueueSubmit(VkQueue q, uint32_t n, const VkSubmitInfo *i, VkFence f) { - (void)q; assert(queue_locked && !n && !i && f && current_core->paused); submits++; return VK_SUCCESS; -} -VkResult vkCreateImage(VkDevice d, const VkImageCreateInfo *i, const VkAllocationCallbacks *a, VkImage *v) { - (void)d; (void)a; assert(i->format == VK_FORMAT_A2B10G10R10_UNORM_PACK32); - assert(i->extent.width == 3840 && i->extent.height == 2160 && i->extent.depth == 1); - assert(i->usage == (VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_SAMPLED_BIT)); - *v = H(VkImage, make(R_IMAGE)); return VK_SUCCESS; -} -void vkGetImageMemoryRequirements(VkDevice d, VkImage v, VkMemoryRequirements *r) { - (void)d; (void)v; *r = (VkMemoryRequirements){.size = target_bytes, .memoryTypeBits = 1}; -} -VkResult vkAllocateMemory(VkDevice d, const VkMemoryAllocateInfo *i, const VkAllocationCallbacks *a, VkDeviceMemory *v) { - (void)d; (void)a; assert(i->allocationSize <= MP_FEL_PROBE_MEMORY_LIMIT); - if (fail_memory) return VK_ERROR_OUT_OF_DEVICE_MEMORY; - *v = H(VkDeviceMemory, make(R_MEMORY)); return VK_SUCCESS; -} -VkResult vkBindImageMemory(VkDevice d, VkImage image, VkDeviceMemory memory, VkDeviceSize offset) { - (void)d; assert(image && memory && !offset); return VK_SUCCESS; -} -VkResult vkCreateImageView(VkDevice d, const VkImageViewCreateInfo *i, const VkAllocationCallbacks *a, VkImageView *v) { - (void)d; (void)a; assert(i->image && i->format == VK_FORMAT_A2B10G10R10_UNORM_PACK32); - *v = H(VkImageView, make(R_VIEW)); return VK_SUCCESS; -} -void vkDestroyImageView(VkDevice d, VkImageView v, const VkAllocationCallbacks *a) { - (void)d; (void)v; (void)a; assert(!command_live && made[R_POOL] == freed[R_POOL]); freed[R_VIEW]++; -} -void vkDestroyImage(VkDevice d, VkImage v, const VkAllocationCallbacks *a) { - (void)d; (void)v; (void)a; assert(made[R_VIEW] == freed[R_VIEW]); freed[R_IMAGE]++; -} -void vkFreeMemory(VkDevice d, VkDeviceMemory v, const VkAllocationCallbacks *a) { - (void)d; (void)v; (void)a; assert(made[R_IMAGE] == freed[R_IMAGE]); freed[R_MEMORY]++; -} -VkResult vkCreateCommandPool(VkDevice d, const VkCommandPoolCreateInfo *i, const VkAllocationCallbacks *a, VkCommandPool *v) { - (void)d; (void)a; assert(i->flags == VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT); - assert(!command_live); command_live = true; *v = H(VkCommandPool, make(R_COMMAND_POOL)); return VK_SUCCESS; -} -VkResult vkAllocateCommandBuffers(VkDevice d, const VkCommandBufferAllocateInfo *i, VkCommandBuffer *v) { - (void)d; assert(i->commandBufferCount == 1 && i->level == VK_COMMAND_BUFFER_LEVEL_PRIMARY); - *v = H(VkCommandBuffer, ++next_handle); return VK_SUCCESS; -} -void vkDestroyCommandPool(VkDevice d, VkCommandPool v, const VkAllocationCallbacks *a) { - (void)d; (void)v; (void)a; command_live = false; freed[R_COMMAND_POOL]++; -} -VkResult vkCreateDescriptorSetLayout(VkDevice d, const VkDescriptorSetLayoutCreateInfo *i, const VkAllocationCallbacks *a, VkDescriptorSetLayout *v) { - (void)d; (void)a; assert(!i->flags && i->bindingCount >= 1 && i->bindingCount <= 2); - unsigned kind = 0; - for (unsigned j = 0; j < i->bindingCount; j++) { - const VkDescriptorSetLayoutBinding *b = &i->pBindings[j]; - assert(b->descriptorCount == 1 && b->stageFlags == VK_SHADER_STAGE_COMPUTE_BIT); - if (!b->binding) { assert(b->pImmutableSamplers && *b->pImmutableSamplers); kind |= 1; } - else { assert(b->binding == 1 && !b->pImmutableSamplers); kind |= 2; } - } - unsigned h = make(R_LAYOUT); tags[h] = kind; *v = H(VkDescriptorSetLayout, h); return VK_SUCCESS; -} -VkResult vkCreatePipelineLayout(VkDevice d, const VkPipelineLayoutCreateInfo *i, const VkAllocationCallbacks *a, VkPipelineLayout *v) { - (void)d; (void)a; assert(i->setLayoutCount == 1); - unsigned h = make(R_PIPELINE_LAYOUT); tags[h] = tags[(uintptr_t)*i->pSetLayouts]; - *v = H(VkPipelineLayout, h); return VK_SUCCESS; -} -VkResult vkCreateDescriptorPool(VkDevice d, const VkDescriptorPoolCreateInfo *i, const VkAllocationCallbacks *a, VkDescriptorPool *v) { - (void)d; (void)a; assert(i->maxSets == 1 && !i->flags); - for (unsigned j = 0; j < i->poolSizeCount; j++) - assert(i->pPoolSizes[j].descriptorCount == - (i->pPoolSizes[j].type == VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER ? 4 : 1)); - *v = H(VkDescriptorPool, make(R_POOL)); return VK_SUCCESS; -} -VkResult vkAllocateDescriptorSets(VkDevice d, const VkDescriptorSetAllocateInfo *i, VkDescriptorSet *v) { - (void)d; assert(i->descriptorSetCount == 1); - unsigned h = ++next_handle; tags[h] = tags[(uintptr_t)*i->pSetLayouts]; - *v = H(VkDescriptorSet, h); return VK_SUCCESS; -} -void vkUpdateDescriptorSets(VkDevice d, uint32_t n, const VkWriteDescriptorSet *w, uint32_t copies, const VkCopyDescriptorSet *c) { - (void)d; assert(n >= 1 && n <= 2 && !copies && !c); unsigned bits = 0; - for (unsigned j = 0; j < n; j++) { - assert(w[j].descriptorCount == 1 && w[j].pImageInfo->imageView); - bits |= w[j].dstBinding == 0 ? 1 : 2; - } - assert(tags[(uintptr_t)w[0].dstSet] == bits); writes++; -} -void vkDestroyDescriptorPool(VkDevice d, VkDescriptorPool v, const VkAllocationCallbacks *a) { - (void)d; (void)v; (void)a; assert(!command_live); freed[R_POOL]++; -} -void vkDestroyPipelineLayout(VkDevice d, VkPipelineLayout v, const VkAllocationCallbacks *a) { - (void)d; (void)v; (void)a; assert(!command_live); freed[R_PIPELINE_LAYOUT]++; -} -void vkDestroyDescriptorSetLayout(VkDevice d, VkDescriptorSetLayout v, const VkAllocationCallbacks *a) { - (void)d; (void)v; (void)a; assert(!command_live); freed[R_LAYOUT]++; -} -VkResult vkResetCommandBuffer(VkCommandBuffer c, VkCommandBufferResetFlags flags) { - assert(c && !flags); resets++; return fail_reset == (int)resets ? VK_ERROR_OUT_OF_HOST_MEMORY : VK_SUCCESS; -} -VkResult vkBeginCommandBuffer(VkCommandBuffer c, const VkCommandBufferBeginInfo *i) { - assert(c && i->flags == VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); begins++; return VK_SUCCESS; -} -void vkCmdBindDescriptorSets(VkCommandBuffer c, VkPipelineBindPoint point, VkPipelineLayout layout, - uint32_t first, uint32_t count, const VkDescriptorSet *sets, uint32_t dynamic, const uint32_t *offsets) { - assert(c && point == VK_PIPELINE_BIND_POINT_COMPUTE && !first && count == 1 && !dynamic && !offsets); - assert(tags[(uintptr_t)layout] == tags[(uintptr_t)*sets]); binds++; - now_ns += 35000000; cpu_ns += 1000000; - if (binds == cancel_at_bind) mp_fel_probe_cancel(current_probe); -} -VkResult vkEndCommandBuffer(VkCommandBuffer c) { assert(c); ends++; return VK_SUCCESS; } -void vkGetPhysicalDeviceProperties(VkPhysicalDevice d, VkPhysicalDeviceProperties *p) { - (void)d; memset(p, 0, sizeof(*p)); strcpy(p->deviceName, "contract-spy"); -} - -#include "video/out/hwdec/hwdec_aimagereader_vk_probe.inc" - -static void wake_core(void *ctx) { wakes++; if (ack_pause) handle_fel_bind_probe(ctx); } -static void reset(void) { - memset(made, 0, sizeof(made)); memset(freed, 0, sizeof(freed)); memset(tags, 0, sizeof(tags)); - next_handle = resets = begins = ends = binds = submits = writes = cancel_at_bind = 0; - command_live = queue_locked = never_signal = device_lost = fail_memory = false; - ack_pause = true; acquire_state = fail_reset = notifications = wakes = 0; - now_ns = cpu_ns = 1; target_bytes = UINT64_C(3840) * 2160 * 4; logs[0] = 0; -} -static void assert_clean(void) { for (unsigned i = 0; i < R_COUNT; i++) assert(made[i] == freed[i]); } - -int main(void) { - struct vo vo = {.config_ok = true}; - typeof(*((MPContext *)0)->opts) opts = {0}; - MPContext core = {.opts = &opts, .video_out = &vo, .vo_chain = &vo, .video_status = STATUS_PLAYING}; - struct test_ra_ctx ra = {.vo = &vo}; struct test_owner owner = {.ra_ctx = &ra}; - struct test_mapper mapper = {.owner = &owner}; - struct fake_vulkan vk = {.queue_graphics = {0, 1}, .queue_compute = {0, 1}, .queue_transfer = {1, 1}, - .lock_queue = lock_queue, .unlock_queue = unlock_queue}; - unsigned char lease = 0; - typeof(*((struct mp_image *)0)->android_fel_staging) ref = {.data = &lease}; - struct mp_image frame = {.android_fel_staging = &ref}; - struct vk_input input = {.view = H(VkImageView, 1000)}; - struct aimagereader_vk_stable p = {.mapper = &mapper, .vk = &vk, .device = H(VkDevice, 1000), - .sampler = H(VkSampler, 1000), .sampler_descriptors = 4, .width = 3840, .height = 2160, - .output_format = VK_FORMAT_A2B10G10R10_UNORM_PACK32, .external_format = 0xf0}; - current_core = &core; current_probe = &vo.fel_bind_probe; - vo.extra.wakeup_cb = wake_core; vo.extra.wakeup_ctx = &core; vo.extra.android_dovi_fel = true; - reset(); - assert(!fel_run_bind_probe(&p, &input, &frame, -1)); assert(!wakes && now_ns == 1); assert_clean(); - assert(mp_fel_probe_start(current_probe, now_ns)); assert(!mp_fel_probe_start(current_probe, now_ns)); - assert(fel_run_bind_probe(&p, &input, &frame, -1)); - assert(mp_fel_probe_state(current_probe) == MP_FEL_PROBE_DONE); - assert(!core.paused && !opts.pause && lease == 48); - assert(resets == 160 && begins == 160 && ends == 160 && binds == 120 && writes == 3 && submits == 2); - assert(strstr(logs, "samples=32 complete=1") && strstr(logs, "group=storage-only") && strstr(logs, "groups=4/4")); - assert_clean(); - - reset(); lease = 0; assert(mp_fel_probe_start(current_probe, now_ns)); cancel_at_bind = 3; - assert(fel_run_bind_probe(&p, &input, &frame, -1)); - assert(mp_fel_probe_state(current_probe) == MP_FEL_PROBE_CANCELLED && !core.paused); assert_clean(); - - reset(); lease = 0; assert(mp_fel_probe_start(current_probe, now_ns)); opts.pause = true; - assert(fel_run_bind_probe(&p, &input, &frame, -1)); assert(core.paused && opts.pause); assert_clean(); - opts.pause = false; update_internal_pause_state(&core); - - reset(); assert(mp_fel_probe_start(current_probe, now_ns)); target_bytes = MP_FEL_PROBE_MEMORY_LIMIT + 1; - assert(fel_run_bind_probe(&p, &input, &frame, -1)); - assert(!made[R_MEMORY] && !binds && mp_fel_probe_state(current_probe) == MP_FEL_PROBE_FAILED); assert_clean(); - - reset(); assert(mp_fel_probe_start(current_probe, now_ns)); fail_memory = true; - assert(fel_run_bind_probe(&p, &input, &frame, -1)); assert_clean(); - - reset(); assert(mp_fel_probe_start(current_probe, now_ns)); fail_reset = 44; - assert(fel_run_bind_probe(&p, &input, &frame, -1)); - assert(mp_fel_probe_state(current_probe) == MP_FEL_PROBE_FAILED); assert_clean(); - - reset(); assert(mp_fel_probe_start(current_probe, now_ns)); never_signal = true; - assert(fel_run_bind_probe(&p, &input, &frame, -1)); - assert(p.fel_probe_drain_count == 2 && !made[R_IMAGE] && !freed[R_FENCE] && !core.paused); - fel_probe_retire_drains(&p, true); assert_clean(); - - reset(); assert(mp_fel_probe_start(current_probe, now_ns)); device_lost = true; - assert(fel_run_bind_probe(&p, &input, &frame, -1)); assert(!made[R_IMAGE]); assert_clean(); - - reset(); assert(mp_fel_probe_start(current_probe, now_ns)); acquire_state = 1; - assert(fel_run_bind_probe(&p, &input, &frame, 7)); assert(!submits && !made[R_IMAGE]); assert_clean(); - - reset(); assert(mp_fel_probe_start(current_probe, now_ns)); ack_pause = false; - assert(fel_run_bind_probe(&p, &input, &frame, -1)); assert(!submits); assert_clean(); - - reset(); assert(mp_fel_probe_start(current_probe, now_ns)); current_probe->deadline = now_ns; - assert(fel_run_bind_probe(&p, &input, &frame, -1)); - assert(mp_fel_probe_state(current_probe) == MP_FEL_PROBE_TIMED_OUT); assert_clean(); - - reset(); current_probe->supported = false; - struct m_property property = {.name = "android-fel-bind-probe"}; char *action = "start"; - assert(mp_property_android_fel_bind_probe(&core, &property, M_PROPERTY_SET, &action) == M_PROPERTY_UNAVAILABLE); - current_probe->supported = true; opts.pause = true; update_internal_pause_state(&core); - assert(mp_property_android_fel_bind_probe(&core, &property, M_PROPERTY_SET, &action) == M_PROPERTY_UNAVAILABLE); - opts.pause = false; update_internal_pause_state(&core); - assert(mp_property_android_fel_bind_probe(&core, &property, M_PROPERTY_SET, &action) == M_PROPERTY_OK); - action = "cancel"; - assert(mp_property_android_fel_bind_probe(&core, &property, M_PROPERTY_SET, &action) == M_PROPERTY_OK); - assert(!mp_fel_probe_active(current_probe)); - char status[64]; mp_property_android_fel_bind_probe(&core, &property, M_PROPERTY_GET, status); - assert(strstr(status, ":cancelled")); - core.paused_for_cache = true; assert(get_internal_paused(&core)); - puts("PASS FEL bind probe: real recording/ownership/budgets/cancel/pause/native property contracts"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_context_options_test.c b/third_party/mpv-player-jni/tests/fel_context_options_test.c deleted file mode 100644 index 939ad05d47..0000000000 --- a/third_party/mpv-player-jni/tests/fel_context_options_test.c +++ /dev/null @@ -1,131 +0,0 @@ -/* Execute the production FEL preinit option block with mpv's real ta allocator - * and option copy/free functions. Only GPU creation and the cache container are - * replaced; the owned option values and their destruction are not mocked. */ -#include -#include -#include -#include -#include -#include "ta/ta_talloc.h" - -typedef struct m_obj_settings { - char *name; - char *label; - bool enabled; - char **attribs; -} m_obj_settings_t; -typedef struct m_option m_option_t; -typedef struct m_option_type { - void (*copy)(const m_option_t *, void *, const void *); - void (*free)(void *); -} m_option_type_t; -struct m_option { const m_option_type_t *type; }; - -#include "option_functions.inc" - -struct ra_ctx_opts { - m_obj_settings_t *context_list; - m_obj_settings_t *context_type_list; - bool probing; - bool want_alpha; -}; -struct priv { void *context; }; -struct vo { - struct priv *priv; - void *global; - struct { bool android_dovi_fel; int android_dovi_fel_vulkan; } extra; -}; -static const int ra_ctx_conf; -static const m_option_t list_option = { .type = &m_option_type_obj_settings_list }; -static struct ra_ctx_opts global_opts; -static int create_calls, fail_attempts, cache_frees, replaced_lists; - -static void original_list_freed(void *ptr) -{ - (void)ptr; - replaced_lists++; -} - -static void free_options(void *ptr) -{ - struct ra_ctx_opts *opts = ptr; - m_option_free(&list_option, &opts->context_list); - m_option_free(&list_option, &opts->context_type_list); - cache_frees++; -} - -static struct ra_ctx_opts *mp_get_config_group(void *parent, void *global, - const void *conf) -{ - (void)parent; (void)global; (void)conf; - struct ra_ctx_opts *opts = talloc_zero(NULL, struct ra_ctx_opts); - m_option_copy(&list_option, &opts->context_list, &global_opts.context_list); - m_option_copy(&list_option, &opts->context_type_list, &global_opts.context_type_list); - opts->probing = true; - talloc_set_destructor(opts->context_list, original_list_freed); - talloc_set_destructor(opts->context_type_list, original_list_freed); - talloc_set_destructor(opts, free_options); - return opts; -} - -static void update_ra_ctx_options(struct vo *vo, struct ra_ctx_opts *opts) -{ - (void)vo; - opts->want_alpha = true; -} - -static void *gpu_ctx_create(struct vo *vo, struct ra_ctx_opts *opts) -{ - bool vk = vo->extra.android_dovi_fel_vulkan && create_calls == 0; - const char *context = vo->extra.android_dovi_fel ? (vk ? "androidvk" : "android") : "saved-context"; - const char *api = vo->extra.android_dovi_fel ? (vk ? "vulkan" : "opengl") : "saved-api"; - assert(strcmp(opts->context_list[0].name, context) == 0); - assert(strcmp(opts->context_type_list[0].name, api) == 0); - assert(opts->want_alpha); - assert(opts->probing == !vo->extra.android_dovi_fel); - create_calls++; - return create_calls <= fail_attempts ? NULL : vo; -} - -#define MP_WARN(...) ((void)0) -#include "fel_preinit.inc" - -static void run_case(const char *name, bool fel, int mode, int failures, - int expected_calls, bool success) -{ - create_calls = cache_frees = replaced_lists = 0; - fail_attempts = failures; - struct priv priv = {0}; - struct vo vo = { .priv = &priv, - .extra = { .android_dovi_fel = fel, .android_dovi_fel_vulkan = mode } }; - assert((run_preinit(&vo) == 0) == success); - assert(create_calls == expected_calls); - assert(cache_frees == 1); - assert(replaced_lists == 2); - assert(strcmp(global_opts.context_list[0].name, "saved-context") == 0); - assert(strcmp(global_opts.context_list[0].label, "saved-label") == 0); - assert(strcmp(global_opts.context_list[0].attribs[1], "saved-value") == 0); - assert(strcmp(global_opts.context_type_list[0].name, "saved-api") == 0); - printf("PASS %s: creates=%d, original lists freed, global settings preserved\n", name, create_calls); -} - -int main(void) -{ - char *attrs[] = {"saved-key", "saved-value", NULL}; - m_obj_settings_t context[] = {{.name="saved-context", .label="saved-label", .enabled=true, .attribs=attrs}, {0}}; - m_obj_settings_t api[] = {{.name="saved-api", .enabled=true}, {0}}; - m_obj_settings_t *context_list=context, *api_list=api; - m_option_copy(&list_option, &global_opts.context_list, &context_list); - m_option_copy(&list_option, &global_opts.context_type_list, &api_list); - run_case("ordinary output", false, 0, 0, 1, true); - run_case("explicit Vulkan", true, 1, 0, 1, true); - run_case("explicit OpenGL", true, 0, 0, 1, true); - run_case("automatic Vulkan", true, 2, 0, 1, true); - run_case("automatic Vulkan to OpenGL", true, 2, 1, 2, true); - run_case("both backends fail", true, 2, 2, 2, false); - run_case("explicit Vulkan failure stays strict", true, 1, 1, 1, false); - m_option_free(&list_option, &global_opts.context_list); - m_option_free(&list_option, &global_opts.context_type_list); - puts("PASS actual mpv ta and option ownership lifecycle"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_contract_test.cpp b/third_party/mpv-player-jni/tests/fel_contract_test.cpp deleted file mode 100644 index c8126a74e9..0000000000 --- a/third_party/mpv-player-jni/tests/fel_contract_test.cpp +++ /dev/null @@ -1,84 +0,0 @@ -#include "node_json.h" -#include "filters/f_android_fel.h" -#include -#include -#include - -static mpv_node integer(long long value) { - mpv_node node{}; - node.format = MPV_FORMAT_INT64; - node.u.int64 = value; - return node; -} - -int main() { - std::string json; - mpv_node number = integer(9223372036854775807LL); - assert(mpv_node_json::encode(&number, &json)); - assert(json == "9223372036854775807"); - - mpv_node title{}; - title.format = MPV_FORMAT_STRING; - title.u.string = const_cast("国语 🎬\n\t\"\\"); - assert(mpv_node_json::encode(&title, &json)); - assert(json == "\"国语 🎬\\u000a\\u0009\\\"\\\\\""); - - char *keys[] = {const_cast("id"), const_cast("title")}; - mpv_node values[] = {integer(2), title}; - mpv_node_list fields{2, values, keys}; - mpv_node map{}; - map.format = MPV_FORMAT_NODE_MAP; - map.u.list = &fields; - mpv_node_list list{1, &map, nullptr}; - mpv_node array{}; - array.format = MPV_FORMAT_NODE_ARRAY; - array.u.list = &list; - assert(mpv_node_json::encode(&array, &json)); - assert(json.rfind("[{\"id\":2,\"title\":", 0) == 0); - - mpv_node floating{}; - floating.format = MPV_FORMAT_DOUBLE; - floating.u.double_ = std::numeric_limits::infinity(); - assert(mpv_node_json::encode(&floating, &json) && json == "null"); - floating.u.double_ = 1.25; - assert(mpv_node_json::encode(&floating, &json) && json == "1.25"); - - // Malformed lists, cycles, large strings and unsupported types fail closed. - list.num = -1; - assert(!mpv_node_json::encode(&array, &json) && json.empty()); - list.num = 1; - list.values = &array; - assert(!mpv_node_json::encode(&array, &json) && json.empty()); - std::string oversized(mpv_node_json::MAX_BYTES, 'x'); - title.u.string = oversized.data(); - assert(!mpv_node_json::encode(&title, &json) && json.empty()); - title.format = MPV_FORMAT_BYTE_ARRAY; - assert(!mpv_node_json::encode(&title, &json)); - assert(!mpv_node_json::encode(nullptr, &json)); - - // EL can spend an arbitrary warm-up interval decoding/reordering while the - // BL output is held. A full one-frame queue must never cause BL-only output. - static_assert(MP_ANDROID_FEL_BL_PENDING == 1); - static_assert(MP_ANDROID_FEL_BL_PREFETCH == 1); - static_assert(MP_ANDROID_FEL_EL_PREFETCH > 0); - for (int iteration = 0; iteration < 10000; ++iteration) - assert(!mp_android_fel_can_skip_bl(false, false, 0)); - assert(!mp_android_fel_can_skip_bl(false, true, -1)); // discard old EL, not BL - assert(!mp_android_fel_can_skip_bl(false, true, 0)); // exact pair - assert(mp_android_fel_can_skip_bl(false, true, 1)); // affirmative missing partner - assert(mp_android_fel_can_skip_bl(true, false, 0)); // EL EOF - for (int errors = 0; errors < MP_ANDROID_FEL_ERROR_LIMIT; ++errors) - assert(!mp_android_fel_decode_failed(true, errors)); - assert(mp_android_fel_decode_failed(true, MP_ANDROID_FEL_ERROR_LIMIT)); - assert(!mp_android_fel_decode_failed(false, 100)); - assert(!mp_android_fel_decode_failed(true, 0)); // successful frame/seek resets budget - assert(mp_android_fel_stage_output(true, true)); - assert(!mp_android_fel_stage_output(false, true)); // ordinary direct playback unchanged - assert(!mp_android_fel_stage_output(true, false)); // explicit conversion path unchanged - assert(!mp_android_fel_preserve_depth(true, 8)); - assert(mp_android_fel_preserve_depth(true, 10)); - assert(mp_android_fel_preserve_depth(true, 16)); - assert(mp_android_fel_preserve_depth(false, 8)); // existing SDR policy unchanged - static_assert(MP_ANDROID_FEL_COPY_WAIT_NS > 0 && MP_ANDROID_FEL_COPY_WAIT_NS <= 100000000LL); - std::cout << "PASS: bounded NODE serialization, FEL warm-up/backpressure, failure budget and GPU staging policy\n"; -} diff --git a/third_party/mpv-player-jni/tests/fel_core_preload_test.c b/third_party/mpv-player-jni/tests/fel_core_preload_test.c deleted file mode 100644 index 25f9b45e1b..0000000000 --- a/third_party/mpv-player-jni/tests/fel_core_preload_test.c +++ /dev/null @@ -1,816 +0,0 @@ -// The script appends the production core lookahead, VO request/worker and GPU -// cache selection bodies. Only the codec/GPU/platform are deterministic fakes. -// One available codec output is a regression model, not a measured TV DPB size. -#include "filters/f_android_fel_trace.h" -#include "filters/f_android_fel.h" -#include "video/out/fel_bind_probe.h" -#include -#include -#include -#include - -#define CHECK(c) do { if (!(c)) { \ - fprintf(stderr, "FAIL: %s (line %d)\n", #c, __LINE__); abort(); \ -} } while (0) -#define mp_assert CHECK -#define MP_ARRAY_SIZE(a) ((int)(sizeof(a) / sizeof((a)[0]))) -#define MPCLAMP(v, lo, hi) ((v) < (lo) ? (lo) : (v) > (hi) ? (hi) : (v)) -#define MPMAX(a, b) ((a) > (b) ? (a) : (b)) -#define MP_TIME_MS_TO_NS(ms) ((ms) * 1000000LL) -#define MP_NOPTS_VALUE (-1e20) -#define MP_INFO(...) ((void)0) -#define MP_ERR(...) ((void)0) -static void test_warn(const char *format, ...) { (void)format; } -#define MP_WARN(ctx, ...) test_warn(__VA_ARGS__) -enum { VO_TRUE = 1, VO_FALSE = 0, VO_ERROR = -1, VO_NOTIMPL = -3 }; -enum { IMGFMT_MEDIACODEC = 1, IMGFMT_YUV420P10 = 2 }; -enum { PL_COLOR_SYSTEM_DOLBYVISION = 7 }; -enum { VO_CAP_NORETAIN = 1, VO_CAP_UNTIMED = 2, VO_CAP_GPU_DOVI_EL_SW = 4 }; -enum { VOCTRL_PREPARE_FEL_FRAME = 1, STATUS_SYNCING = 1 }; -enum { VD_ERROR = -1, VD_EOF = 0, VD_PROGRESS = 1, VD_NEW_FRAME = 2, VD_WAIT = 3 }; -enum { MP_FRAME_NONE, MP_FRAME_EOF, MP_FRAME_VIDEO, MP_FRAME_OTHER }; -#define VO_MAX_REQ_FRAMES 10 -#define FEL_OUTPUT_CAPACITY (2 * VO_MAX_REQ_FRAMES + 4) - -struct mp_rect { int x0, y0, x1, y1; }; -struct mp_image_params { - int imgfmt, w, h, rotate, p_w, p_h; - bool vflip; - struct mp_rect crop; - struct { void *dovi; int sys; } repr; - struct { int hdr; } color; -}; -struct pixels { unsigned refs; int id; bool released; }; -struct mp_image { - int imgfmt; - double pts; - struct mp_image_params params; - struct pixels *pixels; - AVBufferRef *android_fel_staging; - uint64_t android_fel_prepared; -}; -struct vo_frame { struct mp_image *current, *frames[VO_MAX_REQ_FRAMES]; int num_frames; }; -struct vo; -struct vo_driver { unsigned caps; int (*control)(struct vo *, int, void *); }; -struct mp_vo_opts { bool android_dovi_fel; }; -struct vo_internal { - int lock; - struct mp_image *fel_prepare_image; - uint64_t fel_prepare_generation; - int fel_prepare_result; - int64_t fel_prepare_started, fel_prepare_retry; - int64_t fel_prepare_phase_started; - unsigned char fel_prepare_phase; - bool fel_render_init_attempted, fel_render_initializing; - int64_t fel_render_init_started; - bool send_reset; -}; -struct vo { - struct mp_vo_opts extra; - struct vo_internal *in; - struct mp_vo_opts *opts; - struct vo_driver *driver; - struct mp_fel_trace fel_trace; - int requested; -}; -struct mp_frame { int type; struct mp_image *data; }; -struct fake_filter { void *pins[2]; }; -struct fake_vf { struct fake_filter *f; bool got_output_eof; }; -struct vo_chain { bool is_sparse, is_coverart; struct fake_vf *filter; }; -struct MPOpts { struct mp_vo_opts *vo; bool untimed, video_latency_hacks; }; -struct MPContext { - struct vo *video_out; - struct MPOpts *opts; - struct vo_chain *vo_chain; - struct mp_image *saved_frame, *next_frames[VO_MAX_REQ_FRAMES + 1]; - int num_next_frames, video_status, play_dir, max_frames; - double video_pts, hrseek_pts, playback_pts; - bool hrseek_backstep, hrseek_active, hrseek_lastframe; -}; -struct vk_output { - struct mp_image *source_frame, **source_aliases; - int num_source_aliases; - bool pending; -}; -struct aimagereader_vk_stable { - bool android_fel; - int output_count, output_index; - struct vk_output outputs[FEL_OUTPUT_CAPACITY]; -}; -struct ra_hwdec_mapper { struct mp_image_params src_params, dst_params; }; -struct ra_hwdec { int unused; }; -struct timer_pool { int unused; }; -struct fake_ra { struct vo *vo; void *ra; }; -struct priv { struct fake_ra *ra_ctx; }; - -static int live_images, live_pixels, codec_held, codec_capacity, cursor, total; -static int controls, wakes, polls, control_result, mapper_creations; -static bool reset_during_control, software, unexpected, has_frame, defer_completion; -static int64_t fake_now; -static int64_t initialization_ns; -static struct mp_image *initializing_client; -static struct aimagereader_vk_stable cache; -static struct vo_internal in; -static struct mp_vo_opts vo_opts; -static struct MPOpts opts; -static struct vo_driver driver; -static struct vo vo; -static struct fake_filter filter; -static struct fake_vf vf; -static struct vo_chain chain; -static struct MPContext ctx; - -static bool output_has_live_fel_frame(struct aimagereader_vk_stable *, struct vk_output *); -static int select_reusable_output(struct aimagereader_vk_stable *, int *); -static void cancel_fel_prepare(struct vo *); -void vo_cancel_fel_frame(struct vo *, struct mp_image *); -int vo_prepare_fel_frame(struct vo *, struct mp_image *); -static int64_t process_fel_prepare(struct vo *); -#ifdef FEL_RENDER_WARMUP_BASELINE -// Old VO has no renderer phase: emulate only entering/leaving draw_frame. -static bool begin_fel_render_init(struct vo *v, const struct vo_frame *f) -{ (void)v; (void)f; return true; } -static void end_fel_render_init(struct vo *v) { (void)v; } -#else -static bool begin_fel_render_init(struct vo *, const struct vo_frame *); -static void end_fel_render_init(struct vo *); -#endif -static int video_output_image(struct MPContext *, bool *); -static int get_req_frames(struct MPContext *, bool); -static bool hwdec_reconfig(struct priv *, struct ra_hwdec_mapper **, - struct timer_pool **, struct ra_hwdec *, - const struct mp_image_params *); -static void restore_fel_display_params(struct vo *, struct mp_image *, - struct mp_image_params *); - -static void release_codec(struct mp_image *img) -{ - if (!img->pixels->released) { - img->pixels->released = true; - if (img->imgfmt == IMGFMT_MEDIACODEC) - codec_held--; - } -} -static struct mp_image *new_image(int id) -{ - struct mp_image *img = calloc(1, sizeof(*img)); - CHECK(img); - img->pixels = calloc(1, sizeof(*img->pixels)); - CHECK(img->pixels); - img->pixels->refs = 1; - img->pixels->id = id; - img->imgfmt = software ? IMGFMT_YUV420P10 : IMGFMT_MEDIACODEC; - img->params.imgfmt = img->imgfmt; - img->pts = id / 24.0; - live_images++; - live_pixels++; - if (!software) - codec_held++; - return img; -} -static struct mp_image *mp_image_new_ref(struct mp_image *src) -{ - struct mp_image *img = malloc(sizeof(*img)); - CHECK(img); - *img = *src; - img->pixels->refs++; - if (src->android_fel_staging) { - img->android_fel_staging = av_buffer_ref(src->android_fel_staging); - CHECK(img->android_fel_staging); - } - live_images++; - return img; -} -static void talloc_free(struct mp_image *img) -{ - if (!img) - return; - av_buffer_unref(&img->android_fel_staging); - if (!--img->pixels->refs) { - release_codec(img); - free(img->pixels); - live_pixels--; - } - free(img); - live_images--; -} -static void mp_image_unrefp(struct mp_image **img) { talloc_free(*img); *img = NULL; } -static void mp_image_setrefp(struct mp_image **dst, struct mp_image *src) -{ - mp_image_unrefp(dst); - *dst = src ? mp_image_new_ref(src) : NULL; -} -static void mp_mutex_lock(int *lock) { CHECK(!*lock); *lock = 1; } -static void mp_mutex_unlock(int *lock) { CHECK(*lock); *lock = 0; } -static int64_t mp_time_ns(void) { return fake_now; } -static void wakeup_locked(struct vo *v) { CHECK(v->in->lock); } -static void wakeup_core(struct vo *v) { (void)v; wakes++; } -static void update_opts(struct vo *v) { CHECK(!v->in->lock); } -static int vo_get_num_req_frames(struct vo *v) { return v->requested; } -static bool vo_has_frame(struct vo *v) { (void)v; return has_frame; } -static void handle_new_frame(struct MPContext *c) { (void)c; } -static double get_play_end_pts(struct MPContext *c) { (void)c; return MP_NOPTS_VALUE; } -static void mp_set_timeout(struct MPContext *c, double seconds) -{ (void)c; CHECK(seconds == .020); polls++; } -static struct mp_frame mp_pin_out_read(void *pin) -{ - (void)pin; - if (unexpected) - return (struct mp_frame){MP_FRAME_OTHER, NULL}; - if (cursor == total) - return (struct mp_frame){MP_FRAME_EOF, NULL}; - if (!software && codec_held >= codec_capacity) - return (struct mp_frame){0}; - return (struct mp_frame){MP_FRAME_VIDEO, new_image(cursor++)}; -} -static void mp_pin_out_unread(void *pin, struct mp_frame frame) -{ (void)pin; talloc_free(frame.data); } -static void mp_frame_unref(struct mp_frame *frame) { talloc_free(frame->data); } -static const char *mp_frame_type_str(int type) { (void)type; return "fake"; } - -static bool mp_image_params_static_equal(const struct mp_image_params *a, - const struct mp_image_params *b) -{ - return a->imgfmt == b->imgfmt && a->w == b->w && a->h == b->h && - a->rotate == b->rotate && a->vflip == b->vflip && - a->p_w == b->p_w && a->p_h == b->p_h && a->repr.sys == b->repr.sys && - !memcmp(&a->crop, &b->crop, sizeof(a->crop)); -} -static void ra_hwdec_mapper_free(struct ra_hwdec_mapper **m) { free(*m); *m = NULL; } -static struct ra_hwdec_mapper *ra_hwdec_mapper_create(struct ra_hwdec *h, - const struct mp_image_params *p) -{ - (void)h; - struct ra_hwdec_mapper *m = calloc(1, sizeof(*m)); - CHECK(m); - m->src_params = m->dst_params = *p; - mapper_creations++; - return m; -} -static struct timer_pool *timer_pool_create(void *ra) -{ (void)ra; return calloc(1, sizeof(struct timer_pool)); } -static void timer_pool_destroy(struct timer_pool *t) { free(t); } - -static void stage_image(struct mp_image *image) -{ - for (int n = 0; n < cache.output_count; n++) { - if (cache.outputs[n].source_frame && - cache.outputs[n].source_frame->pixels == image->pixels) { - if (!defer_completion) { - release_codec(image); - mp_android_fel_staging_complete(image->android_fel_staging->data); - } - return; - } - } - int waiting; - int slot = select_reusable_output(&cache, &waiting); - CHECK(waiting < 0); - if (slot < 0) { - CHECK(cache.output_count < FEL_OUTPUT_CAPACITY); - slot = cache.output_count++; - } - mp_image_unrefp(&cache.outputs[slot].source_frame); - cache.outputs[slot].source_frame = mp_image_new_ref(image); - cache.output_index = slot; - if (!defer_completion) { - release_codec(image); - if (image->android_fel_staging) - mp_android_fel_staging_complete(image->android_fel_staging->data); - } -} -static int control(struct vo *v, int request, void *data) -{ - CHECK(!v->in->lock); // a blocking GPU must never hold the core's VO lock - CHECK(request == VOCTRL_PREPARE_FEL_FRAME); - controls++; - if (initialization_ns) { - struct vo_frame *frame = data; - unsigned char *state = frame->current->android_fel_staging->data; - // The actual mapper publishes these monotonic phase bits while the - // VO callback is still running. Poll from the producer in that window, - // not after the fake mapper has already returned a ready texture. - __atomic_fetch_or(state, 4, __ATOMIC_RELEASE); - int64_t end = fake_now + initialization_ns; - while (fake_now < end) { - fake_now += end - fake_now < MP_TIME_MS_TO_NS(50) - ? end - fake_now : MP_TIME_MS_TO_NS(50); - CHECK(vo_prepare_fel_frame(v, initializing_client) == VO_FALSE); - } - __atomic_fetch_or(state, 8, __ATOMIC_RELEASE); - } - if (reset_during_control) { - mp_mutex_lock(&v->in->lock); - cancel_fel_prepare(v); - mp_mutex_unlock(&v->in->lock); - } - if (control_result == VO_TRUE) { - struct vo_frame *frame = data; - for (int n = 0; n < frame->num_frames; n++) - stage_image(frame->frames[n]); - } - return control_result; -} -static void reset_test(void) -{ - CHECK(!live_images && !live_pixels && !codec_held); - controls = wakes = polls = cursor = 0; - total = 40; - codec_capacity = 1; - fake_now = 1000000000LL; - initialization_ns = 0; - initializing_client = NULL; - control_result = VO_TRUE; - reset_during_control = software = unexpected = has_frame = defer_completion = false; - in = (struct vo_internal){.fel_prepare_generation = 1}; - vo_opts = (struct mp_vo_opts){true}; - opts = (struct MPOpts){.vo = &vo_opts}; - driver = (struct vo_driver){VO_CAP_GPU_DOVI_EL_SW, control}; - vo = (struct vo){.in = &in, .opts = &vo_opts, .extra = vo_opts, .driver = &driver, .requested = 2}; - mp_fel_trace_init(&vo.fel_trace); - atomic_store(&vo.fel_trace.enabled, true); - vf = (struct fake_vf){.f = &filter}; - chain = (struct vo_chain){.filter = &vf}; - ctx = (struct MPContext){.video_out = &vo, .opts = &opts, .vo_chain = &chain, - .play_dir = 1, .max_frames = -1, .video_pts = 1, .playback_pts = MP_NOPTS_VALUE}; - cache = (struct aimagereader_vk_stable){.android_fel = true, - .output_count = 4, .output_index = -1}; -} -static void cleanup(void) -{ - mp_mutex_lock(&in.lock); - cancel_fel_prepare(&vo); - mp_mutex_unlock(&in.lock); - mp_image_unrefp(&ctx.saved_frame); - for (int n = 0; n < ctx.num_next_frames; n++) - mp_image_unrefp(&ctx.next_frames[n]); - for (int n = 0; n < cache.output_count; n++) - mp_image_unrefp(&cache.outputs[n].source_frame); - CHECK(!live_images && !live_pixels && !codec_held); -} -static void test_lookahead(bool fel, int requested, bool sw) -{ - reset_test(); - vo.extra.android_dovi_fel = fel; - vo.requested = requested; - software = sw; - bool eof = false; - int presented = 0; - for (int iteration = 0; iteration < 1000; iteration++) { - int result = video_output_image(&ctx, &eof); - CHECK(result != VD_ERROR); - process_fel_prepare(&vo); - if (result == VD_NEW_FRAME) { - for (int n = 0; n < ctx.num_next_frames; n++) { - CHECK(ctx.next_frames[n]->pixels->id == presented + n); - if (fel && !sw) { - CHECK(ctx.next_frames[n]->android_fel_staging); - stage_image(ctx.next_frames[n]); // normal VO draw - } - } - talloc_free(ctx.next_frames[0]); - ctx.num_next_frames--; - memmove(ctx.next_frames, ctx.next_frames + 1, - ctx.num_next_frames * sizeof(ctx.next_frames[0])); - presented++; - } - if (eof && !ctx.num_next_frames) - break; - fake_now += 1000000; - } - if (fel || sw) { - CHECK(presented == total && eof); - CHECK(get_req_frames(&ctx, false) == requested); - } else { - CHECK(!presented && cursor == 1 && ctx.num_next_frames == 1); - CHECK(!controls && !polls); // old ordering deadlocks this output model - } - if (sw) - CHECK(!controls && !atomic_load(&vo.fel_trace.core_prepare_requests)); - cleanup(); -} -static void test_async_lifecycle(void) -{ - reset_test(); - struct mp_image *a = new_image(1), *b = new_image(2); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE && !controls); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE && !b->android_fel_staging); - control_result = VO_FALSE; - CHECK(process_fel_prepare(&vo) == fake_now + MP_TIME_MS_TO_NS(5)); - CHECK(controls == 1); - process_fel_prepare(&vo); - CHECK(controls == 1); // no busy retry - fake_now += MP_TIME_MS_TO_NS(5); - control_result = VO_TRUE; - CHECK(!process_fel_prepare(&vo) && wakes == 1); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_TRUE); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_TRUE && controls == 2); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - process_fel_prepare(&vo); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_TRUE); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_TRUE); // A/B do not evict completion - CHECK(atomic_load(&vo.fel_trace.core_prepare_requests) == 2); - CHECK(atomic_load(&vo.fel_trace.core_prepare_completed) == 2); - talloc_free(a); talloc_free(b); - cleanup(); - - reset_test(); - a = new_image(1); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - in.send_reset = true; - process_fel_prepare(&vo); - CHECK(!controls); - in.send_reset = false; - reset_during_control = true; - process_fel_prepare(&vo); - CHECK(!in.fel_prepare_image && !a->android_fel_prepared); - CHECK(!atomic_load(&vo.fel_trace.core_prepare_completed)); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - reset_during_control = false; - process_fel_prepare(&vo); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_TRUE); - talloc_free(a); - cleanup(); - - reset_test(); - a = new_image(1); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - fake_now += MP_TIME_MS_TO_NS(750); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_ERROR); - CHECK(!in.fel_prepare_image && !controls); - CHECK(atomic_load(&vo.fel_trace.core_prepare_errors) == 1); - driver.caps = 0; - CHECK(vo_prepare_fel_frame(&vo, a) == VO_NOTIMPL); - talloc_free(a); - cleanup(); -} -static void test_deferred_handoff(void) -{ - reset_test(); - struct mp_image *a = new_image(1), *b = new_image(2); - defer_completion = true; - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - // A valid texture is not permission to publish an unreturned codec image. - // This is the >100 ms copy case from log41, without real-time sleeps. - CHECK(process_fel_prepare(&vo) == fake_now + MP_TIME_MS_TO_NS(5)); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - CHECK(!a->android_fel_prepared && !a->pixels->released && !wakes); - fake_now += MP_TIME_MS_TO_NS(5); - defer_completion = false; - CHECK(process_fel_prepare(&vo) == 0); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_TRUE); - CHECK(!in.fel_prepare_image && a->pixels->released); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE && b->android_fel_staging); - // A cached older frame must not consume B's outstanding request. - CHECK(vo_prepare_fel_frame(&vo, a) == VO_TRUE); - CHECK(in.fel_prepare_image->pixels == b->pixels); - process_fel_prepare(&vo); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_TRUE && !in.fel_prepare_image); - talloc_free(a); talloc_free(b); - cleanup(); - - reset_test(); - a = new_image(1); b = new_image(2); - a->android_fel_prepared = in.fel_prepare_generation; - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - process_fel_prepare(&vo); - // A ready fast path must still acknowledge its own in-flight request. - CHECK(vo_prepare_fel_frame(&vo, a) == VO_TRUE && !in.fel_prepare_image); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE && b->android_fel_staging); - process_fel_prepare(&vo); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_TRUE); - talloc_free(a); talloc_free(b); - cleanup(); -} - -static void test_cold_initialization(void) -{ - reset_test(); - struct mp_image *a = new_image(1); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - initializing_client = a; - initialization_ns = MP_TIME_MS_TO_NS(2130); // log42 measured cold map cost - process_fel_prepare(&vo); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_TRUE); - CHECK(!in.fel_prepare_image && a->pixels->released); - CHECK(atomic_load(&vo.fel_trace.core_prepare_errors) == 0); - CHECK(atomic_load(&vo.fel_trace.core_prepare_completed) == 1); - talloc_free(a); - cleanup(); -} - -static void test_diagnostic_deadline(void) -{ - reset_test(); - struct mp_image *a = new_image(1); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - mp_android_fel_staging_begin_probe(a->android_fel_staging->data); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - fake_now += MP_FEL_PROBE_TIMEOUT_NS - 1; - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - mp_android_fel_staging_begin_probe(a->android_fel_staging->data); - fake_now++; - CHECK(vo_prepare_fel_frame(&vo, a) == VO_ERROR); - talloc_free(a); - cleanup(); - - reset_test(); - a = new_image(1); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - mp_android_fel_staging_begin_probe(a->android_fel_staging->data); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - fake_now += MP_TIME_MS_TO_NS(4000); - mp_android_fel_staging_end_probe(a->android_fel_staging->data); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - fake_now += MP_ANDROID_FEL_FRAME_TIMEOUT_NS; - CHECK(vo_prepare_fel_frame(&vo, a) == VO_ERROR); - talloc_free(a); - cleanup(); -} - -static void test_initialization_deadlines_and_cancel(void) -{ - reset_test(); - struct mp_image *a = new_image(1), *b = new_image(2); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - mp_android_fel_staging_begin_init(a->android_fel_staging->data); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - fake_now += MP_ANDROID_FEL_INIT_TIMEOUT_NS - 1; - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - // Same phase / repeated polling cannot extend a hung initialization. - mp_android_fel_staging_begin_init(a->android_fel_staging->data); - fake_now++; - CHECK(vo_prepare_fel_frame(&vo, a) == VO_ERROR); - CHECK(!in.fel_prepare_image && !a->pixels->released); - CHECK(!mp_android_fel_staging_ready(a->android_fel_staging->data)); - talloc_free(a); talloc_free(b); - cleanup(); - - reset_test(); - a = new_image(1); b = new_image(2); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - mp_android_fel_staging_begin_init(a->android_fel_staging->data); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - fake_now += MP_TIME_MS_TO_NS(2130); - mp_android_fel_staging_end_init(a->android_fel_staging->data); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - fake_now += MP_ANDROID_FEL_FRAME_TIMEOUT_NS - 1; - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - mp_android_fel_staging_end_init(a->android_fel_staging->data); - fake_now++; - CHECK(vo_prepare_fel_frame(&vo, a) == VO_ERROR); // steady state still 750ms - talloc_free(a); talloc_free(b); - cleanup(); - - reset_test(); - a = new_image(1); b = new_image(2); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - vo_cancel_fel_frame(&vo, b); // another frame must not cancel A - CHECK(in.fel_prepare_image && !b->android_fel_staging); - uint64_t generation = in.fel_prepare_generation; - vo_cancel_fel_frame(&vo, a); - CHECK(!in.fel_prepare_image && in.fel_prepare_generation != generation); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - process_fel_prepare(&vo); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_TRUE); - talloc_free(a); talloc_free(b); - cleanup(); -} - -static bool render_begin(struct mp_image *image) -{ - struct vo_frame frame = {.current = image}; - mp_mutex_lock(&in.lock); - bool result = begin_fel_render_init(&vo, &frame); - mp_mutex_unlock(&in.lock); - return result; -} - -static void render_end(void) -{ - mp_mutex_lock(&in.lock); - end_fel_render_init(&vo); - mp_mutex_unlock(&in.lock); -} - -static void test_render_initialization(void) -{ - reset_test(); - struct mp_image *a = new_image(1), *b = new_image(2); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - fake_now += MP_TIME_MS_TO_NS(100); // spent before first draw, not forgiven - CHECK(render_begin(a)); - fake_now += MP_TIME_MS_TO_NS(900); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); // old VO times out here - render_end(); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - CHECK(!render_begin(a)); // subsequent draws cannot extend the deadline - process_fel_prepare(&vo); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_TRUE); - CHECK(!atomic_load(&vo.fel_trace.core_prepare_errors)); - talloc_free(a); talloc_free(b); cleanup(); - - // Entire initialization between producer polls; preserve time spent before - // it and do not grant another full frame deadline when it finishes. - reset_test(); - a = new_image(1); b = new_image(2); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - fake_now += MP_TIME_MS_TO_NS(100); - CHECK(render_begin(a)); - fake_now += MP_TIME_MS_TO_NS(1200); - render_end(); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - fake_now += MP_TIME_MS_TO_NS(650) - 1; - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - fake_now++; - CHECK(vo_prepare_fel_frame(&vo, b) == VO_ERROR); - talloc_free(a); talloc_free(b); cleanup(); - - // A request first arriving during draw receives only its actual wait time. - reset_test(); - a = new_image(1); b = new_image(2); - CHECK(render_begin(a)); - fake_now += MP_TIME_MS_TO_NS(200); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - fake_now += MP_TIME_MS_TO_NS(900); - render_end(); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - fake_now += MP_ANDROID_FEL_FRAME_TIMEOUT_NS; - CHECK(vo_prepare_fel_frame(&vo, b) == VO_ERROR); - talloc_free(a); talloc_free(b); cleanup(); - - // The renderer's hard limit starts at draw, not at each request/poll. - reset_test(); - a = new_image(1); b = new_image(2); - CHECK(render_begin(a)); - fake_now += MP_TIME_MS_TO_NS(200); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - fake_now += MP_ANDROID_FEL_INIT_TIMEOUT_NS - MP_TIME_MS_TO_NS(200) - 1; - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - CHECK(!render_begin(a)); - fake_now++; - CHECK(vo_prepare_fel_frame(&vo, b) == VO_ERROR); - render_end(); - talloc_free(a); talloc_free(b); cleanup(); - - // Even a delayed producer cannot miss a >10s warmup failure. - reset_test(); - a = new_image(1); b = new_image(2); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - CHECK(render_begin(a)); - fake_now += MP_ANDROID_FEL_INIT_TIMEOUT_NS; - render_end(); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_ERROR); - CHECK(atomic_load(&vo.fel_trace.core_prepare_errors) == 1); - talloc_free(a); talloc_free(b); cleanup(); - - reset_test(); - a = new_image(1); b = new_image(2); - CHECK(vo_prepare_fel_frame(&vo, a) == VO_FALSE); - CHECK(render_begin(a)); - vo_cancel_fel_frame(&vo, a); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - fake_now += MP_TIME_MS_TO_NS(900); - render_end(); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_FALSE); - process_fel_prepare(&vo); - CHECK(vo_prepare_fel_frame(&vo, b) == VO_TRUE); - CHECK(!render_begin(a)); // cancel/ordinary seek does not repeat cold draw - talloc_free(a); talloc_free(b); cleanup(); - - reset_test(); - a = new_image(1); - vo.extra.android_dovi_fel = false; - CHECK(!render_begin(a)); - vo.extra.android_dovi_fel = true; - driver.caps = 0; - CHECK(!render_begin(a)); - driver.caps = VO_CAP_GPU_DOVI_EL_SW; - a->imgfmt = IMGFMT_YUV420P10; - CHECK(!render_begin(a)); - a->imgfmt = IMGFMT_MEDIACODEC; - CHECK(!in.fel_render_init_attempted); - CHECK(render_begin(a)); - render_end(); - talloc_free(a); cleanup(); -} - -static void test_seek_and_errors(void) -{ - reset_test(); - ctx.hrseek_active = true; - ctx.hrseek_lastframe = true; - ctx.video_status = STATUS_SYNCING; - ctx.hrseek_pts = 10; - total = 8; - bool eof = false; - int result = VD_WAIT; - for (int n = 0; n < 100 && result != VD_NEW_FRAME; n++) { - result = video_output_image(&ctx, &eof); - CHECK(result != VD_ERROR); - process_fel_prepare(&vo); - fake_now += 1000000; - } - CHECK(eof && result == VD_NEW_FRAME && ctx.num_next_frames == 1); - CHECK(ctx.next_frames[0]->pixels->id == 7 && !ctx.saved_frame); - cleanup(); - - reset_test(); - CHECK(video_output_image(&ctx, &eof) == VD_PROGRESS); - CHECK(video_output_image(&ctx, &eof) == VD_WAIT); - control_result = VO_ERROR; - process_fel_prepare(&vo); - CHECK(video_output_image(&ctx, &eof) == VD_ERROR); - cleanup(); -} -static void test_leases_and_crop(void) -{ - reset_test(); - struct mp_image *a = new_image(1); - a->android_fel_staging = av_buffer_alloc(1); - CHECK(a->android_fel_staging); - struct vk_output *o = &cache.outputs[0]; - o->source_frame = mp_image_new_ref(a); - CHECK(output_has_live_fel_frame(&cache, o)); - struct mp_image *aliases[] = {mp_image_new_ref(a)}; - o->source_aliases = aliases; - o->num_source_aliases = 1; - talloc_free(a); - CHECK(!output_has_live_fel_frame(&cache, o)); - a = mp_image_new_ref(aliases[0]); - CHECK(output_has_live_fel_frame(&cache, o)); - int waiting; - cache.output_index = 3; - CHECK(select_reusable_output(&cache, &waiting) == 1 && waiting < 0); - cache.android_fel = false; - CHECK(select_reusable_output(&cache, &waiting) == 0); - cache.android_fel = true; - talloc_free(a); - talloc_free(aliases[0]); - o->num_source_aliases = 0; - struct fake_ra ra = {.vo = &vo}; - struct priv p = {.ra_ctx = &ra}; - struct ra_hwdec hwdec = {0}; - struct ra_hwdec_mapper *mapper = NULL; - struct timer_pool *timer = NULL; - struct mp_image_params params = {.imgfmt = IMGFMT_MEDIACODEC, .w = 3840, - .h = 2160, .crop = {0, 0, 3840, 2160}, .p_w = 1, .p_h = 1}; - mapper_creations = 0; - CHECK(hwdec_reconfig(&p, &mapper, &timer, &hwdec, ¶ms)); - params.crop = (struct mp_rect){40, 50, 3000, 2000}; - params.rotate = 90; params.vflip = true; params.p_w = 2; - CHECK(hwdec_reconfig(&p, &mapper, &timer, &hwdec, ¶ms)); - CHECK(mapper_creations == 1); // display geometry cannot discard staged pixels - CHECK(mapper->src_params.repr.sys == PL_COLOR_SYSTEM_DOLBYVISION); - // The producer stages pure BL. Inheriting EL RPU must not invalidate pixels. - params.repr.sys = PL_COLOR_SYSTEM_DOLBYVISION; - params.repr.dovi = ¶ms; - params.color.hdr = 500; - CHECK(hwdec_reconfig(&p, &mapper, &timer, &hwdec, ¶ms)); - CHECK(mapper_creations == 1 && mapper->dst_params.repr.dovi == ¶ms); - struct mp_image display = {.imgfmt = IMGFMT_MEDIACODEC, .params = params}; - struct mp_image_params storage = mapper->dst_params; - struct mp_rect storage_crop = storage.crop; - storage.imgfmt = IMGFMT_YUV420P10; // simulate the mapper's distinct output format - restore_fel_display_params(&vo, &display, &storage); - CHECK(storage.rotate == 90 && storage.vflip && storage.p_w == 2); - CHECK(storage.imgfmt == IMGFMT_YUV420P10 && storage.w == 3840); - CHECK(!memcmp(&storage.crop, &storage_crop, sizeof(storage_crop))); - display.params.repr.sys = 3; // BL-only interpretation without synthetic RPU - restore_fel_display_params(&vo, &display, &storage); - CHECK(storage.repr.sys == 3); - params.w = 1920; - CHECK(hwdec_reconfig(&p, &mapper, &timer, &hwdec, ¶ms)); - CHECK(mapper_creations == 2); // actual pixel format is never ignored - vo.extra.android_dovi_fel = false; - storage.rotate = 0; - restore_fel_display_params(&vo, &display, &storage); - CHECK(storage.rotate == 0); // never override another path's presentation - CHECK(hwdec_reconfig(&p, &mapper, &timer, &hwdec, ¶ms)); - params.crop.x0++; - CHECK(hwdec_reconfig(&p, &mapper, &timer, &hwdec, ¶ms)); - CHECK(mapper_creations == 4); // ordinary mapping contract unchanged - ra_hwdec_mapper_free(&mapper); - timer_pool_destroy(timer); - cleanup(); -} -int main(void) -{ - test_render_initialization(); - test_cold_initialization(); - test_initialization_deadlines_and_cancel(); - test_diagnostic_deadline(); - test_lookahead(false, 2, false); - test_lookahead(true, 2, false); - test_lookahead(true, 6, false); - test_lookahead(true, 10, false); - test_lookahead(false, 2, true); - test_lookahead(true, 2, true); - test_async_lifecycle(); - test_deferred_handoff(); - test_seek_and_errors(); - test_leases_and_crop(); - puts("PASS: actual FEL core/VO functions: limited-output progress, 2/6/10-frame " - "lookahead, async retry/timeout/reset, preroll EOF, leases and crop isolation"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_decoder_control_test.cpp b/third_party/mpv-player-jni/tests/fel_decoder_control_test.cpp deleted file mode 100644 index 12eae86b40..0000000000 --- a/third_party/mpv-player-jni/tests/fel_decoder_control_test.cpp +++ /dev/null @@ -1,137 +0,0 @@ -// Compile the actual wrapper functions from the pinned, patched mpv source. -// Only their surrounding decoder/dispatch environment is replaced by a fake. -#include "filters/f_android_fel.h" -#include -#include -#include -#include - -enum dec_ctrl { VDCTRL_FORCE_HWDEC_FALLBACK, VDCTRL_GET_HWDEC, VDCTRL_REINIT }; -enum { CONTROL_UNKNOWN = -1, CONTROL_FALSE = 0, CONTROL_TRUE = 1 }; -struct priv; -struct mp_filter { priv *priv; }; -struct mp_decoder_wrapper { mp_filter *f; }; -struct mp_decoder { - mp_filter *f; - int (*control)(mp_filter *, dec_ctrl, void *); -}; -struct priv { - bool is_group = false; - int num_children = 0; - mp_decoder_wrapper **children = nullptr; - bool android_fel = false; - bool software_el = false; - void *queue = nullptr; - mp_decoder *decoder = nullptr; - std::mutex cache_lock; - char *cur_hwdec = nullptr; - mp_android_fel_hwdec_snapshot fel_hwdec_cache{}; - mp_android_fel_hwdec_snapshot fel_hwdec_read{}; - unsigned long long fel_status_reads = 0; - double fel_status_log_at = 0; - double fps = 23.976; - bool forbid_decoder_wait = false; - int decoder_locks = 0; - int decoder_unlocks = 0; - int decoder_controls = 0; - int decoder_status = CONTROL_FALSE; - char decoder_name[96] = "mediacodec"; -}; -#define mp_assert assert -#define MP_INFO(...) ((void)0) -static void mp_mutex_lock(std::mutex *lock) { lock->lock(); } -static void mp_mutex_unlock(std::mutex *lock) { lock->unlock(); } -static void thread_lock(priv *p) { - ++p->decoder_locks; - if (p->forbid_decoder_wait) - throw std::runtime_error("read-only FEL query waits for the decoder"); -} -static void thread_unlock(priv *p) { ++p->decoder_unlocks; } -[[maybe_unused]] static double mp_time_sec() { return 10.0; } -static int fake_control(mp_filter *f, dec_ctrl cmd, void *arg) { - auto *p = f->priv; - ++p->decoder_controls; - if (cmd == VDCTRL_GET_HWDEC) { - if (p->decoder_status == CONTROL_TRUE) - *static_cast(arg) = p->decoder_name[0] ? p->decoder_name : nullptr; - return p->decoder_status; - } - return CONTROL_TRUE; -} - -static int update_cached_values(priv *p); -int mp_decoder_wrapper_control(mp_decoder_wrapper *, dec_ctrl, void *); -double mp_decoder_wrapper_get_container_fps(mp_decoder_wrapper *); - -int main() { - priv state; - mp_filter filter{&state}; - mp_decoder decoder{&filter, fake_control}; - mp_decoder_wrapper wrapper{&filter}; - state.decoder = &decoder; - state.android_fel = true; - state.queue = &state; // An asynchronous FEL decoder. - state.forbid_decoder_wait = true; - char *name = nullptr; - try { - // Probing is unavailable, not an invented software-decoding result. - update_cached_values(&state); - assert(mp_decoder_wrapper_control(&wrapper, VDCTRL_GET_HWDEC, &name) == CONTROL_FALSE); - assert(mp_decoder_wrapper_get_container_fps(&wrapper) == state.fps); - state.decoder_status = CONTROL_TRUE; - update_cached_values(&state); - assert(mp_decoder_wrapper_control(&wrapper, VDCTRL_GET_HWDEC, &name) == CONTROL_TRUE); - assert(!strcmp(name, "mediacodec")); - assert(name != state.decoder_name && name != state.fel_hwdec_cache.name); - auto revision = state.fel_hwdec_cache.revision; - update_cached_values(&state); - assert(state.fel_hwdec_cache.revision == revision); // no churn for same state - - // Publishing a new decoder must not invalidate the caller's old value. - strcpy(state.decoder_name, "mediacodec-copy"); - update_cached_values(&state); - assert(!strcmp(name, "mediacodec")); - assert(state.fel_hwdec_cache.revision > revision); - assert(mp_decoder_wrapper_control(&wrapper, VDCTRL_GET_HWDEC, &name) == CONTROL_TRUE); - assert(!strcmp(name, "mediacodec-copy")); - - state.decoder_name[0] = 0; - update_cached_values(&state); - assert(mp_decoder_wrapper_control(&wrapper, VDCTRL_GET_HWDEC, &name) == CONTROL_TRUE); - assert(!name[0]); // known software decoder, distinct from unknown - state.decoder_status = CONTROL_FALSE; - update_cached_values(&state); - assert(mp_decoder_wrapper_control(&wrapper, VDCTRL_GET_HWDEC, &name) == CONTROL_FALSE); - state.decoder_status = CONTROL_TRUE; - memset(state.decoder_name, 'x', sizeof(state.decoder_name) - 1); - state.decoder_name[sizeof(state.decoder_name) - 1] = 0; - update_cached_values(&state); - assert(mp_decoder_wrapper_control(&wrapper, VDCTRL_GET_HWDEC, &name) == CONTROL_FALSE); - assert(!name[0]); // refuse overflow/truncation, do not expose stale state - assert(state.decoder_locks == 0); - } catch (const std::runtime_error &error) { - std::cerr << "FAIL: " << error.what() << '\n'; - return 1; - } - - // Modifying operations still serialize with the decoder. - state.forbid_decoder_wait = false; - auto controls = state.decoder_controls; - assert(mp_decoder_wrapper_control(&wrapper, VDCTRL_REINIT, nullptr) == CONTROL_TRUE); - assert(state.decoder_locks == 1 && state.decoder_controls > controls); - assert(mp_decoder_wrapper_control(&wrapper, VDCTRL_FORCE_HWDEC_FALLBACK, nullptr) == CONTROL_TRUE); - assert(state.decoder_locks == 2); - // Neither ordinary playback nor a non-threaded wrapper changes behavior. - state.android_fel = false; - assert(mp_decoder_wrapper_control(&wrapper, VDCTRL_GET_HWDEC, &name) == CONTROL_TRUE); - assert(name == state.decoder_name && state.decoder_locks == 3); - assert(mp_decoder_wrapper_get_container_fps(&wrapper) == state.fps); - assert(state.decoder_locks == 4); - state.android_fel = true; - state.queue = nullptr; - assert(mp_decoder_wrapper_control(&wrapper, VDCTRL_GET_HWDEC, &name) == CONTROL_TRUE); - assert(state.decoder_locks == 5); - assert(state.decoder_unlocks == state.decoder_locks); - std::cout << "PASS: actual decoder-control functions avoid FEL read waits; " - "probe status, owned names and synchronous mutations preserved\n"; -} diff --git a/third_party/mpv-player-jni/tests/fel_el_rpu_test.c b/third_party/mpv-player-jni/tests/fel_el_rpu_test.c deleted file mode 100644 index 0f17799822..0000000000 --- a/third_party/mpv-player-jni/tests/fel_el_rpu_test.c +++ /dev/null @@ -1,150 +0,0 @@ -/* Validate the RPU source used by pure-BL Android FEL with a real decoder. */ -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define SAMPLE_FRAMES 120 -struct rpu_record { - int64_t pts; - uint8_t *data; - size_t size; - int seen; -}; - -static void save_rpu(struct rpu_record *r, const AVPacket *packet, int length_size) -{ - r->pts = packet->pts; - for (int offset = 0; offset < packet->size;) { - assert(packet->size - offset >= length_size); - unsigned length = 0; - for (int i = 0; i < length_size; i++) - length = (length << 8) | packet->data[offset++]; - assert(length >= 2 && length <= (unsigned)(packet->size - offset)); - const uint8_t *nal = packet->data + offset; - if ((nal[0] >> 1 & 63) == 62) { - assert(!r->data && length > 2); - r->size = length - 2; - r->data = av_memdup(nal + 2, r->size); - assert(r->data); - } - offset += length; - } - assert(r->data); -} - -static int drain_frames(AVCodecContext *decoder, AVFrame *frame, - struct rpu_record *records, int count, int *decoded) -{ - int ret; - while ((ret = avcodec_receive_frame(decoder, frame)) >= 0) { - int found = -1; - for (int i = 0; i < count; i++) { - if (records[i].pts == frame->pts) { - assert(found < 0); - found = i; - } - } - assert(found >= 0); - struct rpu_record *r = &records[found]; - assert(!r->seen++); - const AVFrameSideData *raw = - av_frame_get_side_data(frame, AV_FRAME_DATA_DOVI_RPU_BUFFER); - const AVFrameSideData *sd = - av_frame_get_side_data(frame, AV_FRAME_DATA_DOVI_METADATA); - assert(raw && raw->size == r->size && !memcmp(raw->data, r->data, r->size)); - assert(sd); - const AVDOVIMetadata *metadata = (const void *)sd->data; - const AVDOVIRpuDataHeader *header = av_dovi_get_header(metadata); - const AVDOVIDataMapping *mapping = av_dovi_get_mapping(metadata); - assert(!header->disable_residual_flag); - assert(header->bl_bit_depth == 10 && header->el_bit_depth == 10); - assert(mapping->nlq_method_idc == AV_DOVI_NLQ_LINEAR_DZ); - assert(frame->width == 1920 && frame->height == 1080); - assert(frame->format == AV_PIX_FMT_YUV420P10LE); - (*decoded)++; - av_frame_unref(frame); - } - assert(ret == AVERROR(EAGAIN) || ret == AVERROR_EOF); - return ret; -} - -static void sample_segment(AVFormatContext *format, int stream, int64_t seek_us) -{ - AVStream *st = format->streams[stream]; - int64_t seek_pts = av_rescale_q(seek_us, AV_TIME_BASE_Q, st->time_base); - assert(av_seek_frame(format, stream, seek_pts, AVSEEK_FLAG_BACKWARD) >= 0); - AVBSFContext *el = NULL; - assert(av_bsf_alloc(av_bsf_get_by_name("dovi_split"), &el) == 0); - assert(avcodec_parameters_copy(el->par_in, st->codecpar) == 0); - el->time_base_in = st->time_base; - assert(av_opt_set(el, "mode", "el_rpu", AV_OPT_SEARCH_CHILDREN) == 0); - assert(av_bsf_init(el) == 0); - const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_HEVC); - AVCodecContext *decoder = avcodec_alloc_context3(codec); - assert(decoder && avcodec_parameters_to_context(decoder, el->par_out) == 0); - decoder->pkt_timebase = el->time_base_out; - decoder->thread_count = 2; - assert(avcodec_open2(decoder, codec, NULL) == 0); - AVFrame *frame = av_frame_alloc(); - AVPacket *packet = av_packet_alloc(), *filtered = av_packet_alloc(); - assert(frame && packet && filtered); - struct rpu_record records[SAMPLE_FRAMES] = {0}; - int count = 0, decoded = 0; - assert(st->codecpar->extradata_size >= 23 && st->codecpar->extradata[0] == 1); - int length_size = (st->codecpar->extradata[21] & 3) + 1; - while (count < SAMPLE_FRAMES && av_read_frame(format, packet) >= 0) { - if (packet->stream_index == stream) { - save_rpu(&records[count++], packet, length_size); - assert(av_bsf_send_packet(el, packet) == 0); - assert(av_bsf_receive_packet(el, filtered) == 0); - int ret = avcodec_send_packet(decoder, filtered); - if (ret == AVERROR(EAGAIN)) { - drain_frames(decoder, frame, records, count, &decoded); - ret = avcodec_send_packet(decoder, filtered); - } - assert(ret == 0); - av_packet_unref(filtered); - drain_frames(decoder, frame, records, count, &decoded); - } - av_packet_unref(packet); - } - assert(count == SAMPLE_FRAMES); - assert(avcodec_send_packet(decoder, NULL) == 0); - assert(drain_frames(decoder, frame, records, count, &decoded) == AVERROR_EOF); - assert(decoded == count); - for (int i = 0; i < count; i++) { - assert(records[i].seen == 1); - av_free(records[i].data); - } - printf("PASS EL/RPU: seek-us=%lld decoded=%d; exact PTS/raw-RPU, active FEL NLQ, 1080p10 software frames\n", - (long long)seek_us, decoded); - av_frame_free(&frame); - av_packet_free(&packet); - av_packet_free(&filtered); - avcodec_free_context(&decoder); - av_bsf_free(&el); -} - -int main(int argc, char **argv) -{ - if (argc < 2) { - puts("SKIP EL/RPU sample decode: supply the GIJoe Profile 7 sample as argument 2 to the test script"); - return 0; - } - AVFormatContext *format = NULL; - assert(avformat_open_input(&format, argv[1], NULL, NULL) == 0); - assert(avformat_find_stream_info(format, NULL) >= 0); - int video = av_find_best_stream(format, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0); - assert(video >= 0); - sample_segment(format, video, 0); - sample_segment(format, video, 3212000); - sample_segment(format, video, 33575000); - avformat_close_input(&format); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_image_lease_test.c b/third_party/mpv-player-jni/tests/fel_image_lease_test.c deleted file mode 100644 index 8ae2efdbf7..0000000000 --- a/third_party/mpv-player-jni/tests/fel_image_lease_test.c +++ /dev/null @@ -1,105 +0,0 @@ -// Compile mp_image's actual ref/destructor/dummy/unref-data bodies with real -// AVBufferRef ownership. Fake only the small talloc allocation interface. -#include -#include -#include -#include -#include -#include -#include - -#define MP_MAX_PLANES 4 -#define MP_HANDLE_OOM(p) assert(p) -struct mp_ff_side_data { AVBufferRef *buf; }; -typedef struct mp_image { - uint8_t *planes[MP_MAX_PLANES]; - int stride[MP_MAX_PLANES]; - AVBufferRef *bufs[MP_MAX_PLANES], *hwctx, *icc_profile, *a53_cc, *dovi, *film_grain; - struct mp_ff_side_data *ff_side_data; - int num_ff_side_data; - struct mp_image *enhancement_layer; - AVBufferRef *android_fel_staging; - uint64_t android_fel_prepared; -} mp_image_t; -struct allocation { void (*destroy)(void *); max_align_t alignment; }; -static int allocations; -static void *allocate(size_t size) -{ - struct allocation *a = calloc(1, sizeof(*a) + size); - assert(a); - allocations++; - return a + 1; -} -static void talloc_set_destructor(void *p, void (*destroy)(void *)) -{ ((struct allocation *)p - 1)->destroy = destroy; } -static void talloc_free(void *p) -{ - if (!p) - return; - struct allocation *a = (struct allocation *)p - 1; - if (a->destroy) - a->destroy(p); - free(a); - allocations--; -} -#define talloc_ptrtype(ctx, ptr) allocate(sizeof(*(ptr))) -static void *talloc_memdup(void *ctx, const void *src, size_t size) -{ - (void)ctx; - if (!size) - return NULL; - void *p = allocate(size); - memcpy(p, src, size); - return p; -} -static void mp_image_destructor(void *p); -static void mp_image_unrefp(struct mp_image **p) { talloc_free(*p); *p = NULL; } -struct mp_image *mp_image_new_ref(struct mp_image *img); -struct mp_image *mp_image_new_dummy_ref(struct mp_image *img); -void mp_image_unref_data(struct mp_image *img); -static struct mp_image *mp_image_new_copy(struct mp_image *img) -{ (void)img; abort(); } // all tested sources own a real frame buffer - -static void free_lease(void *opaque, uint8_t *data) -{ - (*(int *)opaque)++; - free(data); -} -static struct mp_image *new_image(void) -{ - struct mp_image *img = allocate(sizeof(*img)); - talloc_set_destructor(img, mp_image_destructor); - img->bufs[0] = av_buffer_alloc(16); - assert(img->bufs[0]); - img->planes[0] = img->bufs[0]->data; - return img; -} -int main(void) -{ - int destroyed = 0; - struct mp_image *src = new_image(); - src->enhancement_layer = new_image(); - src->android_fel_staging = av_buffer_create(malloc(1), 1, free_lease, &destroyed, 0); - assert(src->android_fel_staging); - src->android_fel_prepared = 123; - struct mp_image *ref = mp_image_new_ref(src); - assert(av_buffer_get_ref_count(src->android_fel_staging) == 2); - assert(av_buffer_get_ref_count(src->bufs[0]) == 2); - assert(ref->android_fel_prepared == 123); - assert(ref->enhancement_layer != src->enhancement_layer); - struct mp_image *dummy = mp_image_new_dummy_ref(src); - assert(!dummy->android_fel_staging && !dummy->android_fel_prepared); - talloc_free(dummy); - assert(!destroyed && av_buffer_get_ref_count(src->android_fel_staging) == 2); - mp_image_unref_data(ref); - assert(!ref->android_fel_staging && !ref->android_fel_prepared); - assert(!ref->bufs[0] && !ref->planes[0]); - assert(av_buffer_get_ref_count(src->android_fel_staging) == 1); - talloc_free(ref); - assert(!destroyed); - talloc_free(src); - assert(destroyed == 1 && !allocations); - puts("PASS: actual mp_image FEL lease ownership: clone, EL refs, dummy, " - "unref-data, generation reset and final destructor (ASan/UBSan)"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_packet_test.c b/third_party/mpv-player-jni/tests/fel_packet_test.c deleted file mode 100644 index 89479f05ef..0000000000 --- a/third_party/mpv-player-jni/tests/fel_packet_test.c +++ /dev/null @@ -1,285 +0,0 @@ -#include "video/decode/android_fel_packet.h" -#include -#include -#include -#include -#include -#include - -struct nal_view { const uint8_t *data; int size; }; - -static int nals(const AVPacket *p, int length_size, struct nal_view *out) -{ - int count = 0; - for (int pos = 0; pos < p->size;) { - int size = 0; - if (length_size) { - assert(p->size - pos >= length_size); - for (int j = 0; j < length_size; j++) - size = (size << 8) | p->data[pos++]; - } else { - assert(p->size - pos >= 4 && AV_RB32(p->data + pos) == 1); - pos += 4; - size = p->size - pos; - for (int end = pos; end + 3 < p->size; end++) { - if (AV_RB32(p->data + end) == 1) { - size = end - pos; - break; - } - } - } - assert(size >= 2 && size <= p->size - pos && count < 128); - out[count++] = (struct nal_view){p->data + pos, size}; - pos += size; - } - return count; -} - -static int compare_bl(const AVPacket *input, const AVPacket *output, - int length_size) -{ - struct nal_view src[128], dst[128]; - int ni = nals(input, length_size, src); - int no = nals(output, length_size, dst); - int kept = 0, removed = 0; - for (int i = 0; i < ni; i++) { - int type = (src[i].data[0] >> 1) & 63; - if (type == 62 || type == 63) { - removed++; - continue; - } - bool size_ok = kept < no && (src[i].size == dst[kept].size || - (!length_size && src[i].size < dst[kept].size)); - if (!size_ok) { - fprintf(stderr, "NAL mismatch length-size=%d input-count=%d output-count=%d " - "input-index=%d type=%d size=%d output-index=%d size=%d\n", - length_size, ni, no, i, type, src[i].size, kept, - kept < no ? dst[kept].size : -1); - } - assert(size_ok); - assert(!memcmp(src[i].data, dst[kept].data, src[i].size)); - // Annex-B permits trailing_zero_8bits. FFmpeg's three-byte start-code - // scanner retains the first zero of the next four-byte start code. - // Allow only added zero padding, never altered/truncated payload bytes. - for (int extra = src[i].size; extra < dst[kept].size; extra++) - assert(dst[kept].data[extra] == 0); - kept++; - } - assert(kept == no); - assert(input->pts == output->pts && input->dts == output->dts); - assert(input->duration == output->duration && input->flags == output->flags); - int props = 0; - for (int i = 0; i < input->side_data_elems; i++) { - const AVPacketSideData *sd = &input->side_data[i]; - size_t size = 0; - uint8_t *data = av_packet_get_side_data(output, sd->type, &size); - if (sd->type == AV_PKT_DATA_DOVI_CONF || sd->type == AV_PKT_DATA_HEVC_CONF) { - assert(!data); - continue; - } - assert(data && size == sd->size && !memcmp(data, sd->data, size)); - props++; - } - assert(props == output->side_data_elems); - return removed; -} - -static AVCodecContext *context(int length_size) -{ - AVCodecContext *c = avcodec_alloc_context3(NULL); - assert(c); - c->codec_type = AVMEDIA_TYPE_VIDEO; - c->codec_id = AV_CODEC_ID_HEVC; - c->width = 3840; - c->height = 2160; - c->pkt_timebase = (AVRational){1, 1000}; - if (length_size) { - c->extradata = av_mallocz(23 + AV_INPUT_BUFFER_PADDING_SIZE); - assert(c->extradata); - c->extradata_size = 23; - c->extradata[0] = 1; - c->extradata[21] = length_size - 1; - } - AVPacketSideData *sd = av_packet_side_data_new(&c->coded_side_data, - &c->nb_coded_side_data, AV_PKT_DATA_DOVI_CONF, - sizeof(AVDOVIDecoderConfigurationRecord), 0); - assert(sd); - AVDOVIDecoderConfigurationRecord *cfg = (void *)sd->data; - *cfg = (AVDOVIDecoderConfigurationRecord){.dv_profile = 7, - .bl_present_flag = 1, .el_present_flag = 1, .rpu_present_flag = 1}; - return c; -} - -static AVPacket *packet(int length_size, bool only_el) -{ - static const uint8_t payload[][6] = { - {0x02, 0x01, 0xa5, 0x33, 0x51, 0x80}, // BL - {0x7c, 0x01, 0x11, 0x22, 0x31, 0x80}, // verbatim RPU - {0x7e, 0x01, 0x02, 0x01, 0x44, 0x80}, // wrapped EL - }; - AVPacket *p = av_packet_alloc(); - assert(p); - int prefix = length_size ? length_size : 4; - assert(av_new_packet(p, (prefix + 6) * (only_el ? 1 : 3)) == 0); - uint8_t *dst = p->data; - for (int i = only_el ? 2 : 0; i < 3; i++) { - for (int j = 0; j < prefix; j++) - *dst++ = (length_size ? 6U : 1U) >> (8 * (prefix - 1 - j)); - memcpy(dst, payload[i], 6); - dst += 6; - } - p->pts = 19810; - p->dts = 19726; - p->duration = 42; - p->flags = AV_PKT_FLAG_KEY; - uint8_t *sd = av_packet_new_side_data(p, AV_PKT_DATA_STRINGS_METADATA, 4); - assert(sd); - memcpy(sd, "x\0y\0", 4); - sd = av_packet_new_side_data(p, AV_PKT_DATA_DOVI_CONF, - sizeof(AVDOVIDecoderConfigurationRecord)); - assert(sd); - *(AVDOVIDecoderConfigurationRecord *)sd = - (AVDOVIDecoderConfigurationRecord){.dv_profile = 7, - .bl_present_flag = 1, .el_present_flag = 1, .rpu_present_flag = 1}; - sd = av_packet_new_side_data(p, AV_PKT_DATA_HEVC_CONF, 4); - assert(sd); - memcpy(sd, "hvcE", 4); - return p; -} - -static void policy_test(void) -{ - for (int fel = 0; fel < 2; fel++) { - for (int sw = 0; sw < 2; sw++) { - for (int mc = 0; mc < 2; mc++) { - for (int profile = 5; profile <= 8; profile++) { - AVCodecContext *c = context(0); - struct mp_android_fel_packet_filter f = {0}; - assert(mp_android_fel_packet_init(&f, c, fel, sw, mc, profile) == 0); - bool active = fel && !sw && mc && profile == 7; - assert(!!f.bsf == active && !!f.packet == active); - if (!active) { - assert(c->nb_coded_side_data == 1); - AVPacket *p = packet(0, false); - const AVPacket *out = NULL; - assert(mp_android_fel_packet_prepare(&f, p, &out) == 1); - assert(out == p); // unchanged, no packet allocation or scan - av_packet_free(&p); - } - mp_android_fel_packet_reset(&f); - mp_android_fel_packet_uninit(&f); - avcodec_free_context(&c); - } - } - } - } - AVCodecContext *c = context(0); - c->codec_id = AV_CODEC_ID_H264; - struct mp_android_fel_packet_filter f = {0}; - assert(mp_android_fel_packet_init(&f, c, true, false, true, 7) == 0); - assert(!f.bsf && !f.packet); - avcodec_free_context(&c); -} - -static void format_test(int length_size) -{ - AVCodecContext *c = context(length_size); - struct mp_android_fel_packet_filter f = {0}; - assert(mp_android_fel_packet_init(&f, c, true, false, true, 7) == 0); - assert(c->nb_coded_side_data == 0); - const AVDOVIDecoderConfigurationRecord *cfg = - (const void *)f.bsf->par_in->coded_side_data[0].data; - assert(cfg->dv_profile == 7 && cfg->el_present_flag && cfg->rpu_present_flag); - AVPacket *p = packet(length_size, false); - AVPacket borrowed = *p; // same ownership contract as mp_set_av_packet - const AVPacket *out = NULL; - for (int retry = 0; retry < 4; retry++) { - assert(mp_android_fel_packet_prepare(&f, &borrowed, &out) == 1); - assert(compare_bl(p, out, length_size) == 2); - assert(av_buffer_get_ref_count(p->buf) == 1); - AVPacket *accepted = av_packet_clone(out); - assert(accepted); - mp_android_fel_packet_reset(&f); - assert(compare_bl(p, accepted, length_size) == 2); - av_packet_free(&accepted); - // The decoder could have returned EAGAIN. The borrowed packet must - // still be available for exactly the same retry, including side data. - assert(borrowed.data == p->data && borrowed.buf == p->buf); - } - // Foreign demux input without an AVBufferRef must also stay borrowed. - borrowed.buf = NULL; - assert(mp_android_fel_packet_prepare(&f, &borrowed, &out) == 1); - assert(compare_bl(p, out, length_size) == 2); - AVPacket *el = packet(length_size, true); - assert(mp_android_fel_packet_prepare(&f, el, &out) == 0 && !out); - assert(mp_android_fel_packet_prepare(&f, p, &out) == 1); - assert(compare_bl(p, out, length_size) == 2); - assert(mp_android_fel_packet_prepare(&f, NULL, &out) == 1 && !out); - mp_android_fel_packet_reset(&f); - if (length_size) { - AVPacket *bad = av_packet_clone(p); - assert(bad && av_packet_make_writable(bad) == 0); - memset(bad->data, 0, length_size); - bad->data[length_size - 1] = 127; // length exceeds the actual packet - assert(mp_android_fel_packet_prepare(&f, bad, &out) < 0 && !out); - av_packet_free(&bad); - assert(mp_android_fel_packet_prepare(&f, p, &out) == 1); - assert(compare_bl(p, out, length_size) == 2); - } - av_packet_free(&el); - av_packet_free(&p); - mp_android_fel_packet_uninit(&f); - avcodec_free_context(&c); -} - -static void sample_test(const char *path) -{ - AVFormatContext *format = NULL; - assert(avformat_open_input(&format, path, NULL, NULL) == 0); - assert(avformat_find_stream_info(format, NULL) >= 0); - int video = av_find_best_stream(format, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0); - assert(video >= 0); - AVStream *st = format->streams[video]; - AVCodecContext *c = avcodec_alloc_context3(NULL); - assert(c && avcodec_parameters_to_context(c, st->codecpar) == 0); - c->pkt_timebase = st->time_base; - struct mp_android_fel_packet_filter f = {0}; - assert(mp_android_fel_packet_init(&f, c, true, false, true, 7) == 0); - assert(st->codecpar->extradata_size >= 23 && st->codecpar->extradata[0] == 1); - int length_size = (st->codecpar->extradata[21] & 3) + 1; - AVPacket *p = av_packet_alloc(); - assert(p); - int packets = 0, removed = 0; - long long input_bytes = 0, output_bytes = 0; - while (packets < 120 && av_read_frame(format, p) >= 0) { - if (p->stream_index == video) { - const AVPacket *out = NULL; - assert(mp_android_fel_packet_prepare(&f, p, &out) == 1); - removed += compare_bl(p, out, length_size); - packets++; - input_bytes += p->size; - output_bytes += out->size; - } - av_packet_unref(p); - } - assert(packets == 120 && removed > 0 && input_bytes > output_bytes); - printf("PASS sample: packets=%d removed-DV-NALs=%d input-bytes=%lld " - "output-bytes=%lld; every BL payload and timestamp identical\n", - packets, removed, input_bytes, output_bytes); - av_packet_free(&p); - mp_android_fel_packet_uninit(&f); - avcodec_free_context(&c); - avformat_close_input(&format); -} - -int main(int argc, char **argv) -{ - policy_test(); - for (int length_size = 0; length_size <= 4; length_size++) - format_test(length_size); - if (argc > 1) - sample_test(argv[1]); - puts("PASS: FEL-only pure-BL isolation, 5 packet formats, ownership/retry, " - "reset/drain, empty/malformed packets and private decoder config isolation"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_producer_handoff_test.c b/third_party/mpv-player-jni/tests/fel_producer_handoff_test.c deleted file mode 100644 index 751e39bb39..0000000000 --- a/third_party/mpv-player-jni/tests/fel_producer_handoff_test.c +++ /dev/null @@ -1,552 +0,0 @@ -// Compile actual producer handoff + GPU completion/cache-hit function bodies. -// The device has one available output; this is a contract model, not a claim -// about the tested TV's measured hardware DPB capacity or GPU performance. -#include "filters/f_android_fel.h" -#include "filters/f_android_fel_perf.h" -#include -#include -#include -#include -#include - -#define MP_TIME_MS_TO_NS(ms) ((ms) * 1000000LL) -#define MPMAX(a, b) ((a) > (b) ? (a) : (b)) -#define MP_INFO(...) ((void)0) -#define MP_ERR(...) ((void)0) -#define mp_warn(...) ((void)0) -#define mp_info(...) ((void)0) -#define talloc_array(ctx, type, count) ((type *)calloc(count, sizeof(type))) -#define talloc_free free -#define POOL_LOG_INTERVAL 120 -enum { MP_FRAME_VIDEO = 1, MP_FRAME_EOF = 2 }; -enum { IMGFMT_MEDIACODEC = 1, IMGFMT_YUV420P10 = 2 }; -enum { VO_ERROR = -1, VO_NOTIMPL = -3, VO_FALSE = 0, VO_TRUE = 1 }; -enum { VK_SUCCESS = 0, VK_NOT_READY = 1, VK_TIMEOUT = 2, VK_TRUE = 1 }; -enum { VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO = 8, VK_QUERY_TYPE_TIMESTAMP = 2, - VK_QUERY_RESULT_64_BIT = 1 }; -enum { VK_STRUCTURE_TYPE_EXPORT_SEMAPHORE_CREATE_INFO = 3, - VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, - VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR, VK_STRUCTURE_TYPE_SUBMIT_INFO, - VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT = 1, - VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT = 2, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT = 4 }; -#define VK_NULL_HANDLE 0 -typedef int VkResult; -typedef int VkCommandBuffer; -typedef unsigned VkQueryPool; -typedef struct { unsigned timestampValidBits; } VkQueueFamilyProperties; -typedef struct { struct { float timestampPeriod; } limits; } VkPhysicalDeviceProperties; -typedef struct { int sType, queryType; unsigned queryCount; } VkQueryPoolCreateInfo; -typedef unsigned VkPipelineStageFlags; -struct fake_semaphore { bool signal; }; -typedef struct fake_semaphore *VkSemaphore; -typedef struct { int sType; unsigned handleTypes; } VkExportSemaphoreCreateInfo; -typedef struct { int sType; const void *pNext; } VkSemaphoreCreateInfo; -typedef struct { int sType; VkSemaphore semaphore; int handleType; } VkSemaphoreGetFdInfoKHR; -typedef struct { - int sType; - unsigned waitSemaphoreCount, commandBufferCount, signalSemaphoreCount; - const VkSemaphore *pWaitSemaphores, *pSignalSemaphores; - const VkPipelineStageFlags *pWaitDstStageMask; - const int *pCommandBuffers; -} VkSubmitInfo; -struct fake_fence { int64_t ready_at; bool reset; }; -typedef struct fake_fence *VkFence; -struct fake_image { bool returned; }; -struct mp_image { - int imgfmt; - double pts; - void *planes[4]; - AVBufferRef *android_fel_staging; -}; -struct mp_frame { int type; struct mp_image *data; }; -struct vk_input { int users; bool removed; }; -struct vk_output { - uint64_t fel_serial; - int fel_input_slot; - bool fel_query_recorded; - VkFence fence; - VkSemaphore available, acquire, ready, source_release; - VkCommandBuffer command, active_command; - struct fake_image *source_image; - struct mp_image *source_frame, **source_aliases; - int num_source_aliases; - struct vk_input *input; - bool pending; - void *ratex; -}; -struct mapper { void *tex[4]; }; -struct fake_vk { - void (*lock_queue)(struct fake_vk *, unsigned, unsigned); - void (*unlock_queue)(struct fake_vk *, unsigned, unsigned); - int phys_device; - struct { unsigned index; int count; } queue_compute, queue_graphics; -}; -struct aimagereader_vk_stable { - struct { unsigned count; } fel_map_window; - bool android_fel, release_sync_fd; - int device, output_count; - int queue; - unsigned queue_family; - struct fake_vk *vk; - VkResult (*GetSemaphoreFdKHR)(int, const VkSemaphoreGetFdInfoKHR *, int *); - uint64_t fence_timeouts, submitted_outputs, completed_outputs, reclaimed_outputs; - uint64_t fel_async_returns, fel_release_failures; - struct { void (*AImage_delete)(struct fake_image *); - void (*AImage_deleteAsync)(struct fake_image *, int); } api; - struct mapper *mapper; - struct vk_output outputs[1]; - bool fel_profile, fel_profile_warm, fel_query_failed; - int output_capacity; - int64_t fel_map_started, fel_map_cpu_started, fel_map_checkpoint; - struct mp_fel_perf_stat fel_map_warm, fel_map_cold, fel_map_cpu; - struct mp_fel_perf_stat fel_map_stages[MP_FEL_PERF_COUNT], fel_gpu_copy; - VkQueryPool fel_query_pool; - unsigned fel_timestamp_bits; - double fel_timestamp_period; - uint64_t fel_query_unavailable; -}; -struct vo { struct aimagereader_vk_stable *gpu; }; -struct priv { - bool android_fel, software_el; - void *queue; - struct { struct vo *dr_vo; } stream_info; - unsigned long long fel_staged, fel_stage_retries; - int64_t fel_stage_ns, fel_stage_max_ns, fel_stage_started; - double fel_stage_log_at; -}; - -static int stage_fel_before_publish(struct priv *, struct mp_frame); -static bool finish_output(struct aimagereader_vk_stable *, struct vk_output *, uint64_t); -static VkSemaphore create_fel_release_semaphore(struct aimagereader_vk_stable *); -static bool release_fel_source_async(struct aimagereader_vk_stable *, struct vk_output *); -static bool submit_conversion(struct aimagereader_vk_stable *, struct vk_output *, bool, bool); -static void fel_perf_checkpoint(struct aimagereader_vk_stable *, enum mp_fel_perf_stage); -static void fel_perf_finish_map(struct aimagereader_vk_stable *); -// Latency distribution arithmetic is exercised by fel_vk_cache_test. This -// fixture observes only whether producer/copy completion records a sample. -static void fel_latency_add(void *window, uint64_t ns) -{ (void)ns; (*(unsigned *)window)++; } -static void init_fel_gpu_timer(struct aimagereader_vk_stable *); -static void collect_fel_gpu_time(struct aimagereader_vk_stable *, struct vk_output *); -bool aimagereader_vk_stable_reuse(struct aimagereader_vk_stable *, struct mp_image *); -static int64_t now_ns, gpu_delay_ns; -static int64_t prepare_started_ns; -static int prepare_calls, held_outputs, returned_outputs, injected_result; -static struct fake_image source_image; -static struct fake_fence fence; -static struct vk_input input; -static struct mapper mapper; -static struct aimagereader_vk_stable gpu; -static struct vo vo; -static struct priv producer; -static struct fake_semaphore ready_sem, source_sem, available_sem, acquire_sem; -static int create_result, export_result, exported_fd, fd_transfers, queue_locked; -static unsigned submit_waits, submit_signals; -static int query_calls, query_result, query_create_result, query_create_calls; -static unsigned query_bits = 36; -static float query_period = 1.0f; -static uint64_t query_values[2]; - -static void queue_lock(struct fake_vk *vk, unsigned family, unsigned index) -{ (void)vk; (void)family; assert(index == 0 && !queue_locked); queue_locked = 1; } -static void queue_unlock(struct fake_vk *vk, unsigned family, unsigned index) -{ (void)vk; (void)family; assert(index == 0 && queue_locked); queue_locked = 0; } -static struct fake_vk vk = {.lock_queue = queue_lock, .unlock_queue = queue_unlock}; -static void vkGetPhysicalDeviceQueueFamilyProperties(int device, unsigned *count, - VkQueueFamilyProperties *families) -{ - (void)device; - if (families) { assert(*count == 1); families[0].timestampValidBits = query_bits; } - *count = 1; -} -static void vkGetPhysicalDeviceProperties(int device, VkPhysicalDeviceProperties *props) -{ (void)device; props->limits.timestampPeriod = query_period; } -static VkResult vkCreateQueryPool(int device, const VkQueryPoolCreateInfo *info, - void *allocator, VkQueryPool *pool) -{ - (void)device; - assert(!allocator && info->queryCount == 2 && info->queryType == VK_QUERY_TYPE_TIMESTAMP); - query_create_calls++; - if (query_create_result == VK_SUCCESS) *pool = 7; - return query_create_result; -} -static VkResult vkGetQueryPoolResults(int device, VkQueryPool pool, unsigned first, - unsigned count, size_t size, void *data, size_t stride, unsigned flags) -{ - (void)device; - assert(pool == 7 && first == 0 && count == 2 && size == sizeof(query_values)); - assert(stride == sizeof(uint64_t) && flags == VK_QUERY_RESULT_64_BIT); // Never WAIT. - assert(!fence.reset && now_ns >= fence.ready_at); // Read only this submission's results. - query_calls++; - if (query_result == VK_SUCCESS) { - uint64_t *values = data; - values[0] = query_values[0]; values[1] = query_values[1]; - } - return query_result; -} -static VkResult vkCreateSemaphore(int device, const VkSemaphoreCreateInfo *info, - const void *allocator, VkSemaphore *sem) -{ - (void)device; assert(!allocator && info->sType == VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO); - const VkExportSemaphoreCreateInfo *export = info->pNext; - assert(export && export->handleTypes == VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT); - if (create_result != VK_SUCCESS) return create_result; - *sem = &source_sem; - return VK_SUCCESS; -} -static VkResult vkQueueSubmit(int queue, unsigned count, const VkSubmitInfo *info, VkFence f) -{ - (void)queue; assert(queue_locked && count == 1 && f == &fence); - assert(info->commandBufferCount == 1 && info->signalSemaphoreCount >= 1); - assert(*info->pCommandBuffers == (gpu.outputs[0].active_command - ? gpu.outputs[0].active_command : gpu.outputs[0].command)); - assert(info->pSignalSemaphores[0] == &ready_sem); - submit_signals = info->signalSemaphoreCount; - submit_waits = info->waitSemaphoreCount; - if (submit_signals == 2) { - assert(info->pSignalSemaphores[1] == &source_sem && !source_sem.signal); - source_sem.signal = true; // Pending signal; not yet GPU-completed. - } - return VK_SUCCESS; -} -static VkResult export_fd(int device, const VkSemaphoreGetFdInfoKHR *info, int *fd) -{ - (void)device; - assert(info->semaphore == &source_sem && source_sem.signal); - assert(info->handleType == VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT); - if (export_result != VK_SUCCESS) return export_result; - source_sem.signal = false; // SYNC_FD copy transference consumes this signal. - *fd = exported_fd; - return VK_SUCCESS; -} - -static int64_t mp_time_ns(void) { return now_ns; } -static double mp_time_sec(void) { return now_ns / 1e9; } -static bool vk_success(struct aimagereader_vk_stable *p, int result, const char *what) -{ (void)p; (void)what; return result == VK_SUCCESS; } -// Frame trace formatting/rate limits are exercised with the real functions in -// fel_vk_cache_test. Here logging must not change the source/fence contract. -static void trace_fel_frame_order(struct aimagereader_vk_stable *p, char action, - int output, int input, uint64_t serial, double requested, double mapped) -{ (void)p; (void)action; (void)output; (void)input; (void)serial; (void)requested; (void)mapped; } -static VkResult vkGetFenceStatus(int device, VkFence f) -{ (void)device; return !f->reset && now_ns >= f->ready_at ? VK_SUCCESS : VK_NOT_READY; } -static VkResult vkWaitForFences(int device, int count, VkFence *f, int all, uint64_t timeout) -{ - assert(count == 1 && all && timeout); - if (vkGetFenceStatus(device, *f) == VK_SUCCESS) return VK_SUCCESS; - now_ns += timeout; - return vkGetFenceStatus(device, *f) == VK_SUCCESS ? VK_SUCCESS : VK_TIMEOUT; -} -static VkResult vkResetFences(int device, int count, VkFence *f) -{ (void)device; assert(count == 1); (*f)->reset = true; return VK_SUCCESS; } -static void destroy_input(struct aimagereader_vk_stable *p, struct vk_input *i) -{ (void)p; assert(i->users == 0); } -static void delete_image(struct fake_image *img) -{ - assert(img == &source_image && !img->returned); - assert(now_ns >= fence.ready_at); // Never return source before GPU completes. - struct mp_image *src = gpu.outputs[0].source_frame; - assert(!mp_android_fel_staging_ready(src->android_fel_staging->data)); - img->returned = true; - held_outputs--; - returned_outputs++; -} -static void delete_image_async(struct fake_image *img, int fd) -{ - assert(img == &source_image && !img->returned && fd == exported_fd); - assert(!mp_android_fel_staging_ready(gpu.outputs[0].source_frame->android_fel_staging->data)); - if (fd == -1) assert(now_ns >= fence.ready_at); // Already signaled, not export failure. - // This transfers AImage/fd ownership; actual reuse remains fence-gated. - img->returned = true; - held_outputs--; - returned_outputs++; - fd_transfers++; -} -static void clear_cached_frame(void) -{ - struct vk_output *out = &gpu.outputs[0]; - if (out->source_frame) { - av_buffer_unref(&out->source_frame->android_fel_staging); - free(out->source_frame); - out->source_frame = NULL; - } -} -static int vo_prepare_fel_frame(struct vo *v, struct mp_image *image) -{ - assert(v == &vo); - prepare_calls++; - if (injected_result != VO_TRUE) return injected_result; - if (!prepare_started_ns) prepare_started_ns = now_ns; - if (now_ns - prepare_started_ns >= MP_ANDROID_FEL_FRAME_TIMEOUT_NS) - return VO_ERROR; - if (aimagereader_vk_stable_reuse(v->gpu, image)) - return mp_android_fel_staging_ready(image->android_fel_staging->data) - ? VO_TRUE : VO_FALSE; - clear_cached_frame(); - image->android_fel_staging = av_buffer_allocz(1); - assert(image->android_fel_staging); - struct mp_image *ref = malloc(sizeof(*ref)); - assert(ref); - *ref = *image; - ref->android_fel_staging = av_buffer_ref(image->android_fel_staging); - assert(ref->android_fel_staging); - fence = (struct fake_fence){.ready_at = now_ns + gpu_delay_ns}; - input = (struct vk_input){.users = 1}; - gpu.outputs[0] = (struct vk_output){.fence = &fence, - .source_image = &source_image, .source_frame = ref, .input = &input, - .pending = true, .ratex = &gpu, .ready = &ready_sem, - .available = &available_sem, .acquire = &acquire_sem, - .source_release = create_fel_release_semaphore(&gpu)}; - assert(submit_conversion(&gpu, &gpu.outputs[0], false, false)); - release_fel_source_async(&gpu, &gpu.outputs[0]); - return VO_FALSE; // Submitted is not completed, even if the handle exists. -} -static void reset(void) -{ - clear_cached_frame(); - now_ns = 1000000000LL; - prepare_started_ns = 0; - prepare_calls = held_outputs = returned_outputs = 0; - gpu_delay_ns = MP_TIME_MS_TO_NS(12); - injected_result = VO_TRUE; - create_result = export_result = VK_SUCCESS; - query_calls = query_create_calls = 0; - query_result = query_create_result = VK_SUCCESS; - query_bits = 36; - query_period = 1.0f; - exported_fd = 37; - fd_transfers = queue_locked = 0; - ready_sem = source_sem = available_sem = acquire_sem = (struct fake_semaphore){0}; - gpu = (struct aimagereader_vk_stable){.android_fel = true, .output_count = 1, - .api = {delete_image, delete_image_async}, .mapper = &mapper, - .vk = &vk, .GetSemaphoreFdKHR = export_fd}; - gpu.output_capacity = 1; - vo = (struct vo){.gpu = &gpu}; - producer = (struct priv){.android_fel = true, .queue = &producer, - .stream_info.dr_vo = &vo}; -} -static struct mp_image make_frame(int id) -{ - assert(held_outputs == 0); // Calling the codec here would stall otherwise. - held_outputs++; - prepare_started_ns = 0; - source_image = (struct fake_image){0}; - return (struct mp_image){.imgfmt = IMGFMT_MEDIACODEC, .pts = id / 24.0, - .planes[3] = (void *)(uintptr_t)(id + 1)}; -} -static bool await_handoff(struct mp_image *image) -{ - int result; - // Model separate dispatch ticks: each production call must return without - // sleeping. VO phase/deadline behavior is tested with its real bodies in - // fel_core_preload_test; these fakes only supply the GPU/codec contract. - do { - int64_t before = now_ns; - result = stage_fel_before_publish(&producer, - (struct mp_frame){MP_FRAME_VIDEO, image}); - assert(now_ns == before); - if (result == VO_FALSE) now_ns += MP_TIME_MS_TO_NS(2); - } while (result == VO_FALSE); - return result == VO_TRUE; -} -static void test_handoff(void) -{ - reset(); - // Old publish-then-decode order still owns this output at its next call. - struct mp_image image = make_frame(0); - assert(held_outputs == 1 && !image.android_fel_staging); - // The actual new handoff closes that window without changing the codec. - for (int id = 0; id < 120; id++) { - if (id) image = make_frame(id); - assert(await_handoff(&image)); - assert(held_outputs == 0 && source_image.returned && input.users == 0); - assert(mp_android_fel_staging_ready(image.android_fel_staging->data)); - assert(!gpu.outputs[0].pending && gpu.completed_outputs == (unsigned)id + 1); - assert(image.pts == id / 24.0); // Pairing timestamp is unchanged. - av_buffer_unref(&image.android_fel_staging); - } - assert(producer.fel_staged == 120 && returned_outputs == 120); - assert(producer.fel_stage_max_ns == gpu_delay_ns); - clear_cached_frame(); -} -static void test_timeout_and_isolation(void) -{ - reset(); - struct mp_image image = make_frame(0); - gpu_delay_ns = MP_TIME_MS_TO_NS(2000); - int64_t started = now_ns; - assert(!await_handoff(&image)); - assert(now_ns - started == MP_TIME_MS_TO_NS(750)); - assert(held_outputs == 1 && !producer.fel_staged); - assert(!mp_android_fel_staging_ready(image.android_fel_staging->data)); - now_ns = fence.ready_at; // Cleanup remains GPU-safe after a timed-out caller. - assert(finish_output(&gpu, &gpu.outputs[0], 0)); - assert(held_outputs == 0 && returned_outputs == 1); - av_buffer_unref(&image.android_fel_staging); - clear_cached_frame(); - - for (int kind = 0; kind < 7; kind++) { - reset(); - image = (struct mp_image){.imgfmt = IMGFMT_MEDIACODEC}; - struct mp_frame frame = {MP_FRAME_VIDEO, &image}; - if (kind == 0) producer.android_fel = false; - if (kind == 1) producer.software_el = true; - if (kind == 2) producer.queue = NULL; - if (kind == 3) frame.type = MP_FRAME_EOF; - if (kind == 4) image.imgfmt = IMGFMT_YUV420P10; - if (kind == 5) injected_result = VO_NOTIMPL; - if (kind == 6) injected_result = VO_ERROR; - assert(stage_fel_before_publish(&producer, frame) == (kind == 6 ? VO_ERROR : VO_TRUE)); - assert(!image.android_fel_staging && !producer.fel_staged); - assert(prepare_calls == (kind >= 5)); - } -} -static void test_fenced_release(void) -{ - reset(); - gpu.release_sync_fd = true; - for (int id = 0; id < 120; id++) { - struct mp_image image = make_frame(id); - int64_t started = now_ns; - assert(await_handoff(&image)); - assert(now_ns - started == MP_TIME_MS_TO_NS(2)); // No per-frame GPU wait. - assert(held_outputs == 0 && returned_outputs == id + 1 && submit_signals == 2); - assert(mp_android_fel_staging_ready(image.android_fel_staging->data)); - assert(!mp_android_fel_staging_gpu_complete(image.android_fel_staging->data)); - assert(gpu.outputs[0].pending && input.users == 1 && now_ns < fence.ready_at); - assert(!release_fel_source_async(&gpu, &gpu.outputs[0])); // No double fd transfer. - // Kernel/consumer reuse must still wait for this fence. The GPU input - // cannot be freed even though AImage ownership has already returned. - assert(!finish_output(&gpu, &gpu.outputs[0], 0) && input.users == 1); - now_ns = fence.ready_at; - assert(finish_output(&gpu, &gpu.outputs[0], 0)); - assert(input.users == 0 && mp_android_fel_staging_gpu_complete(image.android_fel_staging->data)); - assert(gpu.completed_outputs == (unsigned)id + 1 && fd_transfers == id + 1); - av_buffer_unref(&image.android_fel_staging); - } - assert(!source_sem.signal && gpu.fel_async_returns == 120); - clear_cached_frame(); - - // Unsupported/allocation/export failures retain CPU-fence protection. - for (int kind = 0; kind < 3; kind++) { - reset(); - gpu.release_sync_fd = kind != 0; - if (kind == 1) create_result = -7; - if (kind == 2) export_result = -7; - struct mp_image image = make_frame(0); - int64_t started = now_ns; - assert(await_handoff(&image)); - assert(now_ns - started == gpu_delay_ns && fd_transfers == 0); - assert(!gpu.release_sync_fd && gpu.fel_release_failures == (unsigned)(kind != 0)); - assert(mp_android_fel_staging_gpu_complete(image.android_fel_staging->data)); - av_buffer_unref(&image.android_fel_staging); - // A failed export leaves a signal unconsumed. Disabling future export - // must also stop future submissions from signaling that semaphore. - gpu.outputs[0].active_command = 73; // Cached commands use the same per-frame semaphores. - assert(submit_conversion(&gpu, &gpu.outputs[0], true, true)); - assert(submit_signals == 1 && submit_waits == 2 && !queue_locked); - clear_cached_frame(); - } - - reset(); - gpu.release_sync_fd = true; - exported_fd = -1; - gpu_delay_ns = 0; - struct mp_image image = make_frame(0); - assert(await_handoff(&image)); - assert(fd_transfers == 1 && gpu.release_sync_fd && gpu.fel_async_returns == 1); - assert(finish_output(&gpu, &gpu.outputs[0], 0)); - av_buffer_unref(&image.android_fel_staging); - clear_cached_frame(); - - reset(); - gpu.android_fel = false; - gpu.release_sync_fd = true; - assert(!create_fel_release_semaphore(&gpu)); - assert(!release_fel_source_async(&gpu, &gpu.outputs[0])); - assert(!gpu.fel_async_returns && !fd_transfers); -} -static void test_performance_queries(void) -{ - uint64_t ns = 99; - assert(mp_fel_gpu_duration(100, 120, 64, 2.5, &ns) && ns == 50); - assert(mp_fel_gpu_duration(UINT64_MAX - 4, 5, 64, 1, &ns) && ns == 10); - assert(mp_fel_gpu_duration((UINT64_C(1) << 36) - 4, 7, 36, 1, &ns) && ns == 11); - assert(!mp_fel_gpu_duration(0, 1, 0, 1, &ns)); - assert(!mp_fel_gpu_duration(0, 1, 65, 1, &ns)); - assert(!mp_fel_gpu_duration(0, 1, 64, NAN, &ns)); - assert(!mp_fel_gpu_duration(0, 1, 64, 0, &ns)); - assert(!mp_fel_gpu_duration(0, UINT64_MAX, 64, 2, &ns)); - - for (int kind = 0; kind < 6; kind++) { - reset(); - gpu.fel_profile = kind != 0; - if (kind == 1) query_bits = 0; - if (kind == 2) query_period = 0; - if (kind == 3) query_period = NAN; - if (kind == 4) query_create_result = -7; - init_fel_gpu_timer(&gpu); - assert(query_create_calls == (kind >= 4)); - assert((gpu.fel_query_pool != 0) == (kind == 5)); - } - - for (int result = 0; result < 3; result++) { - reset(); - gpu.release_sync_fd = gpu.fel_profile = true; - init_fel_gpu_timer(&gpu); - struct mp_image image = make_frame(0); - assert(await_handoff(&image)); - struct vk_output *out = &gpu.outputs[0]; - out->fel_query_recorded = true; - query_result = result == 0 ? VK_SUCCESS : result == 1 ? VK_NOT_READY : -7; - query_values[0] = (UINT64_C(1) << 36) - 4; - query_values[1] = 7; - assert(!finish_output(&gpu, out, 0) && !query_calls && out->fel_query_recorded); - now_ns = fence.ready_at; - assert(finish_output(&gpu, out, 0)); - assert(query_calls == 1 && !out->fel_query_recorded && !out->pending); - assert(gpu.fel_gpu_copy.count == (unsigned)(result == 0)); - assert(gpu.fel_gpu_copy.total == (unsigned)(result == 0 ? 11 : 0)); - assert(gpu.fel_query_unavailable == (unsigned)(result != 0)); - assert(gpu.fel_query_failed == (result == 2)); - assert(finish_output(&gpu, out, 0) && query_calls == 1); // No stale double sample. - assert(mp_android_fel_staging_gpu_complete(image.android_fel_staging->data)); - av_buffer_unref(&image.android_fel_staging); - clear_cached_frame(); - } - - reset(); - gpu.fel_map_started = gpu.fel_map_checkpoint = now_ns; - now_ns += 100; - fel_perf_checkpoint(&gpu, MP_FEL_PERF_IMPORT); - now_ns += 50; - fel_perf_finish_map(&gpu); - assert(gpu.fel_map_cold.count == 1 && gpu.fel_map_cold.total == 150); - assert(!gpu.fel_map_stages[MP_FEL_PERF_IMPORT].count); - gpu.fel_profile_warm = true; - gpu.fel_map_started = gpu.fel_map_checkpoint = now_ns; - now_ns += 20; - fel_perf_checkpoint(&gpu, MP_FEL_PERF_SUBMIT); - now_ns += 10; - fel_perf_finish_map(&gpu); - assert(gpu.fel_map_warm.count == 1 && gpu.fel_map_warm.total == 30); - assert(gpu.fel_map_stages[MP_FEL_PERF_SUBMIT].total == 20); - assert(gpu.fel_map_stages[MP_FEL_PERF_OTHER].total == 10); - assert(!gpu.fel_map_cpu.count && !gpu.fel_map_started && !gpu.fel_map_checkpoint); - fel_perf_finish_map(&gpu); // No active profiling cannot change counters. - assert(gpu.fel_map_warm.count == 1); -} - -int main(void) -{ - assert(!mp_android_fel_staging_ready(NULL)); - mp_android_fel_staging_complete(NULL); - test_handoff(); - test_timeout_and_isolation(); - test_fenced_release(); - test_performance_queries(); - puts("PASS: actual producer/GPU handoff: 120 CPU-fence + 120 async-fence frames, independent render/source signals, fd ownership/reuse/failure, bounded timeout, opt-in isolation"); - puts("PASS: real FEL timing helpers: cold/warm CPU stages, unsupported/failed timers, wrap/overflow, fence-before-query, no WAIT, unavailable results preserve playback"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_progress_test.c b/third_party/mpv-player-jni/tests/fel_progress_test.c deleted file mode 100644 index 969f71ab67..0000000000 --- a/third_party/mpv-player-jni/tests/fel_progress_test.c +++ /dev/null @@ -1,93 +0,0 @@ -// Compile the production header; no replacement progress/formatting algorithm. -#include "filters/f_android_fel_trace.h" -#include -#include -#include - -struct writer_args { - struct mp_fel_trace *trace; - enum mp_fel_lane lane; -}; - -static void *write_progress(void *arg) -{ - struct writer_args *args = arg; - for (int n = 0; n < 10000; n++) { - enum mp_fel_stage stage = n % 2 ? MP_FEL_SEND : MP_FEL_RECEIVE; - mp_fel_trace_record(args->trace, args->lane, stage, - 1000 + n * 32 + args->lane, 18.0, 1, 1, 1); - } - return NULL; -} - -int main(void) -{ - struct mp_fel_trace trace; - mp_fel_trace_init(&trace); - assert(atomic_is_lock_free(&trace.lanes[MP_FEL_BL].stamp)); - mp_fel_trace_record(&trace, MP_FEL_BL, MP_FEL_SEND, 1000, 18.0, 1, 1, 1); - assert(atomic_load(&trace.lanes[MP_FEL_BL].stamp) == 0); - assert(atomic_load(&trace.lanes[MP_FEL_BL].inputs) == 0); - atomic_store(&trace.enabled, true); - - mp_fel_trace_record(&trace, MP_FEL_BL, MP_FEL_RECEIVE, 1000, 18.125, 1, 3, 2); - char text[1024]; - mp_fel_trace_format(&trace, 1750, text, sizeof(text)); - assert(strstr(text, "v=1 requested=1 refs=2")); - assert(strstr(text, "BL={codec-receive age=750ms pts=18125 in=3 out=2 held=1}")); - assert(strstr(text, "VO={unknown age=0ms pts=-1 in=0 out=0 held=0}")); - - // Missing/malformed PTS never undergoes an undefined floating-int cast. - mp_fel_trace_record(&trace, MP_FEL_BL, MP_FEL_DECODE_IDLE, 1800, NAN, -1, 0, 0); - mp_fel_trace_record(&trace, MP_FEL_BL, MP_FEL_DECODE_IDLE, 1800, INFINITY, -1, 0, 0); - mp_fel_trace_record(&trace, MP_FEL_BL, MP_FEL_DECODE_IDLE, 1800, 1e30, -1, 0, 0); - assert(atomic_load(&trace.lanes[MP_FEL_BL].pts_ms) == 18125); - assert(atomic_load(&trace.lanes[MP_FEL_BL].held) == 1); - - // The packed 32-bit millisecond timestamp wraps without inventing a stall. - mp_fel_trace_record(&trace, MP_FEL_VO, MP_FEL_VO_SWAP, - (int64_t)UINT32_MAX - 99, 18.0, 2, 1, 0); - mp_fel_trace_format(&trace, (int64_t)UINT32_MAX + 101, text, sizeof(text)); - assert(strstr(text, "VO={vo-swap-buffers age=200ms pts=18000 in=1 out=0 held=2}")); - - char tiny[] = {'x', 'y', 'z'}; - mp_fel_trace_format(&trace, 2000, tiny, 0); - assert(tiny[0] == 'x' && tiny[1] == 'y'); - mp_fel_trace_format(&trace, 2000, tiny, 1); - assert(tiny[0] == 0 && tiny[1] == 'y'); - mp_fel_trace_format(NULL, 2000, text, sizeof(text)); - assert(!strcmp(text, "VO unavailable")); - assert(!strcmp(mp_fel_stage_name(255), "unknown")); - - // Four independent native lanes publish while the fatal reader snapshots. - // A single stamp must never contain a stage from a different timestamp. - struct mp_fel_trace concurrent; - mp_fel_trace_init(&concurrent); - atomic_store(&concurrent.enabled, true); - pthread_t threads[MP_FEL_LANES]; - struct writer_args args[MP_FEL_LANES]; - for (int n = 0; n < MP_FEL_LANES; n++) { - args[n] = (struct writer_args){&concurrent, n}; - assert(!pthread_create(&threads[n], NULL, write_progress, &args[n])); - } - for (int read = 0; read < 4000; read++) { - for (int n = 0; n < MP_FEL_LANES; n++) { - uint64_t stamp = atomic_load(&concurrent.lanes[n].stamp); - if (!stamp) - continue; - uint32_t tick = (uint32_t)(stamp >> 8) - 1000 - n; - assert(tick % 32 == 0); - unsigned expected = (tick / 32) % 2 ? MP_FEL_SEND : MP_FEL_RECEIVE; - assert((stamp & 255) == expected); - } - mp_fel_trace_format(&concurrent, 400000, text, sizeof(text)); - assert(strstr(text, "core={") && strstr(text, "VO={")); - } - for (int n = 0; n < MP_FEL_LANES; n++) { - assert(!pthread_join(threads[n], NULL)); - assert(atomic_load(&concurrent.lanes[n].inputs) == 10000); - assert(atomic_load(&concurrent.lanes[n].outputs) == 10000); - } - puts("PASS: production FEL progress snapshots: disabled isolation, bounded formatting, clock wrap and concurrent reads/writes"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_rpu_inherit_test.c b/third_party/mpv-player-jni/tests/fel_rpu_inherit_test.c deleted file mode 100644 index d61ac977dc..0000000000 --- a/third_party/mpv-player-jni/tests/fel_rpu_inherit_test.c +++ /dev/null @@ -1,57 +0,0 @@ -/* Compile the actual inheritance helper with real AVBuffer ownership. */ -#include -#include -#include -#include -#include - -struct hdr { - float min_luma, max_luma, max_pq_y, avg_pq_y; -}; -struct mp_image { - AVBufferRef *dovi; - struct { - bool no_dovi; - struct { int sys; void *dovi; int levels, bits; } repr; - struct { int primaries, transfer; struct hdr hdr; } color; - } params; -}; -static void inherit_dovi_from_el(struct mp_image *bl, struct mp_image *el); - -int main(void) -{ - struct mp_image el = {0}, bl = {0}; - el.dovi = av_buffer_alloc(256); - assert(el.dovi); - memset(el.dovi->data, 0xa5, el.dovi->size); - el.params.repr.sys = 7; - el.params.color.primaries = 9; - el.params.color.transfer = 16; - el.params.color.hdr = (struct hdr){0.005f, 1000.0f, 0.72f, 0.34f}; - bl.params.repr.levels = 1; - bl.params.repr.bits = 10; - inherit_dovi_from_el(&bl, &el); - assert(bl.dovi && av_buffer_get_ref_count(bl.dovi) == 2); - assert(bl.params.repr.dovi == el.dovi->data); - assert(bl.params.repr.sys == 7); - assert(bl.params.repr.levels == 1 && bl.params.repr.bits == 10); - assert(!memcmp(&bl.params.color, &el.params.color, sizeof(el.params.color))); - av_buffer_unref(&el.dovi); - assert(av_buffer_get_ref_count(bl.dovi) == 1 && bl.dovi->data[255] == 0xa5); - - el.dovi = av_buffer_alloc(256); - assert(el.dovi); - uint8_t *original = bl.dovi->data; - inherit_dovi_from_el(&bl, &el); - assert(bl.dovi->data == original); // existing BL metadata has priority - av_buffer_unref(&bl.dovi); - bl.params.no_dovi = true; - inherit_dovi_from_el(&bl, &el); - assert(!bl.dovi); // preserve explicit metadata suppression - av_buffer_unref(&el.dovi); - bl.params.no_dovi = false; - inherit_dovi_from_el(&bl, &el); - assert(!bl.dovi); // missing EL metadata cannot fabricate FEL - puts("PASS: actual EL-to-BL RPU inheritance, color fields, pixel range/depth and AVBuffer lifetime"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_startup_selection_test.c b/third_party/mpv-player-jni/tests/fel_startup_selection_test.c deleted file mode 100644 index e917eb93c2..0000000000 --- a/third_party/mpv-player-jni/tests/fel_startup_selection_test.c +++ /dev/null @@ -1,216 +0,0 @@ -// Compile the actual selected-track decision and video-chain constructor. -// External VO/decoder APIs are fakes so ordering, reuse and failure are exact. -#include -#include -#include -#include -#include -#define HAVE_ANDROID 1 -#define VO_CAP_NATIVE_DOVI 1 -#define MP_OUTPUT_CHAIN_VIDEO 1 -#define MPV_ERROR_VO_INIT_FAILED -12 -#define STATUS_READY 3 -#define mp_assert assert -#define MP_INFO(...) ((void)0) -#define MP_WARN(...) ((void)0) -#define MP_FATAL(...) ((void)0) -#define talloc_zero(parent, type) calloc(1, sizeof(type)) -struct m_obj_settings { char *name; }; -struct mp_vo_opts { - bool android_dovi_fel; - int android_dovi_fel_vulkan; - struct m_obj_settings *video_driver_list; -}; -struct MPOpts { struct mp_vo_opts *vo; }; -struct mp_codec_params { int dv_profile, source_dv_profile; }; -struct sh_stream { struct mp_codec_params *codec; bool still_image; }; -struct mp_filter { void *pins[2]; }; -struct mp_decoder_wrapper { struct mp_filter *f; }; -struct vo_chain; -struct track { - bool attached_picture; - struct sh_stream *stream; - int user_tid; - struct vo_chain *vo_c; - struct mp_decoder_wrapper *dec; -}; -struct vo_extra { - void *input_ctx, *osd, *encode_lavc_ctx, *wakeup_ctx; - void (*wakeup_cb)(void *); - bool prefer_hdr_output, android_dovi_fel; - int android_dovi_fel_vulkan; -}; -struct vo_driver { int caps; }; -struct vo { - struct vo_extra extra; - const struct vo_driver *driver; - const char *context_name; - bool config_ok, has_frame; -}; -struct mp_output_chain { - struct mp_filter *f; - void (*update_subtitles)(void *); - void *update_subtitles_ctx; - double container_fps; -}; -struct vo_chain { - void *log, *dec_src; - struct vo *vo; - struct mp_output_chain *filter; - struct track *track; - bool is_coverart, is_sparse; -}; -typedef struct MPContext { - struct MPOpts *opts; - struct vo *video_out; - struct vo_chain *vo_chain; - struct track *android_dolby_vision_direct_failed_track; - void *input, *osd, *encode_lavc_ctx, *global, *log, *filter_root; - int error_playing, video_status; - bool mouse_cursor_visible; -} MPContext; -struct m_property { int unused; }; -static bool should_use_android_fel_output(struct MPContext *, struct track *); -void reinit_video_chain_src(struct MPContext *, struct track *); -static int mp_property_android_fel_active(void *, struct m_property *, int, void *); -static int mp_property_video_frame_submitted(void *, struct m_property *, int, void *); -static int creates, destroys, decodes; -static bool fail_vo; -static const struct vo_driver gpu_driver = {0}, direct_driver = {VO_CAP_NATIVE_DOVI}; -static struct mp_filter decoder_filter; -static struct mp_decoder_wrapper decoder = {&decoder_filter}; -static bool should_use_android_dolby_vision_direct_output(struct MPContext *m, struct track *t) -{ return false; } -static bool is_android_dolby_vision_direct_output_active(struct MPContext *m) -{ return m->video_out && (m->video_out->driver->caps & VO_CAP_NATIVE_DOVI); } -static bool is_android_opengl_output_active(struct MPContext *m) -{ return m->video_out && !strcmp(m->video_out->context_name, "android"); } -static bool should_prefer_android_hdr_output(struct MPContext *m, struct track *t) -{ return t && t->stream && t->stream->codec && t->stream->codec->dv_profile > 0; } -static void uninit_video_chain(struct MPContext *m) -{ - if (!m->vo_chain) return; - if (m->vo_chain->track) m->vo_chain->track->vo_c = NULL; - free(m->vo_chain->filter->f); - free(m->vo_chain->filter); - free(m->vo_chain); - m->vo_chain = NULL; -} -static void uninit_video_out(struct MPContext *m) -{ - uninit_video_chain(m); - if (m->video_out) destroys++; - free(m->video_out); - m->video_out = NULL; -} -static void mp_wakeup_core_cb(void *p) {} -static struct vo *init_video_out_by_name(void *g, struct vo_extra *ex, const char *name) -{ - creates++; - if (fail_vo) return NULL; - struct vo *v = calloc(1, sizeof(*v)); - v->extra = *ex; - v->driver = !strcmp(name, "mediacodec_embed") ? &direct_driver : &gpu_driver; - v->context_name = ex->android_dovi_fel_vulkan ? "androidvk" : "android"; - assert(!ex->android_dovi_fel || !strcmp(name, "gpu-next")); - return v; -} -static struct vo *init_best_video_out(void *g, struct vo_extra *ex) -{ return init_video_out_by_name(g, ex, "configured"); } -static struct mp_output_chain *mp_output_chain_create(void *r, int type) -{ - struct mp_output_chain *f = calloc(1, sizeof(*f)); - f->f = calloc(1, sizeof(*f->f)); - return f; -} -static void mp_output_chain_set_vo(struct mp_output_chain *f, struct vo *v) { assert(v); } -static void filter_update_subtitles(void *p) {} -static bool try_init_video_decoder(struct MPContext *m, struct track *t) -{ - // The constructor must decide/initialize the VO before creating any decoder. - assert(m->video_out && t->vo_c && t->vo_c->vo == m->video_out); - assert(m->video_out->extra.android_dovi_fel == should_use_android_fel_output(m, t)); - t->dec = &decoder; - decodes++; - return true; -} -static double mp_decoder_wrapper_get_container_fps(struct mp_decoder_wrapper *d) { return 24; } -static void mp_decoder_wrapper_set_coverart_flag(struct mp_decoder_wrapper *d, bool b) {} -static void mp_pin_connect(void *a, void *b) {} -static void update_vo_chain_el_state(struct MPContext *m) {} -static bool recreate_video_filters(struct MPContext *m) { return true; } -static void update_content_type(struct MPContext *m, struct track *t) {} -static void update_screensaver_state(struct MPContext *m) {} -static void update_window_title(struct MPContext *m, bool b) {} -static bool get_internal_paused(struct MPContext *m) { return false; } -static void vo_set_paused(struct vo *v, bool p) {} -static void reset_video_state(struct MPContext *m) { m->video_status = 0; } -static void term_osd_clear_subs(struct MPContext *m) {} -static void error_on_track(struct MPContext *m, struct track *t) {} -static void handle_force_window(struct MPContext *m, bool b) {} -static bool vo_has_frame(struct vo *v) { return v->has_frame; } -static int m_property_bool_ro(int action, void *arg, bool value) -{ *(bool *)arg = value; return 1; } - -int main(void) -{ - struct mp_vo_opts vo_opts = {.android_dovi_fel = true, .android_dovi_fel_vulkan = 2}; - struct MPOpts opts = {.vo = &vo_opts}; - struct MPContext m = {.opts = &opts}; - struct mp_codec_params codec = {.dv_profile = 7}; - struct sh_stream stream = {.codec = &codec}; - struct track selected = {.stream = &stream, .user_tid = 3}; - reinit_video_chain_src(&m, &selected); - assert(creates == 1 && decodes == 1 && m.video_out->extra.android_dovi_fel); - assert(m.video_out->extra.android_dovi_fel_vulkan == 2); - struct vo *first = m.video_out; - uninit_video_chain(&m); - reinit_video_chain_src(&m, &selected); - assert(m.video_out == first && creates == 1 && decodes == 2); - bool active = false; - mp_property_android_fel_active(&m, NULL, 0, &active); - assert(active); - m.video_out->config_ok = true; - m.video_status = STATUS_READY; - mp_property_video_frame_submitted(&m, NULL, 0, &active); - assert(!active); // configured dimensions or audio restart are insufficient - m.video_out->has_frame = true; - mp_property_video_frame_submitted(&m, NULL, 0, &active); - assert(active); - m.video_status = 0; - mp_property_video_frame_submitted(&m, NULL, 0, &active); - assert(!active); // a retained old image after seek is insufficient - uninit_video_chain(&m); - codec.dv_profile = 5; - reinit_video_chain_src(&m, &selected); - assert(creates == 2 && destroys == 1 && !m.video_out->extra.android_dovi_fel); - assert(vo_opts.android_dovi_fel && vo_opts.android_dovi_fel_vulkan); - for (int profile = 0; profile <= 8; profile++) { - codec.dv_profile = profile; - assert(should_use_android_fel_output(&m, &selected) == (profile == 7)); - } - codec.dv_profile = 8; - codec.source_dv_profile = 7; - assert(should_use_android_fel_output(&m, &selected)); - selected.attached_picture = true; - assert(!should_use_android_fel_output(&m, &selected)); - selected.attached_picture = false; - vo_opts.android_dovi_fel = false; - assert(!should_use_android_fel_output(&m, &selected)); - vo_opts.android_dovi_fel = true; - assert(!should_use_android_fel_output(&m, NULL)); - struct m_obj_settings null_vo[] = {{.name = "null"}, {0}}; - vo_opts.video_driver_list = null_vo; - assert(!should_use_android_fel_output(&m, &selected)); - vo_opts.video_driver_list = NULL; - uninit_video_out(&m); - reinit_video_chain_src(&m, NULL); - assert(!m.video_out->extra.android_dovi_fel && !m.vo_chain->track); - uninit_video_out(&m); - fail_vo = true; - int before_creates = creates, before_decodes = decodes; - reinit_video_chain_src(&m, &selected); - assert(creates == before_creates + 1 && decodes == before_decodes); - assert(!m.video_out && !m.vo_chain && m.error_playing == MPV_ERROR_VO_INIT_FAILED); - puts("PASS: selected-track FEL before decoder, VO reuse/transition, null/coverart/profile isolation, failure and first-frame epoch"); -} diff --git a/third_party/mpv-player-jni/tests/fel_vk_cache_test.c b/third_party/mpv-player-jni/tests/fel_vk_cache_test.c deleted file mode 100644 index 48136c856e..0000000000 --- a/third_party/mpv-player-jni/tests/fel_vk_cache_test.c +++ /dev/null @@ -1,829 +0,0 @@ -// Included before the actual stable mapper function bodies, not a second -// implementation of the cache. Vulkan calls below model resource validation; -// call-count results are not a device throughput or pixel-quality benchmark. -#define VK_NO_PROTOTYPES -#include -#include "filters/f_android_fel_perf.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define INPUT_CACHE_SIZE 8 -#define FEL_INPUT_CACHE_SIZE 32 -#define FEL_RECORD_CACHE_SIZE 128 -#define FEL_ORDER_CAPACITY 8 -#define FEL_LATENCY_SAMPLES 128 -#define STABLE_WORKGROUP_X 16 -#define STABLE_WORKGROUP_Y 8 -#define MPMAX(a, b) ((a) > (b) ? (a) : (b)) -#define MPMIN(a, b) ((a) < (b) ? (a) : (b)) -#define MP_TIME_MS_TO_NS(v) ((v) * INT64_C(1000000)) -#define MP_ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0])) -#define MP_ALIGN_UP(n, a) (((n) + (a) - 1) / (a) * (a)) -#define mp_err(...) ((void)0) -#define HANDLE(type, n) ((type)(uintptr_t)(n)) -#define ID(h) ((unsigned)(uintptr_t)(h)) - -typedef struct { unsigned id; } AHardwareBuffer; -typedef struct { uint32_t width, height; } AHardwareBuffer_Desc; -typedef struct { int32_t left, top, right, bottom; } AImageCropRect; -struct conversion_push_constants { - float uv_offset[2], uv_scale[2]; - int32_t output_size[2]; -}; -struct vk_input { - AHardwareBuffer *buffer; - VkImage image; - VkDeviceMemory memory; - VkImageView view; - uint64_t last_used; - int users; - bool initialized, removed; -}; -struct vk_output { - VkImage image; - VkImageView view; - VkDescriptorSet descriptor; - VkCommandBuffer command, active_command; - bool pending, written, fel_query_recorded; -}; -struct vk_recording { - VkCommandBuffer command; - VkDescriptorSet descriptor; - struct vk_input *input; - struct vk_output *output; - VkImageView input_view, output_view; - AImageCropRect crop; - uint32_t width, height; - uint64_t last_used; - bool valid, timestamps; -}; -enum fel_api_op { - FEL_API_DESCRIBE, FEL_API_PROPERTIES, FEL_API_IMAGE, FEL_API_MEMORY, - FEL_API_BIND, FEL_API_VIEW, FEL_API_DESCRIPTOR, FEL_API_RESET, - FEL_API_BEGIN, FEL_API_RECORD, FEL_API_END, FEL_API_BARRIER_IN, - FEL_API_PIPELINE, FEL_API_DESCRIPTORS, FEL_API_PUSH, FEL_API_DISPATCH, - FEL_API_BARRIER_OUT, FEL_API_PUSH_DESCRIPTORS, FEL_API_COUNT, -}; -struct fel_api_clock { int64_t wall, cpu; }; -struct fel_latency_window { - uint64_t ns[FEL_LATENCY_SAMPLES]; - unsigned count, next; -}; -struct fel_order_event { - uint64_t serial; - double requested_pts, mapped_pts; - int output, input; - char action; -}; -typedef const struct test_vulkan { - VkInstance instance; - VkPhysicalDevice phys_device; - PFN_vkGetInstanceProcAddr get_proc_addr; - const char *const *extensions; - int num_extensions; -} *pl_vulkan; -struct aimagereader_vk_stable { - void *log; - bool android_fel, fel_profile, fel_profile_warm, fel_query_failed; - VkDevice device; - pl_vulkan vk; - bool push_descriptors; - PFN_vkCmdPushDescriptorSetKHR CmdPushDescriptorSetKHR; - uint32_t queue_family, sampler_descriptors; - VkPipeline pipeline; - VkPipelineLayout pipeline_layout; - VkCommandPool command_pool; - VkDescriptorSetLayout descriptor_layout; - VkDescriptorPool recording_pool; - VkQueryPool fel_query_pool; - int width, height, num_inputs, num_recordings; - uint64_t input_serial; - struct vk_input inputs[FEL_INPUT_CACHE_SIZE]; - struct vk_output outputs[5]; - struct vk_recording recordings[FEL_RECORD_CACHE_SIZE]; - bool recording_cache_disabled; - uint64_t recording_serial, recording_hits, recording_misses; - uint64_t recording_evictions, recording_invalidations, recording_fallbacks; - uint64_t recording_cold; - uint64_t descriptor_content_hits, descriptor_writes, descriptor_rebinds; - uint64_t input_hits, input_misses, input_evictions, input_removals; - struct mp_fel_perf_stat fel_api_wall[FEL_API_COUNT], fel_api_cpu[FEL_API_COUNT]; - struct fel_latency_window fel_bind_window, fel_record_window, fel_map_window; - int64_t fel_map_started, fel_map_cpu_started, fel_map_checkpoint; - struct mp_fel_perf_stat fel_map_warm, fel_map_cold, fel_map_cpu; - struct mp_fel_perf_stat fel_map_stages[MP_FEL_PERF_COUNT]; - uint64_t submitted_outputs, fresh_commands, fel_order_count, fel_pts_differences; - struct fel_order_event fel_order[FEL_ORDER_CAPACITY], fel_last_pts_difference; - double fel_active_pts; - int fel_active_input, fel_active_output; - int64_t fel_slow_window; - unsigned fel_slow_count; -}; - -// The independent wait-probe test compiles the real diagnostics. These cache -// scenarios keep optional sampling disabled and retain their Vulkan assertions. -struct fel_wait_sample { bool active; }; -static struct fel_wait_sample fel_wait_probe_begin(struct aimagereader_vk_stable *p) -{ - return (struct fel_wait_sample){0}; -} -static void fel_wait_probe_end(struct aimagereader_vk_stable *p, - const struct fel_wait_sample *sample) {} - -static struct fel_api_clock fel_api_begin(struct aimagereader_vk_stable *); -static void fel_api_end(struct aimagereader_vk_stable *, enum fel_api_op, struct fel_api_clock); -static void fel_latency_add(struct fel_latency_window *, uint64_t); -static int fel_latency_compare(const void *, const void *); -static void fel_latency_summary(const struct fel_latency_window *, uint64_t [3]); -static void fel_perf_checkpoint(struct aimagereader_vk_stable *, enum mp_fel_perf_stage); -static void fel_perf_finish_map(struct aimagereader_vk_stable *); -static void trace_fel_frame_order(struct aimagereader_vk_stable *, char, int, int, uint64_t, double, double); -static void format_fel_frame_order(struct aimagereader_vk_stable *, char *, size_t); -static void invalidate_input_recordings(struct aimagereader_vk_stable *, struct vk_input *); -static void destroy_input(struct aimagereader_vk_stable *, struct vk_input *); -static void destroy_recording_cache(struct aimagereader_vk_stable *); -static struct vk_input *find_input(struct aimagereader_vk_stable *, AHardwareBuffer *); -static void purge_removed_inputs(struct aimagereader_vk_stable *); -static struct vk_input *select_input_slot(struct aimagereader_vk_stable *); -static bool recording_pending(const struct vk_recording *); -static bool recording_matches(const struct vk_recording *, struct vk_output *, struct vk_input *, - const AHardwareBuffer_Desc *, const AImageCropRect *, bool); -static struct vk_recording *select_recording(struct aimagereader_vk_stable *, struct vk_output *, - struct vk_input *, const AHardwareBuffer_Desc *, - const AImageCropRect *, bool *); -static bool allocate_recording(struct aimagereader_vk_stable *, struct vk_recording *); -static bool has_extension(pl_vulkan, const char *); -static bool create_conversion_descriptor_layout(struct aimagereader_vk_stable *, - const VkDescriptorSetLayoutCreateInfo *, uint32_t); -static void update_conversion_descriptor(struct aimagereader_vk_stable *, VkCommandBuffer, VkDescriptorSet, - struct vk_input *, struct vk_output *, bool); -static bool record_conversion(struct aimagereader_vk_stable *, struct vk_output *, struct vk_input *, - const AHardwareBuffer_Desc *, const AImageCropRect *, struct vk_recording *); -static bool prepare_conversion(struct aimagereader_vk_stable *, struct vk_output *, struct vk_input *, - const AHardwareBuffer_Desc *, const AImageCropRect *); -void aimagereader_vk_stable_buffer_removed(struct aimagereader_vk_stable *, AHardwareBuffer *); - -struct command_state { - bool pending, executable, freed, allocated; - VkCommandBufferUsageFlags flags; - VkDescriptorSet descriptor; - bool pushed; - VkImageView pushed_views[2]; - unsigned barriers, dispatch[3], timestamps, resets; - VkImageMemoryBarrier acquire[2], release[2]; - struct conversion_push_constants push; -}; -static struct command_state cmds[512]; -static VkImageView bindings[512][2]; -static unsigned command_next, descriptor_next; -static unsigned update_calls, reset_calls, begin_calls, end_calls; -static unsigned pool_calls, descriptor_calls, command_calls, command_frees, pool_frees; -static unsigned removed_views, imports; -static int fail_allocation, fail_record; -static int64_t wall_clock; -static unsigned info_logs; -static char last_info[512]; -static unsigned push_calls, bind_calls, dispatch_calls, layout_calls, property_calls; -static uint32_t max_push; -static int missing_entry; -static bool fail_push_layout, fail_set_layout; -static VkDescriptorSetLayoutCreateFlags last_layout_flags; -static struct test_vulkan test_vk; - -static void mp_info(void *log, const char *format, ...) -{ - (void)log; - va_list args; - va_start(args, format); - vsnprintf(last_info, sizeof(last_info), format, args); - va_end(args); - info_logs++; -} - -static int64_t mp_time_ns(void) { return ++wall_clock * 1000; } -static bool vk_success(struct aimagereader_vk_stable *p, VkResult result, const char *action) -{ (void)p; (void)action; return result == VK_SUCCESS; } -static struct command_state *command(VkCommandBuffer cmd) -{ assert(ID(cmd) > 0 && ID(cmd) < MP_ARRAY_SIZE(cmds)); return &cmds[ID(cmd)]; } - -static void VKAPI_CALL fake_push_descriptors(VkCommandBuffer cmd, VkPipelineBindPoint point, - VkPipelineLayout layout, uint32_t set, uint32_t count, const VkWriteDescriptorSet *writes) -{ - assert(point == VK_PIPELINE_BIND_POINT_COMPUTE && set == 0 && count == 2); - struct command_state *c = command(cmd); - assert(!c->pending && !c->descriptor && !c->executable && !c->pushed); - for (unsigned n = 0; n < count; n++) { - assert(writes[n].dstSet == VK_NULL_HANDLE && writes[n].dstBinding == n); - assert(writes[n].descriptorCount == 1 && writes[n].dstArrayElement == 0); - assert(writes[n].descriptorType == (n ? VK_DESCRIPTOR_TYPE_STORAGE_IMAGE - : VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)); - assert(writes[n].pImageInfo->imageLayout == (n ? VK_IMAGE_LAYOUT_GENERAL - : VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)); - assert(!writes[n].pImageInfo->sampler); // immutable sampler comes from layout - c->pushed_views[n] = writes[n].pImageInfo->imageView; - } - c->pushed = true; - push_calls++; -} -static void VKAPI_CALL fake_properties(VkPhysicalDevice device, VkPhysicalDeviceProperties2 *props) -{ - VkPhysicalDevicePushDescriptorPropertiesKHR *push = props->pNext; - assert(push->sType == VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PUSH_DESCRIPTOR_PROPERTIES_KHR); - push->maxPushDescriptors = max_push; - property_calls++; -} -static PFN_vkVoidFunction VKAPI_CALL fake_device_proc(VkDevice device, const char *name) -{ - assert(!strcmp(name, "vkCmdPushDescriptorSetKHR")); - return missing_entry == 1 ? NULL : (PFN_vkVoidFunction)fake_push_descriptors; -} -static PFN_vkVoidFunction VKAPI_CALL fake_instance_proc(VkInstance instance, const char *name) -{ - if (!strcmp(name, "vkGetDeviceProcAddr")) - return missing_entry == 2 ? NULL : (PFN_vkVoidFunction)fake_device_proc; - assert(!strcmp(name, "vkGetPhysicalDeviceProperties2") || - !strcmp(name, "vkGetPhysicalDeviceProperties2KHR")); - return missing_entry == 3 ? NULL : (PFN_vkVoidFunction)fake_properties; -} -static VkResult vkCreateDescriptorSetLayout(VkDevice device, const VkDescriptorSetLayoutCreateInfo *info, - const VkAllocationCallbacks *alloc, VkDescriptorSetLayout *layout) -{ - layout_calls++; - last_layout_flags = info->flags; - assert(info->bindingCount == 2 && info->pBindings[0].pImmutableSamplers); - assert(info->pBindings[0].descriptorType == VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER); - bool push = info->flags & VK_DESCRIPTOR_SET_LAYOUT_CREATE_PUSH_DESCRIPTOR_BIT_KHR; - if ((push && fail_push_layout) || (!push && fail_set_layout)) { - *layout = HANDLE(VkDescriptorSetLayout, 511); // invalid on failure; never publish it - return VK_ERROR_OUT_OF_HOST_MEMORY; - } - *layout = HANDLE(VkDescriptorSetLayout, 2); - return VK_SUCCESS; -} - -static VkResult vkCreateDescriptorPool(VkDevice device, const VkDescriptorPoolCreateInfo *info, - const VkAllocationCallbacks *alloc, VkDescriptorPool *pool) -{ - pool_calls++; - assert(!info->flags && info->maxSets == FEL_RECORD_CACHE_SIZE); - assert(info->pPoolSizes[0].descriptorCount == 4 * FEL_RECORD_CACHE_SIZE); - if (fail_allocation == 1) { *pool = HANDLE(VkDescriptorPool, 511); return VK_ERROR_OUT_OF_HOST_MEMORY; } - *pool = HANDLE(VkDescriptorPool, 1); - return VK_SUCCESS; -} -static VkResult vkAllocateDescriptorSets(VkDevice device, const VkDescriptorSetAllocateInfo *info, - VkDescriptorSet *set) -{ - descriptor_calls++; - assert(info->descriptorSetCount == 1 && info->descriptorPool); - if (fail_allocation == 2) { *set = HANDLE(VkDescriptorSet, 511); return VK_ERROR_OUT_OF_HOST_MEMORY; } - *set = HANDLE(VkDescriptorSet, descriptor_next++); - return VK_SUCCESS; -} -static VkResult vkAllocateCommandBuffers(VkDevice device, const VkCommandBufferAllocateInfo *info, - VkCommandBuffer *cmd) -{ - command_calls++; - assert(info->commandBufferCount == 1 && info->level == VK_COMMAND_BUFFER_LEVEL_PRIMARY); - if (fail_allocation == 3) { *cmd = HANDLE(VkCommandBuffer, 511); return VK_ERROR_OUT_OF_HOST_MEMORY; } - *cmd = HANDLE(VkCommandBuffer, command_next++); - command(*cmd)->allocated = true; - return VK_SUCCESS; -} -static void vkUpdateDescriptorSets(VkDevice device, uint32_t count, - const VkWriteDescriptorSet *writes, - uint32_t copies, const VkCopyDescriptorSet *copy) -{ - update_calls++; - assert(!copies && (count == 1 || count == 2)); - for (unsigned w = 0; w < count; w++) { - unsigned ds = ID(writes[w].dstSet); - assert(ds < MP_ARRAY_SIZE(bindings) && writes[w].dstBinding < 2); - for (unsigned i = 1; i < MP_ARRAY_SIZE(cmds); i++) { - if (cmds[i].descriptor != writes[w].dstSet) continue; - assert(!cmds[i].pending); - cmds[i].executable = false; // Vulkan invalidates an old binding. - } - bindings[ds][writes[w].dstBinding] = writes[w].pImageInfo->imageView; - } -} -static VkResult vkResetCommandBuffer(VkCommandBuffer cmd, VkCommandBufferResetFlags flags) -{ - struct command_state *c = command(cmd); - assert(!c->pending && !c->freed && !flags); - if (fail_record == 1) return VK_ERROR_OUT_OF_HOST_MEMORY; - *c = (struct command_state){.allocated = c->allocated}; - reset_calls++; - return VK_SUCCESS; -} -static VkResult vkBeginCommandBuffer(VkCommandBuffer cmd, const VkCommandBufferBeginInfo *info) -{ - if (fail_record == 2) return VK_ERROR_OUT_OF_HOST_MEMORY; - command(cmd)->flags = info->flags; - begin_calls++; - return VK_SUCCESS; -} -static VkResult vkEndCommandBuffer(VkCommandBuffer cmd) -{ - end_calls++; - if (fail_record == 3) return VK_ERROR_OUT_OF_HOST_MEMORY; - command(cmd)->executable = true; - return VK_SUCCESS; -} -static void vkCmdResetQueryPool(VkCommandBuffer cmd, VkQueryPool pool, uint32_t first, uint32_t count) -{ assert(pool && count == 2); command(cmd)->resets++; } -static void vkCmdWriteTimestamp(VkCommandBuffer cmd, VkPipelineStageFlagBits stage, - VkQueryPool pool, uint32_t query) -{ assert(pool); command(cmd)->timestamps++; } -static void vkCmdPipelineBarrier(VkCommandBuffer cmd, VkPipelineStageFlags src, - VkPipelineStageFlags dst, VkDependencyFlags flags, - uint32_t nm, const VkMemoryBarrier *m, - uint32_t nb, const VkBufferMemoryBarrier *b, - uint32_t ni, const VkImageMemoryBarrier *images) -{ - struct command_state *c = command(cmd); - assert(ni == 2 && !nm && !nb && c->barriers < 2); - memcpy(c->barriers++ ? c->release : c->acquire, images, sizeof(c->acquire)); -} -static void vkCmdBindPipeline(VkCommandBuffer cmd, VkPipelineBindPoint point, VkPipeline pipeline) -{ assert(point == VK_PIPELINE_BIND_POINT_COMPUTE); } -static void vkCmdBindDescriptorSets(VkCommandBuffer cmd, VkPipelineBindPoint point, - VkPipelineLayout layout, uint32_t first, uint32_t count, - const VkDescriptorSet *sets, uint32_t offsets, const uint32_t *values) -{ assert(count == 1 && !offsets && *sets); command(cmd)->descriptor = *sets; bind_calls++; } -static void vkCmdPushConstants(VkCommandBuffer cmd, VkPipelineLayout layout, - VkShaderStageFlags stages, uint32_t offset, uint32_t size, const void *data) -{ assert(size == sizeof(command(cmd)->push)); memcpy(&command(cmd)->push, data, size); } -static void vkCmdDispatch(VkCommandBuffer cmd, uint32_t x, uint32_t y, uint32_t z) -{ command(cmd)->dispatch[0] = x; command(cmd)->dispatch[1] = y; command(cmd)->dispatch[2] = z; dispatch_calls++; } -static void vkFreeCommandBuffers(VkDevice device, VkCommandPool pool, uint32_t count, - const VkCommandBuffer *buffers) -{ - for (unsigned i = 0; i < count; i++) { - struct command_state *c = command(buffers[i]); - assert(!c->pending && !c->freed && c->allocated); - c->freed = true; - command_frees++; - } -} -static void vkDestroyDescriptorPool(VkDevice device, VkDescriptorPool pool, const VkAllocationCallbacks *a) -{ assert(pool == HANDLE(VkDescriptorPool, 1)); pool_frees++; } -static void vkDestroyImageView(VkDevice device, VkImageView view, const VkAllocationCallbacks *a) -{ - for (unsigned i = 1; i < MP_ARRAY_SIZE(cmds); i++) { - unsigned ds = ID(cmds[i].descriptor); - VkImageView *views = cmds[i].pushed ? cmds[i].pushed_views : bindings[ds]; - if (views[0] != view && views[1] != view) continue; - assert(!cmds[i].pending); - cmds[i].executable = false; - } - removed_views++; -} -static void vkDestroyImage(VkDevice device, VkImage image, const VkAllocationCallbacks *a) {} -static void vkFreeMemory(VkDevice device, VkDeviceMemory memory, const VkAllocationCallbacks *a) {} - -static const AHardwareBuffer_Desc desc = {3840, 2160}; -static const AImageCropRect full = {0, 0, 3840, 2160}; -static AHardwareBuffer buffers[40]; - -static void reset(struct aimagereader_vk_stable *p, bool fel) -{ - memset(p, 0, sizeof(*p)); memset(cmds, 0, sizeof(cmds)); memset(bindings, 0, sizeof(bindings)); - update_calls = reset_calls = begin_calls = end_calls = 0; - pool_calls = descriptor_calls = command_calls = command_frees = pool_frees = 0; - imports = removed_views = 0; fail_allocation = fail_record = 0; - push_calls = bind_calls = dispatch_calls = layout_calls = property_calls = 0; - max_push = 32; missing_entry = 0; fail_push_layout = fail_set_layout = false; - last_layout_flags = 0; - static const char *const extensions[] = {VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME}; - test_vk = (struct test_vulkan){.get_proc_addr = fake_instance_proc, - .extensions = extensions, .num_extensions = 1}; - p->vk = &test_vk; - command_next = descriptor_next = 100; - p->android_fel = fel; p->sampler_descriptors = 4; - p->width = 3840; p->height = 2160; p->queue_family = 2; - for (unsigned i = 0; i < MP_ARRAY_SIZE(p->outputs); i++) { - p->outputs[i].command = HANDLE(VkCommandBuffer, i + 1); - command(p->outputs[i].command)->allocated = true; - p->outputs[i].descriptor = HANDLE(VkDescriptorSet, i + 1); - p->outputs[i].image = HANDLE(VkImage, i + 1); - p->outputs[i].view = HANDLE(VkImageView, i + 1); - bindings[i + 1][1] = p->outputs[i].view; - } -} -static struct vk_input *get_input(struct aimagereader_vk_stable *p, unsigned b) -{ - struct vk_input *input = find_input(p, &buffers[b]); - if (!input) { - input = select_input_slot(p); assert(input); - if (input == &p->inputs[p->num_inputs]) p->num_inputs++; - imports++; - *input = (struct vk_input){.buffer = &buffers[b], - .image = HANDLE(VkImage, imports + 100), .view = HANDLE(VkImageView, imports + 100), - .memory = HANDLE(VkDeviceMemory, imports + 100)}; - } - input->last_used = ++p->input_serial; - return input; -} -static VkCommandBuffer active(struct vk_output *output) -{ return output->active_command ? output->active_command : output->command; } -static void complete_frame(struct vk_output *output, struct vk_input *input) -{ - struct command_state *c = command(active(output)); - assert(c->executable && !c->pending); - assert(c->barriers == 2 && c->dispatch[0] == 240 && c->dispatch[1] == 270 && c->dispatch[2] == 1); - assert(c->acquire[0].srcQueueFamilyIndex == VK_QUEUE_FAMILY_FOREIGN_EXT); - assert(c->release[0].dstQueueFamilyIndex == VK_QUEUE_FAMILY_FOREIGN_EXT); - assert(c->acquire[0].dstAccessMask == VK_ACCESS_SHADER_READ_BIT); - assert(c->release[1].oldLayout == VK_IMAGE_LAYOUT_GENERAL); - VkImageView *views = c->pushed ? c->pushed_views : bindings[ID(c->descriptor)]; - assert(views[0] == input->view); - assert(views[1] == output->view); - input->initialized = output->written = true; -} - -static void test_rotation_and_default(void) -{ - struct aimagereader_vk_stable p; - unsigned base_imports = 0, base_updates = 0; - for (int fel = 0; fel <= 1; fel++) { - reset(&p, fel); - for (unsigned n = 0; n < 1200; n++) { - struct vk_input *input = get_input(&p, n % 12); - struct vk_output *output = &p.outputs[n % 5]; - assert(prepare_conversion(&p, output, input, &desc, &full)); - complete_frame(output, input); - } - if (!fel) { - base_imports = imports; base_updates = update_calls; - assert(imports == 1200 && update_calls == 1200 && !p.num_recordings && !pool_calls); - assert(p.num_inputs == INPUT_CACHE_SIZE); - assert(command(p.outputs[0].command)->flags == VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); - } else { - assert(imports == 12 && update_calls == 72 && end_calls == 1200); - assert(p.recording_hits == 1128 && p.fresh_commands == 1200); - assert(p.descriptor_content_hits == 1128 && p.descriptor_writes == 72); - assert(bind_calls == 1200 && dispatch_calls == 1200 && p.descriptor_rebinds == 1200); - assert(!p.fel_bind_window.count && !p.fel_record_window.count); - assert(p.num_inputs == 12 && p.num_recordings == 60 && !p.input_evictions); - assert(pool_calls == 1 && command_calls == 60 && descriptor_calls == 60); - printf("PASS: 12-input/5-output, 1200 frames: imports %u -> %u; descriptor writes %u -> %u; 1200 fresh binds/recordings/dispatches; content hits=%llu, no replay (call counts only)\n", - base_imports, imports, base_updates, update_calls, (unsigned long long)p.recording_hits); - destroy_recording_cache(&p); - assert(command_frees == 60 && pool_frees == 1 && !p.num_recordings); - } - } -} - -static void test_keys_pending_and_removal(void) -{ - struct aimagereader_vk_stable p; reset(&p, true); - struct vk_input *input = get_input(&p, 0); - struct vk_output *output = &p.outputs[0]; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(!output->active_command && p.recording_cold == 1); - assert(command(active(output))->acquire[0].oldLayout == VK_IMAGE_LAYOUT_UNDEFINED); - complete_frame(output, input); - assert(prepare_conversion(&p, output, input, &desc, &full)); - VkCommandBuffer first = active(output); - assert(command(first)->flags == VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); - assert(command(first)->acquire[0].oldLayout == VK_IMAGE_LAYOUT_GENERAL); - unsigned calls = update_calls; - unsigned records = end_calls; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(active(output) == first && update_calls == calls && end_calls == records + 1); - assert(p.recording_hits == 1 && command(first)->flags == VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); - calls = update_calls; - - output->pending = command(first)->pending = true; input->users = 1; - assert(!prepare_conversion(&p, output, input, &desc, &full)); - assert(active(output) == first && update_calls == calls); - aimagereader_vk_stable_buffer_removed(&p, input->buffer); - assert(input->removed && input->view && !removed_views && p.recordings[0].valid); - command(first)->pending = output->pending = false; input->users = 0; - purge_removed_inputs(&p); - assert(removed_views == 1 && !p.recordings[0].valid && !p.recordings[0].input); - assert(!command(first)->executable && p.recording_invalidations == 1); - input = get_input(&p, 0); // Same AHB pointer and slot, new Vulkan objects. - input->initialized = true; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(update_calls == calls + 1 && command(active(output))->executable); - - AImageCropRect crop = {4, 8, 1920, 1080}; - assert(prepare_conversion(&p, output, input, &desc, &crop)); - assert(fabsf(command(active(output))->push.uv_offset[0] - 4.0f / 3840) < 1e-7f); - AHardwareBuffer_Desc different = {4096, 2176}; - calls = update_calls; - assert(prepare_conversion(&p, output, input, &different, &crop)); - assert(update_calls == calls + 1); - p.fel_query_pool = HANDLE(VkQueryPool, 1); - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(output->fel_query_recorded && command(active(output))->timestamps == 2); - calls = update_calls; - output->fel_query_recorded = false; // The prior frame's query was collected. - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(output->fel_query_recorded && update_calls == calls); - p.fel_query_failed = true; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(!output->fel_query_recorded && !command(active(output))->timestamps); - destroy_recording_cache(&p); - puts("PASS: cold/warm layouts, pending guard, deferred removal/ABA, crop/geometry and query invalidation"); -} - -static void test_descriptor_lifetimes(void) -{ - struct aimagereader_vk_stable p; reset(&p, true); - struct vk_input *input = get_input(&p, 0); input->initialized = true; - struct vk_input *slot = input; - VkImageView original_view = input->view; - struct vk_output *output = &p.outputs[0]; output->written = true; - assert(prepare_conversion(&p, output, input, &desc, &full)); - unsigned calls = update_calls; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(update_calls == calls && p.descriptor_content_hits == 1); - - // A stable C slot does not make a replaced Vulkan view equivalent. - input->view = HANDLE(VkImageView, 300); - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(update_calls == ++calls); - complete_frame(output, input); - output->view = HANDLE(VkImageView, 301); - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(update_calls == ++calls); - complete_frame(output, input); - - // Destruction invalidates even when AHB, C slot and Vulkan handle all recur. - destroy_input(&p, input); - input = get_input(&p, 0); - assert(input == slot); - input->view = original_view; - input->initialized = true; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(update_calls == ++calls && p.descriptor_content_hits == 1); - complete_frame(output, input); - - // Output, immutable sampler, YCbCr and layout rebuilds own a new cache life. - destroy_recording_cache(&p); - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(update_calls == ++calls && p.num_recordings == 1); - complete_frame(output, input); - destroy_recording_cache(&p); - puts("PASS: exact view identity, recycled AHB/slot/view invalidation and new cache lifetime force descriptor writes"); -} - -static void test_limits_and_fallback(void) -{ - struct aimagereader_vk_stable p; reset(&p, true); - for (unsigned n = 0; n < 40; n++) get_input(&p, n); - assert(p.num_inputs == FEL_INPUT_CACHE_SIZE && p.input_evictions == 8); - struct vk_input *input = get_input(&p, 0); input->initialized = true; - struct vk_output *output = &p.outputs[0]; output->written = true; - AImageCropRect crop = full; - for (int n = 0; n < FEL_RECORD_CACHE_SIZE; n++) { - crop.left = n; - assert(prepare_conversion(&p, output, input, &desc, &crop)); - } - assert(p.num_recordings == FEL_RECORD_CACHE_SIZE && !p.recording_evictions); - crop.left++; - assert(prepare_conversion(&p, output, input, &desc, &crop)); - assert(p.recording_evictions == 1 && command_calls == FEL_RECORD_CACHE_SIZE); - struct vk_output pending[FEL_RECORD_CACHE_SIZE]; memset(pending, 0, sizeof(pending)); - for (int n = 0; n < FEL_RECORD_CACHE_SIZE; n++) { - pending[n].pending = true; pending[n].active_command = p.recordings[n].command; - p.recordings[n].output = &pending[n]; - } - unsigned calls = update_calls; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(!output->active_command && update_calls == calls + 1 && p.recording_fallbacks == 1); - assert(p.recording_evictions == 1); // No pending entry was reset or evicted. - destroy_recording_cache(&p); - - for (int failure = 1; failure <= 3; failure++) { - reset(&p, true); input = get_input(&p, 0); input->initialized = true; - output = &p.outputs[0]; output->written = true; fail_allocation = failure; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(p.recording_cache_disabled && !output->active_command); - unsigned attempts = pool_calls + command_calls + descriptor_calls; - fail_allocation = 0; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(attempts == pool_calls + command_calls + descriptor_calls && p.recording_fallbacks == 2); - destroy_recording_cache(&p); - } - for (int failure = 1; failure <= 3; failure++) { - reset(&p, true); input = get_input(&p, 0); input->initialized = true; - output = &p.outputs[0]; output->written = true; fail_record = failure; - assert(!prepare_conversion(&p, output, input, &desc, &full)); - assert(!p.recordings[0].valid && !output->active_command); - fail_record = 0; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(!p.recording_hits && p.num_recordings == 1); - fail_record = failure; // Failed fresh recording cannot select old commands. - assert(!prepare_conversion(&p, output, input, &desc, &full)); - assert(!p.recordings[0].valid && !output->active_command && !p.recording_hits); - calls = update_calls; - fail_record = 0; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(update_calls == calls + 1 && !p.descriptor_content_hits); - complete_frame(output, input); - destroy_recording_cache(&p); - } - puts("PASS: bounded LRU/imports, in-flight exhaustion, optional allocation fallback and failed recording not reusable"); -} - -static void test_api_measurements(void) -{ - struct aimagereader_vk_stable p; reset(&p, true); - struct vk_input *input = get_input(&p, 0); input->initialized = true; - struct vk_output *output = &p.outputs[0]; output->written = true; - p.fel_profile = true; p.fel_profile_warm = false; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(!p.fel_api_wall[FEL_API_DESCRIPTOR].count); - p.fel_profile_warm = true; - AImageCropRect crop = full; crop.left = 1; - assert(prepare_conversion(&p, output, input, &desc, &crop)); - assert(p.fel_api_wall[FEL_API_DESCRIPTOR].count == 1 && p.fel_api_wall[FEL_API_END].count == 1); - assert(prepare_conversion(&p, output, input, &desc, &crop)); - assert(p.fel_api_wall[FEL_API_DESCRIPTOR].count == 1 && p.fel_api_wall[FEL_API_RESET].count == 2); - for (int op = FEL_API_BARRIER_IN; op < FEL_API_PUSH_DESCRIPTORS; op++) - assert(p.fel_api_wall[op].count == 2); - assert(!p.fel_api_wall[FEL_API_PUSH_DESCRIPTORS].count); - assert(p.fel_bind_window.count == 2 && p.fel_record_window.count == 2); - destroy_recording_cache(&p); - puts("PASS: API diagnostics exclude cold initialization; content hits skip writes but measure every fresh bind/record"); -} - -static void test_latency_windows(void) -{ - struct fel_latency_window window = {0}; - uint64_t summary[3]; - fel_latency_summary(&window, summary); - assert(!summary[0] && !summary[1] && !summary[2]); - for (uint64_t n = 1; n <= 256; n++) - fel_latency_add(&window, n * 1000); - struct fel_latency_window saved = window; - fel_latency_summary(&window, summary); - assert(window.count == 128 && window.next == 0); - assert(summary[0] == 192 && summary[1] == 250 && summary[2] == 256); - assert(!memcmp(&window, &saved, sizeof(window))); - - struct aimagereader_vk_stable p; reset(&p, true); - fel_perf_finish_map(&p); // profiling disabled: no clock or sample - assert(!p.fel_map_window.count && !p.fel_map_cold.count); - p.fel_map_started = p.fel_map_checkpoint = mp_time_ns(); - fel_perf_finish_map(&p); - assert(!p.fel_map_window.count && p.fel_map_cold.count == 1); - p.fel_profile_warm = true; - p.fel_map_started = p.fel_map_checkpoint = mp_time_ns(); - fel_perf_finish_map(&p); - assert(p.fel_map_window.count == 1 && p.fel_map_warm.count == 1); - puts("PASS: bounded 128-call latency window, nearest-rank quantiles, warm-only map samples and non-mutating summaries"); -} - -static void test_bounded_diagnostics(void) -{ - struct aimagereader_vk_stable p; reset(&p, false); - trace_fel_frame_order(&p, 'M', 0, 0, 1, 1, 1); - assert(!p.fel_order_count); - p.android_fel = true; - trace_fel_frame_order(&p, 'M', 0, 0, 1, 1, 1); - assert(!p.fel_order_count); // Disabled diagnostics add no frame history. - p.fel_profile = p.fel_profile_warm = true; - for (int n = 1; n <= 12; n++) - trace_fel_frame_order(&p, 'M', n % 5, n % 12, n, n, n); - trace_fel_frame_order(&p, 'R', 1, 11, 11, 11, 11); - trace_fel_frame_order(&p, 'R', 2, 0, 12, 12.5, 12); - trace_fel_frame_order(&p, 'A', 2, 0, 12, 13, 12); - assert(p.fel_order_count == 15 && p.fel_pts_differences == 1); - char order[1024]; format_fel_frame_order(&p, order, sizeof(order)); - assert(strstr(order, "M#8/o3") && !strstr(order, "M#7/")); - assert(strstr(order, "R#12/o2/i0:12.500000>12.000000")); - assert(strstr(order, "R#11/") < strstr(order, "A#12/")); - char tiny[4] = {'x', 'x', 'x', 'z'}; - format_fel_frame_order(&p, tiny, 3); - assert(tiny[2] == '\0' && tiny[3] == 'z'); - format_fel_frame_order(&p, tiny, 0); - assert(tiny[3] == 'z'); - for (int n = 16; n < 40; n++) - trace_fel_frame_order(&p, 'M', 0, 0, n, n, n); - format_fel_frame_order(&p, order, sizeof(order)); - assert(strstr(order, "last-diff=#12/o2/i0:12.500000>12.000000")); - assert(!strstr(order, " R#12/")); // A difference survives ring rollover. - - info_logs = 0; wall_clock = 200000; - p.fel_active_pts = 16.225; p.fel_active_input = 8; p.fel_active_output = -1; - for (int n = 0; n < 20; n++) - fel_api_end(&p, FEL_API_MEMORY, (struct fel_api_clock){1, 0}); - assert(info_logs == 4 && strstr(last_info, "op=memory pts=16.225000 input=8 output=-1")); - wall_clock = 4000000; - fel_api_end(&p, FEL_API_RECORD, (struct fel_api_clock){1, 0}); - assert(info_logs == 4); // Do not double-log aggregate and nested call. - fel_api_end(&p, FEL_API_DISPATCH, (struct fel_api_clock){1, 0}); - assert(info_logs == 5 && strstr(last_info, "op=dispatch")); - struct fel_api_clock start = fel_api_begin(&p); - fel_api_end(&p, FEL_API_END, start); - assert(info_logs == 5); // Normal short calls never emit per-frame logs. - puts("PASS: bounded chronological frame-identity ring, safe truncation, diagnostic gating and slow-API rate limit"); -} - -static bool create_test_layout(struct aimagereader_vk_stable *p) -{ - VkSampler immutable = HANDLE(VkSampler, 1); - VkDescriptorSetLayoutBinding bindings[] = { - {.binding = 0, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, - .descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT, - .pImmutableSamplers = &immutable}, - {.binding = 1, .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, - .descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT}, - }; - VkDescriptorSetLayoutCreateInfo info = { - .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO, - .bindingCount = 2, .pBindings = bindings, - }; - return create_conversion_descriptor_layout(p, &info, p->sampler_descriptors); -} - -static void test_push_capabilities_and_frames(void) -{ - struct aimagereader_vk_stable p; - reset(&p, false); - assert(create_test_layout(&p) && !p.push_descriptors && !property_calls && !last_layout_flags); - reset(&p, true); - test_vk.num_extensions = 0; // A callable symbol alone is not enabled support. - assert(create_test_layout(&p) && !p.push_descriptors && !property_calls); - assert(strstr(last_info, "extension-not-enabled")); - for (int absent = 1; absent <= 3; absent++) { - reset(&p, true); missing_entry = absent; - assert(create_test_layout(&p) && !p.push_descriptors && !property_calls); - assert(strstr(last_info, "entrypoint-unavailable")); - } - reset(&p, true); max_push = 4; - assert(create_test_layout(&p) && !p.push_descriptors && !last_layout_flags); - assert(strstr(last_info, "descriptor-limit")); - reset(&p, true); fail_push_layout = true; - assert(create_test_layout(&p) && !p.push_descriptors && layout_calls == 2 && !last_layout_flags); - assert(strstr(last_info, "push-layout-failed")); - reset(&p, true); fail_push_layout = fail_set_layout = true; - assert(!create_test_layout(&p) && !p.push_descriptors && !p.descriptor_layout); - - reset(&p, true); - assert(create_test_layout(&p) && p.push_descriptors); - assert(last_layout_flags == VK_DESCRIPTOR_SET_LAYOUT_CREATE_PUSH_DESCRIPTOR_BIT_KHR); - assert(strstr(last_info, "mode=push reason=enabled max-push=32")); - p.fel_profile = p.fel_profile_warm = true; - for (unsigned n = 0; n < 1200; n++) { - struct vk_input *input = get_input(&p, n % 12); - struct vk_output *output = &p.outputs[n % 5]; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(command(active(output))->flags == VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); - complete_frame(output, input); - } - assert(push_calls == 1200 && end_calls == 1200 && !update_calls && !bind_calls); - assert(!pool_calls && !descriptor_calls && command_calls == 60); - assert(p.fel_api_wall[FEL_API_PUSH_DESCRIPTORS].count == 1200); - assert(!p.fel_api_wall[FEL_API_DESCRIPTORS].count && !p.fel_api_wall[FEL_API_DESCRIPTOR].count); - struct vk_input *input = get_input(&p, 0); - struct vk_output *output = &p.outputs[0]; - assert(prepare_conversion(&p, output, input, &desc, &full)); - VkCommandBuffer cmd = active(output); - output->pending = command(cmd)->pending = true; input->users = 1; - assert(!prepare_conversion(&p, output, input, &desc, &full)); - aimagereader_vk_stable_buffer_removed(&p, input->buffer); - assert(input->view && input->removed && !removed_views); - output->pending = command(cmd)->pending = false; input->users = 0; - purge_removed_inputs(&p); - assert(!command(cmd)->executable && removed_views == 1); - destroy_recording_cache(&p); - assert(command_frees == 60 && !pool_frees); - - reset(&p, true); - assert(create_test_layout(&p) && p.push_descriptors); - input = get_input(&p, 0); input->initialized = true; - output = &p.outputs[0]; output->written = true; - fail_allocation = 3; - assert(prepare_conversion(&p, output, input, &desc, &full)); - assert(p.recording_cache_disabled && !output->active_command); - complete_frame(output, input); - assert(push_calls == 1 && !update_calls && !descriptor_calls && !pool_calls); - destroy_recording_cache(&p); - puts("PASS: enabled-extension/entry/YCbCr limit gates and layout fallback; 1200 fresh full pushes, no descriptor sets, unchanged barriers/lifetime and command-allocation fallback"); -} - -int main(void) -{ - test_rotation_and_default(); - test_keys_pending_and_removal(); - test_descriptor_lifetimes(); - test_limits_and_fallback(); - test_api_measurements(); - test_latency_windows(); - test_bounded_diagnostics(); - test_push_capabilities_and_frames(); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_vo_drop_test.c b/third_party/mpv-player-jni/tests/fel_vo_drop_test.c deleted file mode 100644 index fd44cacb62..0000000000 --- a/third_party/mpv-player-jni/tests/fel_vo_drop_test.c +++ /dev/null @@ -1,289 +0,0 @@ -// Compile real vo.c::render_frame and gpu-next's dropped-frame preparation. -// The fake codec has one free output beyond its DPB. A future frame occupies -// that output until GPU staging returns it; retaining it after a VO drop must -// not make the core's two-frame lookahead wait for itself. -#include "filters/f_android_fel_trace.h" -#include "filters/f_android_fel.h" -#include -#include -#include - -#define CHECK(c) do { if (!(c)) { \ - fprintf(stderr, "FAIL: %s (line %d)\n", #c, __LINE__); exit(1); \ -} } while (0) -#define mp_assert CHECK -#define MPMAX(a, b) ((a) > (b) ? (a) : (b)) -#define MP_TIME_MS_TO_NS(ms) ((ms) * 1000000LL) -#define MP_NOPTS_VALUE (-1e20) -#define MP_WARN(...) ((void)0) -#define MP_INFO(...) ((void)0) -#define MP_ERR(...) ((void)0) -#define MP_VERBOSE(...) ((void)0) -#define MP_STATS(...) ((void)0) -enum { VO_TRUE = 1, VO_FALSE = 0, VO_ERROR = -1, VO_NOTIMPL = -3 }; -enum { IMGFMT_MEDIACODEC = 1, IMGFMT_YUV420P10 = 2 }; -enum { VO_CAP_FRAMEDROP = 1, VO_CAP_NORETAIN = 2, - VO_CAP_FRAMEOWNER = 4, VO_CAP_GPU_DOVI_EL_SW = 8 }; -enum { VOCTRL_PREPARE_FEL_FRAME = 1, RA_HWDEC_MAP_RETRY = -2 }; - -struct mp_image_params { int imgfmt; }; -struct mp_image { - int imgfmt; - double pts; - struct mp_image_params params; - bool staged; -}; -struct vo_frame { - int64_t pts, duration; - bool display_synced, can_drop, repeat; - int num_vsyncs, num_frames, frame_id; - double vsync_offset, vsync_interval; - double ideal_frame_vsync, ideal_frame_vsync_duration; - struct mp_image *current, *frames[4]; -}; -struct vo_internal { - int lock, wakeup; - struct vo_frame *current_frame, *frame_queued; - bool paused, hasframe, hasframe_rendered, dropped_frame; - bool expecting_vsync, request_redraw, want_redraw, rendering, visible; - int64_t prev_vsync, flip_queue_offset, drop_count; - int64_t drop_late_total, drop_late_max; - uint64_t drop_diag_frames; - struct mp_image *fel_prepare_image; - int fel_prepare_result; - int64_t fel_prepare_phase_started, fel_render_init_started; - bool fel_render_init_attempted, fel_render_initializing; - void *stats; -}; -struct vo; -struct vo_vsync_info { int64_t last_queue_display_time, skipped_vsyncs; }; -struct vo_driver { - unsigned caps; - int (*control)(struct vo *, uint32_t, void *); - bool (*draw_frame)(struct vo *, struct vo_frame *); - void (*flip_page)(struct vo *); - void (*get_vsync)(struct vo *, struct vo_vsync_info *); -}; -struct mp_vo_opts { bool android_dovi_fel; }; -struct vo { - struct mp_vo_opts extra; - struct vo_internal *in; - struct mp_vo_opts *opts; - struct vo_driver *driver; - struct mp_fel_trace fel_trace; - void *priv; -}; -struct ra_hwdec_mapper { int unused; }; -struct timer_pool { int unused; }; -struct ra_hwdec_mapper_driver { int unused; }; -struct ra_hwdec_driver { struct ra_hwdec_mapper_driver *mapper; }; -struct ra_hwdec { struct ra_hwdec_driver *driver; }; -struct priv { - int hwdec_ctx; - struct ra_hwdec_mapper *hwdec_mapper; - struct timer_pool *hwdec_timer; - bool deferred, available; -}; -static const struct vo_driver video_out_mediacodec_embed = {0}; -static struct ra_hwdec_mapper_driver mapper_driver; -static struct ra_hwdec_driver hwdec_driver = {&mapper_driver}; -static struct ra_hwdec hwdec = {&hwdec_driver}; -static struct priv *active_gpu; -static struct vo *active_vo; -static int codec_outputs, copies, map_calls, controls, draws, flips; -static int fail_map, backend_errors, core_wakeups; -static int64_t fake_now = 1000000000LL; - -static int64_t mp_time_ns(void) { return fake_now; } -static void mp_mutex_lock(int *lock) { CHECK(!*lock); *lock = 1; } -static void mp_mutex_unlock(int *lock) { CHECK(*lock); *lock = 0; } -static void mp_cond_broadcast(int *cond) { (void)cond; } -static void update_display_fps(struct vo *vo) { (void)vo; } -static void wakeup_core(struct vo *vo) { (void)vo; core_wakeups++; } -static void stats_time_start(void *stats, const char *name) { (void)stats; (void)name; } -static void stats_time_end(void *stats, const char *name) { (void)stats; (void)name; } -static void wait_until(struct vo *vo, int64_t time) { (void)vo; (void)time; } -static void update_vsync_timing_after_swap(struct vo *vo, struct vo_vsync_info *info) -{ (void)vo; (void)info; } -static void vo_report_backend_error(struct vo *vo) -{ CHECK(!vo->in->lock); backend_errors++; } -static struct vo_frame *vo_frame_ref(struct vo_frame *frame) -{ - struct vo_frame *copy = malloc(sizeof(*copy)); - CHECK(copy); - *copy = *frame; - return copy; -} -static void talloc_free(void *ptr) { free(ptr); } -static struct ra_hwdec *ra_hwdec_get(int *ctx, int format) -{ - (void)ctx; - return active_gpu->available && format == IMGFMT_MEDIACODEC ? &hwdec : NULL; -} -static bool hwdec_driver_uses_deferred_dst_params( - struct priv *p, const struct ra_hwdec_mapper_driver *driver) -{ return driver && p->deferred; } -static int preload_hwdec_image(struct priv *p, struct ra_hwdec_mapper **mapper, - struct timer_pool **timer, struct ra_hwdec *hw, - struct mp_image *image, bool *preloaded, bool measure) -{ - (void)p; (void)mapper; (void)timer; (void)hw; (void)measure; - CHECK(!active_vo->in->lock); // Never wait for a Surface with the VO lock held. - map_calls++; - if (fail_map) return fail_map; - if (!image->staged) { - CHECK(codec_outputs > 0); - codec_outputs--; - copies++; - image->staged = true; // Retain independent pixels, not a raw codec output. - } - *preloaded = true; - return 1; -} - -static int preload_dropped_fel_frame(struct vo *vo, struct vo_frame *frame); -static bool begin_fel_render_init(struct vo *vo, const struct vo_frame *frame); -static void end_fel_render_init(struct vo *vo); -static bool render_frame(struct vo *vo); -#ifdef FEL_VO_OLD_PATH -// Only used when compiling the unmodified scheduler to demonstrate failure. -static int preload_dropped_fel_frame(struct vo *vo, struct vo_frame *frame) -{ (void)vo; (void)frame; return VO_NOTIMPL; } -#endif - -static int fake_control(struct vo *vo, uint32_t command, void *data) -{ - CHECK(command == VOCTRL_PREPARE_FEL_FRAME); - CHECK(!vo->in->lock && vo->in->rendering); - controls++; - return preload_dropped_fel_frame(vo, data); -} -static bool fake_draw(struct vo *vo, struct vo_frame *frame) -{ - CHECK(!vo->in->lock); - bool fel = vo->extra.android_dovi_fel && - (vo->driver->caps & VO_CAP_GPU_DOVI_EL_SW) && - frame->current->imgfmt == IMGFMT_MEDIACODEC; - if (!draws) CHECK(vo->in->fel_render_initializing == fel); - draws++; - return true; -} -static void fake_flip(struct vo *vo) { (void)vo; flips++; } - -struct fixture { - struct vo vo; - struct vo_internal in; - struct mp_vo_opts opts; - struct vo_driver driver; - struct priv gpu; - struct mp_image images[2]; -}; -static void init_fixture(struct fixture *f) -{ - memset(f, 0, sizeof(*f)); - codec_outputs = 1; - copies = map_calls = controls = draws = flips = 0; - fail_map = backend_errors = core_wakeups = 0; - f->opts.android_dovi_fel = true; - f->driver = (struct vo_driver) { - .caps = VO_CAP_GPU_DOVI_EL_SW, .control = fake_control, - .draw_frame = fake_draw, .flip_page = fake_flip, - }; - f->gpu.deferred = f->gpu.available = true; - f->vo = (struct vo) { .in = &f->in, .opts = &f->opts, .extra = f->opts, - .driver = &f->driver, .priv = &f->gpu }; - mp_fel_trace_init(&f->vo.fel_trace); - atomic_store(&f->vo.fel_trace.enabled, true); - active_gpu = &f->gpu; - active_vo = &f->vo; - f->in.hasframe = f->in.hasframe_rendered = true; - f->in.prev_vsync = fake_now - MP_TIME_MS_TO_NS(50); - f->images[0] = (struct mp_image) { - .imgfmt = IMGFMT_MEDIACODEC, .pts = 19.144, .staged = true, - }; - f->images[1] = (struct mp_image) { - .imgfmt = IMGFMT_MEDIACODEC, .pts = 19.186, - }; - struct vo_frame frame = { - .pts = fake_now - MP_TIME_MS_TO_NS(80), .duration = MP_TIME_MS_TO_NS(40), - .can_drop = true, .num_vsyncs = 1, .num_frames = 2, .frame_id = 4, - .current = &f->images[0], .frames = {&f->images[0], &f->images[1]}, - }; - f->in.frame_queued = vo_frame_ref(&frame); -} -static void finish_fixture(struct fixture *f) -{ - CHECK(!f->in.lock && !f->in.rendering && !f->in.fel_render_initializing); - free(f->in.current_frame); - free(f->in.frame_queued); -} - -int main(void) -{ - struct fixture f; - init_fixture(&f); - CHECK(!render_frame(&f.vo)); - CHECK(f.in.drop_count == 1 && draws == 0 && flips == 0); - CHECK(f.in.current_frame && f.in.current_frame->num_frames == 2); - CHECK(f.in.current_frame->frames[1] == &f.images[1]); - CHECK(codec_outputs == 0); // Old code deadlocks here: no room for next BL. - CHECK(copies == 1 && controls == 1 && map_calls == 2 && !backend_errors); - CHECK(f.images[0].staged && f.images[1].staged); - CHECK(core_wakeups > 0); - finish_fixture(&f); - - init_fixture(&f); - f.vo.extra.android_dovi_fel = false; - render_frame(&f.vo); - CHECK(f.in.drop_count == 1 && controls == 0 && copies == 0); - finish_fixture(&f); - - init_fixture(&f); - f.images[0].imgfmt = f.images[1].imgfmt = IMGFMT_YUV420P10; - render_frame(&f.vo); - CHECK(f.in.drop_count == 1 && controls == 0 && copies == 0); - finish_fixture(&f); - - init_fixture(&f); - f.driver.caps = 0; // Unrelated VO, even if a custom config set the FEL option. - render_frame(&f.vo); - CHECK(f.in.drop_count == 1 && controls == 0 && copies == 0); - finish_fixture(&f); - - init_fixture(&f); - f.in.frame_queued->pts = fake_now; // On-time frames keep the usual draw path. - render_frame(&f.vo); - CHECK(!f.in.drop_count && controls == 0 && draws == 1 && flips == 1); - CHECK(f.in.fel_render_init_attempted && !f.in.fel_render_initializing); - finish_fixture(&f); - - init_fixture(&f); - f.in.paused = true; - render_frame(&f.vo); - CHECK(!f.in.drop_count && controls == 0 && draws == 1); - finish_fixture(&f); - - init_fixture(&f); - f.gpu.deferred = false; // Do not pre-acquire several GL external images. - render_frame(&f.vo); - CHECK(f.in.drop_count == 1 && copies == 0 && !backend_errors); - finish_fixture(&f); - - init_fixture(&f); - fail_map = RA_HWDEC_MAP_RETRY; - render_frame(&f.vo); - CHECK(!backend_errors && f.in.request_redraw); - CHECK(f.in.current_frame && codec_outputs == 1); - fail_map = 0; - CHECK(preload_dropped_fel_frame(&f.vo, f.in.current_frame) == VO_TRUE); - CHECK(codec_outputs == 0 && copies == 1); - finish_fixture(&f); - - init_fixture(&f); - fail_map = VO_ERROR; - render_frame(&f.vo); - CHECK(backend_errors == 1 && copies == 0); - finish_fixture(&f); - puts("PASS: real VO drop/control paths free finite codec output without drawing; FEL-off, software, other VO, timing, pause, GL, retry, fatal and lock isolation"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/fel_wait_probe_test.c b/third_party/mpv-player-jni/tests/fel_wait_probe_test.c deleted file mode 100644 index 61f6cb0450..0000000000 --- a/third_party/mpv-player-jni/tests/fel_wait_probe_test.c +++ /dev/null @@ -1,279 +0,0 @@ -// Compile the production probe bodies with deterministic kernel/Vulkan inputs. -// These tests verify observation and failure isolation, not television timing. -#define VK_NO_PROTOTYPES -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#ifndef RUSAGE_THREAD -#define RUSAGE_THREAD 1 -#endif - -#define MPMAX(a, b) ((a) > (b) ? (a) : (b)) -#define MP_TIME_MS_TO_NS(v) ((v) * INT64_C(1000000)) -#define MSGL_INFO 40 -struct fel_api_clock { int64_t wall, cpu; }; -struct fel_wait_snapshot { - uint64_t runtime, runqueue, slices, voluntary, involuntary; - int sched_error, usage_error, acquire; -}; -struct fel_wait_sample { - bool active; - struct fel_api_clock clock; - struct fel_wait_snapshot before; - int64_t probe_ns; -}; -typedef const struct test_vulkan { - VkInstance instance; - VkPhysicalDevice phys_device; - PFN_vkGetInstanceProcAddr get_proc_addr; - uint32_t api_version; - const char *const *extensions; - int num_extensions; -} *pl_vulkan; -struct aimagereader_vk_stable { - void *log; - bool android_fel, fel_profile, fel_profile_warm, push_descriptors; - int64_t fel_wait_sample_at; - int fel_sched_disabled_error, fel_acquire_fence; - double fel_active_pts; - int fel_active_input, fel_active_output; - pl_vulkan vk; -}; -static int fel_parse_schedstat(const char *, struct fel_wait_snapshot *); -static int fel_probe_acquire_fence(int); -static int64_t fel_wait_delta(uint64_t, uint64_t, bool); -static struct fel_wait_sample fel_wait_probe_begin(struct aimagereader_vk_stable *); -static void fel_wait_probe_end(struct aimagereader_vk_stable *, const struct fel_wait_sample *); -static void log_fel_descriptor_capabilities(struct aimagereader_vk_stable *); - -static int64_t now_ns = INT64_C(1000000000), cpu_ns = INT64_C(1000000); -static bool info_enabled = true, oom, enumerate_available = true, advertise_push; -static int open_error, usage_error, poll_error, poll_result; -static short poll_events; -static unsigned opens, reads, closes, polls, usages, allocations; -static long voluntary, involuntary; -static const char *sched_text = "1000000 2000000 10\n"; -static uint32_t extension_count = 1; -static VkResult enum_result = VK_SUCCESS; -static char message[2048]; -static int64_t mp_time_ns(void) { return now_ns; } -static int64_t mp_fel_thread_cpu_ns(void) { return cpu_ns; } -static bool mp_msg_test(void *log, int level) { assert(level == 40); return info_enabled; } -static void mp_info(void *log, const char *fmt, ...) -{ - va_list args; - va_start(args, fmt); - vsnprintf(message, sizeof(message), fmt, args); - va_end(args); -} -static pid_t fake_gettid(void) { return 123; } -static int fake_open(const char *path, int flags) -{ - assert(!strcmp(path, "/proc/self/task/123/schedstat")); - assert(flags == (O_RDONLY | O_CLOEXEC)); - opens++; - now_ns += 1000; - if (open_error) { errno = open_error; return -1; } - return 99; -} -static ssize_t fake_read(int fd, void *buffer, size_t size) -{ - assert(fd == 99 && size == 159); - reads++; - now_ns += 1000; - size_t length = strlen(sched_text); - if (length > size) length = size; - memcpy(buffer, sched_text, length); - return length; -} -static int fake_close(int fd) { assert(fd == 99); closes++; return 0; } -static int fake_poll(struct pollfd *fds, nfds_t count, int timeout) -{ - assert(count == 1 && timeout == 0 && fds->fd == 7 && fds->events == POLLIN); - polls++; - now_ns += 1000; - if (poll_error) { errno = poll_error; return -1; } - fds->revents = poll_events; - return poll_result; -} -static int fake_getrusage(int who, struct rusage *usage) -{ - assert(who == RUSAGE_THREAD); - usages++; - now_ns += 1000; - if (usage_error) { errno = usage_error; return -1; } - memset(usage, 0, sizeof(*usage)); - usage->ru_nvcsw = voluntary; - usage->ru_nivcsw = involuntary; - return 0; -} -static void *fake_calloc(size_t count, size_t size) -{ - allocations++; - assert(count <= 512); - return oom ? NULL : calloc(count, size); -} -static VKAPI_ATTR VkResult VKAPI_CALL enumerate_extensions( - VkPhysicalDevice dev, const char *layer, uint32_t *count, VkExtensionProperties *out) -{ - if (!out) { *count = extension_count; return VK_SUCCESS; } - assert(*count >= 1); - snprintf(out[0].extensionName, sizeof(out[0].extensionName), "%s", - advertise_push ? VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME : "VK_OTHER"); - *count = 1; - return enum_result; -} -static VKAPI_ATTR void VKAPI_CALL physical_properties(VkPhysicalDevice dev, - VkPhysicalDeviceProperties *props) -{ - props->apiVersion = VK_MAKE_VERSION(1, 1, 128); - snprintf(props->deviceName, sizeof(props->deviceName), "GPU\nname"); -} -static VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL get_proc(VkInstance instance, const char *name) -{ - if (!strcmp(name, "vkEnumerateDeviceExtensionProperties")) - return enumerate_available ? (PFN_vkVoidFunction)enumerate_extensions : NULL; - if (!strcmp(name, "vkGetPhysicalDeviceProperties")) - return (PFN_vkVoidFunction)physical_properties; - return NULL; -} -static struct aimagereader_vk_stable fresh(void) -{ - return (struct aimagereader_vk_stable){ - .android_fel = true, .fel_profile = true, .fel_profile_warm = true, - .fel_acquire_fence = 7, - }; -} - -int main(void) -{ - struct fel_wait_snapshot parsed = {0}; - assert(!fel_parse_schedstat(" 123 456 7\n", &parsed)); - assert(parsed.runtime == 123 && parsed.runqueue == 456 && parsed.slices == 7); - assert(fel_parse_schedstat("0 0 0\n", &parsed) == ENODATA); - assert(fel_parse_schedstat("-1 0 2", &parsed) == EINVAL); - assert(fel_parse_schedstat("1 2", &parsed) == EINVAL); - assert(fel_parse_schedstat("1 2 3 extra", &parsed) == EINVAL); - assert(fel_parse_schedstat("18446744073709551616 0 1", &parsed) == EOVERFLOW); - assert(fel_wait_delta(100, 90, true) == -1); - assert(fel_wait_delta(0, UINT64_MAX, true) == -1); - assert(fel_wait_delta(1, 2, false) == -1); - assert(fel_wait_delta(1, 2, true) == 1); - - struct aimagereader_vk_stable p = fresh(); - errno = EBUSY; - struct fel_wait_sample sample = fel_wait_probe_begin(&p); - assert(sample.active && errno == EBUSY && opens == 1 && closes == 1); - now_ns += 33000000; - cpu_ns += 1000000; - sched_text = "2000000 32000000 12\n"; - involuntary = 2; - poll_result = 1; - poll_events = POLLIN; - fel_wait_probe_end(&p, &sample); - assert(errno == EBUSY && opens == 2 && closes == 2 && polls == 2); - assert(strstr(message, "wall-us=33000 thread-cpu-us=1000 runqueue-us=30000")); - assert(strstr(message, "slices=2 voluntary=0 involuntary=2")); - assert(strstr(message, "acquire=1/2 probe-us=8")); - assert(!fel_wait_probe_begin(&p).active && opens == 2); - - // Active sleeping: low runqueue delay, voluntary switch, already-ready fd. - now_ns += 3000000000; - sample = fel_wait_probe_begin(&p); - now_ns += 33000000; cpu_ns += 1000000; - sched_text = "3000000 32010000 13\n"; - voluntary = 1; - fel_wait_probe_end(&p, &sample); - assert(strstr(message, "runqueue-us=10")); - assert(strstr(message, "voluntary=1 involuntary=0")); - assert(strstr(message, "acquire=2/2")); - - // Disabled paths perform no proc, rusage, fence or allocation operations. - unsigned previous = opens + polls + usages + allocations; - p = fresh(); p.android_fel = false; assert(!fel_wait_probe_begin(&p).active); - p = fresh(); p.fel_profile = false; assert(!fel_wait_probe_begin(&p).active); - p = fresh(); p.fel_profile_warm = false; assert(!fel_wait_probe_begin(&p).active); - p = fresh(); info_enabled = false; assert(!fel_wait_probe_begin(&p).active); - log_fel_descriptor_capabilities(&p); - info_enabled = true; - assert(previous == opens + polls + usages + allocations); - - // A denied interface stays unknown, is not reopened repeatedly, and never - // affects playback/fence ownership. A missing usage counter is independent. - p = fresh(); open_error = EACCES; usage_error = ENOSYS; - previous = opens; - sample = fel_wait_probe_begin(&p); now_ns += 35000000; - fel_wait_probe_end(&p, &sample); - assert(opens == previous + 1 && strstr(message, "runqueue-us=-1")); - assert(strstr(message, "voluntary=-1 involuntary=-1")); - now_ns += 3000000000; sample = fel_wait_probe_begin(&p); - fel_wait_probe_end(&p, &sample); assert(opens == previous + 1); - open_error = usage_error = 0; - p = fresh(); sched_text = "0 0 0\n"; - sample = fel_wait_probe_begin(&p); fel_wait_probe_end(&p, &sample); - assert(p.fel_sched_disabled_error == ENODATA && strstr(message, "runqueue-us=-1")); - - // Truncated proc input and regressing counters cannot become valid zeros. - char oversized[200]; memset(oversized, '1', sizeof(oversized) - 1); - oversized[sizeof(oversized) - 1] = 0; - p = fresh(); sched_text = oversized; - sample = fel_wait_probe_begin(&p); fel_wait_probe_end(&p, &sample); - assert(strstr(message, "runqueue-us=-1")); - p = fresh(); sched_text = "100 100 10"; - sample = fel_wait_probe_begin(&p); sched_text = "50 50 5"; - fel_wait_probe_end(&p, &sample); assert(strstr(message, "runqueue-us=-1")); - now_ns = 1; assert(fel_wait_probe_begin(&p).active); // Clock discontinuity. - - previous = closes; - assert(fel_probe_acquire_fence(-1) == 0); - poll_events = POLLERR; assert(fel_probe_acquire_fence(7) == -EINVAL); - poll_events = POLLNVAL; assert(fel_probe_acquire_fence(7) == -EINVAL); - poll_events = POLLHUP; assert(fel_probe_acquire_fence(7) == -EIO); - poll_error = EINTR; assert(fel_probe_acquire_fence(7) == -EINTR); - assert(closes == previous); // Never close or consume the observed sync fd. - - struct test_vulkan vk = {.get_proc_addr = get_proc, .api_version = VK_MAKE_VERSION(1, 1, 0)}; - p = fresh(); p.vk = &vk; - log_fel_descriptor_capabilities(&p); - assert(strstr(message, "push-supported=0 push-enabled=0")); - assert(strstr(message, "gpu=\"GPU?name\"")); - advertise_push = true; log_fel_descriptor_capabilities(&p); - assert(strstr(message, "push-supported=1 push-enabled=0")); - const char *enabled[] = {VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME}; - vk.extensions = enabled; vk.num_extensions = 1; - log_fel_descriptor_capabilities(&p); - assert(strstr(message, "push-supported=1 push-enabled=1")); - enumerate_available = false; log_fel_descriptor_capabilities(&p); - assert(strstr(message, "push-supported=-1")); - enumerate_available = true; oom = true; log_fel_descriptor_capabilities(&p); - assert(strstr(message, "push-supported=-1")); - oom = false; extension_count = 513; previous = allocations; - log_fel_descriptor_capabilities(&p); - assert(strstr(message, "push-supported=-1") && allocations == previous); - extension_count = 1; enum_result = VK_INCOMPLETE; advertise_push = false; - log_fel_descriptor_capabilities(&p); assert(strstr(message, "push-supported=-1")); - advertise_push = true; log_fel_descriptor_capabilities(&p); - assert(strstr(message, "push-supported=1")); - assert(reads == closes); - puts("PASS: FEL wait sampling, disabled/denied/invalid counters, fence ownership and Vulkan capabilities"); - return 0; -} - -#define gettid fake_gettid -#define open fake_open -#define read fake_read -#define close fake_close -#define poll fake_poll -#define getrusage fake_getrusage -#define calloc fake_calloc diff --git a/third_party/mpv-player-jni/tests/iso_cache_tests.gradle b/third_party/mpv-player-jni/tests/iso_cache_tests.gradle deleted file mode 100644 index 40d2c71770..0000000000 --- a/third_party/mpv-player-jni/tests/iso_cache_tests.gradle +++ /dev/null @@ -1,9 +0,0 @@ -// Host-side regression tests for the Java source feeding the ISO JNI callback. -// Usage: bash gradlew --init-script third_party/mpv-player-jni/tests/iso_cache_tests.gradle \ -// :app:testMobileArm64_v8aDebugUnitTest --tests 'com.fongmi.android.tv.player.iso.*' -gradle.beforeProject { project -> - project.plugins.withId('com.android.application') { - project.android.sourceSets.getByName('test').java.srcDir( - new File(project.rootDir, 'third_party/mpv-player-jni/tests/java')) - } -} diff --git a/third_party/mpv-player-jni/tests/java/androidx/media3/mpvplayer/IsoCacheTestSupport.java b/third_party/mpv-player-jni/tests/java/androidx/media3/mpvplayer/IsoCacheTestSupport.java deleted file mode 100644 index e1fbbb614c..0000000000 --- a/third_party/mpv-player-jni/tests/java/androidx/media3/mpvplayer/IsoCacheTestSupport.java +++ /dev/null @@ -1,13 +0,0 @@ -package androidx.media3.mpvplayer; - -import java.io.File; -import java.util.concurrent.atomic.AtomicLong; - -public final class IsoCacheTestSupport { - public static MpvHlsCacheCoordinator coordinator(File directory, boolean available) { - AtomicLong clock = new AtomicLong(System.currentTimeMillis()); - return new MpvHlsCacheCoordinator(directory, - ignored -> new MpvHlsCacheCoordinator.StorageFacts(available, 8L << 30, 10L << 30), - clock::incrementAndGet, 0, 300_000); - } -} diff --git a/third_party/mpv-player-jni/tests/java/com/fongmi/android/tv/player/iso/HttpRangeIsoSourceTest.java b/third_party/mpv-player-jni/tests/java/com/fongmi/android/tv/player/iso/HttpRangeIsoSourceTest.java deleted file mode 100644 index f7ce0d8e1c..0000000000 --- a/third_party/mpv-player-jni/tests/java/com/fongmi/android/tv/player/iso/HttpRangeIsoSourceTest.java +++ /dev/null @@ -1,209 +0,0 @@ -package com.fongmi.android.tv.player.iso; - -import org.junit.After; -import org.junit.Test; - -import java.io.IOException; -import java.util.Map; -import java.util.ArrayList; -import java.util.List; -import java.util.Arrays; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import mockwebserver3.MockResponse; -import mockwebserver3.MockWebServer; -import okhttp3.Call; -import okhttp3.EventListener; -import okhttp3.OkHttpClient; -import okhttp3.Response; -import okhttp3.MediaType; -import okhttp3.Protocol; -import okhttp3.ResponseBody; -import okio.Buffer; -import okio.BufferedSource; -import okio.Okio; -import okio.Source; -import okio.Timeout; - -import static org.junit.Assert.*; - -public class HttpRangeIsoSourceTest { - private final MockWebServer server = new MockWebServer(); - private final ExecutorService readers = Executors.newFixedThreadPool(2); - private HttpRangeIsoSource source; - - @After - public void tearDown() { - if (source != null) source.close(); - readers.shutdownNow(); - server.close(); - } - - @Test - public void closeCancelsTwoConcurrentResponseBodiesAfterHeaders() throws Exception { - CountDownLatch headers = new CountDownLatch(2); - OkHttpClient client = new OkHttpClient.Builder().eventListener(new EventListener() { - @Override public void responseHeadersEnd(Call call, Response response) { - if (!call.request().header("Range").equals("bytes=0-0")) headers.countDown(); - } - }).build(); - server.enqueue(range("bytes 0-0/16", "a", "one").build()); - // Identical ranges deliberately exercise two active Calls, not the page cache's dedup. - server.enqueue(range("bytes 1-1/16", "b", "one").bodyDelay(30, TimeUnit.SECONDS).build()); - server.enqueue(range("bytes 1-1/16", "b", "one").bodyDelay(30, TimeUnit.SECONDS).build()); - server.start(); - source = new HttpRangeIsoSource(server.url("/disc.iso").toString(), Map.of(), client); - assertEquals(16, source.length()); - Future first = readers.submit(() -> assertThrows(IOException.class, () -> source.readAt(1, new byte[1], 0, 1))); - Future second = readers.submit(() -> assertThrows(IOException.class, () -> source.readAt(1, new byte[1], 0, 1))); - assertTrue(headers.await(3, TimeUnit.SECONDS)); - source.close(); - first.get(3, TimeUnit.SECONDS); - second.get(3, TimeUnit.SECONDS); - } - - @Test - public void validatorChangeIsRejectedAndCallerRangeHeadersAreOverridden() throws Exception { - server.enqueue(range("bytes 0-0/16", "a", "one").build()); - server.enqueue(range("bytes 2-2/16", "c", "two").build()); - server.start(); - source = new HttpRangeIsoSource(server.url("/disc.iso").toString(), - Map.of("Range", "bytes=500-600", "Accept-Encoding", "gzip"), new OkHttpClient()); - assertEquals(16, source.length()); - assertEquals(IsoSourceException.Reason.SOURCE_CHANGED, - assertThrows(IsoSourceException.class, () -> source.readAt(2, new byte[1], 0, 1)).reason()); - assertEquals("bytes=0-0", server.takeRequest(3, TimeUnit.SECONDS).getHeaders().get("Range")); - assertEquals("identity", server.takeRequest(3, TimeUnit.SECONDS).getHeaders().get("Accept-Encoding")); - } - - @Test - public void ignoredRangeStillFailsWithoutReadingWholeIso() throws Exception { - server.enqueue(new MockResponse.Builder().code(200).body("not a range").build()); - server.start(); - source = new HttpRangeIsoSource(server.url("/disc.iso").toString(), Map.of(), new OkHttpClient()); - assertEquals(IsoSourceException.Reason.RANGE_UNSUPPORTED, - assertThrows(IsoSourceException.class, () -> source.length()).reason()); - } - - @Test - public void progressiveBodyPublishesBeforeCompletionAndCancelsOnlyItsOwnCall() throws Exception { - server.enqueue(range("bytes 0-0/32768", "a", "one").build()); - server.enqueue(range("bytes 0-32767/32768", "x".repeat(32768), "one") - .throttleBody(8192, 1, TimeUnit.SECONDS).build()); - server.enqueue(range("bytes 8-8/32768", "y", "one").build()); - server.start(); - source = new HttpRangeIsoSource(server.url("/disc.iso").toString(), Map.of(), new OkHttpClient()); - assertEquals(32768, source.length()); - CountDownLatch prefix = new CountDownLatch(1); - byte[] data = new byte[32768]; - RemoteIsoSource.ReadRequest request = new RemoteIsoSource.ReadRequest(count -> { - if (count >= 8192) prefix.countDown(); - }); - Future result = readers.submit(() -> assertThrows(IOException.class, - () -> source.readAt(0, data, 0, data.length, request))); - assertTrue(prefix.await(2, TimeUnit.SECONDS)); - assertFalse(result.isDone()); - assertEquals('x', data[0]); - request.cancel(); - result.get(2, TimeUnit.SECONDS); - assertEquals(1, source.readAt(8, new byte[1], 0, 1)); - assertEquals(3, server.getRequestCount()); - } - - @Test - public void progressiveRetryResumesAfterPublishedPrefixWithoutOverwritingIt() throws Exception { - List ranges = new ArrayList<>(); - AtomicBoolean interrupted = new AtomicBoolean(); - byte[] data = new byte[12]; - Arrays.fill(data, (byte) 77); - OkHttpClient client = new OkHttpClient.Builder().addInterceptor(chain -> { - String range = chain.request().header("Range"); - ranges.add(range); - String contentRange; - ResponseBody body; - if (range.equals("bytes=0-0")) { - contentRange = "bytes 0-0/16"; - body = ResponseBody.create("a", (MediaType) null); - } else if (!interrupted.getAndSet(true)) { - contentRange = "bytes 0-7/16"; - BufferedSource stream = Okio.buffer(new Source() { - boolean sent; - public long read(Buffer sink, long count) throws IOException { - if (sent) throw new IOException("connection reset after prefix"); - sent = true; - sink.writeUtf8("abcd"); - return 4; - } - public Timeout timeout() { return Timeout.NONE; } - public void close() {} - }); - body = new ResponseBody() { - public MediaType contentType() { return null; } - public long contentLength() { return 8; } - public BufferedSource source() { return stream; } - }; - } else { - assertEquals("bytes=4-7", range); - assertArrayEquals("abcd".getBytes(), Arrays.copyOfRange(data, 2, 6)); - contentRange = "bytes 4-7/16"; - body = ResponseBody.create("efgh", (MediaType) null); - } - return new Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1) - .code(206).message("Partial Content").header("Content-Range", contentRange) - .header("ETag", "one").body(body).build(); - }).build(); - source = new HttpRangeIsoSource("http://test.invalid/disc.iso", Map.of(), client); - List progress = new ArrayList<>(); - assertEquals(8, source.readAt(0, data, 2, 8, new RemoteIsoSource.ReadRequest(progress::add))); - assertEquals(List.of("bytes=0-0", "bytes=0-7", "bytes=4-7"), ranges); - assertEquals(List.of(4, 8), progress); - assertArrayEquals("abcdefgh".getBytes(), Arrays.copyOfRange(data, 2, 10)); - assertEquals(77, data[1]); - assertEquals(77, data[10]); - } - - @Test - public void progressiveValidatorChangePublishesNoUnvalidatedBytes() throws Exception { - server.enqueue(range("bytes 0-0/16", "a", "one").build()); - server.enqueue(range("bytes 2-2/16", "c", "two").build()); - server.start(); - source = new HttpRangeIsoSource(server.url("/disc.iso").toString(), Map.of(), new OkHttpClient()); - byte[] data = new byte[]{77}; - AtomicInteger progress = new AtomicInteger(); - assertEquals(IsoSourceException.Reason.SOURCE_CHANGED, assertThrows(IsoSourceException.class, - () -> source.readAt(2, data, 0, 1, new RemoteIsoSource.ReadRequest(progress::set))).reason()); - assertEquals(0, progress.get()); - assertEquals(77, data[0]); - } - - @Test - public void cancellationBeforeCallRegistrationStillCancelsNewlyAttachedCall() throws Exception { - AtomicInteger cancellations = new AtomicInteger(); - RemoteIsoSource.ReadRequest request = new RemoteIsoSource.ReadRequest(count -> fail("cancelled read published")); - request.cancel(); - request.attach(cancellations::incrementAndGet); - assertEquals(1, cancellations.get()); - assertThrows(IOException.class, () -> request.publish(1)); - } - - @Test - public void contentRangeBeyondSourceLengthIsRejectedBeforePublishing() throws Exception { - server.enqueue(range("bytes 0-0/16", "a", "one").build()); - server.enqueue(range("bytes 15-16/16", "xx", "one").build()); - server.start(); - source = new HttpRangeIsoSource(server.url("/disc.iso").toString(), Map.of(), new OkHttpClient()); - assertEquals(IsoSourceException.Reason.RANGE_INVALID, assertThrows(IsoSourceException.class, - () -> source.readAt(15, new byte[2], 0, 2, new RemoteIsoSource.ReadRequest(count -> fail("invalid range")))).reason()); - } - - private static MockResponse.Builder range(String range, String body, String validator) { - return new MockResponse.Builder().code(206).addHeader("Content-Range", range) - .addHeader("ETag", validator).body(body); - } -} diff --git a/third_party/mpv-player-jni/tests/java/com/fongmi/android/tv/player/iso/IsoPageCacheTest.java b/third_party/mpv-player-jni/tests/java/com/fongmi/android/tv/player/iso/IsoPageCacheTest.java deleted file mode 100644 index 9591d6900f..0000000000 --- a/third_party/mpv-player-jni/tests/java/com/fongmi/android/tv/player/iso/IsoPageCacheTest.java +++ /dev/null @@ -1,316 +0,0 @@ -package com.fongmi.android.tv.player.iso; - -import androidx.media3.mpvplayer.IsoCacheTestSupport; -import androidx.media3.mpvplayer.MpvHlsCacheCoordinator; - -import org.junit.After; -import org.junit.Test; - -import java.io.EOFException; -import java.io.File; -import java.io.IOException; -import java.lang.reflect.Field; -import java.nio.file.Files; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.Assert.*; - -public class IsoPageCacheTest { - private final List caches = new ArrayList<>(); - private final List stores = new ArrayList<>(); - private final List directories = new ArrayList<>(); - private final ExecutorService readers = Executors.newFixedThreadPool(3); - - @After - public void tearDown() { - for (IsoPageCache cache : caches) cache.close(); - for (IsoDiskPageStore store : stores) store.close(); - readers.shutdownNow(); - for (File directory : directories) { - File[] files = directory.listFiles(); - if (files != null) for (File file : files) file.delete(); - directory.delete(); - } - } - - @Test - public void thirtyRealSizePagesLoopWithoutNetworkAfterWarmupAndKeepEightInMemory() throws Exception { - int pageSize = IsoPageCache.DEFAULT_PAGE_SIZE; - FakeSource source = new FakeSource(pageSize, 40); - IsoDiskPageStore disk = disk(128L << 20, true); - IsoPageCache cache = cache(source, pageSize, 8, disk); - for (int loop = 0; loop < 4; loop++) { - for (int page = 0; page < 30; page++) { - byte[] result = new byte[1]; - assertEquals(1, cache.readAt((long) page * pageSize + 100, result, 0, 1)); - assertEquals((byte) page, result[0]); - } - } - for (int page = 0; page < 30; page++) assertEquals("page " + page, 1, source.count(page)); - Field pages = IsoPageCache.class.getDeclaredField("pages"); - pages.setAccessible(true); - assertEquals(8, ((Map) pages.get(cache)).size()); - } - - @Test - public void sequentialPartialReadsStartNextPageBeforeCurrentPageEnds() throws Exception { - CountDownLatch nextStarted = new CountDownLatch(1); - FakeSource source = new FakeSource(1024, 8); - source.hook = page -> { if (page == 1) nextStarted.countDown(); }; - IsoPageCache cache = cache(source, 1024, 8, null); - cache.readAt(0, new byte[128], 0, 128); - cache.readAt(128, new byte[128], 0, 128); - assertTrue(nextStarted.await(3, TimeUnit.SECONDS)); - assertEquals(1, source.count(1)); - } - - @Test - public void seekDiscardsQueuedHintsAndDoesNotWaitForOldPrefetch() throws Exception { - CountDownLatch started = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - CountDownLatch latest = new CountDownLatch(1); - FakeSource source = new FakeSource(1024, 30); - source.hook = page -> { - if (page == 1) { started.countDown(); await(release); } - if (page == 21) latest.countDown(); - }; - IsoPageCache cache = cache(source, 1024, 8, null); - cache.readAt(0, new byte[1024], 0, 1024); - assertTrue(started.await(3, TimeUnit.SECONDS)); - Future seek = readers.submit(() -> cache.readAt(10 * 1024L, new byte[1024], 0, 1024)); - assertEquals(1024, (int) seek.get(3, TimeUnit.SECONDS)); - cache.readAt(20 * 1024L, new byte[1024], 0, 1024); - release.countDown(); - assertTrue(latest.await(3, TimeUnit.SECONDS)); - assertEquals(0, source.count(11)); - } - - @Test - public void concurrentSamePageSharesOneSourceRead() throws Exception { - CountDownLatch started = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - FakeSource source = new FakeSource(1024, 4); - source.hook = page -> { started.countDown(); await(release); }; - IsoPageCache cache = cache(source, 1024, 8, null); - Future first = readers.submit(() -> cache.readAt(0, new byte[1], 0, 1)); - assertTrue(started.await(3, TimeUnit.SECONDS)); - Future second = readers.submit(() -> cache.readAt(0, new byte[1], 0, 1)); - release.countDown(); - assertEquals(1, (int) first.get(3, TimeUnit.SECONDS)); - assertEquals(1, (int) second.get(3, TimeUnit.SECONDS)); - assertEquals(1, source.count(0)); - } - - @Test - public void navigationPrefetchHasTwoWorkersAndFourPageHorizon() throws Exception { - CountDownLatch started = new CountDownLatch(2); - CountDownLatch release = new CountDownLatch(1); - CountDownLatch horizon = new CountDownLatch(4); - FakeSource source = new FakeSource(1024, 30); - source.hook = page -> { - if (page == 1 || page == 2) { started.countDown(); await(release); } - if (page >= 1 && page <= 4) horizon.countDown(); - }; - IsoPageCache cache = cache(source, 1024, 8, disk(32 * 1024, true)); - cache.readAt(0, new byte[1024], 0, 1024); - assertTrue("both Range reads must start without waiting for the other", started.await(3, TimeUnit.SECONDS)); - assertEquals(0, source.count(3)); - release.countDown(); - assertTrue(horizon.await(3, TimeUnit.SECONDS)); - cache.close(); - assertEquals(0, source.count(5)); - for (int page = 0; page <= 4; page++) assertEquals(1, source.count(page)); - } - - @Test - public void navigationSeekReplacesBothWorkersQueuedHorizon() throws Exception { - CountDownLatch started = new CountDownLatch(2); - CountDownLatch release = new CountDownLatch(1); - CountDownLatch latest = new CountDownLatch(2); - FakeSource source = new FakeSource(1024, 40); - source.hook = page -> { - if (page == 1 || page == 2) { started.countDown(); await(release); } - if (page == 21 || page == 22) latest.countDown(); - }; - IsoPageCache cache = cache(source, 1024, 8, disk(64 * 1024, true)); - cache.readAt(0, new byte[1024], 0, 1024); - assertTrue(started.await(3, TimeUnit.SECONDS)); - cache.readAt(20 * 1024L, new byte[1024], 0, 1024); - release.countDown(); - assertTrue(latest.await(3, TimeUnit.SECONDS)); - cache.close(); - assertEquals(0, source.count(3)); - assertEquals(0, source.count(4)); - } - - @Test - public void closeUnblocksOwnersAndWaitersAndRejectsCacheHits() throws Exception { - CountDownLatch started = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - FakeSource source = new FakeSource(1024, 4); - source.hook = page -> { started.countDown(); await(release); }; - source.onClose = release::countDown; - IsoPageCache cache = cache(source, 1024, 8, null); - Future first = readers.submit(() -> assertThrows(IOException.class, () -> cache.readAt(0, new byte[1], 0, 1))); - assertTrue(started.await(3, TimeUnit.SECONDS)); - Future second = readers.submit(() -> assertThrows(IOException.class, () -> cache.readAt(0, new byte[1], 0, 1))); - cache.close(); - first.get(3, TimeUnit.SECONDS); - second.get(3, TimeUnit.SECONDS); - assertThrows(IOException.class, () -> cache.readAt(0, new byte[1], 0, 1)); - } - - @Test - public void shortReadsAreCompletedAndTruncatedPagesAreNeverCached() throws Exception { - FakeSource source = new FakeSource(1024, 4); - source.readLimit = 100; - IsoPageCache cache = cache(source, 1024, 8, null); - assertEquals(1, cache.readAt(7, new byte[1], 0, 1)); - assertEquals(11, source.count(0)); - source.readLimit = 0; - assertThrows(EOFException.class, () -> cache.readAt(1024, new byte[1], 0, 1)); - source.readLimit = 1024; - assertEquals(1, cache.readAt(1024, new byte[1], 0, 1)); - assertEquals(2, source.count(1)); - } - - @Test - public void changedSourceInvalidatesMemoryAndDisk() throws Exception { - FakeSource source = new FakeSource(1024, 4); - IsoDiskPageStore disk = disk(16 * 1024, true); - IsoPageCache cache = cache(source, 1024, 8, disk); - cache.readAt(0, new byte[1], 0, 1); - source.hook = page -> { throw new IsoSourceException(IsoSourceException.Reason.SOURCE_CHANGED, "changed"); }; - assertEquals(IsoSourceException.Reason.SOURCE_CHANGED, - assertThrows(IsoSourceException.class, () -> cache.readAt(1024, new byte[1], 0, 1)).reason()); - assertEquals(IsoSourceException.Reason.SOURCE_CHANGED, - assertThrows(IsoSourceException.class, () -> cache.readAt(0, new byte[1], 0, 1)).reason()); - assertNull(disk.read(0, 1024)); - } - - @Test - public void diskBudgetIsSharedAndCloseDoesNotDeleteOtherSessions() throws Exception { - File directory = directory(); - long budget = 3 * (4096 + 256); - MpvHlsCacheCoordinator coordinator = IsoCacheTestSupport.coordinator(directory, true); - IsoDiskPageStore first = new IsoDiskPageStore(directory, budget, coordinator); - IsoDiskPageStore second = new IsoDiskPageStore(directory, budget, coordinator); - stores.add(first); - stores.add(second); - for (int page = 0; page < 6; page++) first.write(page, new byte[4096], false); - byte[] different = new byte[4096]; - Arrays.fill(different, (byte) 9); - second.write(5, different, false); - assertTrue(coordinator.cacheBytes() <= budget); - assertArrayEquals(different, second.read(5, 4096)); - first.close(); - assertArrayEquals(different, second.read(5, 4096)); - second.close(); - assertEquals(0, coordinator.cacheBytes()); - } - - @Test - public void missingOrTruncatedDiskPageFallsBackToSource() throws Exception { - FakeSource source = new FakeSource(1024, 8); - IsoDiskPageStore disk = disk(32 * 1024, true); - IsoPageCache cache = cache(source, 1024, 1, disk); - cache.readAt(0, new byte[1], 0, 1); - cache.readAt(1024, new byte[1], 0, 1); - File page = Arrays.stream(directories.get(0).listFiles()) - .filter(file -> file.getName().endsWith("-0.bin")).findFirst().orElseThrow(); - Files.write(page.toPath(), new byte[3]); - cache.readAt(0, new byte[1], 0, 1); - assertEquals(2, source.count(0)); - cache.readAt(1024, new byte[1], 0, 1); - assertTrue(page.delete()); - cache.readAt(0, new byte[1], 0, 1); - assertEquals(3, source.count(0)); - } - - @Test - public void unavailableStorageFallsBackToBoundedMemoryAndNetwork() throws Exception { - FakeSource source = new FakeSource(1024, 8); - IsoPageCache cache = cache(source, 1024, 1, disk(32 * 1024, false)); - cache.readAt(0, new byte[1], 0, 1); - cache.readAt(1024, new byte[1], 0, 1); - cache.readAt(0, new byte[1], 0, 1); - assertEquals(2, source.count(0)); - assertEquals(0, directories.get(0).listFiles().length); - } - - @Test - public void eofAndInvalidBoundsDoNotIssueRequests() throws Exception { - FakeSource source = new FakeSource(1024, 2); - IsoPageCache cache = cache(source, 1024, 8, null); - assertEquals(0, cache.readAt(2048, new byte[1], 0, 1)); - assertEquals(0, cache.readAt(0, new byte[1], 0, 0)); - assertThrows(IndexOutOfBoundsException.class, () -> cache.readAt(-1, new byte[1], 0, 1)); - assertThrows(IndexOutOfBoundsException.class, () -> cache.readAt(0, new byte[1], Integer.MAX_VALUE, 1)); - assertTrue(source.reads.isEmpty()); - } - - private File directory() throws IOException { - File directory = Files.createTempDirectory("p9-iso-cache-").toFile(); - directories.add(directory); - return directory; - } - - private IsoDiskPageStore disk(long capacity, boolean available) throws IOException { - File directory = directory(); - IsoDiskPageStore store = new IsoDiskPageStore(directory, capacity, IsoCacheTestSupport.coordinator(directory, available)); - stores.add(store); - return store; - } - - private IsoPageCache cache(FakeSource source, int pageSize, int maxPages, IsoDiskPageStore disk) { - IsoPageCache cache = new IsoPageCache(source, pageSize, maxPages, disk); - caches.add(cache); - return cache; - } - - private static void await(CountDownLatch latch) throws IOException { - try { - if (!latch.await(5, TimeUnit.SECONDS)) throw new IOException("Test read not released"); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new IOException(error); - } - } - - private interface Hook { void read(long page) throws IOException; } - - private static final class FakeSource implements RemoteIsoSource { - final int pageSize; - final long total; - final Map reads = new ConcurrentHashMap<>(); - volatile Hook hook = page -> {}; - volatile Runnable onClose = () -> {}; - volatile int readLimit = Integer.MAX_VALUE; - volatile boolean closed; - - FakeSource(int pageSize, int pages) { this.pageSize = pageSize; total = (long) pageSize * pages; } - int count(long page) { return reads.getOrDefault(page, new AtomicInteger()).get(); } - public long length() { return total; } - public String validator() { return "test"; } - public void close() { closed = true; onClose.run(); } - public int readAt(long offset, byte[] buffer, int bufferOffset, int length) throws IOException { - long page = offset / pageSize; - reads.computeIfAbsent(page, ignored -> new AtomicInteger()).incrementAndGet(); - hook.read(page); - if (closed) throw new IOException("closed"); - int count = (int) Math.min(Math.min(length, readLimit), total - offset); - Arrays.fill(buffer, bufferOffset, bufferOffset + count, (byte) page); - return count; - } - } -} diff --git a/third_party/mpv-player-jni/tests/java/com/fongmi/android/tv/player/iso/ProgressiveIsoPageCacheTest.java b/third_party/mpv-player-jni/tests/java/com/fongmi/android/tv/player/iso/ProgressiveIsoPageCacheTest.java deleted file mode 100644 index af9169585e..0000000000 --- a/third_party/mpv-player-jni/tests/java/com/fongmi/android/tv/player/iso/ProgressiveIsoPageCacheTest.java +++ /dev/null @@ -1,448 +0,0 @@ -package com.fongmi.android.tv.player.iso; - -import androidx.media3.mpvplayer.IsoCacheTestSupport; - -import org.junit.After; -import org.junit.Test; - -import java.io.EOFException; -import java.io.File; -import java.io.IOException; -import java.lang.reflect.Field; -import java.nio.file.Files; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -import static org.junit.Assert.*; - -public class ProgressiveIsoPageCacheTest { - private final List caches = new ArrayList<>(); - private final List directories = new ArrayList<>(); - private final ExecutorService readers = Executors.newFixedThreadPool(5); - - @After - public void tearDown() throws Exception { - for (ProgressiveIsoPageCache cache : caches) cache.close(); - readers.shutdownNow(); - assertTrue(readers.awaitTermination(5, TimeUnit.SECONDS)); - for (File directory : directories) { - File[] files = directory.listFiles(); - if (files != null) for (File file : files) file.delete(); - directory.delete(); - } - } - - @Test - public void smallDemandReturnsBeforeRestOfFourMiBAndOnlyCompletePageReachesDisk() throws Exception { - int size = IsoPageCache.DEFAULT_PAGE_SIZE; - FakeSource source = new FakeSource(size, 2); - CountDownLatch prefix = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - source.hook = read -> { - read.request.attach(release::countDown); - read.publish(64 * 1024); - prefix.countDown(); - await(release); - read.request.checkCancelled(); - read.publish(read.length); - }; - IsoDiskPageStore disk = disk(16L << 20, true); - ProgressiveIsoPageCache cache = cache(source, size, 8, disk); - byte[] first = new byte[2048]; - Future result = readers.submit(() -> cache.readAt(0, first, 0, first.length)); - assertTrue(prefix.await(3, TimeUnit.SECONDS)); - assertEquals(first.length, (int) result.get(1, TimeUnit.SECONDS)); - assertBytes(0, first); - assertNull("partial pages must never become disk cache hits", disk.read(0, size)); - byte[] second = new byte[1024]; - assertEquals(second.length, cache.readAt(4096, second, 0, second.length)); - assertBytes(4096, second); - assertEquals(1, source.reads.size()); - release.countDown(); - awaitIdle(cache); - assertNotNull(disk.read(0, size)); - assertEquals(size, source.bytes.get()); - } - - @Test - public void coldPageTailStartsNearDemandAndDoesNotWaitForEarlierMegabytes() throws Exception { - int size = IsoPageCache.DEFAULT_PAGE_SIZE; - FakeSource source = new FakeSource(size, 1); - CountDownLatch earlierStarted = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - source.hook = read -> { - if (read.offset == 0) { - read.request.attach(release::countDown); - earlierStarted.countDown(); - await(release); - read.request.checkCancelled(); - } - read.publish(read.length); - }; - ProgressiveIsoPageCache cache = cache(source, size, 1, null); - byte[] data = new byte[2048]; - Future result = readers.submit(() -> cache.readAt(size - 4096L, data, 0, data.length)); - assertEquals(data.length, (int) result.get(1, TimeUnit.SECONDS)); - assertTrue(earlierStarted.await(3, TimeUnit.SECONDS)); - assertEquals(size - 64 * 1024L, source.reads.get(0).offset); - assertBytes(size - 4096L, data); - release.countDown(); - awaitIdle(cache); - assertEquals("no duplicate bytes when filling the earlier gap", size, source.bytes.get()); - } - - @Test - public void concurrentSamePageReadersShareProgressAndOneRequest() throws Exception { - FakeSource source = new FakeSource(1024, 1); - CountDownLatch started = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - source.hook = read -> { - read.request.attach(release::countDown); - read.publish(512); - started.countDown(); - await(release); - read.request.checkCancelled(); - read.publish(read.length); - }; - ProgressiveIsoPageCache cache = cache(source, 1024, 8, null); - Future first = readers.submit(() -> cache.readAt(0, new byte[32], 0, 32)); - assertTrue(started.await(3, TimeUnit.SECONDS)); - Future second = readers.submit(() -> cache.readAt(128, new byte[32], 0, 32)); - assertEquals(32, (int) first.get(1, TimeUnit.SECONDS)); - assertEquals(32, (int) second.get(1, TimeUnit.SECONDS)); - assertEquals(1, source.reads.size()); - release.countDown(); - } - - @Test - public void demandCanConsumeAnInFlightPrefetchBeforeItsPageEnds() throws Exception { - FakeSource source = new FakeSource(1024, 8); - CountDownLatch prefix = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - source.hook = read -> { - if (read.offset == 1024) { - read.request.attach(release::countDown); - read.publish(128); - prefix.countDown(); - await(release); - read.request.checkCancelled(); - } - read.publish(read.length); - }; - ProgressiveIsoPageCache cache = cache(source, 1024, 8, null); - cache.readAt(0, new byte[1024], 0, 1024); - assertTrue(prefix.await(3, TimeUnit.SECONDS)); - Future result = readers.submit(() -> cache.readAt(1024, new byte[32], 0, 32)); - assertEquals(32, (int) result.get(1, TimeUnit.SECONDS)); - assertEquals(1, source.count(1)); - assertTrue(source.peak.get() <= 3); - release.countDown(); - } - - @Test - public void seekCancelsObsoleteBodiesAndQueuedHorizonWithoutClosingSource() throws Exception { - FakeSource source = new FakeSource(1024, 40); - CountDownLatch started = new CountDownLatch(3); - AtomicInteger cancelled = new AtomicInteger(); - source.hook = read -> { - long page = read.offset / 1024; - if (page >= 1 && page <= 3) { - CountDownLatch release = new CountDownLatch(1); - read.request.attach(() -> { cancelled.incrementAndGet(); release.countDown(); }); - started.countDown(); - await(release); - read.request.checkCancelled(); - } - read.publish(read.length); - }; - ProgressiveIsoPageCache cache = cache(source, 1024, 8, null); - cache.readAt(0, new byte[1024], 0, 1024); - assertTrue(started.await(3, TimeUnit.SECONDS)); - byte[] data = new byte[32]; - Future seek = readers.submit(() -> cache.readAt(20 * 1024L, data, 0, data.length)); - assertEquals(data.length, (int) seek.get(2, TimeUnit.SECONDS)); - assertBytes(20 * 1024L, data); - assertEquals(3, cancelled.get()); - assertEquals(0, source.count(4)); - assertFalse(source.closed); - assertTrue(source.peak.get() <= 3); - } - - @Test - public void seekDoesNotCancelAnotherActiveDemandReader() throws Exception { - FakeSource source = new FakeSource(1024, 20); - CountDownLatch started = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - AtomicInteger cancelled = new AtomicInteger(); - source.hook = read -> { - if (read.offset == 0) { - read.request.attach(() -> { cancelled.incrementAndGet(); release.countDown(); }); - started.countDown(); - await(release); - read.request.checkCancelled(); - } - read.publish(read.length); - }; - ProgressiveIsoPageCache cache = cache(source, 1024, 8, null); - Future first = readers.submit(() -> cache.readAt(0, new byte[16], 0, 16)); - assertTrue(started.await(3, TimeUnit.SECONDS)); - assertEquals(16, cache.readAt(10 * 1024L, new byte[16], 0, 16)); - assertEquals(0, cancelled.get()); - release.countDown(); - assertEquals(16, (int) first.get(2, TimeUnit.SECONDS)); - } - - @Test - public void cancelledPartialPageRetainsValidatedBytesAndResumesOnlyTheGap() throws Exception { - FakeSource source = new FakeSource(1024, 10); - CountDownLatch prefix = new CountDownLatch(1); - source.hook = read -> { - if (read.offset == 0) { - CountDownLatch release = new CountDownLatch(1); - read.request.attach(release::countDown); - read.publish(128); - prefix.countDown(); - await(release); - read.request.checkCancelled(); - } - read.publish(read.length); - }; - ProgressiveIsoPageCache cache = cache(source, 1024, 8, null); - assertEquals(32, cache.readAt(0, new byte[32], 0, 32)); - assertTrue(prefix.await(3, TimeUnit.SECONDS)); - cache.readAt(5 * 1024L, new byte[32], 0, 32); - source.hook = read -> read.publish(read.length); - byte[] all = new byte[1024]; - assertEquals(1024, cache.readAt(0, all, 0, all.length)); - assertBytes(0, all); - assertEquals(2, source.count(0)); - assertTrue(source.reads.stream().anyMatch(read -> read.offset == 128)); - } - - @Test - public void closeUnblocksAllLoadsAndSamePageWaitersAndRejectsHits() throws Exception { - FakeSource source = new FakeSource(1024, 40); - CountDownLatch started = new CountDownLatch(3); - source.hook = read -> { - CountDownLatch release = new CountDownLatch(1); - read.request.attach(release::countDown); - started.countDown(); - await(release); - read.request.checkCancelled(); - }; - ProgressiveIsoPageCache cache = cache(source, 1024, 8, null); - List> results = new ArrayList<>(); - for (int page : new int[]{0, 10, 20, 0}) { - results.add(readers.submit(() -> assertThrows(IOException.class, - () -> cache.readAt(page * 1024L, new byte[32], 0, 32)))); - } - assertTrue(started.await(3, TimeUnit.SECONDS)); - cache.close(); - for (Future result : results) result.get(2, TimeUnit.SECONDS); - assertThrows(IOException.class, () -> cache.readAt(0, new byte[1], 0, 1)); - assertTrue(source.closed); - } - - @Test - public void shortAndFailedReadsNeverPublishAnIncompleteDiskPage() throws Exception { - FakeSource source = new FakeSource(1024, 1); - source.hook = read -> { read.publish(32); throw new EOFException("truncated"); }; - IsoDiskPageStore disk = disk(16 * 1024, true); - ProgressiveIsoPageCache cache = cache(source, 1024, 8, disk); - assertThrows(EOFException.class, () -> cache.readAt(0, new byte[1024], 0, 1024)); - assertNull(disk.read(0, 1024)); - source.hook = read -> read.publish(Math.min(100, read.length)); - byte[] data = new byte[1024]; - assertEquals(data.length, cache.readAt(0, data, 0, data.length)); - assertBytes(0, data); - awaitIdle(cache); - assertArrayEquals(data, disk.read(0, 1024)); - assertEquals(1024, source.bytes.get()); - } - - @Test - public void sourceChangeClosesSessionAndInvalidatesMemoryAndDisk() throws Exception { - FakeSource source = new FakeSource(1024, 4); - IsoDiskPageStore disk = disk(16 * 1024, true); - ProgressiveIsoPageCache cache = cache(source, 1024, 8, disk); - cache.readAt(0, new byte[16], 0, 16); - awaitIdle(cache); - source.hook = read -> { throw new IsoSourceException(IsoSourceException.Reason.SOURCE_CHANGED, "changed"); }; - assertEquals(IsoSourceException.Reason.SOURCE_CHANGED, - assertThrows(IsoSourceException.class, () -> cache.readAt(2048, new byte[1], 0, 1)).reason()); - assertEquals(IsoSourceException.Reason.SOURCE_CHANGED, - assertThrows(IsoSourceException.class, () -> cache.readAt(0, new byte[1], 0, 1)).reason()); - assertNull(disk.read(0, 1024)); - } - - @Test - public void thirtyRealPagesLoopFromDiskWithoutMoreNetworkAndKeepEightInMemory() throws Exception { - int size = IsoPageCache.DEFAULT_PAGE_SIZE; - FakeSource source = new FakeSource(size, 30); - ProgressiveIsoPageCache cache = cache(source, size, 8, disk(128L << 20, true)); - byte[] data = new byte[size]; - for (int loop = 0; loop < 3; loop++) { - for (int page = 0; page < 30; page++) { - assertEquals(size, cache.readAt((long) page * size, data, 0, size)); - assertEquals(value((long) page * size), data[0]); - assertEquals(value((long) (page + 1) * size - 1), data[size - 1]); - } - awaitIdle(cache); - } - for (int page = 0; page < 30; page++) assertEquals("page " + page, 1, source.count(page)); - assertEquals(30L * size, source.bytes.get()); - assertTrue(source.peak.get() <= 3); - assertEquals(8, map(cache, "pages").size()); - } - - @Test - public void missingDiskOrStorageFallsBackWithoutChangingByteContents() throws Exception { - FakeSource source = new FakeSource(1024, 4); - ProgressiveIsoPageCache cache = cache(source, 1024, 1, disk(16 * 1024, false)); - byte[] data = new byte[1024]; - cache.readAt(0, data, 0, data.length); - awaitIdle(cache); - cache.readAt(1024, data, 0, data.length); - awaitIdle(cache); - cache.readAt(0, data, 0, data.length); - awaitIdle(cache); - assertBytes(0, data); - assertEquals(2, source.count(0)); - assertEquals(1, map(cache, "pages").size()); - } - - @Test - public void eofBoundsAndFinalShortPageDoNotExposeUnwrittenBytes() throws Exception { - FakeSource source = new FakeSource(1024, 2); - source.total = 1300; - ProgressiveIsoPageCache cache = cache(source, 1024, 1, null); - assertEquals(0, cache.readAt(1300, new byte[1], 0, 1)); - assertEquals(0, cache.readAt(0, new byte[1], 0, 0)); - assertThrows(IndexOutOfBoundsException.class, () -> cache.readAt(-1, new byte[1], 0, 1)); - assertThrows(IndexOutOfBoundsException.class, () -> cache.readAt(0, new byte[1], Integer.MAX_VALUE, 1)); - assertTrue(source.reads.isEmpty()); - byte[] data = new byte[300]; - Arrays.fill(data, (byte) 77); - assertEquals(276, cache.readAt(1024, data, 0, 300)); - assertBytes(1024, Arrays.copyOf(data, 276)); - assertEquals(77, data[276]); - } - - private ProgressiveIsoPageCache cache(FakeSource source, int size, int count, IsoDiskPageStore disk) { - ProgressiveIsoPageCache cache = new ProgressiveIsoPageCache(source, size, count, disk); - caches.add(cache); - return cache; - } - - private IsoDiskPageStore disk(long capacity, boolean available) throws IOException { - File directory = Files.createTempDirectory("p9-progressive-test-").toFile(); - directories.add(directory); - return new IsoDiskPageStore(directory, capacity, IsoCacheTestSupport.coordinator(directory, available)); - } - - private static Map map(ProgressiveIsoPageCache cache, String name) throws Exception { - Field field = ProgressiveIsoPageCache.class.getDeclaredField(name); - field.setAccessible(true); - return (Map) field.get(cache); - } - - private static void awaitIdle(ProgressiveIsoPageCache cache) throws Exception { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); - synchronized (cache) { - while (!map(cache, "loads").isEmpty()) { - long left = deadline - System.nanoTime(); - assertTrue("cache load did not finish", left > 0); - TimeUnit.NANOSECONDS.timedWait(cache, left); - } - } - } - - private static void await(CountDownLatch latch) throws IOException { - try { - if (!latch.await(5, TimeUnit.SECONDS)) throw new IOException("test source timed out"); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException(e); - } - } - - private static byte value(long offset) { return (byte) (offset * 31 + offset / 1024); } - - private static void assertBytes(long offset, byte[] bytes) { - for (int i = 0; i < bytes.length; i++) assertEquals("byte " + i, value(offset + i), bytes[i]); - } - - private interface Hook { void read(Read read) throws IOException; } - - private static final class Read { - final long offset; - final int length; - final byte[] buffer; - final int bufferOffset; - final RemoteIsoSource.ReadRequest request; - final AtomicLong bytes; - int written; - - Read(long offset, byte[] buffer, int bufferOffset, int length, - RemoteIsoSource.ReadRequest request, AtomicLong bytes) { - this.offset = offset; - this.buffer = buffer; - this.bufferOffset = bufferOffset; - this.length = length; - this.request = request; - this.bytes = bytes; - } - - void publish(int count) throws IOException { - request.checkCancelled(); - for (int i = written; i < count; i++) buffer[bufferOffset + i] = value(offset + i); - bytes.addAndGet(count - written); - written = count; - request.publish(count); - } - } - - private static final class FakeSource implements RemoteIsoSource { - final int pageSize; - final List reads = new CopyOnWriteArrayList<>(); - final AtomicInteger active = new AtomicInteger(); - final AtomicInteger peak = new AtomicInteger(); - final AtomicLong bytes = new AtomicLong(); - volatile long total; - volatile boolean closed; - volatile Hook hook = read -> read.publish(read.length); - - FakeSource(int size, int pages) { pageSize = size; total = (long) size * pages; } - long count(int page) { return reads.stream().filter(read -> read.offset / pageSize == page).count(); } - public long length() { return total; } - public String validator() { return "test"; } - public void close() { closed = true; for (Read read : reads) read.request.cancel(); } - public int readAt(long offset, byte[] buffer, int bufferOffset, int length) throws IOException { - return readAt(offset, buffer, bufferOffset, length, new ReadRequest(count -> {})); - } - public int readAt(long offset, byte[] buffer, int bufferOffset, int length, ReadRequest request) throws IOException { - if (closed) throw new IOException("closed"); - Read read = new Read(offset, buffer, bufferOffset, length, request, bytes); - reads.add(read); - peak.accumulateAndGet(active.incrementAndGet(), Math::max); - try { - hook.read(read); - request.checkCancelled(); - return read.written; - } finally { - request.detach(); - active.decrementAndGet(); - } - } - } -} diff --git a/third_party/mpv-player-jni/tests/mediacodec_output_serialization_test.c b/third_party/mpv-player-jni/tests/mediacodec_output_serialization_test.c deleted file mode 100644 index 9ef8c20cbe..0000000000 --- a/third_party/mpv-player-jni/tests/mediacodec_output_serialization_test.c +++ /dev/null @@ -1,171 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define AVERROR(e) (-(e)) -#define AVERROR_EXTERNAL (-999) -#define AV_LOG_DEBUG 1 -#define AV_LOG_ERROR 2 -#define av_log(ctx, level, ...) do { (void)(ctx); (void)(level); if (false) fprintf(stderr, __VA_ARGS__); } while (0) -#define ff_mutex_lock pthread_mutex_lock -#define ff_mutex_unlock pthread_mutex_unlock -typedef struct { int unused; } AVCodecContext; -typedef struct MediaCodecDecContext MediaCodecDecContext; -typedef struct { MediaCodecDecContext *owner; } FFAMediaCodec; -struct MediaCodecDecContext { - atomic_int refcount, hw_buffer_count, serial; - pthread_mutex_t output_mutex; - bool output_mutex_initialized, delay_flush; - FFAMediaCodec *codec; - int output_buffer_count, draining, flushing, eos, current_input_buffer; - void *hdr10_plus_metadata; -}; -typedef struct { - MediaCodecDecContext *ctx; - atomic_int released; - int serial; - ssize_t index; - int64_t pts; -} AVMediaCodecBuffer; -static atomic_int platform_active, release_calls, flush_calls, stop_calls; -static int platform_result; -static void enter_platform(FFAMediaCodec *codec) -{ - // Both validity checks and the actual platform operation must hold one lock. - assert(pthread_mutex_trylock(&codec->owner->output_mutex) == EBUSY); - assert(atomic_fetch_add(&platform_active, 1) == 0); - sched_yield(); // Widen the race with the decoder/VO worker. -} -static int leave_platform(void) -{ - assert(atomic_fetch_sub(&platform_active, 1) == 1); - return platform_result; -} -static int ff_AMediaCodec_releaseOutputBuffer(FFAMediaCodec *codec, ssize_t index, int render) -{ - (void)index; (void)render; - enter_platform(codec); - atomic_fetch_add(&release_calls, 1); - return leave_platform(); -} -static int ff_AMediaCodec_releaseOutputBufferAtTime(FFAMediaCodec *codec, ssize_t index, int64_t time) -{ - (void)time; - return ff_AMediaCodec_releaseOutputBuffer(codec, index, 1); -} -static int ff_AMediaCodec_flush(FFAMediaCodec *codec) -{ - enter_platform(codec); - atomic_fetch_add(&flush_calls, 1); - return leave_platform(); -} -static int ff_AMediaCodec_stop(FFAMediaCodec *codec) -{ - enter_platform(codec); - atomic_fetch_add(&stop_calls, 1); - return leave_platform(); -} -static void mediacodec_packet_props_clear(MediaCodecDecContext *ctx) { (void)ctx; } -static void av_buffer_unref(void **buffer) { *buffer = NULL; } -static void av_freep(void *address) { void **value = address; free(*value); *value = NULL; } -// The context is stack-owned by this fixture; track the real release ordering. -static void ff_mediacodec_dec_unref(MediaCodecDecContext *ctx) -{ - assert(atomic_fetch_sub(&ctx->refcount, 1) > 0); -} -#include "mediacodec_release_under_test.h" -#include "mediacodec_lifecycle_under_test.h" - -struct worker { AVMediaCodecBuffer *buffer; MediaCodecDecContext *ctx; int mode; bool close; }; -static void *release_worker(void *arg) -{ - struct worker *work = arg; - if (work->mode == 0) assert(av_mediacodec_release_buffer_status(work->buffer, 1) >= 0); - if (work->mode == 1) assert(av_mediacodec_render_buffer_at_time(work->buffer, 1234) == 0); - if (work->mode == 2) mediacodec_buffer_release(work->buffer, NULL); - return NULL; -} -static void *lifecycle_worker(void *arg) -{ - struct worker *work = arg; - if (work->close) assert(ff_mediacodec_dec_close(NULL, work->ctx) == 0); - else assert(mediacodec_dec_flush_codec(NULL, work->ctx) == 0); - return NULL; -} -static AVMediaCodecBuffer *setup(MediaCodecDecContext *ctx, FFAMediaCodec *codec) -{ - *ctx = (MediaCodecDecContext){.output_mutex_initialized = true, .codec = codec}; - atomic_init(&ctx->refcount, 2); // Decoder and one external frame. - atomic_init(&ctx->hw_buffer_count, 1); - atomic_init(&ctx->serial, 1); - assert(!pthread_mutex_init(&ctx->output_mutex, NULL)); - codec->owner = ctx; - AVMediaCodecBuffer *buffer = calloc(1, sizeof(*buffer)); - assert(buffer); - buffer->ctx = ctx; - buffer->serial = 1; - atomic_init(&buffer->released, 0); - atomic_store(&release_calls, 0); - atomic_store(&flush_calls, 0); - atomic_store(&stop_calls, 0); - return buffer; -} -int main(void) -{ - alarm(20); // A stuck mutex is a failed test, not an unbounded test runner. - for (int close = 0; close < 2; close++) { - for (int mode = 0; mode < 3; mode++) { - for (int round = 0; round < 80; round++) { - MediaCodecDecContext ctx; - FFAMediaCodec codec; - AVMediaCodecBuffer *buffer = setup(&ctx, &codec); - struct worker work = {buffer, &ctx, mode, close}; - pthread_t release, lifecycle; - assert(!pthread_create(&release, NULL, release_worker, &work)); - assert(!pthread_create(&lifecycle, NULL, lifecycle_worker, &work)); - assert(!pthread_join(release, NULL)); - assert(!pthread_join(lifecycle, NULL)); - assert(atomic_load(&platform_active) == 0); - assert(atomic_load(&release_calls) <= 1); - assert(atomic_load(&ctx.hw_buffer_count) == 0); - if (!close) assert(atomic_load(&flush_calls) == 1 && atomic_load(&ctx.serial) == 2); - if (mode != 2) { - // Invalid/duplicate buffers are discarded, never resubmitted. - int calls = atomic_load(&release_calls); - assert(av_mediacodec_release_buffer_status(buffer, 1) == 0); - mediacodec_buffer_release(buffer, NULL); - assert(atomic_load(&release_calls) == calls); - } - if (!close) assert(ff_mediacodec_dec_close(NULL, &ctx) == 0); - assert(atomic_load(&ctx.refcount) == 0); - assert(!pthread_mutex_destroy(&ctx.output_mutex)); - } - } - } - MediaCodecDecContext ctx; - FFAMediaCodec codec; - AVMediaCodecBuffer *buffer = setup(&ctx, &codec); - platform_result = -1; - assert(av_mediacodec_release_buffer_status(buffer, 1) == -1); - assert(!pthread_mutex_trylock(&ctx.output_mutex)); - pthread_mutex_unlock(&ctx.output_mutex); - assert(mediacodec_dec_flush_codec(NULL, &ctx) == AVERROR_EXTERNAL); - assert(!pthread_mutex_trylock(&ctx.output_mutex)); - pthread_mutex_unlock(&ctx.output_mutex); - platform_result = 0; - mediacodec_buffer_release(buffer, NULL); - ff_mediacodec_dec_close(NULL, &ctx); - assert(!pthread_mutex_destroy(&ctx.output_mutex)); - assert(av_mediacodec_release_buffer_status(NULL, 0) == AVERROR(EINVAL)); - alarm(0); - puts("PASS: 480 output/flush/close races, timed/immediate/final release, stale/duplicate buffers and error unlock"); - return 0; -} diff --git a/third_party/mpv-player-jni/tests/test_audiotrack_underrun.sh b/third_party/mpv-player-jni/tests/test_audiotrack_underrun.sh deleted file mode 100644 index 4652512c50..0000000000 --- a/third_party/mpv-player-jni/tests/test_audiotrack_underrun.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -test_dir="$(cd "$(dirname "$0")" && pwd)" -mpv_source="${1:?usage: bash test_audiotrack_underrun.sh }" -test_work="$(mktemp -d /tmp/p9-audiotrack-test.XXXXXX)" -awk ' - /^static MP_THREAD_VOID ao_thread\(void \*arg\)/ { emit = 1 } - emit { print } - emit && /^}/ { exit } -' "$mpv_source/audio/out/ao_audiotrack.c" > "$test_work/ao_thread_under_test.h" -test -s "$test_work/ao_thread_under_test.h" -cc -std=c11 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-unused-variable \ - -I "$test_work" "$test_dir/audiotrack_underrun_test.c" -o "$test_work/test" -"$test_work/test" diff --git a/third_party/mpv-player-jni/tests/test_bluray_menu_routing.sh b/third_party/mpv-player-jni/tests/test_bluray_menu_routing.sh deleted file mode 100644 index ec9fd990fd..0000000000 --- a/third_party/mpv-player-jni/tests/test_bluray_menu_routing.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -test_dir="$(cd "$(dirname "$0")" && pwd)" -bluray_source="${1:?usage: bash test_bluray_menu_routing.sh }" -test_work="$(mktemp -d /tmp/p9-menu-routing.XXXXXX)" -awk '/^\/\* WebHTV menu visit state/ { emit=1 } emit { print } /^\/\* End WebHTV menu visit state/ { exit }' \ - "$bluray_source/src/libbluray/decoders/graphics_controller.c" > "$test_work/menu_visit_types.h" -awk '/^\/\* WebHTV observed menu transitions/ { emit=1 } emit { print } /^\/\* End WebHTV observed menu transitions/ { exit }' \ - "$bluray_source/src/libbluray/decoders/graphics_controller.c" > "$test_work/menu_history_under_test.h" -awk '/^#define VK_IS_NUMERIC/ { emit=1 } /^static void _set_button_page/ { exit } emit { print }' \ - "$bluray_source/src/libbluray/decoders/graphics_controller.c" > "$test_work/menu_route_under_test.h" -awk '/^static int _mouse_move/ { emit=1 } /^static int _animate/ { exit } emit { print }' \ - "$bluray_source/src/libbluray/decoders/graphics_controller.c" >> "$test_work/menu_route_under_test.h" -test -s "$test_work/menu_route_under_test.h" -cc -std=c11 -Wall -Wextra -Werror -I "$test_work" -I "$bluray_source/src" -I "$bluray_source/src/libbluray" \ - -fsanitize=address,undefined -fno-omit-frame-pointer \ - "$test_dir/bluray_menu_routing_test.c" -o "$test_work/menu_route_test" -"$test_work/menu_route_test" -# Exercise helpers above, and verify they are wired into real lifecycle paths. -awk ' - /^static int _restore_page_state/ { section="restore" } - /^static void _select_page/ { section="select" } - /^static void _gc_reset/ { section="reset" } - /^int gc_decode_ts/ { section="decode" } - /case GC_CTRL_POPUP:/ { section="popup" } - /case GC_CTRL_INIT_MENU:/ { section="init" } - /_clear_menu_history\(gc\)/ { if (section != "") clear[section]=1 } - /_observe_menu_page_change\(gc, page_id\)/ { if (section == "select") observed=1 } - /^}/ { section="" } - END { if (!observed || !clear["restore"] || !clear["reset"] || !clear["decode"] || !clear["popup"] || !clear["init"]) exit 1 } -' "$bluray_source/src/libbluray/decoders/graphics_controller.c" -printf '%s\n' 'PASS: actual page-change / IG replacement / reset / restore / init / popup history invalidation hooks' diff --git a/third_party/mpv-player-jni/tests/test_disc_navigation_input.sh b/third_party/mpv-player-jni/tests/test_disc_navigation_input.sh deleted file mode 100644 index 70147e3d62..0000000000 --- a/third_party/mpv-player-jni/tests/test_disc_navigation_input.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -test_dir="$(cd "$(dirname "$0")" && pwd)" -mpv_source="${1:?usage: bash test_disc_navigation_input.sh }" -test_work="$(mktemp -d /tmp/p9-disc-input.XXXXXX)" -awk ' - /^bool disc_nav_window_pos_to_src\(/ { emit = 1 } - /^\/\/ Push the current menu\/highlight state/ { exit } - emit { print } -' "$mpv_source/player/discnav.c" > "$test_work/disc_pointer_under_test.h" -awk ' - /^static void cmd_discnav\(/ { emit = 1 } - /^static void cmd_flush_status_line\(/ { exit } - emit { print } -' "$mpv_source/player/command.c" > "$test_work/disc_command_under_test.h" -awk ' - /^static int bluray_call_user_menu\(/ { emit = 1 } - /^static int bluray_stream_control\(/ { exit } - emit { print } -' "$mpv_source/stream/stream_bluray.c" > "$test_work/disc_menu_call_under_test.h" -awk ' - /^ case STREAM_CTRL_NAV_CMD:/ { emit = 1 } - /^ case STREAM_CTRL_GET_NAV_STATE:/ { exit } - emit { print } -' "$mpv_source/stream/stream_bluray.c" > "$test_work/disc_bluray_input_under_test.h" -awk ' - /^ case STREAM_CTRL_NAV_POLL:/ { emit = 1 } - /^ case STREAM_CTRL_GET_NUM_CHAPTERS:/ { exit } - emit { print } -' "$mpv_source/stream/stream_bluray.c" > "$test_work/disc_pump_under_test.h" -awk ' - /^static bool d_read_packet\(/ { emit = 1 } - emit && /^ struct stream_nav_state nav =/ { exit } - emit { print } -' "$mpv_source/demux/demux_disc.c" > "$test_work/disc_demux_input_under_test.h" -awk ' - /^static inline bool stream_nav_action_activates\(/ { emit = 1 } - emit { print } - emit && /^}/ { exit } -' "$mpv_source/stream/stream.h" > "$test_work/disc_action_under_test.h" -for part in pointer command bluray_input menu_call action pump demux_input; do - test -s "$test_work/disc_${part}_under_test.h" -done -cc -std=c11 -Wall -Wextra -Werror -I "$test_work" \ - "$test_dir/disc_navigation_input_test.c" -o "$test_work/disc_input_test" -"$test_work/disc_input_test" diff --git a/third_party/mpv-player-jni/tests/test_disc_navigation_liveness.sh b/third_party/mpv-player-jni/tests/test_disc_navigation_liveness.sh deleted file mode 100644 index 808f56e64e..0000000000 --- a/third_party/mpv-player-jni/tests/test_disc_navigation_liveness.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -test_dir="$(cd "$(dirname "$0")" && pwd)" -mpv_source="${1:?usage: bash test_disc_navigation_liveness.sh }" -test_work="$(mktemp -d /tmp/p9-disc-liveness.XXXXXX)" -awk ' - /^void demux_drive_nav\(/ { emit = 1 } - /^const char \*stream_type_name\(/ { exit } - emit { print } -' "$mpv_source/demux/demux.c" > "$test_work/disc_requests_under_test.h" -awk ' - /^static bool poll_nav\(/ { emit = 1 } - emit { print } - emit && /^}/ { exit } -' "$mpv_source/demux/demux.c" > "$test_work/disc_poll_under_test.h" -awk ' - /^static void poll_disc_nav\(/ { emit = 1 } - /^void disc_nav_update\(/ { exit } - emit { print } -' "$mpv_source/player/discnav.c" > "$test_work/disc_tick_under_test.h" -for part in requests poll tick; do - test -s "$test_work/disc_${part}_under_test.h" -done -cc -std=c11 -Wall -Wextra -Werror -I "$test_work" \ - "$test_dir/disc_navigation_liveness_test.c" -o "$test_work/disc_liveness_test" -"$test_work/disc_liveness_test" diff --git a/third_party/mpv-player-jni/tests/test_disc_navigation_options.sh b/third_party/mpv-player-jni/tests/test_disc_navigation_options.sh deleted file mode 100644 index 32742909fb..0000000000 --- a/third_party/mpv-player-jni/tests/test_disc_navigation_options.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -test_dir="$(cd "$(dirname "$0")" && pwd)" -mpv_source="${1:?usage: bash test_disc_navigation_options.sh }" -test_work="$(mktemp -d /tmp/p9-disc-options.XXXXXX)" -awk ' - /^static void load_disc_navigation_options\(/ { emit = 1 } - /^static MP_THREAD_VOID open_demux_thread\(/ { exit } - emit { print } -' "$mpv_source/player/loadfile.c" > "$test_work/disc_options_under_test.h" -test -s "$test_work/disc_options_under_test.h" -# It must run after on_preloaded and before any decoder/filter initialization. -awk ' - /process_hooks\(mpctx, "on_preloaded"\)/ { preloaded = NR } - /^ load_disc_navigation_options\(mpctx\);/ { applied = NR } - /^ if \(reinit_complex_filters\(mpctx, false\)/ { decoder = NR } - END { exit !(preloaded > 0 && applied > preloaded && decoder > applied) } -' "$mpv_source/player/loadfile.c" -cc -std=c11 -Wall -Wextra -Werror -I "$test_work" \ - "$test_dir/disc_navigation_options_test.c" -o "$test_work/disc_options_test" -"$test_work/disc_options_test" diff --git a/third_party/mpv-player-jni/tests/test_disc_navigation_progress.sh b/third_party/mpv-player-jni/tests/test_disc_navigation_progress.sh deleted file mode 100644 index b666dcfea3..0000000000 --- a/third_party/mpv-player-jni/tests/test_disc_navigation_progress.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -test_dir="$(cd "$(dirname "$0")" && pwd)" -mpv_source="${1:?usage: bash test_disc_navigation_progress.sh }" -test_work="$(mktemp -d /tmp/p9-disc-progress.XXXXXX)" -awk ' - /^static int bluray_poll_hdmv_events\(/ { emit = 1 } - /^static int bluray_stream_fill_buffer\(/ { emit = 1 } - /^static int bluray_call_user_menu\(/ { exit } - /^static int bluray_stream_control\(/ { exit } - emit { print } -' "$mpv_source/stream/stream_bluray.c" > "$test_work/disc_progress_under_test.h" -awk ' - /^static bool bd_stream_pid_valid\(/ { emit = 1 } - /^static struct sh_stream \*find_outer_for_slave\(/ { exit } - emit { print } -' "$mpv_source/demux/demux_disc.c" > "$test_work/disc_pid_under_test.h" -awk ' - /if \(.*bd_stream_pid_valid\(src\)/ { - sub(/^[[:space:]]*if \(/, "#define DISC_STREAM_IGNORED "); - sub(/\) \{[[:space:]]*$/, ""); - print; exit; - } -' "$mpv_source/demux/demux_disc.c" >> "$test_work/disc_pid_under_test.h" -test -s "$test_work/disc_progress_under_test.h" -test -s "$test_work/disc_pid_under_test.h" -cc -std=c11 -Wall -Wextra -Werror -I "$test_work" \ - "$test_dir/disc_navigation_progress_test.c" -o "$test_work/disc_progress_test" -"$test_work/disc_progress_test" diff --git a/third_party/mpv-player-jni/tests/test_disc_navigation_start.sh b/third_party/mpv-player-jni/tests/test_disc_navigation_start.sh deleted file mode 100644 index ccea1dbaab..0000000000 --- a/third_party/mpv-player-jni/tests/test_disc_navigation_start.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -test_dir="$(cd "$(dirname "$0")" && pwd)" -mpv_source="${1:?usage: bash test_disc_navigation_start.sh }" -test_work="$(mktemp -d /tmp/p9-disc-start.XXXXXX)" -awk ' - /^ b->hdmv_mode = b->cfg_title == BLURAY_MENU_TITLE;/ { emit = 1 } - /^ \/\/ Angle selection is only valid/ { exit } - emit { print } -' "$mpv_source/stream/stream_bluray.c" > "$test_work/disc_start_under_test.h" -test -s "$test_work/disc_start_under_test.h" -cc -std=c11 -Wall -Wextra -Werror -I "$test_work" \ - "$test_dir/disc_navigation_start_test.c" -o "$test_work/disc_start_test" -"$test_work/disc_start_test" diff --git a/third_party/mpv-player-jni/tests/test_disc_navigation_trace.sh b/third_party/mpv-player-jni/tests/test_disc_navigation_trace.sh deleted file mode 100644 index b8f3b7d52d..0000000000 --- a/third_party/mpv-player-jni/tests/test_disc_navigation_trace.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -test_dir="$(cd "$(dirname "$0")" && pwd)" -mpv_source="${1:?usage: bash test_disc_navigation_trace.sh }" -test_work="$(mktemp -d /tmp/p9-disc-trace.XXXXXX)" -awk ' - /^\/\/ The libbluray logger is process-global/ { emit = 1 } - /^\/\/ Lazy \(re-\)allocation/ { exit } - emit { print } -' "$mpv_source/stream/stream_bluray.c" > "$test_work/disc_trace_under_test.h" -test -s "$test_work/disc_trace_under_test.h" -cc -std=c11 -Wall -Wextra -Werror -pthread -I "$test_work" \ - "$test_dir/disc_navigation_trace_test.c" -o "$test_work/disc_trace_test" -"$test_work/disc_trace_test" diff --git a/third_party/mpv-player-jni/tests/test_disc_reopen.sh b/third_party/mpv-player-jni/tests/test_disc_reopen.sh deleted file mode 100644 index 2834713b65..0000000000 --- a/third_party/mpv-player-jni/tests/test_disc_reopen.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -test_dir="$(cd "$(dirname "$0")" && pwd)" -mpv_source="${1:?usage: bash test_disc_reopen.sh }" -test_work="$(mktemp -d /tmp/p9-disc-reopen.XXXXXX)" -awk ' - /^static bool reopen_slave\(/ { emit = 1 } - /^static bool d_read_packet\(/ { exit } - emit { print } -' "$mpv_source/demux/demux_disc.c" > "$test_work/disc_reopen_under_test.h" -test -s "$test_work/disc_reopen_under_test.h" -cc -std=c11 -Wall -Wextra -Werror -I "$test_work" \ - "$test_dir/disc_reopen_test.c" -o "$test_work/disc_reopen_test" -"$test_work/disc_reopen_test" diff --git a/third_party/mpv-player-jni/tests/test_mediacodec_output_serialization.sh b/third_party/mpv-player-jni/tests/test_mediacodec_output_serialization.sh deleted file mode 100644 index 15f2854db8..0000000000 --- a/third_party/mpv-player-jni/tests/test_mediacodec_output_serialization.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -test_dir="$(cd "$(dirname "$0")" && pwd)" -ffmpeg_source="${1:?usage: bash test_mediacodec_output_serialization.sh }" -test_work="$(mktemp -d /tmp/p9-mediacodec-lock.XXXXXX)" -awk ' - /^int av_mediacodec_release_buffer_status\(/ { emit = 1 } - /^#else/ { exit } - emit { print } -' "$ffmpeg_source/libavcodec/mediacodec.c" > "$test_work/mediacodec_release_under_test.h" -awk ' - /^static void mediacodec_buffer_release\(/ { emit = 1 } - /^static int mediacodec_dec_flush_codec\(/ { emit = 1 } - /^int ff_mediacodec_dec_close\(/ { emit = 1 } - emit { print } - emit && /^}/ { emit = 0 } -' "$ffmpeg_source/libavcodec/mediacodecdec_common.c" > "$test_work/mediacodec_lifecycle_under_test.h" -test -s "$test_work/mediacodec_release_under_test.h" -test -s "$test_work/mediacodec_lifecycle_under_test.h" -# FFmpeg callbacks retain unused ABI parameters (e.g. AVBuffer free data). -cc -std=c11 -pthread -Wall -Wextra -Werror -Wno-unused-parameter -I "$test_work" \ - "$test_dir/mediacodec_output_serialization_test.c" -o "$test_work/mediacodec_lock_test" -"$test_work/mediacodec_lock_test" diff --git a/third_party/nextlib-media3ext-compat/avs3_decode_probe.c b/third_party/nextlib-media3ext-compat/avs3_decode_probe.c deleted file mode 100644 index 13f714a194..0000000000 --- a/third_party/nextlib-media3ext-compat/avs3_decode_probe.c +++ /dev/null @@ -1,146 +0,0 @@ -/* Decode real AVS3 access units through the shipped FFmpeg ABI; repeat after flush. */ -#include -#include -#include -#include -#include -#include -#include - -static int reject_header(const AVCodec *codec, const uint8_t *header, int size, - int container, int expected) { - AVCodecContext *ctx = avcodec_alloc_context3(codec); - if (!ctx) return -1; - ctx->thread_count = 2; - int ret; - if (container < 2) { - ctx->extradata_size = size + (container ? 4 : 0); - ctx->extradata = av_mallocz(ctx->extradata_size + AV_INPUT_BUFFER_PADDING_SIZE); - if (!ctx->extradata) { avcodec_free_context(&ctx); return -1; } - if (container) { - ctx->extradata[0] = 1; - ctx->extradata[1] = size >> 8; - ctx->extradata[2] = size; - } - memcpy(ctx->extradata + (container ? 3 : 0), header, size); - ret = avcodec_open2(ctx, codec, NULL); - } else { - ret = avcodec_open2(ctx, codec, NULL); - if (ret >= 0) { - AVPacket *pkt = av_packet_alloc(); - if (!pkt || av_new_packet(pkt, size) < 0) { - av_packet_free(&pkt); - avcodec_free_context(&ctx); - return -1; - } - memcpy(pkt->data, header, size); - ret = avcodec_send_packet(ctx, pkt); - if (ret >= 0) { - AVFrame *frame = av_frame_alloc(); - if (!frame) { av_packet_free(&pkt); avcodec_free_context(&ctx); return -1; } - ret = avcodec_receive_frame(ctx, frame); - av_frame_free(&frame); - } - av_packet_free(&pkt); - } - } - avcodec_free_context(&ctx); - if (ret != expected) { - fprintf(stderr, "header rejection failed: container=%d size=%d ret=%d expected=%d\n", - container, size, ret, expected); - return -1; - } - return 0; -} - -static int check_unsupported_profiles(const AVCodec *codec) { - uint8_t header[] = {0, 0, 1, 0xb0, 0x30, 0x55}; - for (int container = 0; container < 3; container++) { - for (int profile = 0x30; profile <= 0x32; profile += 2) { - header[4] = profile; - if (reject_header(codec, header, sizeof(header), container, AVERROR_PATCHWELCOME)) return -1; - } - if (reject_header(codec, header, 4, container, AVERROR_INVALIDDATA)) return -1; - } - puts("unsupported profiles: raw/av3c/in-band 0x30/0x32 and truncated headers rejected"); - return 0; -} - -static int drain(AVCodecContext *ctx, AVFrame *frame, struct AVSHA *sha, int *frames) { - int ret; - while ((ret = avcodec_receive_frame(ctx, frame)) >= 0) { - const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(frame->format); - if (!desc || (desc->comp[0].depth != 8 && desc->comp[0].depth != 10)) return -1; - if (frame->width != 256 || frame->height != 144) return -1; - int scale = desc->comp[0].depth > 8 ? 2 : 1; - for (int p = 0; p < 3; p++) { - int w = p ? frame->width / 2 : frame->width; - int h = p ? frame->height / 2 : frame->height; - for (int y = 0; y < h; y++) - av_sha_update(sha, frame->data[p] + y * frame->linesize[p], w * scale); - } - (*frames)++; - av_frame_unref(frame); - } - return ret == AVERROR(EAGAIN) || ret == AVERROR_EOF ? 0 : ret; -} - -int main(int argc, char **argv) { - if (argc != 2) return 2; - FILE *file = fopen(argv[1], "rb"); - if (!file) return 2; - fseek(file, 0, SEEK_END); - long size = ftell(file); - rewind(file); - if (size <= 0 || size > 4 * 1024 * 1024) return 2; - uint8_t *data = av_mallocz(size + AV_INPUT_BUFFER_PADDING_SIZE); - if (!data || fread(data, 1, size, file) != (size_t) size) return 2; - fclose(file); - const AVCodec *codec = avcodec_find_decoder_by_name("libuavs3d"); - AVCodecContext *ctx = avcodec_alloc_context3(codec); - if (!codec || !ctx) return 3; - if (check_unsupported_profiles(codec)) return 11; - ctx->thread_count = 2; - int ret = avcodec_open2(ctx, codec, NULL); - if (ret < 0) return 4; - AVPacket *packet = av_packet_alloc(); - AVFrame *frame = av_frame_alloc(); - struct AVSHA *sha = av_sha_alloc(); - if (!packet || !frame || !sha) return 4; - for (int pass = 0; pass < 2; pass++) { - AVCodecParserContext *parser = av_parser_init(AV_CODEC_ID_AVS3); - if (!parser) return 5; - av_sha_init(sha, 256); - int frames = 0; - int offset = 0; - while (offset < size) { - int consumed = av_parser_parse2(parser, ctx, &packet->data, &packet->size, - data + offset, size - offset, AV_NOPTS_VALUE, AV_NOPTS_VALUE, offset); - if (consumed < 0 || (!consumed && !packet->size)) return 6; - offset += consumed; - if (!packet->size) continue; - if (avcodec_send_packet(ctx, packet) < 0 || drain(ctx, frame, sha, &frames) < 0) return 7; - } - av_parser_parse2(parser, ctx, &packet->data, &packet->size, NULL, 0, - AV_NOPTS_VALUE, AV_NOPTS_VALUE, offset); - if (packet->size && (avcodec_send_packet(ctx, packet) < 0 || drain(ctx, frame, sha, &frames) < 0)) return 8; - if (avcodec_send_packet(ctx, NULL) < 0 || drain(ctx, frame, sha, &frames) < 0) return 9; - uint8_t digest[32]; - av_sha_final(sha, digest); - printf("pass=%d frames=%d sha256=", pass, frames); - for (int i = 0; i < 32; i++) printf("%02x", digest[i]); - printf("\n"); - if (frames != 16) return 10; - av_parser_close(parser); - avcodec_flush_buffers(ctx); - } - av_free(sha); - av_frame_free(&frame); - // packet data is owned by the parser/input, never by this AVPacket. - packet->data = NULL; - packet->size = 0; - av_packet_free(&packet); - avcodec_free_context(&ctx); - av_free(data); - return 0; -} diff --git a/third_party/nextlib-media3ext-compat/avs3_mux_fixture.c b/third_party/nextlib-media3ext-compat/avs3_mux_fixture.c deleted file mode 100644 index 785fdf7259..0000000000 --- a/third_party/nextlib-media3ext-compat/avs3_mux_fixture.c +++ /dev/null @@ -1,112 +0,0 @@ -/* Test-fixture muxer: obtain display order from the decoder before assigning PTS. - * Input: the committed 16-frame independently verified AVS3 fixture. - * Usage: avs3-mux-fixture input.avs3 output.{mp4,mkv,ts} [repetitions] [movflags] - */ -#include -#include -#include -#include -#include - -#define CHECK(call) do { int r = (call); if (r < 0) { \ - char error[AV_ERROR_MAX_STRING_SIZE]; av_strerror(r, error, sizeof(error)); \ - fprintf(stderr, "%s: %s\n", #call, error); return 1; } } while (0) - -static int collect(AVCodecContext *decoder, AVFrame *frame, int order[16], int *count) { - int ret; - while ((ret = avcodec_receive_frame(decoder, frame)) >= 0) { - if (frame->pts < 0 || frame->pts >= 16 || *count >= 16) return AVERROR_INVALIDDATA; - order[frame->pts] = (*count)++; - av_frame_unref(frame); - } - return ret == AVERROR(EAGAIN) || ret == AVERROR_EOF ? 0 : ret; -} - -int main(int argc, char **argv) { - if (argc < 3 || argc > 5) return 2; - int repeats = argc >= 4 ? atoi(argv[3]) : 1; - if (repeats < 1 || repeats > 1000) return 2; - AVFormatContext *input = NULL, *output = NULL; - CHECK(avformat_open_input(&input, argv[1], NULL, NULL)); - CHECK(avformat_find_stream_info(input, NULL)); - const AVCodec *codec = avcodec_find_decoder_by_name("libuavs3d"); - AVCodecContext *decoder = avcodec_alloc_context3(codec); - if (!decoder || input->nb_streams != 1) return 3; - CHECK(avcodec_parameters_to_context(decoder, input->streams[0]->codecpar)); - decoder->thread_count = 2; - CHECK(avcodec_open2(decoder, codec, NULL)); - AVPacket *packets[16] = {0}, *packet = av_packet_alloc(); - AVFrame *frame = av_frame_alloc(); - int count = 0, frames = 0, order[16]; - for (int i = 0; i < 16; i++) order[i] = -1; - int ret; - while ((ret = av_read_frame(input, packet)) >= 0) { - if (count == 16) return 4; - packet->pts = packet->dts = count; - packets[count++] = av_packet_clone(packet); - if (!packets[count - 1]) return 4; - CHECK(avcodec_send_packet(decoder, packet)); - CHECK(collect(decoder, frame, order, &frames)); - av_packet_unref(packet); - } - if (ret != AVERROR_EOF) return 4; - CHECK(avcodec_send_packet(decoder, NULL)); - CHECK(collect(decoder, frame, order, &frames)); - if (count != 16 || frames != 16) return 4; - int delay = 0; - for (int i = 0; i < 16; i++) { - if (order[i] < 0) return 4; - if (i - order[i] > delay) delay = i - order[i]; - } - CHECK(avformat_alloc_output_context2(&output, NULL, NULL, argv[2])); - AVStream *stream = avformat_new_stream(output, NULL); - if (!stream) return 5; - CHECK(avcodec_parameters_copy(stream->codecpar, input->streams[0]->codecpar)); - stream->codecpar->codec_tag = 0; - // The raw demuxer does not synthesize codecpar extradata. MOV requires an - // av3c record; its muxer wraps this raw sequence header in that record. - int sequence_size = 0; - for (int i = 4; i + 3 < packets[0]->size; i++) { - const uint8_t *p = packets[0]->data + i; - if (!p[0] && !p[1] && p[2] == 1 && p[3] == 0xb3) { - sequence_size = i; - break; - } - } - if (sequence_size < 13 || sequence_size > 65535) return 5; - av_freep(&stream->codecpar->extradata); - stream->codecpar->extradata = av_mallocz(sequence_size + AV_INPUT_BUFFER_PADDING_SIZE); - if (!stream->codecpar->extradata) return 5; - memcpy(stream->codecpar->extradata, packets[0]->data, sequence_size); - stream->codecpar->extradata_size = sequence_size; - stream->time_base = (AVRational){1, 25}; - stream->avg_frame_rate = (AVRational){25, 1}; - CHECK(avio_open(&output->pb, argv[2], AVIO_FLAG_WRITE)); - // Write the final fragmented MP4 directly: a second remux through a demuxer - // without av3c support would discard this sequence configuration. - AVDictionary *options = NULL; - if (argc == 5) CHECK(av_dict_set(&options, "movflags", argv[4], 0)); - CHECK(avformat_write_header(output, &options)); - av_dict_free(&options); - for (int loop = 0; loop < repeats; loop++) { - for (int i = 0; i < 16; i++) { - CHECK(av_packet_ref(packet, packets[i])); - packet->pts = loop * 16 + order[i]; - packet->dts = loop * 16 + i - delay; - packet->duration = 1; - packet->pos = -1; - av_packet_rescale_ts(packet, (AVRational){1, 25}, stream->time_base); - CHECK(av_interleaved_write_frame(output, packet)); - } - } - CHECK(av_write_trailer(output)); - CHECK(avio_closep(&output->pb)); - avformat_free_context(output); - for (int i = 0; i < 16; i++) av_packet_free(&packets[i]); - av_packet_free(&packet); - av_frame_free(&frame); - avcodec_free_context(&decoder); - avformat_close_input(&input); - printf("Muxed %d frames with display-order PTS (reorder delay %d).\n", repeats * 16, delay); - return 0; -} diff --git a/third_party/patches/ffmpeg-audio-mediacodec-hardware-first.patch b/third_party/patches/ffmpeg-audio-mediacodec-hardware-first.patch deleted file mode 100644 index d9ba254579..0000000000 --- a/third_party/patches/ffmpeg-audio-mediacodec-hardware-first.patch +++ /dev/null @@ -1,123 +0,0 @@ -diff --git a/libavcodec/mediacodec_wrapper.h b/libavcodec/mediacodec_wrapper.h -index 5dc8f3d..291597d 100644 ---- a/libavcodec/mediacodec_wrapper.h -+++ b/libavcodec/mediacodec_wrapper.h -@@ -63,3 +63,3 @@ int ff_AMediaCodecProfile_getProfileFromAVCodecContext(AVCodecContext *avctx); - char *ff_AMediaCodecList_getCodecNameByType(const char *mime, int profile, -- int encoder, const char **codec_mime, -+ int encoder, int hardware_only, const char **codec_mime, - void *log_ctx); -diff --git a/libavcodec/mediacodec_wrapper.c b/libavcodec/mediacodec_wrapper.c -index e0da188..00169b0 100644 ---- a/libavcodec/mediacodec_wrapper.c -+++ b/libavcodec/mediacodec_wrapper.c -@@ -50,6 +50,7 @@ struct JNIAMediaCodecListFields { - jmethodID get_supported_types_id; - jmethodID is_encoder_id; - jmethodID is_software_only_id; -+ jmethodID is_hardware_accelerated_id; - - jclass codec_capabilities_class; - jfieldID color_formats_id; -@@ -76,6 +77,7 @@ static const struct FFJniField jni_amediacodeclist_mapping[] = { - { "android/media/MediaCodecInfo", "getSupportedTypes", "()[Ljava/lang/String;", FF_JNI_METHOD, OFFSET(get_supported_types_id), 1 }, - { "android/media/MediaCodecInfo", "isEncoder", "()Z", FF_JNI_METHOD, OFFSET(is_encoder_id), 1 }, - { "android/media/MediaCodecInfo", "isSoftwareOnly", "()Z", FF_JNI_METHOD, OFFSET(is_software_only_id), 0 }, -+ { "android/media/MediaCodecInfo", "isHardwareAccelerated", "()Z", FF_JNI_METHOD, OFFSET(is_hardware_accelerated_id), 0 }, - - { "android/media/MediaCodecInfo$CodecCapabilities", NULL, NULL, FF_JNI_CLASS, OFFSET(codec_capabilities_class), 1 }, - { "android/media/MediaCodecInfo$CodecCapabilities", "colorFormats", "[I", FF_JNI_FIELD, OFFSET(color_formats_id), 1 }, -@@ -490,7 +492,7 @@ static const char *get_codec_mime_alias(const char *codec_name, - } - - char *ff_AMediaCodecList_getCodecNameByType(const char *mime, int profile, -- int encoder, const char **codec_mime, -+ int encoder, int hardware_only, const char **codec_mime, - void *log_ctx) - { - int i; -@@ -561,6 +563,18 @@ char *ff_AMediaCodecList_getCodecNameByType(const char *mime, int profile, - goto done_with_info; - } - -+ if (hardware_only && jfields.is_hardware_accelerated_id) { -+ int is_hardware_accelerated = (*env)->CallBooleanMethod( -+ env, info, jfields.is_hardware_accelerated_id); -+ if (ff_jni_exception_check(env, 1, log_ctx) < 0) { -+ goto done; -+ } -+ -+ if (!is_hardware_accelerated) { -+ goto done_with_info; -+ } -+ } -+ - if (jfields.is_software_only_id) { - int is_software_only = (*env)->CallBooleanMethod(env, info, jfields.is_software_only_id); - if (ff_jni_exception_check(env, 1, log_ctx) < 0) { -@@ -589,8 +603,11 @@ char *ff_AMediaCodecList_getCodecNameByType(const char *mime, int profile, - if ( - strstr(name, "OMX.google") || - strstr(name, "OMX.ffmpeg") || -+ strstr(name, "c2.android") || - (strstr(name, "OMX.SEC") && strstr(name, ".sw.")) || -- !strcmp(name, "OMX.qcom.video.decoder.hevcswvdec")) { -+ !strcmp(name, "OMX.qcom.video.decoder.hevcswvdec") || -+ (hardware_only && (strstr(name, ".sw.") || -+ strstr(name, "software")))) { - goto done_with_info; - } - -diff --git a/libavcodec/mediacodecdec_common.c b/libavcodec/mediacodecdec_common.c -index 06a9bb2..70b0203 100644 ---- a/libavcodec/mediacodecdec_common.c -+++ b/libavcodec/mediacodecdec_common.c -@@ -1353,11 +1353,11 @@ static int mediacodec_dec_get_video_codec(AVCodecContext *avctx, MediaCodecDecCo - } - - s->codec_name = ff_AMediaCodecList_getCodecNameByType( -- mime, profile, 0, &codec_mime, avctx); -+ mime, profile, 0, 0, &codec_mime, avctx); - if (!s->codec_name && (avctx->hwaccel_flags & AV_HWACCEL_FLAG_ALLOW_PROFILE_MISMATCH)) { - profile = -1; - s->codec_name = ff_AMediaCodecList_getCodecNameByType( -- mime, profile, 0, &codec_mime, avctx); -+ mime, profile, 0, 0, &codec_mime, avctx); - } - if (!s->codec_name) { - av_log(avctx, AV_LOG_INFO, "Failed to getCodecNameByType(%s, %d)\n", mime, profile); -@@ -1395,17 +1395,26 @@ static int mediacodec_dec_get_audio_codec(AVCodecContext *avctx, MediaCodecDecCo - const char *mime, FFAMediaFormat *format) - { -- s->codec = ff_AMediaCodec_createDecoderByType(mime, s->use_ndk_codec); -- if (!s->codec) { -- av_log(avctx, AV_LOG_ERROR, "Failed to create media decoder for mime %s\n", mime); -+ const char *codec_mime = mime; -+ -+ s->codec_name = ff_AMediaCodecList_getCodecNameByType( -+ mime, -1, 0, 1, &codec_mime, avctx); -+ if (!s->codec_name) { -+ av_log(avctx, AV_LOG_INFO, -+ "No hardware audio MediaCodec decoder for mime %s\n", mime); - return AVERROR_EXTERNAL; - } - -- s->codec_name = ff_AMediaCodec_getName(s->codec); -- if (!s->codec_name) { -- s->codec_name = av_strdup(mime); -- if (!s->codec_name) -- return AVERROR(ENOMEM); -+ s->codec = ff_AMediaCodec_createCodecByName(s->codec_name, -+ s->use_ndk_codec); -+ if (!s->codec) { -+ av_log(avctx, AV_LOG_ERROR, -+ "Failed to create hardware audio MediaCodec decoder %s for mime %s\n", -+ s->codec_name, mime); -+ return AVERROR_EXTERNAL; - } - -+ ff_AMediaFormat_setString(format, "mime", codec_mime); -+ av_log(avctx, AV_LOG_INFO, -+ "WebHTV hardware audio MediaCodec decoder: %s\n", s->codec_name); - return 0; - } diff --git a/third_party/patches/ffmpeg-avs3-mediacodec.patch b/third_party/patches/ffmpeg-avs3-mediacodec.patch deleted file mode 100644 index 4a641d7512..0000000000 --- a/third_party/patches/ffmpeg-avs3-mediacodec.patch +++ /dev/null @@ -1,167 +0,0 @@ -diff --git a/configure b/configure ---- a/configure -+++ b/configure -@@ -3615,6 +3615,8 @@ - av1_d3d12va_encoder_deps="d3d12va d3d12va_av1_headers" - av1_d3d12va_encoder_select="cbs_av1 d3d12va_encode" - av1_mediacodec_decoder_deps="mediacodec" -+avs3_mediacodec_decoder_deps="mediacodec" -+avs3_mediacodec_decoder_select="avs3_parser" - av1_mediacodec_encoder_deps="mediacodec" - av1_mediacodec_encoder_select="extract_extradata_bsf" - av1_mf_encoder_deps="mediafoundation" -diff --git a/libavcodec/Makefile b/libavcodec/Makefile ---- a/libavcodec/Makefile -+++ b/libavcodec/Makefile -@@ -286,6 +286,7 @@ - OBJS-$(CONFIG_AVRP_DECODER) += r210dec.o - OBJS-$(CONFIG_AVRP_ENCODER) += r210enc.o - OBJS-$(CONFIG_AVS_DECODER) += avs.o -+OBJS-$(CONFIG_AVS3_MEDIACODEC_DECODER) += mediacodecdec.o - OBJS-$(CONFIG_AVUI_DECODER) += avuidec.o - OBJS-$(CONFIG_AVUI_ENCODER) += avuienc.o - OBJS-$(CONFIG_BETHSOFTVID_DECODER) += bethsoftvideo.o -diff --git a/libavcodec/allcodecs.c b/libavcodec/allcodecs.c ---- a/libavcodec/allcodecs.c -+++ b/libavcodec/allcodecs.c -@@ -852,6 +852,7 @@ - extern const FFCodec ff_av1_cuvid_decoder; - extern const FFCodec ff_av1_d3d12va_encoder; - extern const FFCodec ff_av1_mediacodec_decoder; -+extern const FFCodec ff_avs3_mediacodec_decoder; - extern const FFCodec ff_av1_mediacodec_encoder; - extern const FFCodec ff_av1_nvenc_encoder; - extern const FFCodec ff_av1_qsv_decoder; -diff --git a/libavcodec/mediacodecdec.c b/libavcodec/mediacodecdec.c ---- a/libavcodec/mediacodecdec.c -+++ b/libavcodec/mediacodecdec.c -@@ -581,6 +581,40 @@ - } - #endif - -+#if CONFIG_AVS3_MEDIACODEC_DECODER -+static int avs3_set_extradata(AVCodecContext *avctx, FFAMediaFormat *format) -+{ -+ uint8_t *header = avctx->extradata; -+ int size = avctx->extradata_size; -+ -+ /* TS/raw inputs may carry their sequence header in the first access unit. */ -+ if (!size) -+ return 0; -+ if (!header || size < 6) -+ return AVERROR_INVALIDDATA; -+ -+ /* Avs3DecoderConfigurationRecord: version, length, sequence, library flags. -+ * MediaCodec consumes the sequence bytes, not this MP4 container record. */ -+ if (header[0] == 1) { -+ int sequence_size = AV_RB16(header + 1); -+ if (sequence_size < 6 || sequence_size > size - 4) -+ return AVERROR_INVALIDDATA; -+ header += 3; -+ size = sequence_size; -+ } -+ if (AV_RB32(header) != 0x000001B0) -+ return AVERROR_INVALIDDATA; -+ -+ /* Android has no standardized AVS3 CodecProfileLevel values. Preserve the -+ * bitstream profile/precision and let the hardware validate its support. */ -+ ff_AMediaFormat_setBuffer(format, "csd-0", header, size); -+ av_log(avctx, AV_LOG_INFO, -+ "WebHTV AVS3 MediaCodec config: profile=0x%02x level=0x%02x csd=%d\n", -+ header[4], header[5], size); -+ return 0; -+} -+#endif -+ - static const AVDOVIDecoderConfigurationRecord * - mediacodec_get_dovi_config(AVCodecContext *avctx) - { -@@ -650,6 +684,15 @@ - } - - switch (avctx->codec_id) { -+#if CONFIG_AVS3_MEDIACODEC_DECODER -+ case AV_CODEC_ID_AVS3: -+ codec_mime = "video/avs3"; -+ -+ ret = avs3_set_extradata(avctx, format); -+ if (ret < 0) -+ goto done; -+ break; -+#endif - #if CONFIG_AV1_MEDIACODEC_DECODER - case AV_CODEC_ID_AV1: - codec_mime = "video/av01"; -@@ -1150,6 +1193,10 @@ - DECLARE_MEDIACODEC_VDEC(av1, "AV1", AV_CODEC_ID_AV1, NULL) - #endif - -+#if CONFIG_AVS3_MEDIACODEC_DECODER -+DECLARE_MEDIACODEC_VDEC(avs3, "AVS3", AV_CODEC_ID_AVS3, NULL) -+#endif -+ - #define AD AV_OPT_FLAG_AUDIO_PARAM | AV_OPT_FLAG_DECODING_PARAM - static const AVOption ff_mediacodec_adec_options[] = { - { "ndk_codec", "Use MediaCodec from NDK", -diff --git a/libavcodec/mediacodecdec_common.c b/libavcodec/mediacodecdec_common.c ---- a/libavcodec/mediacodecdec_common.c -+++ b/libavcodec/mediacodecdec_common.c -@@ -1314,6 +1314,7 @@ - { - int32_t profile; - int32_t format_profile; -+ const int hardware_only = avctx->codec_id == AV_CODEC_ID_AVS3; - const char *codec_mime = mime; - - enum AVPixelFormat pix_fmt; -@@ -1321,6 +1322,16 @@ - AV_PIX_FMT_MEDIACODEC, - AV_PIX_FMT_NONE, - }; -+ -+ /* A wrapper can also appear in mpv's ordinary decoder retry list after a -+ * software init failure. Require its explicit MediaCodec hwdec context. */ -+ if (hardware_only && !avctx->hwaccel_context && -+ (!avctx->hw_device_ctx || -+ ((AVHWDeviceContext *)avctx->hw_device_ctx->data)->type != AV_HWDEVICE_TYPE_MEDIACODEC)) { -+ av_log(avctx, AV_LOG_ERROR, -+ "AVS3 MediaCodec requires an explicit hardware context\n"); -+ return AVERROR(EINVAL); -+ } - - pix_fmt = ff_get_format(avctx, pix_fmts); - if (pix_fmt == AV_PIX_FMT_MEDIACODEC) { -@@ -1351,13 +1362,20 @@ - } - - s->codec_name = ff_AMediaCodecList_getCodecNameByType( -- mime, profile, 0, 0, &codec_mime, avctx); -+ mime, profile, 0, hardware_only, &codec_mime, avctx); - if (!s->codec_name && (avctx->hwaccel_flags & AV_HWACCEL_FLAG_ALLOW_PROFILE_MISMATCH)) { - profile = -1; - s->codec_name = ff_AMediaCodecList_getCodecNameByType( -- mime, profile, 0, 0, &codec_mime, avctx); -+ mime, profile, 0, hardware_only, &codec_mime, avctx); - } - if (!s->codec_name) { -+ /* Never bypass hardware-only enumeration with createDecoderByType: -+ * NDK MIME selection can also return a software MediaCodec component. */ -+ if (hardware_only) { -+ av_log(avctx, AV_LOG_ERROR, -+ "No hardware AVS3 MediaCodec decoder for mime %s\n", mime); -+ return AVERROR_DECODER_NOT_FOUND; -+ } - av_log(avctx, AV_LOG_INFO, "Failed to getCodecNameByType(%s, %d)\n", mime, profile); - // getCodecNameByType() can fail due to missing JVM, while NDK - // mediacodec can be used without JVM. -@@ -1389,6 +1407,10 @@ - } - - ff_AMediaFormat_setString(format, "mime", codec_mime); -+ -+ if (hardware_only) -+ av_log(avctx, AV_LOG_INFO, -+ "WebHTV hardware AVS3 MediaCodec decoder: %s\n", s->codec_name); - - return 0; - } diff --git a/third_party/patches/ffmpeg-avs3.patch b/third_party/patches/ffmpeg-avs3.patch deleted file mode 100644 index 5aac70027d..0000000000 --- a/third_party/patches/ffmpeg-avs3.patch +++ /dev/null @@ -1,261 +0,0 @@ -diff --git a/libavcodec/libuavs3d.c b/libavcodec/libuavs3d.c ---- a/libavcodec/libuavs3d.c -+++ b/libavcodec/libuavs3d.c -@@ -32,14 +32,18 @@ - #include "codec_internal.h" - #include "decode.h" - #include "uavs3d.h" -+#include "webhtv_hpm.h" - - typedef struct uavs3d_context { - AVCodecContext *avctx; - void *dec_handle; -+ WebHpm *hpm; - int frame_threads; - int got_seqhdr; - uavs3d_io_frm_t dec_frame; - } uavs3d_context; -+ -+#include "webhtv_hpm_ffmpeg.h" - - #define UAVS3D_CHECK_START_CODE(data_ptr, PIC_START_CODE) \ - (AV_RL32(data_ptr) != (PIC_START_CODE << 24) + AVS3_NAL_START_CODE) -@@ -108,6 +112,9 @@ - uavs3d_io_frm_t *frm_dec = &h->dec_frame; - struct uavs3d_com_seqh_t *seqh = frm_dec->seqhdr; - int ret; -+ -+ if (!seqh) -+ return AVERROR_INVALIDDATA; - - if (UAVS3D_CHECK_INVALID_RANGE(seqh->frame_rate_code, 0, 15)) { - av_log(avctx, AV_LOG_ERROR, "Invalid frame rate code: %d.\n", seqh->frame_rate_code); -@@ -144,6 +151,40 @@ - return 0; - } - -+static int libuavs3d_check_sequence_header(AVCodecContext *avctx, -+ const uint8_t *header, int size) -+{ -+ if (size < 6 || UAVS3D_CHECK_START_CODE(header, AVS3_SEQ_START_CODE)) -+ return AVERROR_INVALIDDATA; -+ -+ uavs3d_context *h = avctx->priv_data; -+ if (header[4] == 0x32) { -+ if (!h->hpm) { -+ h->hpm = webhtv_hpm_create(); -+ if (!h->hpm) -+ return AVERROR(ENOMEM); -+ if (h->dec_handle) { -+ uavs3d_delete(h->dec_handle); -+ h->dec_handle = NULL; -+ } -+ h->got_seqhdr = 0; -+ av_log(avctx, AV_LOG_INFO, "AVS3 software backend: %s\n", webhtv_hpm_version()); -+ } -+ return 0; -+ } -+ if (h->hpm) -+ return AVERROR_PATCHWELCOME; -+ // uavs3d's release build coerces unknown profiles to baseline. Reject the -+ // original byte before parsing either extradata or an in-band sequence. -+ if (header[4] != 0x20 && header[4] != 0x22) { -+ av_log(avctx, AV_LOG_ERROR, -+ "Unsupported AVS3 profile 0x%02x; libuavs3d supports only 0x20/0x22.\n", -+ header[4]); -+ return AVERROR_PATCHWELCOME; -+ } -+ return 0; -+} -+ - static int libuavs3d_decode_extradata(AVCodecContext *avctx) - { - uavs3d_context *h = avctx->priv_data; -@@ -151,19 +192,33 @@ - int header_size = avctx->extradata_size; - uavs3d_io_frm_t *frm_dec = &h->dec_frame; - -- if (avctx->extradata_size < 4) { -- av_log(avctx, AV_LOG_WARNING, "Invalid extradata size %d\n", -- avctx->extradata_size); -- return 0; -- } -+ int ret; -+ -+ if (header_size < 6) -+ return AVERROR_INVALIDDATA; - - if (header[0] == 1) { -- // Skip configurationVersion and sequence_header_length -+ int sequence_size = AV_RB16(header + 1); -+ if (sequence_size < 6 || sequence_size > header_size - 4) -+ return AVERROR_INVALIDDATA; - header += 3; -- // Also remove library_dependency_idc at the end -- header_size -= 4; -- } -- -+ header_size = sequence_size; -+ } -+ ret = libuavs3d_check_sequence_header(avctx, header, header_size); -+ if (ret < 0) -+ return ret; -+ -+ if (h->hpm) { -+ WebHpmFrame unused; -+ int consumed; -+ ret = webhtv_hpm_decode(h->hpm, header, header_size, -+ AV_NOPTS_VALUE, AV_NOPTS_VALUE, &consumed, &unused); -+ if (ret < 0) -+ return libhpm_error(avctx, ret); -+ if (ret || consumed != header_size) -+ return AVERROR_INVALIDDATA; -+ return libhpm_update_info(avctx); -+ } - frm_dec->nal_type = 0; - frm_dec->pkt_pos = 0; - frm_dec->pkt_size = header_size; -@@ -171,24 +226,21 @@ - frm_dec->bs_len = header_size; - frm_dec->pts = 0; - frm_dec->dts = 0; -- uavs3d_decode(h->dec_handle, frm_dec); -- if (frm_dec->nal_type == NAL_SEQ_HEADER) { -- int ret = libuavs3d_on_seq_header(avctx); -- if (ret < 0) -- av_log(avctx, AV_LOG_WARNING, -- "Process sequence header failed, %s\n", av_err2str(ret)); -- } else { -- av_log(avctx, AV_LOG_WARNING, -- "Missing sequence header in extradata\n"); -- } -- -- return 0; -+ ret = uavs3d_decode(h->dec_handle, frm_dec); -+ if (ret < 0) { -+ av_log(avctx, AV_LOG_ERROR, -+ "AVS3 sequence header rejected (%d); supported profiles: 0x20/0x22\n", ret); -+ return ret == ERR_OUT_OF_MEMORY ? AVERROR(ENOMEM) : AVERROR_INVALIDDATA; -+ } -+ if (frm_dec->nal_type != NAL_SEQ_HEADER) -+ return AVERROR_INVALIDDATA; -+ return libuavs3d_on_seq_header(avctx); - } - - static av_cold int libuavs3d_init(AVCodecContext *avctx) - { - uavs3d_context *h = avctx->priv_data; -- uavs3d_cfg_t cdsc; -+ uavs3d_cfg_t cdsc = { 0 }; - - cdsc.frm_threads = avctx->thread_count > 0 ? avctx->thread_count : av_cpu_count(); - cdsc.check_md5 = 0; -@@ -214,6 +266,8 @@ - uavs3d_delete(h->dec_handle); - h->dec_handle = NULL; - } -+ webhtv_hpm_close(h->hpm); -+ h->hpm = NULL; - h->got_seqhdr = 0; - - return 0; -@@ -223,8 +277,16 @@ - { - uavs3d_context *h = avctx->priv_data; - -+ if (h->hpm) { -+ webhtv_hpm_flush(h->hpm); -+ h->got_seqhdr = 0; -+ if (avctx->extradata && libuavs3d_decode_extradata(avctx) < 0) -+ av_log(avctx, AV_LOG_ERROR, "AVS3 HPM sequence reset failed\n"); -+ } - if (h->dec_handle) { - uavs3d_reset(h->dec_handle); -+ h->dec_frame.got_pic = 0; -+ h->dec_frame.priv = NULL; - } - } - -@@ -240,6 +302,13 @@ - int ret, finish = 0; - - *got_frame = 0; -+ if (buf_size >= 6 && !UAVS3D_CHECK_START_CODE(buf, AVS3_SEQ_START_CODE)) { -+ ret = libuavs3d_check_sequence_header(avctx, buf, buf_size); -+ if (ret < 0) -+ return ret; -+ } -+ if (h->hpm) -+ return libhpm_decode_frame(avctx, frm, got_frame, avpkt); - frm->pts = -1; - frm->pict_type = AV_PICTURE_TYPE_NONE; - -@@ -261,6 +330,11 @@ - while (!finish) { - int bs_len; - -+ if (buf_end - buf_ptr < 4) { -+ av_frame_unref(frm); -+ return AVERROR_INVALIDDATA; -+ } -+ - if (h->got_seqhdr) { - if (!frm->data[0] && (ret = ff_get_buffer(avctx, frm, 0)) < 0) { - return ret; -@@ -274,11 +348,31 @@ - bs_len = buf_end - buf_ptr; - finish = 1; - } -+ if (!UAVS3D_CHECK_START_CODE(buf_ptr, AVS3_SEQ_START_CODE)) { -+ ret = libuavs3d_check_sequence_header(avctx, buf_ptr, bs_len); -+ if (ret < 0) { -+ av_frame_unref(frm); -+ return ret; -+ } -+ } -+ if (h->hpm) { -+ AVPacket remainder = *avpkt; -+ remainder.data = (uint8_t *)buf_ptr; -+ remainder.size = buf_end - buf_ptr; -+ av_frame_unref(frm); -+ ret = libhpm_decode_frame(avctx, frm, got_frame, &remainder); -+ return ret < 0 ? ret : (int)(buf_ptr - buf) + ret; -+ } - frm_dec->bs = (unsigned char *)buf_ptr; - frm_dec->bs_len = bs_len; - frm_dec->pts = avpkt->pts; - frm_dec->dts = avpkt->dts; -- uavs3d_decode(h->dec_handle, frm_dec); -+ ret = uavs3d_decode(h->dec_handle, frm_dec); -+ if (ret < 0) { -+ av_log(avctx, AV_LOG_ERROR, "AVS3 decode failed (%d)\n", ret); -+ av_frame_unref(frm); -+ return ret == ERR_OUT_OF_MEMORY ? AVERROR(ENOMEM) : AVERROR_INVALIDDATA; -+ } - buf_ptr += bs_len; - - if (frm_dec->nal_type == NAL_SEQ_HEADER) { -@@ -298,12 +392,12 @@ - av_frame_unref(frm); - } - -- return buf_ptr - buf; -+ return buf_size ? buf_ptr - buf : 0; - } - - const FFCodec ff_libuavs3d_decoder = { - .p.name = "libuavs3d", -- CODEC_LONG_NAME("libuavs3d AVS3-P2/IEEE1857.10"), -+ CODEC_LONG_NAME("AVS3-P2/IEEE1857.10 (uavs3d baseline / HPM 15.0 High 10-bit)"), - .p.type = AVMEDIA_TYPE_VIDEO, - .p.id = AV_CODEC_ID_AVS3, - .priv_data_size = sizeof(uavs3d_context), -@@ -311,7 +405,7 @@ - .close = libuavs3d_end, - FF_CODEC_DECODE_CB(libuavs3d_decode_frame), - .p.capabilities = AV_CODEC_CAP_DR1 | AV_CODEC_CAP_DELAY | AV_CODEC_CAP_OTHER_THREADS, -- .caps_internal = FF_CODEC_CAP_NOT_INIT_THREADSAFE | -+ .caps_internal = FF_CODEC_CAP_INIT_CLEANUP | FF_CODEC_CAP_NOT_INIT_THREADSAFE | - FF_CODEC_CAP_AUTO_THREADS, - .flush = libuavs3d_flush, - .p.wrapper_name = "libuavs3d", diff --git a/third_party/patches/ffmpeg-mediacodec-diagnostics.patch b/third_party/patches/ffmpeg-mediacodec-diagnostics.patch deleted file mode 100644 index 559ae14a7a..0000000000 --- a/third_party/patches/ffmpeg-mediacodec-diagnostics.patch +++ /dev/null @@ -1,226 +0,0 @@ ---- a/libavcodec/mediacodec_wrapper.c -+++ b/libavcodec/mediacodec_wrapper.c -@@ -495,8 +495,11 @@ - int encoder, int hardware_only, const char **codec_mime, - void *log_ctx) - { -- int i; -- int codec_count; -+ int i = -1; -+ int codec_count = 0; -+ int diag_query_complete = 0; -+ const char *diag_stage = "jni-env"; -+ const char *diag_reason = "not-visited"; - int found_codec = 0; - char *name = NULL; - char *supported_type = NULL; -@@ -518,16 +521,24 @@ - if (codec_mime) - *codec_mime = mime; - -- JNI_GET_ENV_OR_RETURN(env, log_ctx, NULL); -- -+ av_log(log_ctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-lookup v=1 phase=begin mime=%s profile=%d hardware=%d\n", mime, profile, hardware_only); -+ env = ff_jni_get_env(log_ctx); -+ if (!env) { -+ av_log(log_ctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-lookup v=1 phase=end outcome=jvm-unavailable stage=jni-env\n"); -+ return NULL; -+ } -+ -+ diag_stage = "codec-list-jni-bindings"; - if (ff_jni_init_jfields(env, &jfields, jni_amediacodeclist_mapping, 0, log_ctx) < 0) { - goto done; - } - -+ diag_stage = "format-jni-bindings"; - if (ff_jni_init_jfields(env, &mediaformat_jfields, jni_amediaformat_mapping, 0, log_ctx) < 0) { - goto done; - } - -+ diag_stage = "codec-count"; - codec_count = (*env)->CallStaticIntMethod(env, jfields.mediacodec_list_class, jfields.get_codec_count_id); - if (ff_jni_exception_check(env, 1, log_ctx) < 0) { - goto done; -@@ -544,26 +555,32 @@ - const char *mime_alias; - const char *selected_mime = NULL; - -+ diag_stage = "codec-info"; -+ diag_reason = "profile-mismatch"; - info = (*env)->CallStaticObjectMethod(env, jfields.mediacodec_list_class, jfields.get_codec_info_at_id, i); - if (ff_jni_exception_check(env, 1, log_ctx) < 0) { - goto done; - } - -+ diag_stage = "supported-types"; - types = (*env)->CallObjectMethod(env, info, jfields.get_supported_types_id); - if (ff_jni_exception_check(env, 1, log_ctx) < 0) { - goto done; - } - -+ diag_stage = "encoder-flag"; - is_encoder = (*env)->CallBooleanMethod(env, info, jfields.is_encoder_id); - if (ff_jni_exception_check(env, 1, log_ctx) < 0) { - goto done; - } - - if (is_encoder != encoder) { -+ diag_reason = "encoder-mismatch"; - goto done_with_info; - } - - if (hardware_only && jfields.is_hardware_accelerated_id) { -+ diag_stage = "hardware-flag"; - int is_hardware_accelerated = (*env)->CallBooleanMethod( - env, info, jfields.is_hardware_accelerated_id); - if (ff_jni_exception_check(env, 1, log_ctx) < 0) { -@@ -571,21 +588,25 @@ - } - - if (!is_hardware_accelerated) { -+ diag_reason = "not-hardware-accelerated"; - goto done_with_info; - } - } - - if (jfields.is_software_only_id) { -+ diag_stage = "software-flag"; - int is_software_only = (*env)->CallBooleanMethod(env, info, jfields.is_software_only_id); - if (ff_jni_exception_check(env, 1, log_ctx) < 0) { - goto done; - } - - if (is_software_only) { -+ diag_reason = "software-only"; - goto done_with_info; - } - } - -+ diag_stage = "codec-name"; - codec_name = (*env)->CallObjectMethod(env, info, jfields.get_name_id); - if (ff_jni_exception_check(env, 1, log_ctx) < 0) { - goto done; -@@ -608,9 +629,11 @@ - !strcmp(name, "OMX.qcom.video.decoder.hevcswvdec") || - (hardware_only && (strstr(name, ".sw.") || - strstr(name, "software")))) { -+ diag_reason = "software-name-policy"; - goto done_with_info; - } - -+ diag_stage = "mime-matching"; - mime_alias = get_codec_mime_alias(name, mime); - type_count = (*env)->GetArrayLength(env, types); - /* Prefer the canonical MIME if a decoder exposes both forms. */ -@@ -644,17 +667,21 @@ - break; - } - -- if (type_index < 0) -+ if (type_index < 0) { -+ diag_reason = "mime-mismatch"; - goto done_with_info; -+ } - - type = (*env)->GetObjectArrayElement(env, types, type_index); - if (ff_jni_exception_check(env, 1, log_ctx) < 0) - goto done; - -+ diag_stage = "capabilities-query"; - capabilities = (*env)->CallObjectMethod(env, info, jfields.get_codec_capabilities_id, type); - if (ff_jni_exception_check(env, 1, log_ctx) < 0) - goto done; - -+ diag_stage = "profile-levels-query"; - profile_levels = (*env)->GetObjectField(env, capabilities, jfields.profile_levels_id); - if (ff_jni_exception_check(env, 1, log_ctx) < 0) - goto done; -@@ -679,6 +706,8 @@ - goto done; - - found_codec = profile == supported_profile; -+ av_log(log_ctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-profile v=1 candidate=%d name=%s requested=%d supported=%d accepted=%d\n", -+ i, name, profile, supported_profile, found_codec); - - (*env)->DeleteLocalRef(env, profile_level); - profile_level = NULL; -@@ -700,6 +729,8 @@ - *codec_mime = selected_mime; - - done_with_info: -+ av_log(log_ctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-candidate v=1 index=%d name=%s accepted=%d reason=%s\n", -+ i, name ? name : "not-queried", found_codec, found_codec ? "selected" : diag_reason); - (*env)->DeleteLocalRef(env, info); - info = NULL; - -@@ -712,8 +743,12 @@ - - av_freep(&name); - } -+ diag_query_complete = 1; - - done: -+ av_log(log_ctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-lookup v=1 phase=end outcome=%s stage=%s candidate=%d count=%d name=%s\n", -+ found_codec ? "selected" : diag_query_complete ? "no-eligible-candidate" : "query-failed", -+ diag_stage, i, codec_count, name ? name : "none"); - (*env)->DeleteLocalRef(env, codec_name); - (*env)->DeleteLocalRef(env, info); - (*env)->DeleteLocalRef(env, type); ---- a/libavcodec/mediacodecdec_common.c -+++ b/libavcodec/mediacodecdec_common.c -@@ -1362,12 +1362,15 @@ - // getCodecNameByType() can fail due to missing JVM, while NDK - // mediacodec can be used without JVM. - if (!s->use_ndk_codec) { -+ av_log(avctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-operation v=1 operation=create phase=not-attempted reason=java-lookup-failed\n"); - return AVERROR_EXTERNAL; - } - } else { - av_log(avctx, AV_LOG_DEBUG, "Found decoder %s\n", s->codec_name); - } - -+ av_log(avctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-operation v=1 operation=create phase=begin backend=%s method=%s name=%s\n", -+ s->use_ndk_codec ? "ndk" : "jni", s->codec_name ? "by-name" : "by-mime", s->codec_name ? s->codec_name : mime); - if (s->codec_name) - s->codec = ff_AMediaCodec_createCodecByName(s->codec_name, s->use_ndk_codec); - else { -@@ -1378,6 +1381,7 @@ - s->codec_name = av_strdup(mime); - } - } -+ av_log(avctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-operation v=1 operation=create phase=end outcome=%s\n", s->codec ? "created" : "null"); - if (!s->codec) { - av_log(avctx, AV_LOG_ERROR, "Failed to create media decoder for type %s and name %s\n", - codec_mime, s->codec_name); -@@ -1402,8 +1406,11 @@ - return AVERROR_EXTERNAL; - } - -+ av_log(avctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-operation v=1 operation=create phase=begin backend=%s method=by-name name=%s\n", -+ s->use_ndk_codec ? "ndk" : "jni", s->codec_name); - s->codec = ff_AMediaCodec_createCodecByName(s->codec_name, - s->use_ndk_codec); -+ av_log(avctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-operation v=1 operation=create phase=end outcome=%s\n", s->codec ? "created" : "null"); - if (!s->codec) { - av_log(avctx, AV_LOG_ERROR, - "Failed to create hardware audio MediaCodec decoder %s for mime %s\n", -@@ -1445,7 +1452,9 @@ - if (ret < 0) - goto fail; - -+ av_log(avctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-operation v=1 operation=configure phase=begin name=%s surface=%d\n", s->codec_name, !!s->surface); - status = ff_AMediaCodec_configure(s->codec, format, s->surface, NULL, 0); -+ av_log(avctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-operation v=1 operation=configure phase=end code=%d\n", status); - if (status < 0) { - char *desc = ff_AMediaFormat_toString(format); - av_log(avctx, AV_LOG_ERROR, -@@ -1457,7 +1466,9 @@ - goto fail; - } - -+ av_log(avctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-operation v=1 operation=start phase=begin name=%s\n", s->codec_name); - status = ff_AMediaCodec_start(s->codec); -+ av_log(avctx, AV_LOG_INFO, "WebHTV AV-DIAG codec-operation v=1 operation=start phase=end code=%d\n", status); - if (status < 0) { - char *desc = ff_AMediaFormat_toString(format); - av_log(avctx, AV_LOG_ERROR, diff --git a/third_party/patches/ffmpeg-mediacodec-output-serialization.patch b/third_party/patches/ffmpeg-mediacodec-output-serialization.patch deleted file mode 100644 index dbf8130ee4..0000000000 --- a/third_party/patches/ffmpeg-mediacodec-output-serialization.patch +++ /dev/null @@ -1,152 +0,0 @@ ---- a/libavcodec/mediacodec.c -+++ b/libavcodec/mediacodec.c -@@ -101,7 +101,9 @@ - return AVERROR(EINVAL); - - MediaCodecDecContext *ctx = buffer->ctx; -+ ff_mutex_lock(&ctx->output_mutex); - int released = atomic_fetch_add(&buffer->released, 1); -+ int result = 0; - - if (!released && (ctx->delay_flush || buffer->serial == atomic_load(&ctx->serial))) { - atomic_fetch_sub(&ctx->hw_buffer_count, 1); -@@ -109,10 +111,11 @@ - "Releasing output buffer %zd (%p) ts=%"PRId64" with render=%d [%d pending]\n", - buffer->index, buffer, buffer->pts, render, atomic_load(&ctx->hw_buffer_count)); - int ret = ff_AMediaCodec_releaseOutputBuffer(ctx->codec, buffer->index, render); -- return ret < 0 ? ret : 1; -+ result = ret < 0 ? ret : 1; - } - -- return 0; -+ ff_mutex_unlock(&ctx->output_mutex); -+ return result; - } - - int av_mediacodec_release_buffer(AVMediaCodecBuffer *buffer, int render) -@@ -124,17 +127,20 @@ - int av_mediacodec_render_buffer_at_time(AVMediaCodecBuffer *buffer, int64_t time) - { - MediaCodecDecContext *ctx = buffer->ctx; -+ ff_mutex_lock(&ctx->output_mutex); - int released = atomic_fetch_add(&buffer->released, 1); -+ int result = 0; - - if (!released && (ctx->delay_flush || buffer->serial == atomic_load(&ctx->serial))) { - atomic_fetch_sub(&ctx->hw_buffer_count, 1); - av_log(ctx, AV_LOG_DEBUG, - "Rendering output buffer %zd (%p) ts=%"PRId64" with time=%"PRId64" [%d pending]\n", - buffer->index, buffer, buffer->pts, time, atomic_load(&ctx->hw_buffer_count)); -- return ff_AMediaCodec_releaseOutputBufferAtTime(ctx->codec, buffer->index, time); -+ result = ff_AMediaCodec_releaseOutputBufferAtTime(ctx->codec, buffer->index, time); - } - -- return 0; -+ ff_mutex_unlock(&ctx->output_mutex); -+ return result; - } - - #else ---- a/libavcodec/mediacodecdec_common.c -+++ b/libavcodec/mediacodecdec_common.c -@@ -547,6 +547,8 @@ - } - - av_freep(&s->codec_name); -+ if (s->output_mutex_initialized) -+ ff_mutex_destroy(&s->output_mutex); - av_freep(&s); - } - } -@@ -555,15 +557,7 @@ - { - AVMediaCodecBuffer *buffer = opaque; - MediaCodecDecContext *ctx = buffer->ctx; -- int released = atomic_load(&buffer->released); -- -- if (!released && (ctx->delay_flush || buffer->serial == atomic_load(&ctx->serial))) { -- atomic_fetch_sub(&ctx->hw_buffer_count, 1); -- av_log(ctx, AV_LOG_DEBUG, -- "Releasing output buffer %zd (%p) ts=%"PRId64" on free() [%d pending]\n", -- buffer->index, buffer, buffer->pts, atomic_load(&ctx->hw_buffer_count)); -- ff_AMediaCodec_releaseOutputBuffer(ctx->codec, buffer->index, 0); -- } -+ av_mediacodec_release_buffer(buffer, 0); - - ff_mediacodec_dec_unref(ctx); - av_freep(&buffer); -@@ -1291,18 +1285,22 @@ - FFAMediaCodec *codec = s->codec; - int status; - -+ // Output buffers are held by the VO on another thread. Keep the serial -+ // change and platform flush atomic with their validation/release calls. -+ ff_mutex_lock(&s->output_mutex); - s->output_buffer_count = 0; - - s->draining = 0; - s->flushing = 0; - s->eos = 0; - atomic_fetch_add(&s->serial, 1); -- atomic_init(&s->hw_buffer_count, 0); -+ atomic_store(&s->hw_buffer_count, 0); - s->current_input_buffer = -1; - mediacodec_packet_props_clear(s); - av_buffer_unref(&s->hdr10_plus_metadata); - - status = ff_AMediaCodec_flush(codec); -+ ff_mutex_unlock(&s->output_mutex); - if (status < 0) { - av_log(avctx, AV_LOG_ERROR, "Failed to flush codec\n"); - return AVERROR_EXTERNAL; -@@ -1430,6 +1428,13 @@ - atomic_init(&s->hw_buffer_count, 0); - atomic_init(&s->serial, 1); - s->current_input_buffer = -1; -+ ret = ff_mutex_init(&s->output_mutex, NULL); -+ if (ret) { -+ ret = AVERROR(ret); -+ goto fail; -+ } -+ s->output_mutex_initialized = true; -+ av_log(avctx, AV_LOG_VERBOSE, "WebHTV MediaCodec output release/flush serialization enabled\n"); - - if (avctx->codec_type == AVMEDIA_TYPE_AUDIO) - ret = mediacodec_dec_get_audio_codec(avctx, s, mime, format); -@@ -1819,6 +1824,8 @@ - mediacodec_packet_props_clear(s); - av_buffer_unref(&s->hdr10_plus_metadata); - -+ if (s->output_mutex_initialized) -+ ff_mutex_lock(&s->output_mutex); - if (s->codec) { - if (atomic_load(&s->hw_buffer_count) == 0) { - ff_AMediaCodec_stop(s->codec); -@@ -1828,6 +1835,8 @@ - } - } - -+ if (s->output_mutex_initialized) -+ ff_mutex_unlock(&s->output_mutex); - ff_mediacodec_dec_unref(s); - - return 0; ---- a/libavcodec/mediacodecdec_common.h -+++ b/libavcodec/mediacodecdec_common.h -@@ -30,6 +30,7 @@ - - #include "libavutil/frame.h" - #include "libavutil/pixfmt.h" -+#include "libavutil/thread.h" - - #include "avcodec.h" - #include "mediacodec_wrapper.h" -@@ -41,6 +42,8 @@ - const AVClass *avclass; - atomic_int refcount; - atomic_int hw_buffer_count; -+ AVMutex output_mutex; -+ bool output_mutex_initialized; - - char *codec_name; - diff --git a/third_party/patches/media3-exo-alac-wave.patch b/third_party/patches/media3-exo-alac-wave.patch deleted file mode 100644 index 728c2ce256..0000000000 --- a/third_party/patches/media3-exo-alac-wave.patch +++ /dev/null @@ -1,72 +0,0 @@ -diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java ---- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java -+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java -@@ -2280,0 +2281,17 @@ public final class BoxParser { -+ } else if (MimeTypes.AUDIO_ALAC.equals(mimeType)) { -+ // Some QuickTime files wrap the ALAC atom in a wave container without an esds atom. -+ // Keep initializationData in Media3's canonical magic-cookie form. -+ byte[] initializationDataBytes = -+ parseAlacInitializationDataFromWave(parent, childPosition, childAtomSize); -+ if (initializationDataBytes != null) { -+ int[] parsedAlacConfig = -+ CodecSpecificDataUtil.parseAlacAudioSpecificConfig(initializationDataBytes); -+ sampleRate = parsedAlacConfig[0]; -+ channelCount = parsedAlacConfig[1]; -+ int bitDepth = parsedAlacConfig[2]; -+ pcmEncoding = Util.getPcmEncoding(bitDepth); -+ if (pcmEncoding == C.ENCODING_INVALID) { -+ pcmEncoding = Format.NO_VALUE; -+ } -+ initializationData = ImmutableList.of(initializationDataBytes); -+ } -@@ -2408,0 +2426,26 @@ public final class BoxParser { -+ /** Returns the ALAC magic cookie nested in a QuickTime {@code wave} container, if present. */ -+ @Nullable -+ /* package */ static byte[] parseAlacInitializationDataFromWave( -+ ParsableByteArray parent, int wavePosition, int waveSize) throws ParserException { -+ int childPosition = wavePosition + Mp4Box.HEADER_SIZE; -+ while (childPosition - wavePosition < waveSize) { -+ parent.setPosition(childPosition); -+ int childAtomSize = parent.readInt(); -+ ExtractorUtil.checkContainerInput( -+ childAtomSize >= Mp4Box.HEADER_SIZE, "childAtomSize too small"); -+ int childAtomType = parent.readInt(); -+ ExtractorUtil.checkContainerInput( -+ childPosition + childAtomSize <= wavePosition + waveSize, -+ "child atom exceeds wave container"); -+ if (childAtomType == Mp4Box.TYPE_alac) { -+ int magicCookieSize = childAtomSize - Mp4Box.FULL_HEADER_SIZE; -+ ExtractorUtil.checkContainerInput(magicCookieSize > 0, "ALAC atom has no magic cookie"); -+ byte[] magicCookie = new byte[magicCookieSize]; -+ parent.setPosition(childPosition + Mp4Box.FULL_HEADER_SIZE); -+ parent.readBytes(magicCookie, 0, magicCookieSize); -+ return magicCookie; -+ } -+ childPosition += childAtomSize; -+ } -+ return null; -+ } -+ -diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/mp4/BoxParserTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/mp4/BoxParserTest.java ---- a/libraries/extractor/src/test/java/androidx/media3/extractor/mp4/BoxParserTest.java -+++ b/libraries/extractor/src/test/java/androidx/media3/extractor/mp4/BoxParserTest.java -@@ -45,0 +46,6 @@ public final class BoxParserTest { -+ private static final byte[] ALAC_WAVE_BOX = -+ Util.getBytesFromHexString( -+ "0000002C77617665" -+ + "00000024616C616300000000" -+ + "0010280A0E0600FF0001E0010000000000000000BB80"); -+ -@@ -262,0 +269,11 @@ public final class BoxParserTest { -+ @Test -+ public void parseAlacInitializationDataFromWave_returnsMagicCookie() throws ParserException { -+ byte[] magicCookie = -+ BoxParser.parseAlacInitializationDataFromWave( -+ new ParsableByteArray(ALAC_WAVE_BOX), /* wavePosition= */ 0, ALAC_WAVE_BOX.length); -+ -+ assertThat(magicCookie) -+ .isEqualTo( -+ Util.getBytesFromHexString( -+ "0010280A0E0600FF0001E0010000000000000000BB80")); -+ } -+ diff --git a/third_party/patches/media3-exo-ass-observer.patch b/third_party/patches/media3-exo-ass-observer.patch deleted file mode 100644 index 3003fbf280..0000000000 --- a/third_party/patches/media3-exo-ass-observer.patch +++ /dev/null @@ -1,234 +0,0 @@ -diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java ---- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java -+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java -@@ -74,6 +74,43 @@ - private static final String TAG = "TextRenderer"; - - /** -+ * Optional side-channel for a separately owned subtitle presenter. -+ * -+ *

All callbacks run on the playback thread and must return without waiting, native calls or -+ * I/O. A sample view is valid only for the duration of its callback; consumers must bound and copy -+ * the remaining bytes before returning. Cue output and renderer scheduling are never replaced. -+ */ -+ public interface Observer { -+ void onStream(Stream stream, long generation); -+ void onReset(Stream stream, long generation, long positionUs); -+ void onSample(Stream stream, long generation, Format format, ByteBuffer data, long timeUs); -+ void onClock( -+ Stream readingStream, @Nullable Stream displayingStream, long generation, -+ long positionUs, long textOffsetUs, boolean started, boolean ended); -+ void onCues(@Nullable Stream displayingStream, long generation, CueGroup cues); -+ void onDisabled(long generation); -+ } -+ -+ /** Immutable identity of a selected SampleStream, distinct from a seek generation. */ -+ public static final class Stream { -+ public final long sequence; -+ public final Format format; -+ public final MediaSource.MediaPeriodId mediaPeriodId; -+ public final long startPositionUs; -+ public final long offsetUs; -+ -+ private Stream( -+ long sequence, Format format, MediaSource.MediaPeriodId mediaPeriodId, -+ long startPositionUs, long offsetUs) { -+ this.sequence = sequence; -+ this.format = format; -+ this.mediaPeriodId = mediaPeriodId; -+ this.startPositionUs = startPositionUs; -+ this.offsetUs = offsetUs; -+ } -+ } -+ -+ /** - * The type of a message that can be passed to this renderer via {@link - * androidx.media3.exoplayer.ExoPlayer#createMessage( - * androidx.media3.exoplayer.PlayerMessage.Target)} to set the text display offset, in -@@ -147,6 +184,45 @@ - private long textOffsetUs; - private boolean textOffsetChanged; - private boolean legacyDecodingEnabled; -+ -+ @Nullable private Observer observer; -+ @Nullable private Stream observerReadingStream; -+ @Nullable private Stream observerDecoderStream; -+ @Nullable private Stream observerDisplayingStream; -+ private long observerStreamSequence; -+ private long observerGeneration; -+ -+ /** Installs an observer before enabling this renderer. Null preserves the original path. */ -+ public void setObserver(@Nullable Observer observer) { -+ checkState(getState() == STATE_DISABLED); -+ this.observer = observer; -+ } -+ -+ private void observerFailed(RuntimeException error) { -+ Observer failed = observer; -+ observer = null; -+ Log.e(TAG, "Subtitle observer disabled", error); -+ if (failed != null) { -+ try { -+ failed.onDisabled(++observerGeneration); -+ } catch (RuntimeException ignored) { -+ // Observers must never break the compatible subtitle or audio/video path. -+ } -+ } -+ } -+ -+ private void notifyObserverClock(long positionUs) { -+ if (observer == null || observerReadingStream == null || positionUs == C.TIME_UNSET) { -+ return; -+ } -+ try { -+ observer.onClock( -+ observerReadingStream, observerDisplayingStream, observerGeneration, -+ positionUs, textOffsetUs, getState() == STATE_STARTED, outputStreamEnded); -+ } catch (RuntimeException error) { -+ observerFailed(error); -+ } -+ } - - /** - * @param output The output. -@@ -246,6 +322,18 @@ - textOffsetChanged = true; - if (lastRendererPositionUs != C.TIME_UNSET) { -- clearOutput(); -+ if (observer != null && subtitle != null -+ && observerDisplayingStream == observerReadingStream -+ && decoderReplacementState == REPLACEMENT_STATE_NONE) { -+ long positionUs = getTextPositionUs(lastRendererPositionUs); -+ nextSubtitleEventIndex = subtitle.getNextEventTimeIndex(positionUs); -+ long presentationTimeUs = getPresentationTimeUs( -+ getRendererPositionUs(getCurrentEventTimeUs(positionUs))); -+ updateOutput(new CueGroup(subtitle.getCues(positionUs), presentationTimeUs)); -+ textOffsetChanged = false; -+ } else { -+ clearOutput(); -+ } -+ notifyObserverClock(lastRendererPositionUs); - } - } - -@@ -261,6 +338,15 @@ - long offsetUs, - MediaSource.MediaPeriodId mediaPeriodId) { - streamFormat = formats[0]; -+ if (observer != null) { -+ observerReadingStream = new Stream( -+ ++observerStreamSequence, streamFormat, mediaPeriodId, startPositionUs, offsetUs); -+ try { -+ observer.onStream(observerReadingStream, ++observerGeneration); -+ } catch (RuntimeException error) { -+ observerFailed(error); -+ } -+ } - if (!isCuesWithTiming(streamFormat)) { - assertLegacyDecodingEnabledIfRequired(); - if (subtitleDecoder != null) { -@@ -280,6 +366,14 @@ - protected void onPositionReset( - long positionUs, boolean joining, boolean sampleStreamIsResetToKeyFrame) { - lastRendererPositionUs = positionUs; -+ observerDisplayingStream = observerReadingStream; -+ if (observer != null && observerReadingStream != null) { -+ try { -+ observer.onReset(observerReadingStream, ++observerGeneration, positionUs); -+ } catch (RuntimeException error) { -+ observerFailed(error); -+ } -+ } - if (cuesResolver != null) { - cuesResolver.clear(); - } -@@ -310,6 +404,7 @@ - } - - if (outputStreamEnded) { -+ notifyObserverClock(positionUs); - return; - } - -@@ -320,6 +415,17 @@ - assertLegacyDecodingEnabledIfRequired(); - renderFromSubtitles(positionUs); - } -+ notifyObserverClock(positionUs); -+ } -+ -+ @Override -+ protected void onStarted() { -+ notifyObserverClock(lastRendererPositionUs); -+ } -+ -+ @Override -+ protected void onStopped() { -+ notifyObserverClock(lastRendererPositionUs); - } - - /** -@@ -479,6 +585,7 @@ - } - nextSubtitleEventIndex = nextSubtitle.getNextEventTimeIndex(subtitlePositionUs); - subtitle = nextSubtitle; -+ observerDisplayingStream = observerDecoderStream; - this.nextSubtitle = null; - textRendererNeedsUpdate = true; - } -@@ -553,6 +660,16 @@ - } - nextInputBuffer.subsampleOffsetUs = format.subsampleOffsetUs; - nextInputBuffer.flip(); -+ if (observer != null && observerReadingStream != null -+ && !nextInputBuffer.isEncrypted() && nextInputBuffer.data != null) { -+ try { -+ observer.onSample( -+ observerReadingStream, observerGeneration, format, -+ nextInputBuffer.data.asReadOnlyBuffer(), nextInputBuffer.timeUs); -+ } catch (RuntimeException error) { -+ observerFailed(error); -+ } -+ } - waitingForKeyFrame &= !nextInputBuffer.isKeyFrame(); - } - if (!waitingForKeyFrame) { -@@ -570,6 +687,16 @@ - - @Override - protected void onDisabled() { -+ if (observer != null) { -+ try { -+ observer.onDisabled(++observerGeneration); -+ } catch (RuntimeException error) { -+ observerFailed(error); -+ } -+ } -+ observerReadingStream = null; -+ observerDisplayingStream = null; -+ observerDecoderStream = null; - streamFormat = null; - finalStreamEndPositionUs = C.TIME_UNSET; - clearOutput(); -@@ -643,6 +770,7 @@ - } - - private void initSubtitleDecoder() { -+ observerDecoderStream = observerReadingStream; - waitingForKeyFrame = true; - subtitleDecoder = subtitleDecoderFactory.createDecoder(checkNotNull(streamFormat)); - subtitleDecoder.setOutputStartTimeUs(getLastResetPositionUs()); -@@ -664,6 +792,13 @@ - } - - private void updateOutput(CueGroup cueGroup) { -+ if (observer != null) { -+ try { -+ observer.onCues(observerDisplayingStream, observerGeneration, cueGroup); -+ } catch (RuntimeException error) { -+ observerFailed(error); -+ } -+ } - if (outputHandler != null) { - outputHandler.obtainMessage(MSG_UPDATE_OUTPUT, cueGroup).sendToTarget(); - } else { diff --git a/third_party/patches/media3-exo-av3a-dash-channel-config.patch b/third_party/patches/media3-exo-av3a-dash-channel-config.patch deleted file mode 100644 index b0186a4da7..0000000000 --- a/third_party/patches/media3-exo-av3a-dash-channel-config.patch +++ /dev/null @@ -1,121 +0,0 @@ -diff --git a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java -index ac9e00a938a..b02602f81e0 100644 ---- a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java -+++ b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java -@@ -103,10 +103,14 @@ public class DashManifestParser extends DefaultHandler - private static final int[] MPEG_CHANNEL_CONFIGURATION_MAPPING = - new int[] { - Format.NO_VALUE, 1, 2, 3, 4, 5, 6, 8, 2, 3, 4, 7, 8, 24, 8, 12, 10, 12, 14, 12, 14 - }; - -+ /** Maps AVS3 P7 Audio Vivid DASH channel configuration values to channel counts. */ -+ private static final String AVS3A_P7_AUDIO_CHANNEL_CONFIGURATION_SCHEME = -+ "urn:avs:avs3:p7:2024:audio_channel_configuration"; -+ - private final XmlPullParserFactory xmlParserFactory; - - public DashManifestParser() { - try { - xmlParserFactory = XmlPullParserFactory.newInstance(); -@@ -1607,10 +1611,13 @@ public class DashManifestParser extends DefaultHandler - audioChannels = parseDolbyChannelConfiguration(xpp); - break; - case "tag:dolby.com,2015:dash:audio_channel_configuration:2015": - audioChannels = parseDolbyAC4ChannelConfiguration(xpp, codecs); - break; -+ case AVS3A_P7_AUDIO_CHANNEL_CONFIGURATION_SCHEME: -+ audioChannels = parseAvs3aP7ChannelConfiguration(xpp); -+ break; - default: - audioChannels = Format.NO_VALUE; - break; - } - do { -@@ -2091,10 +2098,31 @@ public class DashManifestParser extends DefaultHandler - return 0 <= index && index < MPEG_CHANNEL_CONFIGURATION_MAPPING.length - ? MPEG_CHANNEL_CONFIGURATION_MAPPING[index] - : Format.NO_VALUE; - } - -+ /** -+ * Parses the AVS3 P7 Audio Vivid channel configuration values used by the test-library MPDs. -+ * Unknown values remain unspecified rather than being guessed as a speaker layout. -+ */ -+ protected static int parseAvs3aP7ChannelConfiguration(XmlPullParser xpp) { -+ @Nullable String value = xpp.getAttributeValue(null, "value"); -+ if (value == null) { -+ return Format.NO_VALUE; -+ } -+ switch (Ascii.toLowerCase(value)) { -+ case "f20101": -+ return 2; -+ case "f20203": -+ return 6; -+ case "f20a04": -+ return 12; -+ default: -+ return Format.NO_VALUE; -+ } -+ } -+ - /** - * Parses the number of channels from the value attribute of an AudioChannelConfiguration with - * schemeIdUri "tag:dts.com,2014:dash:audio_channel_configuration:2012" as defined by Annex G - * (3.2) in ETSI TS 102 114 V1.6.1, or by the legacy schemeIdUri - * "urn:dts:dash:audio_channel_configuration:2012". -diff --git a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java -index 9353be2b46c..ed45c11cba2 100644 ---- a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java -+++ b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java -@@ -675,10 +675,51 @@ public class DashManifestParserTest { - DashManifestParser.parseCea708AccessibilityChannel( - buildCea708AccessibilityDescriptors("Wrong format"))) - .isEqualTo(Format.NO_VALUE); - } - -+ @Test -+ public void parseAvs3aP7ChannelConfiguration() throws Exception { -+ DashManifestParser parser = new DashManifestParser(); -+ assertThat( -+ parseAvs3aP7ChannelConfiguration( -+ parser, -+ "")) -+ .isEqualTo(2); -+ assertThat( -+ parseAvs3aP7ChannelConfiguration( -+ parser, -+ "")) -+ .isEqualTo(6); -+ assertThat( -+ parseAvs3aP7ChannelConfiguration( -+ parser, -+ "")) -+ .isEqualTo(12); -+ assertThat( -+ parseAvs3aP7ChannelConfiguration( -+ parser, -+ "")) -+ .isEqualTo(Format.NO_VALUE); -+ } -+ -+ private static int parseAvs3aP7ChannelConfiguration(DashManifestParser parser, String xml) -+ throws Exception { -+ XmlPullParser xpp = XmlPullParserFactory.newInstance().newPullParser(); -+ xpp.setInput(new StringReader(xml + NEXT_TAG)); -+ xpp.next(); -+ return parser.parseAudioChannelConfiguration(xpp, "av3a.02"); -+ } -+ - @Test - public void parsePeriodAssetIdentifier() throws IOException { - DashManifestParser parser = new DashManifestParser(); - DashManifest manifest = - parser.parse( diff --git a/third_party/patches/media3-exo-av3a-mp4.patch b/third_party/patches/media3-exo-av3a-mp4.patch deleted file mode 100644 index 3e6e6c7681..0000000000 --- a/third_party/patches/media3-exo-av3a-mp4.patch +++ /dev/null @@ -1,42 +0,0 @@ -diff --git a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java ---- a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java -+++ b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java -@@ -532,5 +532,7 @@ public static String getMediaMimeType(@Nullable String codec) { - } - return mimeType == null ? MimeTypes.AUDIO_AAC : mimeType; -+ } else if (codec.startsWith("av3a")) { -+ return MimeTypes.AUDIO_AV3A; - } else if (codec.startsWith("mha1")) { - return MimeTypes.AUDIO_MPEGH_MHA1; - } else if (codec.startsWith("mhm1")) { -diff --git a/libraries/container/src/main/java/androidx/media3/container/Mp4Box.java b/libraries/container/src/main/java/androidx/media3/container/Mp4Box.java ---- a/libraries/container/src/main/java/androidx/media3/container/Mp4Box.java -+++ b/libraries/container/src/main/java/androidx/media3/container/Mp4Box.java -@@ -60,5 +60,8 @@ public final class Mp4Box { - @SuppressWarnings("ConstantCaseForConstants") - public static final int TYPE_avcC = 0x61766343; -+ -+ @SuppressWarnings("ConstantCaseForConstants") -+ public static final int TYPE_av3a = 0x61763361; - - @SuppressWarnings("ConstantCaseForConstants") - public static final int TYPE_hvc1 = 0x68766331; -diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java ---- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java -+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java -@@ -1350,5 +1350,6 @@ public final class BoxParser { - || childAtomType == Mp4Box.TYPE__mp2 - || childAtomType == Mp4Box.TYPE__mp3 - || childAtomType == Mp4Box.TYPE_mha1 -+ || childAtomType == Mp4Box.TYPE_av3a - || childAtomType == Mp4Box.TYPE_mhm1 - || childAtomType == Mp4Box.TYPE_alac -@@ -2191,6 +2192,8 @@ public final class BoxParser { - mimeType = MimeTypes.AUDIO_MPEG; - } else if (atomType == Mp4Box.TYPE_mha1) { - mimeType = MimeTypes.AUDIO_MPEGH_MHA1; -+ } else if (atomType == Mp4Box.TYPE_av3a) { -+ mimeType = MimeTypes.AUDIO_AV3A; - } else if (atomType == Mp4Box.TYPE_mhm1) { - mimeType = MimeTypes.AUDIO_MPEGH_MHM1; - } else if (atomType == Mp4Box.TYPE_alac) { diff --git a/third_party/patches/media3-exo-avs3.patch b/third_party/patches/media3-exo-avs3.patch deleted file mode 100644 index 18fed3f14a..0000000000 --- a/third_party/patches/media3-exo-avs3.patch +++ /dev/null @@ -1,410 +0,0 @@ -diff --git a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java ---- a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java -+++ b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java -@@ -52,6 +52,7 @@ - @UnstableApi public static final String VIDEO_VP8 = BASE_TYPE_VIDEO + "/x-vnd.on2.vp8"; - @UnstableApi public static final String VIDEO_VP9 = BASE_TYPE_VIDEO + "/x-vnd.on2.vp9"; - public static final String VIDEO_AV1 = BASE_TYPE_VIDEO + "/av01"; -+ public static final String VIDEO_AVS3 = BASE_TYPE_VIDEO + "/avs3"; - public static final String VIDEO_MP2T = BASE_TYPE_VIDEO + "/mp2t"; - public static final String VIDEO_MP4V = BASE_TYPE_VIDEO + "/mp4v-es"; - public static final String VIDEO_MPEG = BASE_TYPE_VIDEO + "/mpeg"; -@@ -516,6 +517,8 @@ - || codec.startsWith("dvh1") - || codec.startsWith("dav1")) { - return MimeTypes.VIDEO_DOLBY_VISION; -+ } else if (codec.startsWith("avs3")) { -+ return MimeTypes.VIDEO_AVS3; - } else if (codec.startsWith("av01")) { - return MimeTypes.VIDEO_AV1; - } else if (codec.startsWith("vp9") || codec.startsWith("vp09")) { -diff --git a/libraries/container/src/main/java/androidx/media3/container/Mp4Box.java b/libraries/container/src/main/java/androidx/media3/container/Mp4Box.java ---- a/libraries/container/src/main/java/androidx/media3/container/Mp4Box.java -+++ b/libraries/container/src/main/java/androidx/media3/container/Mp4Box.java -@@ -62,6 +62,12 @@ - - @SuppressWarnings("ConstantCaseForConstants") - public static final int TYPE_av3a = 0x61763361; -+ -+ @SuppressWarnings("ConstantCaseForConstants") -+ public static final int TYPE_avs3 = 0x61767333; -+ -+ @SuppressWarnings("ConstantCaseForConstants") -+ public static final int TYPE_av3c = 0x61763363; - - @SuppressWarnings("ConstantCaseForConstants") - public static final int TYPE_hvc1 = 0x68766331; -diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/Avs3Config.java b/libraries/extractor/src/main/java/androidx/media3/extractor/Avs3Config.java -new file mode 100644 ---- /dev/null -+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/Avs3Config.java -@@ -0,0 +1,95 @@ -+/* -+ * Copyright 2026 WebHTV contributors -+ * Licensed under the Apache License, Version 2.0 (the "License"); -+ * you may not use this file except in compliance with the License. -+ * You may obtain a copy at https://www.apache.org/licenses/LICENSE-2.0 -+ */ -+package androidx.media3.extractor; -+ -+import androidx.media3.common.ParserException; -+import androidx.media3.common.util.ParsableBitArray; -+import androidx.media3.common.util.UnstableApi; -+import androidx.media3.common.util.Util; -+import java.util.Arrays; -+import java.util.Collections; -+import java.util.List; -+ -+/** AVS3 sequence properties, from T/AI 109.2-2021 table 14 and the av3c record. */ -+@UnstableApi -+public final class Avs3Config { -+ private static final double[] FRAME_RATES = { -+ 0, 24000d / 1001, 24, 25, 30000d / 1001, 30, 50, 60000d / 1001, -+ 60, 100, 120, 200, 240, 300, 0, 0 -+ }; -+ -+ public final List initializationData; -+ public final String codecs; -+ public final int profile; -+ public final int width; -+ public final int height; -+ public final int bitdepth; -+ public final float frameRate; -+ public final float pixelWidthHeightRatio; -+ -+ /** Accepts an av3c payload or a start-code-prefixed sequence header. */ -+ public static Avs3Config parse(byte[] data) throws ParserException { -+ try { -+ byte[] sequence = data; -+ if (data.length > 0 && data[0] == 1) { -+ check(data.length >= 4, "Truncated av3c record"); -+ int size = ((data[1] & 0xff) << 8) | (data[2] & 0xff); -+ check(size >= 13 && size <= data.length - 4, "Invalid av3c sequence length"); -+ sequence = Arrays.copyOfRange(data, 3, 3 + size); -+ } -+ check(sequence.length >= 13 && sequence.length <= 65535, "Invalid AVS3 sequence size"); -+ check(sequence[0] == 0 && sequence[1] == 0 && sequence[2] == 1 -+ && (sequence[3] & 0xff) == 0xb0, "Missing AVS3 sequence header"); -+ ParsableBitArray bits = new ParsableBitArray(sequence); -+ bits.skipBits(32); -+ int profile = bits.readBits(8); -+ int level = bits.readBits(8); -+ bits.skipBits(2); // progressive_sequence, field_coded_sequence -+ boolean libraryStream = bits.readBit(); -+ if (!libraryStream && bits.readBit()) { -+ bits.skipBit(); // duplicate_sequence_header_flag -+ } -+ check(bits.readBit(), "Invalid AVS3 width marker"); -+ int width = bits.readBits(14); -+ check(bits.readBit(), "Invalid AVS3 height marker"); -+ int height = bits.readBits(14); -+ bits.skipBits(2); // chroma_format -+ int samplePrecision = bits.readBits(3); -+ int encodingPrecision = (profile == 0x22 || profile == 0x32) ? bits.readBits(3) : 1; -+ check(width > 0 && height > 0, "Invalid AVS3 dimensions"); -+ check(samplePrecision >= 1 && samplePrecision <= 2 -+ && encodingPrecision >= 1 && encodingPrecision <= 2, "Invalid AVS3 bit depth"); -+ check(bits.readBit(), "Invalid AVS3 aspect marker"); -+ int aspectRatio = bits.readBits(4); -+ int rateCode = bits.readBits(4); -+ float pixelRatio = 1; -+ if (aspectRatio == 2) pixelRatio = 4f * height / (3f * width); -+ else if (aspectRatio == 3) pixelRatio = 16f * height / (9f * width); -+ else if (aspectRatio == 4) pixelRatio = 221f * height / (100f * width); -+ return new Avs3Config(sequence.clone(), profile, level, width, height, -+ encodingPrecision == 2 ? 10 : 8, (float) FRAME_RATES[rateCode], pixelRatio); -+ } catch (IllegalArgumentException | IndexOutOfBoundsException e) { -+ throw ParserException.createForMalformedContainer("Truncated AVS3 configuration", e); -+ } -+ } -+ -+ private Avs3Config(byte[] data, int profile, int level, int width, int height, -+ int bitdepth, float frameRate, float pixelRatio) { -+ this.initializationData = Collections.singletonList(data); -+ this.codecs = Util.formatInvariant("avs3.%02X.%02X", profile, level); -+ this.profile = profile; -+ this.width = width; -+ this.height = height; -+ this.bitdepth = bitdepth; -+ this.frameRate = frameRate; -+ this.pixelWidthHeightRatio = pixelRatio; -+ } -+ -+ private static void check(boolean valid, String message) throws ParserException { -+ if (!valid) throw ParserException.createForMalformedContainer(message, null); -+ } -+} -diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mkv/MatroskaExtractor.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mkv/MatroskaExtractor.java ---- a/libraries/extractor/src/main/java/androidx/media3/extractor/mkv/MatroskaExtractor.java -+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mkv/MatroskaExtractor.java -@@ -49,6 +49,7 @@ - import androidx.media3.extractor.AacUtil; - import androidx.media3.extractor.Av1Config; - import androidx.media3.extractor.AvcConfig; -+import androidx.media3.extractor.Avs3Config; - import androidx.media3.extractor.ChunkIndex; - import androidx.media3.extractor.ChunkIndexProvider; - import androidx.media3.extractor.DtsUtil; -@@ -186,6 +187,7 @@ - private static final String CODEC_ID_VP8 = "V_VP8"; - private static final String CODEC_ID_VP9 = "V_VP9"; - private static final String CODEC_ID_AV1 = "V_AV1"; -+ private static final String CODEC_ID_AVS3 = "V_AVS3"; - private static final String CODEC_ID_MPEG2 = "V_MPEG2"; - private static final String CODEC_ID_MPEG4_SP = "V_MPEG4/ISO/SP"; - private static final String CODEC_ID_MPEG4_ASP = "V_MPEG4/ISO/ASP"; -@@ -2450,6 +2452,7 @@ - case CODEC_ID_VP8: - case CODEC_ID_VP9: - case CODEC_ID_AV1: -+ case CODEC_ID_AVS3: - case CODEC_ID_MPEG2: - case CODEC_ID_MPEG4_SP: - case CODEC_ID_MPEG4_ASP: -@@ -2799,6 +2802,16 @@ - case CODEC_ID_VP9: - mimeType = MimeTypes.VIDEO_VP9; - initializationData = codecPrivate == null ? null : ImmutableList.of(codecPrivate); -+ break; -+ case CODEC_ID_AVS3: -+ mimeType = MimeTypes.VIDEO_AVS3; -+ if (codecPrivate != null) { -+ Avs3Config avs3Config = Avs3Config.parse(codecPrivate); -+ initializationData = avs3Config.initializationData; -+ codecs = avs3Config.codecs; -+ bitstreamLumaBitdepth = avs3Config.bitdepth; -+ bitstreamChromaBitdepth = avs3Config.bitdepth; -+ } - break; - case CODEC_ID_AV1: - mimeType = MimeTypes.VIDEO_AV1; -diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java ---- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java -+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java -@@ -53,6 +53,7 @@ - import androidx.media3.extractor.Ac4Util; - import androidx.media3.extractor.Av1Config; - import androidx.media3.extractor.AvcConfig; -+import androidx.media3.extractor.Avs3Config; - import androidx.media3.extractor.ExtractorUtil; - import androidx.media3.extractor.GaplessInfoHolder; - import androidx.media3.extractor.HevcConfig; -@@ -1315,6 +1316,7 @@ - || childAtomType == Mp4Box.TYPE_vp08 - || childAtomType == Mp4Box.TYPE_vp09 - || childAtomType == Mp4Box.TYPE_av01 -+ || childAtomType == Mp4Box.TYPE_avs3 - || childAtomType == Mp4Box.TYPE_dvav - || childAtomType == Mp4Box.TYPE_dva1 - || childAtomType == Mp4Box.TYPE_dvhe -@@ -1735,6 +1737,27 @@ - colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED; - colorTransfer = - ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics); -+ } else if (childAtomType == Mp4Box.TYPE_av3c) { -+ ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null); -+ ExtractorUtil.checkContainerInput( -+ childAtomSize >= Mp4Box.HEADER_SIZE -+ && childAtomSize <= Mp4Box.HEADER_SIZE + 65539 -+ && (long) childStartPosition + childAtomSize <= (long) position + size -+ && (long) childStartPosition + childAtomSize <= parent.limit(), -+ "Invalid av3c box size"); -+ byte[] configBytes = new byte[childAtomSize - Mp4Box.HEADER_SIZE]; -+ parent.readBytes(configBytes, 0, configBytes.length); -+ Avs3Config avs3Config = Avs3Config.parse(configBytes); -+ mimeType = MimeTypes.VIDEO_AVS3; -+ initializationData = avs3Config.initializationData; -+ codecs = avs3Config.codecs; -+ decodedWidth = avs3Config.width; -+ decodedHeight = avs3Config.height; -+ bitdepthLuma = avs3Config.bitdepth; -+ bitdepthChroma = avs3Config.bitdepth; -+ if (!pixelWidthHeightRatioFromPasp) { -+ pixelWidthHeightRatio = avs3Config.pixelWidthHeightRatio; -+ } - } else if (childAtomType == Mp4Box.TYPE_av1C) { - mimeType = MimeTypes.VIDEO_AV1; - -diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/ts/Avs3Reader.java b/libraries/extractor/src/main/java/androidx/media3/extractor/ts/Avs3Reader.java -new file mode 100644 ---- /dev/null -+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/ts/Avs3Reader.java -@@ -0,0 +1,155 @@ -+/* -+ * Copyright (C) 2016 The Android Open Source Project -+ * Copyright 2026 WebHTV contributors -+ * Licensed under the Apache License, Version 2.0 (the "License"); -+ * you may not use this file except in compliance with the License. -+ * You may obtain a copy at https://www.apache.org/licenses/LICENSE-2.0 -+ */ -+package androidx.media3.extractor.ts; -+ -+import static com.google.common.base.Preconditions.checkNotNull; -+ -+import androidx.media3.common.C; -+import androidx.media3.common.ColorInfo; -+import androidx.media3.common.Format; -+import androidx.media3.common.MimeTypes; -+import androidx.media3.common.ParserException; -+import androidx.media3.common.util.ParsableByteArray; -+import androidx.media3.common.util.UnstableApi; -+import androidx.media3.container.NalUnitUtil; -+import androidx.media3.extractor.Avs3Config; -+import androidx.media3.extractor.ExtractorOutput; -+import androidx.media3.extractor.TrackOutput; -+import java.util.Arrays; -+import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -+ -+/** AVS3 access-unit reader using Media3's H262Reader start-code/PES accounting. */ -+@UnstableApi -+public final class Avs3Reader implements ElementaryStreamReader { -+ private static final int SEQUENCE = 0xb0; -+ private static final int I_PICTURE = 0xb3; -+ private static final int PB_PICTURE = 0xb6; -+ private final boolean[] prefixFlags = new boolean[4]; -+ private final byte[] sequence = new byte[65535]; -+ private @MonotonicNonNull TrackOutput output; -+ private @MonotonicNonNull String formatId; -+ private int sequenceLength; -+ private boolean collectingSequence; -+ private boolean hasFormat; -+ private long frameDurationUs; -+ private long totalBytesWritten; -+ private long samplePosition; -+ private long sampleTimeUs = C.TIME_UNSET; -+ private long pesTimeUs = C.TIME_UNSET; -+ private boolean startedSample; -+ private boolean sampleHasPicture; -+ private boolean sampleIsKeyframe; -+ -+ @Override -+ public void seek() { -+ NalUnitUtil.clearPrefixFlags(prefixFlags); -+ collectingSequence = false; -+ sequenceLength = 0; -+ totalBytesWritten = 0; -+ startedSample = false; -+ sampleHasPicture = false; -+ sampleIsKeyframe = false; -+ sampleTimeUs = C.TIME_UNSET; -+ pesTimeUs = C.TIME_UNSET; -+ } -+ -+ @Override -+ public void createTracks(ExtractorOutput extractorOutput, TsPayloadReader.TrackIdGenerator ids) { -+ ids.generateNewId(); -+ formatId = ids.getFormatId(); -+ output = extractorOutput.track(ids.getTrackId(), C.TRACK_TYPE_VIDEO); -+ } -+ -+ @Override -+ public void packetStarted(long timeUs, @TsPayloadReader.Flags int flags) { -+ pesTimeUs = timeUs; -+ } -+ -+ @Override -+ public void consume(ParsableByteArray data) throws ParserException { -+ checkNotNull(output); -+ int offset = data.getPosition(); -+ int limit = data.limit(); -+ byte[] bytes = data.getData(); -+ totalBytesWritten += data.bytesLeft(); -+ output.sampleData(data, data.bytesLeft()); -+ while (true) { -+ int start = NalUnitUtil.findNalUnit(bytes, offset, limit, prefixFlags); -+ if (start == limit) { -+ appendSequence(bytes, offset, limit); -+ return; -+ } -+ int code = bytes[start + 3] & 0xff; -+ int length = start - offset; -+ if (!hasFormat) { -+ if (length > 0) appendSequence(bytes, offset, start); -+ if (collectingSequence) { -+ sequenceLength -= Math.max(0, -length); -+ if (code == I_PICTURE || code == PB_PICTURE || code == SEQUENCE || code == 0xb1) { -+ Avs3Config config = Avs3Config.parse(Arrays.copyOf(sequence, sequenceLength)); -+ output.format(new Format.Builder().setId(formatId) -+ .setContainerMimeType(MimeTypes.VIDEO_MP2T) -+ .setSampleMimeType(MimeTypes.VIDEO_AVS3).setCodecs(config.codecs) -+ .setWidth(config.width).setHeight(config.height).setFrameRate(config.frameRate) -+ .setPixelWidthHeightRatio(config.pixelWidthHeightRatio) -+ .setInitializationData(config.initializationData) -+ .setColorInfo(new ColorInfo.Builder().setLumaBitdepth(config.bitdepth) -+ .setChromaBitdepth(config.bitdepth).build()).build()); -+ frameDurationUs = config.frameRate > 0 ? (long) (1_000_000d / config.frameRate) : 0; -+ hasFormat = true; -+ collectingSequence = false; -+ } -+ } -+ if (!hasFormat) { -+ if (code == SEQUENCE) { -+ collectingSequence = true; -+ sequenceLength = 0; -+ } -+ appendSequence(new byte[] {0, 0, 1}, 0, 3); -+ } -+ } -+ if (code == SEQUENCE || code == I_PICTURE || code == PB_PICTURE) { -+ int bytesPastStart = limit - start; -+ if (sampleHasPicture && hasFormat && sampleTimeUs != C.TIME_UNSET) { -+ output.sampleMetadata(sampleTimeUs, sampleIsKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0, -+ (int) (totalBytesWritten - samplePosition) - bytesPastStart, bytesPastStart, null); -+ } -+ if (!startedSample || sampleHasPicture) { -+ samplePosition = totalBytesWritten - bytesPastStart; -+ sampleTimeUs = pesTimeUs != C.TIME_UNSET ? pesTimeUs -+ : sampleTimeUs != C.TIME_UNSET ? sampleTimeUs + frameDurationUs : C.TIME_UNSET; -+ pesTimeUs = C.TIME_UNSET; -+ sampleIsKeyframe = false; -+ startedSample = true; -+ } -+ sampleHasPicture = code == I_PICTURE || code == PB_PICTURE; -+ sampleIsKeyframe |= code == I_PICTURE; -+ } -+ offset = start + 3; -+ } -+ } -+ -+ @Override -+ public void endOfInputReached() { -+ if (output != null && hasFormat && sampleHasPicture && sampleTimeUs != C.TIME_UNSET) { -+ output.sampleMetadata(sampleTimeUs, sampleIsKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0, -+ (int) (totalBytesWritten - samplePosition), 0, null); -+ sampleHasPicture = false; -+ } -+ } -+ -+ private void appendSequence(byte[] bytes, int offset, int limit) throws ParserException { -+ if (!collectingSequence) return; -+ int length = limit - offset; -+ if (length > sequence.length - sequenceLength) { -+ throw ParserException.createForMalformedContainer("AVS3 sequence header exceeds 65535 bytes", null); -+ } -+ System.arraycopy(bytes, offset, sequence, sequenceLength, length); -+ sequenceLength += length; -+ } -+} -diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/ts/DefaultTsPayloadReaderFactory.java b/libraries/extractor/src/main/java/androidx/media3/extractor/ts/DefaultTsPayloadReaderFactory.java ---- a/libraries/extractor/src/main/java/androidx/media3/extractor/ts/DefaultTsPayloadReaderFactory.java -+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/ts/DefaultTsPayloadReaderFactory.java -@@ -207,6 +207,8 @@ - case TsExtractor.TS_STREAM_TYPE_H262: - case TsExtractor.TS_STREAM_TYPE_DC2_H262: - return new PesReader(new H262Reader(buildUserDataReader(esInfo), MimeTypes.VIDEO_MP2T)); -+ case TsExtractor.TS_STREAM_TYPE_AVS3: -+ return new PesReader(new Avs3Reader()); - case TsExtractor.TS_STREAM_TYPE_H263: - return new PesReader(new H263Reader(buildUserDataReader(esInfo), MimeTypes.VIDEO_MP2T)); - case TsExtractor.TS_STREAM_TYPE_H264: -diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/ts/TsExtractor.java b/libraries/extractor/src/main/java/androidx/media3/extractor/ts/TsExtractor.java ---- a/libraries/extractor/src/main/java/androidx/media3/extractor/ts/TsExtractor.java -+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/ts/TsExtractor.java -@@ -147,6 +147,7 @@ - public static final int TS_STREAM_TYPE_H264 = 0x1B; - public static final int TS_STREAM_TYPE_SAMPLE_AES_H264 = 0xDB; - public static final int TS_STREAM_TYPE_H265 = 0x24; -+ public static final int TS_STREAM_TYPE_AVS3 = 0xD4; - public static final int TS_STREAM_TYPE_MHAS = 0x2D; - public static final int TS_STREAM_TYPE_ID3 = 0x15; - public static final int TS_STREAM_TYPE_SPLICE_INFO = 0x86; diff --git a/third_party/patches/media3-exo-pixel-eac3-joc-guard.patch b/third_party/patches/media3-exo-eac3-joc-pixel-guard.patch similarity index 65% rename from third_party/patches/media3-exo-pixel-eac3-joc-guard.patch rename to third_party/patches/media3-exo-eac3-joc-pixel-guard.patch index 4c9832d858..d12d9616c1 100644 --- a/third_party/patches/media3-exo-pixel-eac3-joc-guard.patch +++ b/third_party/patches/media3-exo-eac3-joc-pixel-guard.patch @@ -1,17 +1,29 @@ diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtil.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtil.java -index 761c0d44e9..41f2744cab 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtil.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtil.java -@@ -50,0 +51 @@ import java.util.List; +@@ -49,6 +49,7 @@ + import java.util.Collections; + import java.util.HashMap; + import java.util.List; +import java.util.Objects; -@@ -385 +386,5 @@ public final class MediaCodecUtil { + import org.checkerframework.checker.nullness.qual.EnsuresNonNull; + import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +@@ -384,5 +385,9 @@ public static List getAlternativeCodecMimeTypes(Format format) { + if (MimeTypes.AUDIO_E_AC3_JOC.equals(format.sampleMimeType)) { + // E-AC3 decoders can decode JOC streams, but in 2-D rather than 3-D. - return Collections.singletonList(MimeTypes.AUDIO_E_AC3); + // Some devices (e.g. Pixel) integrate an EAC3 decoder that does not support EAC3-JOC + // stream decoding. + return supportsEac3JocFallbackDecoding() + ? Collections.singletonList(MimeTypes.AUDIO_E_AC3) + : Collections.emptyList(); -@@ -437,0 +443,11 @@ public final class MediaCodecUtil { + } + if (MimeTypes.AUDIO_MEDIA3_DTS_HD_MA_CORELESS.equals(format.sampleMimeType)) { +@@ -434,6 +439,17 @@ public static List getAlternativeCodecMimeTypes(Format format) { + return Collections.emptyList(); + } + + /** + * Returns whether the device supports decoding E-AC3 JOC streams using a standard E-AC3 decoder + * (in 2-D rather than 3-D). @@ -23,13 +35,23 @@ index 761c0d44e9..41f2744cab 100644 + return !Objects.equals(Build.MANUFACTURER, "Google"); + } + + // Internal methods. + + /** diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtilTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtilTest.java -index 8d7d19b64d..0c31d3095f 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtilTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtilTest.java -@@ -30,0 +31 @@ import org.junit.runner.RunWith; +@@ -29,5 +29,6 @@ + import org.junit.Test; + import org.junit.runner.RunWith; +import org.robolectric.shadows.ShadowBuild; -@@ -262,0 +264,14 @@ public final class MediaCodecUtilTest { + + /** Unit tests for {@link MediaCodecUtil}. */ + @RunWith(AndroidJUnit4.class) +@@ -261,6 +262,21 @@ public void getAlternativeCodecMimeType_withNonFallbackCompatibleFormat_returnsE + .isEqualTo(Collections.singletonList(MimeTypes.VIDEO_AV1)); + } + + @Test + public void getAlternativeCodecMimeTypes_withEac3JocFormatOnNonGoogleDevice_returnsEac3() { + ShadowBuild.setManufacturer("Samsung"); @@ -44,3 +66,7 @@ index 8d7d19b64d..0c31d3095f 100644 + Format format = new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_E_AC3_JOC).build(); + assertThat(MediaCodecUtil.getAlternativeCodecMimeTypes(format)).isEmpty(); + } ++ + private static void assertHevcBaseLayerCodecProfileAndLevelForFormat( + Format format, int profile, int level) { + MediaCodecProfileAndLevel codecProfileAndLevel = diff --git a/third_party/patches/media3-playback-diagnostics.patch b/third_party/patches/media3-playback-diagnostics.patch deleted file mode 100644 index 0de1a626df..0000000000 --- a/third_party/patches/media3-playback-diagnostics.patch +++ /dev/null @@ -1,307 +0,0 @@ ---- a/libraries/common/src/main/java/androidx/media3/common/audio/AudioFocusManager.java -+++ b/libraries/common/src/main/java/androidx/media3/common/audio/AudioFocusManager.java -@@ -233,6 +233,8 @@ - return PLAYER_COMMAND_PLAY_WHEN_READY; - } - int requestResult = requestAudioFocusInternal(); -+ androidx.media3.common.util.PlaybackDiagnostics.event(this, "audio.focus", "request", "returned", 0, requestResult, -+ "gain=" + focusGainToRequest, null); - if (requestResult == AudioManager.AUDIOFOCUS_REQUEST_GRANTED - || requestResult == AudioManager.AUDIOFOCUS_REQUEST_DELAYED) { - setAudioFocusState(AUDIO_FOCUS_STATE_HAVE_FOCUS); -@@ -249,7 +251,8 @@ - return; - } - if (audioFocusRequest != null) { -- AudioManagerCompat.abandonAudioFocusRequest(audioManager.get(), audioFocusRequest); -+ int result = AudioManagerCompat.abandonAudioFocusRequest(audioManager.get(), audioFocusRequest); -+ androidx.media3.common.util.PlaybackDiagnostics.event(this, "audio.focus", "abandon", "returned", 0, result, "Media3", null); - } - } - -@@ -367,12 +370,15 @@ - return; - } - this.volumeMultiplier = volumeMultiplier; -+ if (androidx.media3.common.util.PlaybackDiagnostics.enabled("audio.focus")) -+ androidx.media3.common.util.PlaybackDiagnostics.event(this, "audio.focus", "duck-gain", "applied", 0, 0, Float.toString(volumeMultiplier), null); - if (playerControl != null) { - playerControl.setVolumeMultiplier(volumeMultiplier); - } - } - - private void handlePlatformAudioFocusChange(int focusChange) { -+ androidx.media3.common.util.PlaybackDiagnostics.event(this, "audio.focus", "callback", "received", 0, focusChange, "Media3", null); - switch (focusChange) { - case AudioManager.AUDIOFOCUS_GAIN: - setAudioFocusState(AUDIO_FOCUS_STATE_HAVE_FOCUS); -@@ -398,6 +404,7 @@ - } - - private void executePlayerCommand(@PlayerCommand int playerCommand) { -+ androidx.media3.common.util.PlaybackDiagnostics.event(this, "audio.focus", "player-command", "requested", 0, playerCommand, "Media3", null); - if (playerControl != null) { - playerControl.executePlayerCommand(playerCommand); - } ---- a/libraries/common/src/main/java/androidx/media3/common/audio/AudioProcessingPipeline.java -+++ b/libraries/common/src/main/java/androidx/media3/common/audio/AudioProcessingPipeline.java -@@ -69,6 +69,8 @@ - */ - @UnstableApi - public final class AudioProcessingPipeline { -+ private Object diagnosticOwner = this; -+ public void setDiagnosticOwner(Object owner) { diagnosticOwner = owner; } - - /** The {@link AudioProcessor} instances passed to {@link AudioProcessingPipeline}. */ - private final ImmutableList audioProcessors; -@@ -136,6 +138,9 @@ - for (int i = 0; i < audioProcessors.size(); i++) { - AudioProcessor audioProcessor = audioProcessors.get(i); - AudioFormat nextFormat = audioProcessor.configure(intermediateAudioFormat); -+ if (androidx.media3.common.util.PlaybackDiagnostics.enabled("audio.processing")) -+ androidx.media3.common.util.PlaybackDiagnostics.event(diagnosticOwner, "audio.processing", "configure", "returned", 0, i, -+ audioProcessor.getClass().getSimpleName() + " in=" + intermediateAudioFormat + " out=" + nextFormat, null); - if (audioProcessor.isActive()) { - checkState(!nextFormat.equals(AudioFormat.NOT_SET)); - intermediateAudioFormat = nextFormat; -@@ -192,6 +197,9 @@ - previousStreamMetadata.positionOffsetUs)) - .build(); - activeAudioProcessors.add(audioProcessor); -+ if (androidx.media3.common.util.PlaybackDiagnostics.enabled("audio.processing")) -+ androidx.media3.common.util.PlaybackDiagnostics.event(diagnosticOwner, "audio.processing", "active", "applied", 0, -+ activeAudioProcessors.size() - 1, audioProcessor.getClass().getSimpleName(), null); - } - - outputBuffers = new ByteBuffer[activeAudioProcessors.size()]; ---- /dev/null -+++ b/libraries/common/src/main/java/androidx/media3/common/util/PlaybackDiagnostics.java -@@ -0,0 +1,81 @@ -+package androidx.media3.common.util; -+ -+import android.media.MediaCodec; -+import android.media.MediaCrypto; -+import android.media.MediaFormat; -+import android.os.SystemClock; -+import android.view.Surface; -+import java.io.IOException; -+import java.lang.ref.WeakReference; -+import java.util.WeakHashMap; -+ -+/** Optional observation only. Exceptions and playback decisions remain owned by the caller. */ -+@UnstableApi -+public final class PlaybackDiagnostics { -+ public interface Listener { -+ boolean enabled(String event); -+ void event(Object owner, String event, String operation, String phase, long durationNs, -+ long value, String detail, Throwable error); -+ void write(Object owner, int requested, int result, long ptsUs, boolean header, long durationNs); -+ } -+ private static volatile Listener listener; -+ private static final ThreadLocal context = new ThreadLocal<>(); -+ private static final WeakHashMap> parents = new WeakHashMap<>(); -+ private PlaybackDiagnostics() {} -+ public static void setListener(Listener value) { listener = value; } -+ public static Object enter(Object owner) { Object prior = context.get(); context.set(owner); return prior; } -+ public static void leave(Object prior) { if (prior == null) context.remove(); else context.set(prior); } -+ public static synchronized void link(Object owner, Object parent) { parents.put(owner, new WeakReference<>(parent)); } -+ private static synchronized Object resolve(Object owner) { -+ if (owner == null) return context.get(); -+ for (int i = 0; i < 4; i++) { -+ WeakReference parent = parents.get(owner); -+ if (parent == null || parent.get() == null) break; -+ owner = parent.get(); -+ } -+ return owner; -+ } -+ public static boolean enabled(String event) { -+ Listener current = listener; -+ try { return current != null && current.enabled(event); } -+ catch (RuntimeException | LinkageError ignored) { return false; } -+ } -+ public static long begin(Object owner, String event, String operation, String detail) { -+ if (!enabled(event)) return 0; -+ long now = SystemClock.elapsedRealtimeNanos(); -+ event(owner, event, operation, "begin", 0, 0, detail, null); return now; -+ } -+ public static void end(Object owner, String event, String operation, long start, long value, String detail, Throwable error) { -+ event(owner, event, operation, error == null ? "end" : "failed", -+ start == 0 ? -1 : SystemClock.elapsedRealtimeNanos() - start, value, detail, error); -+ } -+ public static void event(Object owner, String event, String operation, String phase, long duration, -+ long value, String detail, Throwable error) { -+ Listener current = listener; -+ try { if (current != null && current.enabled(event)) current.event(resolve(owner), event, operation, phase, duration, value, detail, error); } -+ catch (RuntimeException | LinkageError ignored) { /* Diagnostics cannot change playback. */ } -+ } -+ public static void write(Object owner, int requested, int result, long ptsUs, boolean header, long start) { -+ Listener current = listener; -+ try { if (current != null && current.enabled("audio.output.write")) current.write(owner, requested, result, ptsUs, header, -+ start == 0 ? -1 : SystemClock.elapsedRealtimeNanos() - start); } -+ catch (RuntimeException | LinkageError ignored) { /* Preserve the original write result. */ } -+ } -+ public static long writeStart() { return enabled("audio.output.write") ? SystemClock.elapsedRealtimeNanos() : 0; } -+ -+ public static MediaCodec createCodec(String name) throws IOException { -+ long start = begin(null, "codec", "create", name); -+ try { MediaCodec codec = MediaCodec.createByCodecName(name); end(null, "codec", "create", start, 0, name, null); return codec; } -+ catch (IOException | RuntimeException error) { end(null, "codec", "create", start, 0, name, error); throw error; } -+ } -+ public static void configureCodec(MediaCodec codec, MediaFormat format, Surface surface, MediaCrypto crypto, int flags) { -+ long start = begin(null, "codec", "configure", "MediaCodec.configure"); -+ try { codec.configure(format, surface, crypto, flags); end(null, "codec", "configure", start, flags, "MediaCodec.configure", null); } -+ catch (RuntimeException error) { end(null, "codec", "configure", start, flags, "MediaCodec.configure", error); throw error; } -+ } -+ public static void startCodec(MediaCodec codec) { -+ long start = begin(null, "codec", "start", "MediaCodec.start"); -+ try { codec.start(); end(null, "codec", "start", start, 0, "MediaCodec.start", null); } -+ catch (RuntimeException error) { end(null, "codec", "start", start, 0, "MediaCodec.start", error); throw error; } -+ } -+} ---- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java -+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java -@@ -392,6 +392,7 @@ - playbackInfo = PlaybackInfo.createDummy(emptyTrackSelectorResult); - analyticsCollector.setPlayer(this.wrappingPlayer, applicationLooper); - PlayerId playerId = new PlayerId(builder.playerName); -+ androidx.media3.common.util.PlaybackDiagnostics.link(playerId, this.wrappingPlayer); - internalPlayer = - new ExoPlayerImplInternal( - applicationContext, ---- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java -+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java -@@ -387,6 +387,7 @@ - handler = clock.createHandler(this.playbackLooper, this); - - audioFocusManager = new AudioFocusManager(context, playbackLooper, /* playerControl= */ this); -+ androidx.media3.common.util.PlaybackDiagnostics.link(audioFocusManager, playerId); - VideoFrameMetadataListener internalVideoFrameMetadataListener = - (presentationTimeUs, releaseTimeNs, format, mediaFormat) -> { - videoFrameMetadataListener.onVideoFrameAboutToBeRendered( ---- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioTrackAudioOutput.java -+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioTrackAudioOutput.java -@@ -257,8 +257,10 @@ - } - bytesWrittenOrError = writeWithAvSync(audioTrack, buffer, presentationTimeUs); - } else { -+ long diagnosticStart = androidx.media3.common.util.PlaybackDiagnostics.writeStart(); - bytesWrittenOrError = - audioTrack.write(buffer, buffer.remaining(), AudioTrack.WRITE_NON_BLOCKING); -+ androidx.media3.common.util.PlaybackDiagnostics.write(audioTrack, bytesRemaining, bytesWrittenOrError, presentationTimeUs, false, diagnosticStart); - } - - if (bytesWrittenOrError < 0) { -@@ -405,8 +407,11 @@ - int size = buffer.remaining(); - if (SDK_INT >= 26) { - // The underlying platform AudioTrack writes AV sync headers directly. -- return audioTrack.write( -+ long diagnosticStart = androidx.media3.common.util.PlaybackDiagnostics.writeStart(); -+ int diagnosticResult = audioTrack.write( - buffer, size, AudioTrack.WRITE_NON_BLOCKING, presentationTimeUs * 1000); -+ androidx.media3.common.util.PlaybackDiagnostics.write(audioTrack, size, diagnosticResult, presentationTimeUs, false, diagnosticStart); -+ return diagnosticResult; - } - if (avSyncHeader == null) { - avSyncHeader = ByteBuffer.allocate(16); -@@ -421,8 +426,10 @@ - } - int avSyncHeaderBytesRemaining = avSyncHeader.remaining(); - if (avSyncHeaderBytesRemaining > 0) { -+ long diagnosticStart = androidx.media3.common.util.PlaybackDiagnostics.writeStart(); - int result = - audioTrack.write(avSyncHeader, avSyncHeaderBytesRemaining, AudioTrack.WRITE_NON_BLOCKING); -+ androidx.media3.common.util.PlaybackDiagnostics.write(audioTrack, avSyncHeaderBytesRemaining, result, presentationTimeUs, true, diagnosticStart); - if (result < 0) { - bytesUntilNextAvSync = 0; - return result; -@@ -431,7 +438,9 @@ - return 0; - } - } -+ long diagnosticStart = androidx.media3.common.util.PlaybackDiagnostics.writeStart(); - int result = audioTrack.write(buffer, size, AudioTrack.WRITE_NON_BLOCKING); -+ androidx.media3.common.util.PlaybackDiagnostics.write(audioTrack, size, result, presentationTimeUs, false, diagnosticStart); - if (result < 0) { - bytesUntilNextAvSync = 0; - return result; ---- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java -+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java -@@ -760,6 +760,7 @@ - pipelineProcessors.add(audioProcessorChain.getAudioProcessors()); - } - audioProcessingPipeline = new AudioProcessingPipeline(pipelineProcessors.build()); -+ audioProcessingPipeline.setDiagnosticOwner(this); - - // If the underlying processors of the new pipeline are the same as the existing pipeline, - // then use the existing one when the configuration is used. -@@ -794,6 +795,7 @@ - } else { - // Audio processing is not supported in offload or passthrough mode. - audioProcessingPipeline = new AudioProcessingPipeline(ImmutableList.of()); -+ audioProcessingPipeline.setDiagnosticOwner(this); - inputPcmFrameSize = C.LENGTH_UNSET; - outputPcmFrameSize = C.LENGTH_UNSET; - afterProcessingFormat = inputFormat; ---- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/AsynchronousMediaCodecAdapter.java -+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/AsynchronousMediaCodecAdapter.java -@@ -122,7 +122,7 @@ - @Nullable MediaCodec codec = null; - try { - TraceUtil.beginSection("createCodec:" + codecName); -- codec = MediaCodec.createByCodecName(codecName); -+ codec = androidx.media3.common.util.PlaybackDiagnostics.createCodec(codecName); - int flags = 0; - MediaCodecBufferEnqueuer bufferEnqueuer; - if (enableSynchronousBufferQueueingWithAsyncCryptoFlag -@@ -203,11 +203,11 @@ - int flags) { - asynchronousMediaCodecCallback.initialize(codec); - TraceUtil.beginSection("configureCodec"); -- codec.configure(mediaFormat, surface, crypto, flags); -+ androidx.media3.common.util.PlaybackDiagnostics.configureCodec(codec, mediaFormat, surface, crypto, flags); - TraceUtil.endSection(); - bufferEnqueuer.start(); - TraceUtil.beginSection("startCodec"); -- codec.start(); -+ androidx.media3.common.util.PlaybackDiagnostics.startCodec(codec); - TraceUtil.endSection(); - if (SDK_INT >= 35 && loudnessCodecController != null) { - loudnessCodecController.addMediaCodec(codec); ---- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/SynchronousMediaCodecAdapter.java -+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/SynchronousMediaCodecAdapter.java -@@ -58,11 +58,11 @@ - && SDK_INT >= 35) { - flags |= MediaCodec.CONFIGURE_FLAG_DETACHED_SURFACE; - } -- codec.configure( -+ androidx.media3.common.util.PlaybackDiagnostics.configureCodec(codec, - configuration.mediaFormat, configuration.surface, configuration.crypto, flags); - TraceUtil.endSection(); - TraceUtil.beginSection("startCodec"); -- codec.start(); -+ androidx.media3.common.util.PlaybackDiagnostics.startCodec(codec); - TraceUtil.endSection(); - return new SynchronousMediaCodecAdapter(codec, configuration.loudnessCodecController); - } catch (IOException | RuntimeException e) { -@@ -78,7 +78,7 @@ - checkNotNull(configuration.codecInfo); - String codecName = configuration.codecInfo.name; - TraceUtil.beginSection("createCodec:" + codecName); -- MediaCodec mediaCodec = MediaCodec.createByCodecName(codecName); -+ MediaCodec mediaCodec = androidx.media3.common.util.PlaybackDiagnostics.createCodec(codecName); - TraceUtil.endSection(); - return mediaCodec; - } ---- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/BundledExtractorsAdapter.java -+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/BundledExtractorsAdapter.java -@@ -111,6 +111,9 @@ - } - } - extractor.init(output); -+ if (androidx.media3.common.util.PlaybackDiagnostics.enabled("media.container")) -+ androidx.media3.common.util.PlaybackDiagnostics.event(output, "media.container", "extractor-selected", "initialized", 0, position, -+ extractor.getUnderlyingImplementation().getClass().getName(), null); - } - - @Override diff --git a/third_party/patches/mpv-aimagereader-gpu-timing.patch b/third_party/patches/mpv-aimagereader-gpu-timing.patch new file mode 100644 index 0000000000..32ff6ca2ae --- /dev/null +++ b/third_party/patches/mpv-aimagereader-gpu-timing.patch @@ -0,0 +1,47 @@ +diff --git a/video/out/gpu/hwdec.h b/video/out/gpu/hwdec.h +index ae827fc7..88fd0774 100644 +--- a/video/out/gpu/hwdec.h ++++ b/video/out/gpu/hwdec.h +@@ -91,6 +91,10 @@ struct ra_hwdec_mapper { + // The common code won't mess with these, so you can e.g. set them in the + // .init() callback. + struct ra_tex *tex[4]; ++ ++ // GPU work submitted by the mapper outside the renderer command stream. ++ uint64_t external_gpu_time_ns; ++ uint64_t external_gpu_time_serial; + }; + + // This can be used to map frames of a specific hw format as GL textures. +diff --git a/video/out/vo_gpu_next.c b/video/out/vo_gpu_next.c +index cd40793b..1a61cf78 100644 +--- a/video/out/vo_gpu_next.c ++++ b/video/out/vo_gpu_next.c +@@ -114,6 +114,7 @@ struct priv { + struct ra_hwdec_mapper *hwdec_mapper; + struct timer_pool *hwdec_timer; + struct mp_pass_perf hwdec_perf; ++ uint64_t hwdec_external_time_serial; + struct ra_hwdec_mapper *el_hwdec_mapper; + struct timer_pool *el_hwdec_timer; + struct timer_pool *sw_upload_timer; +@@ -760,6 +761,19 @@ static bool hwdec_acquire(pl_gpu gpu, struct pl_frame *frame) + if (measuring) { + timer_pool_stop(p->hwdec_timer); + p->hwdec_perf = timer_pool_measure(p->hwdec_timer); ++ if (p->hwdec_mapper->external_gpu_time_serial != ++ p->hwdec_external_time_serial) { ++ p->hwdec_external_time_serial = ++ p->hwdec_mapper->external_gpu_time_serial; ++ uint64_t elapsed = p->hwdec_mapper->external_gpu_time_ns; ++ p->hwdec_perf = (struct mp_pass_perf){ ++ .last = elapsed, ++ .avg = elapsed, ++ .peak = elapsed, ++ .samples = {elapsed}, ++ .count = 1, ++ }; ++ } + stats_time_end(p->stats, "hwdec-map"); + } + diff --git a/third_party/patches/mpv-aimagereader-release-acquire-flow.patch b/third_party/patches/mpv-aimagereader-release-acquire-flow.patch new file mode 100644 index 0000000000..e8c9e6ec19 --- /dev/null +++ b/third_party/patches/mpv-aimagereader-release-acquire-flow.patch @@ -0,0 +1,163 @@ +diff --git a/video/out/hwdec/hwdec_aimagereader.c b/video/out/hwdec/hwdec_aimagereader.c +index 93e06dfc..4952b4fb 100644 +--- a/video/out/hwdec/hwdec_aimagereader.c ++++ b/video/out/hwdec/hwdec_aimagereader.c +@@ -45,6 +45,8 @@ + #endif + + #define IMAGE_TIMESTAMP_TOLERANCE_NS 100000.0 ++#define IMAGE_ACQUIRE_TIMEOUT_NS MP_TIME_MS_TO_NS(100) ++#define TRANSIENT_LOG_INTERVAL 120 + + typedef void *GLeglImageOES; + typedef void *EGLImageKHR; +@@ -61,7 +63,7 @@ struct priv_owner { + // AImageReader callbacks are queued and can outlive individual mappers. + mp_mutex image_lock; + mp_cond image_cond; +- bool image_available; ++ uint64_t image_sequence; + + #if HAVE_VULKAN + mp_mutex vk_lock; +@@ -114,6 +116,7 @@ enum aimage_color_standard { + struct aimage_stats { + uint64_t render_requests; + uint64_t unavailable_outputs; ++ uint64_t no_buffer_polls; + uint64_t pending_replacements; + uint64_t acquire_timeouts; + uint64_t stale_images; +@@ -389,11 +392,33 @@ static void image_callback(void *context, AImageReader *reader) + struct priv_owner *p = context; + + mp_mutex_lock(&p->image_lock); +- p->image_available = true; ++ p->image_sequence++; + mp_cond_signal(&p->image_cond); + mp_mutex_unlock(&p->image_lock); + } + ++static uint64_t get_image_sequence(struct priv_owner *p) ++{ ++ mp_mutex_lock(&p->image_lock); ++ uint64_t sequence = p->image_sequence; ++ mp_mutex_unlock(&p->image_lock); ++ return sequence; ++} ++ ++static bool wait_for_image_signal(struct priv_owner *p, uint64_t *sequence, ++ int64_t deadline) ++{ ++ mp_mutex_lock(&p->image_lock); ++ while (p->image_sequence == *sequence && mp_time_ns() < deadline) { ++ if (mp_cond_timedwait_until(&p->image_cond, &p->image_lock, deadline)) ++ break; ++ } ++ bool changed = p->image_sequence != *sequence; ++ *sequence = p->image_sequence; ++ mp_mutex_unlock(&p->image_lock); ++ return changed; ++} ++ + static int init(struct ra_hwdec *hw) + { + struct priv_owner *p = hw->priv; +@@ -585,6 +610,8 @@ static int mapper_init(struct ra_hwdec_mapper *mapper) + bool raw_dovi = mapper->src_params.repr.dovi != NULL; + + p->log = mapper->log; ++ MP_INFO(mapper, ++ "WebHTV AImageReader uses stable release/acquire flow\n"); + + mapper->dst_params = mapper->src_params; + mapper->dst_params.imgfmt = IMGFMT_RGB0; +@@ -704,9 +731,10 @@ static void mapper_uninit(struct ra_hwdec_mapper *mapper) + + MP_VERBOSE(mapper, "AImageReader stats: render requests %" PRIu64 + ", unavailable outputs %" PRIu64 +- ", pending replacements %" PRIu64 "\n", ++ ", pending replacements %" PRIu64 ++ ", no-buffer polls %" PRIu64 "\n", + p->stats.render_requests, p->stats.unavailable_outputs, +- p->stats.pending_replacements); ++ p->stats.pending_replacements, p->stats.no_buffer_polls); + MP_VERBOSE(mapper, "AImageReader stats: acquired %" PRIu64 + ", mapped %" PRIu64 ", timeouts %" PRIu64 + ", stale %" PRIu64 ", newer %" PRIu64 +@@ -755,11 +783,12 @@ static media_status_t acquire_latest_image(struct ra_hwdec_mapper *mapper, + AImage **image, + int *acquire_fence, + int64_t expected_timestamp_ns, +- bool check_timestamp) ++ bool check_timestamp, ++ uint64_t *image_sequence) + { + struct priv *p = mapper->priv; + struct priv_owner *o = mapper->owner->priv; +- int64_t deadline = mp_time_ns() + MP_TIME_MS_TO_NS(100); ++ int64_t deadline = mp_time_ns() + IMAGE_ACQUIRE_TIMEOUT_NS; + + while (true) { + *image = NULL; +@@ -816,23 +845,22 @@ static media_status_t acquire_latest_image(struct ra_hwdec_mapper *mapper, + *acquire_fence = -1; + return AMEDIA_ERROR_UNKNOWN; + } ++ if (ret == AMEDIA_OK) ++ return ret; + if (ret != AMEDIA_IMGREADER_NO_BUFFER_AVAILABLE) + return ret; + +- int64_t remaining = deadline - mp_time_ns(); +- if (remaining <= 0) { ++ p->stats.no_buffer_polls++; ++ if (p->stats.no_buffer_polls % TRANSIENT_LOG_INTERVAL == 1) { ++ MP_VERBOSE(mapper, "AImageReader has no buffer yet " ++ "(count=%" PRIu64 ")\n", ++ p->stats.no_buffer_polls); ++ } ++ if (mp_time_ns() >= deadline || ++ !wait_for_image_signal(o, image_sequence, deadline)) { + p->stats.acquire_timeouts++; + return ret; + } +- +- // acquireLatestImage() is allowed to consume all images covered by +- // already queued callbacks. Treat the listener as a wakeup hint and +- // retry until the shared deadline rather than failing on a stale one. +- mp_mutex_lock(&o->image_lock); +- if (!o->image_available) +- mp_cond_timedwait(&o->image_cond, &o->image_lock, remaining); +- o->image_available = false; +- mp_mutex_unlock(&o->image_lock); + } + } + +@@ -863,6 +891,7 @@ static int mapper_map(struct ra_hwdec_mapper *mapper) + { + struct priv *p = mapper->priv; + struct priv_owner *o = mapper->owner->priv; ++ uint64_t image_sequence = get_image_sequence(o); + + if (mapper->src->imgfmt != IMGFMT_MEDIACODEC) + return -1; +@@ -929,9 +958,16 @@ static int mapper_map(struct ra_hwdec_mapper *mapper) + int acquire_fence = -1; + media_status_t ret = + acquire_latest_image(mapper, &image, &acquire_fence, +- expected_timestamp_ns, check_timestamp); ++ expected_timestamp_ns, check_timestamp, ++ &image_sequence); + if (ret == AMEDIA_IMGREADER_NO_BUFFER_AVAILABLE) { +- MP_VERBOSE(mapper, "Waiting for frame timed out\n"); ++ if (p->stats.acquire_timeouts % TRANSIENT_LOG_INTERVAL == 1) { ++ MP_WARN(mapper, "AImageReader frame acquisition timed out " ++ "(timeouts=%" PRIu64 ", no-buffer=%" PRIu64 ++ ")\n", ++ p->stats.acquire_timeouts, ++ p->stats.no_buffer_polls); ++ } + return RA_HWDEC_MAP_RETRY; + } + mp_image_unrefp(&p->pending_frame); diff --git a/third_party/patches/mpv-android-fel.patch b/third_party/patches/mpv-android-fel.patch deleted file mode 100644 index a6f3ebeef7..0000000000 --- a/third_party/patches/mpv-android-fel.patch +++ /dev/null @@ -1,5420 +0,0 @@ -diff --git a/filters/f_android_fel.h b/filters/f_android_fel.h -new file mode 100644 ---- /dev/null -+++ b/filters/f_android_fel.h -@@ -0,0 +1,121 @@ -+/* WebHTV Android FEL queue policy. LGPL-2.1-or-later, like mpv. */ -+#pragma once -+ -+#include -+#include -+ -+// Publish under the wrapper's cache lock. The getter copies this value into -+// caller-owned storage: never return a pointer into a live decoder's name. -+struct mp_android_fel_hwdec_snapshot { -+ bool available; -+ unsigned long long revision; -+ char name[64]; // vd_lavc's method_name is currently at most 24 bytes. -+}; -+ -+static inline void mp_android_fel_publish_hwdec( -+ struct mp_android_fel_hwdec_snapshot *snapshot, bool available, -+ const char *name) -+{ -+ if (!name) -+ name = ""; -+ if (!available || strlen(name) >= sizeof(snapshot->name)) { -+ available = false; -+ name = ""; -+ } -+ if (snapshot->available == available && !strcmp(snapshot->name, name)) -+ return; -+ snapshot->available = available; -+ memcpy(snapshot->name, name, strlen(name) + 1); -+ snapshot->revision++; -+} -+ -+// These are retained decoded frames, not an assumed MediaCodec pool capacity. -+#define MP_ANDROID_FEL_BL_PENDING 1 -+#define MP_ANDROID_FEL_EL_PENDING 8 -+#define MP_ANDROID_FEL_BL_PREFETCH 1 -+#define MP_ANDROID_FEL_EL_PREFETCH 4 -+#define MP_ANDROID_FEL_ERROR_LIMIT 8 -+#define MP_ANDROID_FEL_COPY_WAIT_NS 100000000LL -+#define MP_ANDROID_FEL_FRAME_TIMEOUT_NS 750000000LL -+#define MP_ANDROID_FEL_INIT_TIMEOUT_NS 10000000000LL -+ -+// A source is safe to publish after CPU-observed GPU completion, or after an -+// exported GPU release fence has been handed to AImage_deleteAsync. The latter -+// allows overlap; it never permits the codec to overwrite unfinished GPU input. -+// Keep actual GPU completion separate from fence-backed source handoff. -+static inline bool mp_android_fel_staging_ready(const unsigned char *state) -+{ -+ return state && (__atomic_load_n(state, __ATOMIC_ACQUIRE) & 1); -+} -+ -+static inline bool mp_android_fel_staging_gpu_complete(const unsigned char *state) -+{ -+ return state && (__atomic_load_n(state, __ATOMIC_ACQUIRE) & 2); -+} -+ -+static inline void mp_android_fel_staging_release_fenced(unsigned char *state) -+{ -+ if (state) -+ __atomic_fetch_or(state, 1, __ATOMIC_RELEASE); -+} -+ -+static inline void mp_android_fel_staging_complete(unsigned char *state) -+{ -+ if (state) -+ __atomic_fetch_or(state, 3, __ATOMIC_RELEASE); -+} -+ -+// Monotonic, per-lease cold-initialization phases, independent of source/GPU -+// completion. The mapper, not the caller's frame count, identifies cold work. -+static inline unsigned char mp_android_fel_staging_init_phase(const unsigned char *state) -+{ -+ return state ? __atomic_load_n(state, __ATOMIC_ACQUIRE) & 60 : 0; -+} -+ -+// A user-requested diagnostic may hold this one source beyond the ordinary -+// frame deadline. Its own deadline is fixed; polling cannot renew it. -+static inline void mp_android_fel_staging_begin_probe(unsigned char *state) -+{ -+ if (state) -+ __atomic_fetch_or(state, 16, __ATOMIC_RELEASE); -+} -+ -+static inline void mp_android_fel_staging_end_probe(unsigned char *state) -+{ -+ if (state) -+ __atomic_fetch_or(state, 32, __ATOMIC_RELEASE); -+} -+ -+static inline void mp_android_fel_staging_begin_init(unsigned char *state) -+{ -+ if (state) -+ __atomic_fetch_or(state, 4, __ATOMIC_RELEASE); -+} -+ -+static inline void mp_android_fel_staging_end_init(unsigned char *state) -+{ -+ if (state) -+ __atomic_fetch_or(state, 8, __ATOMIC_RELEASE); -+} -+ -+static inline bool mp_android_fel_stage_output(bool fel, bool direct_or_auto) -+{ -+ return fel && direct_or_auto; -+} -+ -+static inline bool mp_android_fel_preserve_depth(bool fel, int depth) -+{ -+ return !fel || depth >= 10; -+} -+ -+static inline bool mp_android_fel_can_skip_bl(bool el_eof, bool have_el, -+ int pts_order) -+{ -+ // A full BL queue is backpressure, never evidence that EL is absent. -+ return el_eof || (have_el && pts_order > 0); -+} -+ -+static inline bool mp_android_fel_decode_failed(bool fel, int consecutive_errors) -+{ -+ return fel && consecutive_errors >= MP_ANDROID_FEL_ERROR_LIMIT; -+} -diff --git a/filters/f_android_fel_perf.h b/filters/f_android_fel_perf.h -new file mode 100644 ---- /dev/null -+++ b/filters/f_android_fel_perf.h -@@ -0,0 +1,52 @@ -+/* Optional Android FEL measurements. LGPL-2.1-or-later, like mpv. */ -+#pragma once -+ -+#include -+#include -+#include -+#include -+ -+enum mp_fel_perf_stage { -+ MP_FEL_PERF_POLL, MP_FEL_PERF_IMPORT, MP_FEL_PERF_RECLAIM, -+ MP_FEL_PERF_RECORD, MP_FEL_PERF_ACQUIRE, MP_FEL_PERF_QUEUE_LOCK, -+ MP_FEL_PERF_SUBMIT, MP_FEL_PERF_TEX_RELEASE, MP_FEL_PERF_EXPORT, -+ MP_FEL_PERF_IMAGE_RETURN, MP_FEL_PERF_OTHER, MP_FEL_PERF_COUNT, -+}; -+ -+struct mp_fel_perf_stat { -+ uint64_t count, total, peak; -+}; -+ -+static inline void mp_fel_perf_add(struct mp_fel_perf_stat *stat, uint64_t ns) -+{ -+ stat->count++; -+ stat->total += ns; -+ if (ns > stat->peak) -+ stat->peak = ns; -+} -+ -+static inline int64_t mp_fel_thread_cpu_ns(void) -+{ -+#ifdef CLOCK_THREAD_CPUTIME_ID -+ struct timespec ts; -+ if (!clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts)) -+ return (int64_t)ts.tv_sec * INT64_C(1000000000) + ts.tv_nsec; -+#endif -+ return 0; // unavailable, never fabricate a CPU utilization value -+} -+ -+static inline bool mp_fel_gpu_duration(uint64_t start, uint64_t end, -+ unsigned bits, double period, -+ uint64_t *ns) -+{ -+ if (!bits || bits > 64 || !isfinite(period) || period <= 0) -+ return false; -+ uint64_t delta = end - start; -+ if (bits < 64) -+ delta &= (UINT64_C(1) << bits) - 1; -+ double duration = delta * period; -+ if (!isfinite(duration) || duration >= (double)UINT64_MAX) -+ return false; -+ *ns = (uint64_t)duration; -+ return true; -+} -diff --git a/filters/f_android_fel_trace.h b/filters/f_android_fel_trace.h -new file mode 100644 ---- /dev/null -+++ b/filters/f_android_fel_trace.h -@@ -0,0 +1,145 @@ -+/* WebHTV FEL progress diagnostics. LGPL-2.1-or-later, like mpv. */ -+#pragma once -+ -+#include -+#include -+#include -+#include -+#include -+ -+// A diagnostic read must never wait for a decoder, the core, or the GPU. -+_Static_assert(ATOMIC_LLONG_LOCK_FREE == 2 && ATOMIC_LONG_LOCK_FREE == 2 && -+ ATOMIC_INT_LOCK_FREE == 2 && ATOMIC_BOOL_LOCK_FREE == 2, -+ "FEL progress stamps require lock-free 64-bit atomics"); -+ -+enum mp_fel_lane { MP_FEL_CORE, MP_FEL_BL, MP_FEL_EL, MP_FEL_VO, MP_FEL_LANES }; -+enum mp_fel_stage { -+ MP_FEL_UNKNOWN, MP_FEL_CORE_BEGIN, MP_FEL_CORE_PULL, MP_FEL_CORE_WAIT, -+ MP_FEL_CORE_SYNC, MP_FEL_CORE_SUBTITLE, MP_FEL_CORE_READY, MP_FEL_CORE_QUEUE, -+ MP_FEL_CORE_IDLE, MP_FEL_SEND, MP_FEL_RECEIVE, MP_FEL_NEED_PACKET, -+ MP_FEL_DECODED, MP_FEL_DECODE_IDLE, MP_FEL_EOF, MP_FEL_RESET, -+ MP_FEL_VO_DRAW, MP_FEL_VO_PRELOAD, MP_FEL_VO_TARGET, MP_FEL_VO_ACQUIRE, -+ MP_FEL_VO_QUEUE, MP_FEL_VO_RENDER, MP_FEL_VO_FLUSH, MP_FEL_VO_SUBMIT, -+ MP_FEL_VO_SWAP, MP_FEL_VO_IDLE, -+ MP_FEL_VO_DROP_PRELOAD, MP_FEL_VO_DROP_DONE, -+ MP_FEL_CORE_PRELOAD, MP_FEL_VO_CORE_PRELOAD, -+}; -+ -+struct mp_fel_lane_trace { -+ // Stage and its start time are one atomic value, even on ARMv7. -+ atomic_uint_fast64_t stamp; -+ atomic_int pts_ms; -+ atomic_uint inputs, outputs, held; -+}; -+ -+struct mp_fel_trace { -+ atomic_bool enabled; -+ atomic_uint requested, references; -+ atomic_uint vo_drops, vo_drop_prepared, vo_drop_retries; -+ atomic_uint core_prepare_requests, core_prepare_completed; -+ atomic_uint core_prepare_retries, core_prepare_errors; -+ struct mp_fel_lane_trace lanes[MP_FEL_LANES]; -+}; -+ -+static inline void mp_fel_trace_init(struct mp_fel_trace *trace) -+{ -+ atomic_init(&trace->enabled, false); -+ atomic_init(&trace->requested, 1); -+ atomic_init(&trace->references, 2); -+ atomic_init(&trace->vo_drops, 0); -+ atomic_init(&trace->vo_drop_prepared, 0); -+ atomic_init(&trace->vo_drop_retries, 0); -+ atomic_init(&trace->core_prepare_requests, 0); -+ atomic_init(&trace->core_prepare_completed, 0); -+ atomic_init(&trace->core_prepare_retries, 0); -+ atomic_init(&trace->core_prepare_errors, 0); -+ for (int n = 0; n < MP_FEL_LANES; n++) { -+ struct mp_fel_lane_trace *lane = &trace->lanes[n]; -+ atomic_init(&lane->stamp, 0); -+ atomic_init(&lane->pts_ms, -1); -+ atomic_init(&lane->inputs, 0); -+ atomic_init(&lane->outputs, 0); -+ atomic_init(&lane->held, 0); -+ } -+} -+ -+static inline void mp_fel_trace_record(struct mp_fel_trace *trace, -+ enum mp_fel_lane index, -+ enum mp_fel_stage stage, int64_t now_ms, -+ double pts, int held, -+ unsigned inputs, unsigned outputs) -+{ -+ if (!trace || !atomic_load_explicit(&trace->enabled, memory_order_relaxed)) -+ return; -+ struct mp_fel_lane_trace *lane = &trace->lanes[index]; -+ if (isfinite(pts) && pts >= 0 && pts <= INT32_MAX / 1000.0) -+ atomic_store_explicit(&lane->pts_ms, (int)(pts * 1000.0), memory_order_relaxed); -+ if (held >= 0) -+ atomic_store_explicit(&lane->held, held, memory_order_relaxed); -+ if (inputs) -+ atomic_fetch_add_explicit(&lane->inputs, inputs, memory_order_relaxed); -+ if (outputs) -+ atomic_fetch_add_explicit(&lane->outputs, outputs, memory_order_relaxed); -+ uint64_t stamp = ((uint64_t)(uint32_t)now_ms << 8) | (unsigned)stage; -+ atomic_store_explicit(&lane->stamp, stamp, memory_order_relaxed); -+} -+ -+static inline const char *mp_fel_stage_name(unsigned stage) -+{ -+ static const char *const names[] = { -+ "unknown", "core-begin", "core-pull", "core-wait-frame", -+ "core-sync-or-pause", "core-subtitle", "core-wait-vo", "core-queue", -+ "core-idle", "codec-send", "codec-receive", "codec-need-packet", -+ "codec-decoded", "codec-idle", "codec-eof", "codec-reset", -+ "vo-draw", "vo-preload", "vo-target-hint", "vo-acquire-swapchain", -+ "vo-queue-update", "vo-render", "vo-flush", "vo-submit", -+ "vo-swap-buffers", "vo-idle", -+ "vo-drop-preload", "vo-drop-done", -+ "core-preload-wait", "vo-core-preload", -+ }; -+ return stage < sizeof(names) / sizeof(names[0]) ? names[stage] : "unknown"; -+} -+ -+// Counters are best-effort independently atomic observations, not a global -+// transaction. Only a lane's stage/time pair is guaranteed to be coherent. -+static inline void mp_fel_trace_format(struct mp_fel_trace *trace, -+ int64_t now_ms, char *text, size_t size) -+{ -+ if (!size) -+ return; -+ if (!trace) { -+ snprintf(text, size, "VO unavailable"); -+ return; -+ } -+ int len = snprintf(text, size, "v=1 requested=%u refs=%u " -+ "surface-drops=%u drop-prepared=%u drop-retries=%u " -+ "core-preload={requests=%u done=%u retries=%u errors=%u}", -+ atomic_load_explicit(&trace->requested, memory_order_relaxed), -+ atomic_load_explicit(&trace->references, memory_order_relaxed), -+ atomic_load_explicit(&trace->vo_drops, memory_order_relaxed), -+ atomic_load_explicit(&trace->vo_drop_prepared, memory_order_relaxed), -+ atomic_load_explicit(&trace->vo_drop_retries, memory_order_relaxed), -+ atomic_load_explicit(&trace->core_prepare_requests, memory_order_relaxed), -+ atomic_load_explicit(&trace->core_prepare_completed, memory_order_relaxed), -+ atomic_load_explicit(&trace->core_prepare_retries, memory_order_relaxed), -+ atomic_load_explicit(&trace->core_prepare_errors, memory_order_relaxed)); -+ if (len < 0) -+ return; -+ size_t used = (size_t)len < size ? (size_t)len : size - 1; -+ static const char *const names[] = {"core", "BL", "EL", "VO"}; -+ for (int n = 0; n < MP_FEL_LANES && used < size - 1; n++) { -+ struct mp_fel_lane_trace *lane = &trace->lanes[n]; -+ uint64_t stamp = atomic_load_explicit(&lane->stamp, memory_order_relaxed); -+ uint32_t age = (uint32_t)now_ms - (uint32_t)(stamp >> 8); -+ len = snprintf(text + used, size - used, -+ " %s={%s age=%" PRIu32 "ms pts=%d in=%u out=%u held=%u}", -+ names[n], mp_fel_stage_name(stamp & 255), stamp ? age : 0, -+ atomic_load_explicit(&lane->pts_ms, memory_order_relaxed), -+ atomic_load_explicit(&lane->inputs, memory_order_relaxed), -+ atomic_load_explicit(&lane->outputs, memory_order_relaxed), -+ atomic_load_explicit(&lane->held, memory_order_relaxed)); -+ if (len < 0) -+ break; -+ used += (size_t)len < size - used ? (size_t)len : size - used - 1; -+ } -+} -diff --git a/filters/f_decoder_wrapper.c b/filters/f_decoder_wrapper.c ---- a/filters/f_decoder_wrapper.c -+++ b/filters/f_decoder_wrapper.c -@@ -48,6 +48,7 @@ - #include "demux/stheader.h" - - #include "f_async_queue.h" -+#include "f_android_fel.h" - #include "f_decoder_wrapper.h" - #include "f_demux_in.h" - #include "f_lavfi.h" -@@ -164,6 +165,16 @@ - struct dec_wrapper_opts *opts; - struct dec_queue_opts *queue_opts; - struct mp_stream_info stream_info; -+ bool android_fel; -+ bool software_el; -+ double fel_queue_log_at; -+ int fel_queue_peak; -+ bool fel_stage_failed; -+ struct mp_frame fel_stage_frame; -+ int64_t fel_stage_started; -+ unsigned long long fel_staged, fel_stage_retries; -+ int64_t fel_stage_ns, fel_stage_max_ns; -+ double fel_stage_log_at; - - struct mp_codec_params *codec; - struct mp_decoder *decoder; -@@ -217,6 +228,9 @@ - // --- The following fields can be accessed only from the mp_decoder_wrapper - // user thread. - struct mp_decoder_wrapper public; -+ struct mp_android_fel_hwdec_snapshot fel_hwdec_read; -+ unsigned long long fel_status_reads; -+ double fel_status_log_at; - - // --- Specific access depending on threading stuff. - struct mp_async_queue *queue; // decoded frame output queue -@@ -229,6 +243,7 @@ - - // --- Protected by cache_lock. - char *cur_hwdec; -+ struct mp_android_fel_hwdec_snapshot fel_hwdec_cache; - bool try_spdif; - bool force_software_audio; - bool attached_picture; -@@ -279,6 +294,9 @@ - p->cur_hwdec = NULL; - if (p->decoder && p->decoder->control) - res = p->decoder->control(p->decoder->f, VDCTRL_GET_HWDEC, &p->cur_hwdec); -+ if (p->android_fel && p->queue) -+ mp_android_fel_publish_hwdec(&p->fel_hwdec_cache, -+ res == CONTROL_TRUE, p->cur_hwdec); - - mp_mutex_unlock(&p->cache_lock); - return res; -@@ -307,10 +325,26 @@ - mp_dispatch_unlock(p->dec_dispatch); - } - -+// Called on the decoder thread, or while its dispatch is locked. -+static void reset_fel_staging(struct priv *p) -+{ -+ if (p->fel_stage_frame.type == MP_FRAME_VIDEO && p->stream_info.dr_vo) -+ vo_cancel_fel_frame(p->stream_info.dr_vo, p->fel_stage_frame.data); -+ mp_frame_unref(&p->fel_stage_frame); -+ p->fel_stage_started = 0; -+} -+ - // Reset decoder-wrapper state without resetting the decoder filter itself. - // mp_filter_reset() already resets children before invoking decf_reset(). - static void reset_decoder_state(struct priv *p) - { -+ reset_fel_staging(p); -+ p->fel_stage_failed = false; -+ if (p->android_fel && p->queue) { -+ mp_mutex_lock(&p->cache_lock); -+ mp_android_fel_publish_hwdec(&p->fel_hwdec_cache, false, NULL); -+ mp_mutex_unlock(&p->cache_lock); -+ } - p->first_packet_pdts = MP_NOPTS_VALUE; - p->start_pts = MP_NOPTS_VALUE; - p->codec_pts = MP_NOPTS_VALUE; -@@ -378,6 +412,25 @@ - } - return res; - } -+ if (p->android_fel && p->queue && cmd == VDCTRL_GET_HWDEC) { -+ // The decoder can be waiting for this very core thread to consume -+ // Surface outputs. A read must not wait for its dispatch lock. -+ mp_mutex_lock(&p->cache_lock); -+ p->fel_hwdec_read = p->fel_hwdec_cache; -+ mp_mutex_unlock(&p->cache_lock); -+ *(char **)arg = p->fel_hwdec_read.name; -+ p->fel_status_reads++; -+ double now = mp_time_sec(); -+ if (p->fel_status_reads <= 2 || now - p->fel_status_log_at >= 3.0) { -+ MP_INFO(p, "WebHTV FEL status snapshot: layer=%s reads=%llu " -+ "revision=%llu available=%d hwdec=%s (no decoder wait).\n", -+ p->software_el ? "EL" : "BL", p->fel_status_reads, -+ p->fel_hwdec_read.revision, p->fel_hwdec_read.available, -+ p->fel_hwdec_read.name); -+ p->fel_status_log_at = now; -+ } -+ return p->fel_hwdec_read.available ? CONTROL_TRUE : CONTROL_FALSE; -+ } - thread_lock(p); - if (cmd == VDCTRL_GET_HWDEC) { - res = update_cached_values(p); -@@ -598,6 +651,9 @@ - double mp_decoder_wrapper_get_container_fps(struct mp_decoder_wrapper *d) - { - struct priv *p = d->f->priv; -+ // fps is fixed before the worker is started; it is not live decoder state. -+ if (p->android_fel && p->queue) -+ return p->fps; - thread_lock(p); - double res = p->fps; - thread_unlock(p); -@@ -1095,11 +1151,76 @@ - p->reverse_queue_complete = eof; - } - -+static int stage_fel_before_publish(struct priv *p, struct mp_frame frame) -+{ -+ if (!p->android_fel || p->software_el || !p->queue || -+ frame.type != MP_FRAME_VIDEO) -+ return VO_TRUE; -+ struct mp_image *image = frame.data; -+ if (image->imgfmt != IMGFMT_MEDIACODEC) -+ return VO_TRUE; -+ struct vo *vo = p->stream_info.dr_vo; -+ if (!vo) -+ return VO_ERROR; -+ -+ // Keep a pending frame in wrapper state, not on a sleeping process stack. -+ // Returning to the existing decoder dispatch allows stop/reset to cancel -+ // without waiting for VO initialization or a GPU fence. The VO owns the -+ // phase-aware deadline; a second wall-clock deadline here is incorrect. -+ if (!p->fel_stage_started) -+ p->fel_stage_started = mp_time_ns(); -+ int result = vo_prepare_fel_frame(vo, image); -+ if (result == VO_NOTIMPL) { -+ p->fel_stage_started = 0; -+ return VO_TRUE; // Existing non-staging (e.g. OpenGL) path. -+ } -+ if (result == VO_TRUE && image->android_fel_staging && -+ mp_android_fel_staging_ready(image->android_fel_staging->data)) -+ { -+ int64_t elapsed = mp_time_ns() - p->fel_stage_started; -+ p->fel_stage_started = 0; -+ p->fel_staged++; -+ p->fel_stage_ns += elapsed; -+ p->fel_stage_max_ns = MPMAX(p->fel_stage_max_ns, elapsed); -+ double now = mp_time_sec(); -+ if (p->fel_staged == 1 || now - p->fel_stage_log_at >= 3.0) { -+ MP_INFO(p, "WebHTV FEL producer handoff: staged=%llu " -+ "retries=%llu avg-us=%lld max-us=%lld pts=%.6f " -+ "source-returned=1 before-publish=1 gpu-complete=%d " -+ "wait=async.\n", -+ p->fel_staged, p->fel_stage_retries, -+ (long long)(p->fel_stage_ns / p->fel_staged / 1000), -+ (long long)(p->fel_stage_max_ns / 1000), image->pts, -+ mp_android_fel_staging_gpu_complete( -+ image->android_fel_staging->data)); -+ p->fel_stage_log_at = now; -+ } -+ return VO_TRUE; -+ } -+ if (result == VO_FALSE) { -+ p->fel_stage_retries++; -+ return VO_FALSE; -+ } -+ -+ MP_ERR(p, "WebHTV FEL fatal: producer handoff failed (result=%d " -+ "pts=%.6f elapsed-ms=%lld staged=%llu); next decode was not " -+ "started with an unreturned published BL frame.\n", -+ result, image->pts, -+ (long long)((mp_time_ns() - p->fel_stage_started) / 1000000), -+ p->fel_staged); -+ p->fel_stage_started = 0; -+ return VO_ERROR; -+} -+ - static void read_frame(struct priv *p) - { - struct mp_pin *pin = p->decf->ppins[0]; - struct mp_frame frame = {0}; - -+ // A signaling frame has zero weight in the async queue. Repeated EOF -+ // writes after failure create an unbounded hot loop, not backpressure. -+ if (p->fel_stage_failed) -+ return; - if (!p->decoder) { - if (mp_pin_in_needs_data(pin)) - mp_pin_in_write(pin, MP_EOF_FRAME); -@@ -1107,6 +1228,12 @@ - } - if (!mp_pin_in_needs_data(pin)) - return; -+ -+ if (p->fel_stage_frame.type) { -+ frame = p->fel_stage_frame; -+ p->fel_stage_frame = MP_NO_FRAME; -+ goto publish_frame; -+ } - - if (p->decoded_coverart.type) { - if (p->coverart_returned == 0) { -@@ -1198,6 +1325,19 @@ - - output_frame: - process_output_frame(p, frame); -+publish_frame: ; -+ int staged = stage_fel_before_publish(p, frame); -+ if (staged == VO_FALSE) { -+ p->fel_stage_frame = frame; -+ return; -+ } -+ if (staged != VO_TRUE) { -+ mp_frame_unref(&frame); -+ p->fel_stage_failed = true; -+ mp_filter_internal_mark_failed(p->decf); -+ mp_pin_in_write(pin, MP_EOF_FRAME); -+ return; -+ } - mp_pin_in_write(pin, frame); - } - -@@ -1212,6 +1352,13 @@ - .max_samples = p->queue_opts->max_samples, - .max_duration = p->queue_opts->max_duration, - }; -+ if (p->android_fel) { -+ // MediaCodec's AD_HOC Surface path ignores extra_hw_frames. Constrain -+ // our retained outputs instead; EL must progress on its own thread. -+ int limit = p->software_el ? MP_ANDROID_FEL_EL_PREFETCH -+ : MP_ANDROID_FEL_BL_PREFETCH; -+ cfg.max_samples = cfg.max_samples > 0 ? MPMIN(cfg.max_samples, limit) : limit; -+ } - mp_async_queue_set_config(p->queue, cfg); - } - -@@ -1220,11 +1367,33 @@ - struct priv *p = f->priv; - mp_assert(p->decf == f); - -+ if (p->fel_stage_failed) -+ return; -+ if (p->fel_stage_frame.type) { -+ read_frame(p); // Do not feed/decode another BL while handoff is pending. -+ return; -+ } -+ - if (m_config_cache_update(p->opt_cache)) - update_queue_config(p); - - feed_packet(p); - read_frame(p); -+ if (p->android_fel && p->queue) { -+ // Publish before the async producer forwards the newly decoded frame, -+ // not only after the whole graph (possibly another decode) finishes. -+ update_cached_values(p); -+ int held = mp_async_queue_get_frames(p->queue); -+ p->fel_queue_peak = MPMAX(p->fel_queue_peak, held); -+ double now = mp_time_sec(); -+ if (now - p->fel_queue_log_at >= 3.0) { -+ MP_INFO(p, "WebHTV FEL decoder queue: layer=%s held=%d peak=%d " -+ "limit=%d (excludes filter pins and codec internal DPB).\n", -+ p->software_el ? "EL" : "BL", held, p->fel_queue_peak, -+ p->software_el ? MP_ANDROID_FEL_EL_PREFETCH : MP_ANDROID_FEL_BL_PREFETCH); -+ p->fel_queue_log_at = now; -+ } -+ } - } - - static MP_THREAD_VOID dec_thread(void *ptr) -@@ -1240,8 +1409,12 @@ - - while (!p->request_terminate_dec_thread) { - mp_filter_graph_run(p->dec_root_filter); -- update_cached_values(p); -- mp_dispatch_queue_process(p->dec_dispatch, INFINITY); -+ if (!p->fel_stage_frame.type) -+ update_cached_values(p); -+ mp_dispatch_queue_process(p->dec_dispatch, -+ p->fel_stage_frame.type ? 0.002 : INFINITY); -+ if (p->fel_stage_frame.type) -+ mp_filter_mark_async_progress(p->decf); - } - - MP_THREAD_RETURN(); -@@ -1255,6 +1428,7 @@ - if (p->queue) { - mp_async_queue_reset(p->queue); - thread_lock(p); -+ reset_fel_staging(p); - mp_filter_reset(p->dec_root_filter); - mp_dispatch_interrupt(p->dec_dispatch); - thread_unlock(p); -@@ -1271,6 +1445,7 @@ - if (p->dec_thread_valid) { - mp_assert(p->dec_dispatch); - thread_lock(p); -+ reset_fel_staging(p); - p->request_terminate_dec_thread = 1; - mp_dispatch_interrupt(p->dec_dispatch); - thread_unlock(p); -@@ -1437,20 +1612,33 @@ - goto error; - } - -- if (p->queue_opts && p->queue_opts->use_queue) { -+ struct mp_stream_info *sinfo = mp_filter_find_stream_info(parent); -+#if HAVE_ANDROID -+ if (src->type == STREAM_VIDEO) { -+ p->software_el = sinfo && sinfo->force_swdec; -+ p->android_fel = sinfo && sinfo->android_fel && -+ (p->software_el || (src->codec && -+ (src->codec->dv_profile == 7 || src->codec->source_dv_profile == 7))); -+ } -+#endif -+ if (p->queue_opts && (p->queue_opts->use_queue || p->android_fel)) { - p->queue = mp_async_queue_create(); - p->dec_dispatch = mp_dispatch_create(p); - p->dec_root_filter = mp_filter_create_root(public_f->global); - mp_filter_graph_set_wakeup_cb(p->dec_root_filter, wakeup_dec_thread, p); - mp_dispatch_set_onlock_fn(p->dec_dispatch, onlock_dec_thread, p); - -- struct mp_stream_info *sinfo = mp_filter_find_stream_info(parent); - if (sinfo) { - p->dec_root_filter->stream_info = &p->stream_info; - p->stream_info = (struct mp_stream_info){ - .dr_vo = sinfo->dr_vo, - .hwdec_devs = sinfo->hwdec_devs, -+ .force_swdec = sinfo->force_swdec, -+ .android_fel = p->android_fel, - }; -+ } else if (p->android_fel) { -+ p->dec_root_filter->stream_info = &p->stream_info; -+ p->stream_info.android_fel = true; - } - - update_queue_config(p); -diff --git a/filters/f_enhancement_pair.c b/filters/f_enhancement_pair.c ---- a/filters/f_enhancement_pair.c -+++ b/filters/f_enhancement_pair.c -@@ -21,9 +21,11 @@ - #include "common/msg.h" - #include "demux/stheader.h" - #include "f_decoder_wrapper.h" -+#include "f_android_fel.h" - #include "f_enhancement_pair.h" - #include "filter_internal.h" - #include "video/mp_image.h" -+#include "osdep/timer.h" - - // PTS-match tolerance, in seconds. - #define PTS_MATCH_TOLERANCE 1e-6 -@@ -43,6 +45,12 @@ - - bool bl_eof; - bool el_eof; -+ bool force_swdec; -+ bool log_first_pair; -+ int peak_bl, peak_el; -+ unsigned long long paired, bl_only, stale_el; -+ unsigned long long rpu_from_bl, rpu_from_el, rpu_missing; -+ double log_at, last_pair_pts; - }; - - static int pts_cmp(double a, double b) -@@ -57,9 +65,9 @@ - // Pull available frames from `pin` into `queue` until either no data is - // ready or the queue is full. Sets *eof if the upstream signaled EOF. - static void drain_pin(struct mp_filter *f, struct mp_pin *pin, -- struct mp_image ***queue, int *num, bool *eof) --{ -- while (!*eof && *num < QUEUE_MAX) { -+ struct mp_image ***queue, int *num, bool *eof, int limit) -+{ -+ while (!*eof && *num < limit) { - if (!mp_pin_out_request_data(pin)) - return; - struct mp_frame fr = mp_pin_out_read(pin); -@@ -114,16 +122,44 @@ - bl->params.color.hdr.avg_pq_y = el->params.color.hdr.avg_pq_y; - } - -+static void log_pair_state(struct mp_filter *f, const char *reason, bool force) -+{ -+ struct priv *p = f->priv; -+ if (!p->force_swdec) return; -+ p->peak_bl = MPMAX(p->peak_bl, p->num_bl_pending); -+ p->peak_el = MPMAX(p->peak_el, p->num_el_pending); -+ double now = mp_time_sec(); -+ if (!force && now - p->log_at < 3.0) return; -+ MP_INFO(f, "WebHTV FEL stats: reason=%s BL-held=%d peak=%d EL-held=%d peak=%d " -+ "paired=%llu bl-only=%llu stale-EL=%llu last-pair=%.6f " -+ "BL-eof=%d EL-eof=%d RPU={BL=%llu EL=%llu missing=%llu}.\n", reason, -+ p->num_bl_pending, p->peak_bl, p->num_el_pending, p->peak_el, -+ p->paired, p->bl_only, p->stale_el, p->last_pair_pts, -+ p->bl_eof, p->el_eof, p->rpu_from_bl, p->rpu_from_el, p->rpu_missing); -+ p->log_at = now; -+} -+ - static void pair_process(struct mp_filter *f) - { - struct priv *p = f->priv; - struct mp_pin *in = f->ppins[0]; - struct mp_pin *out = f->ppins[1]; - -- drain_pin(f, in, &p->bl_pending, &p->num_bl_pending, &p->bl_eof); -- drain_pin(f, p->el_in, &p->el_pending, &p->num_el_pending, &p->el_eof); -+ if (!p->force_swdec) { -+ drain_pin(f, in, &p->bl_pending, &p->num_bl_pending, &p->bl_eof, QUEUE_MAX); -+ drain_pin(f, p->el_in, &p->el_pending, &p->num_el_pending, &p->el_eof, QUEUE_MAX); -+ } - - while (mp_pin_in_needs_data(out)) { -+ if (p->force_swdec) { -+ // Give the independent EL decoder demand before reserving another -+ // Surface output. Emit a ready pair before prefetching another BL. -+ drain_pin(f, p->el_in, &p->el_pending, &p->num_el_pending, -+ &p->el_eof, MP_ANDROID_FEL_EL_PENDING); -+ drain_pin(f, in, &p->bl_pending, &p->num_bl_pending, -+ &p->bl_eof, MP_ANDROID_FEL_BL_PENDING); -+ log_pair_state(f, "progress", false); -+ } - if (p->num_bl_pending == 0) { - if (p->bl_eof) { - while (p->num_el_pending) -@@ -140,14 +176,34 @@ - // EL older than BL: its BL partner already left or never arrived. - if (p->num_el_pending > 0 && cmp < 0) { - pop_head(&p->el_pending, &p->num_el_pending); -+ p->stale_el++; - continue; - } - - if (p->num_el_pending > 0 && cmp == 0) { - struct mp_image *el = take_head(&p->el_pending, &p->num_el_pending); - take_head(&p->bl_pending, &p->num_bl_pending); -+ bool rpu_on_bl = bl->dovi != NULL; - inherit_dovi_from_el(bl, el); -- if (bl->params.no_enhancement_layer) { -+ if (p->force_swdec) { -+ p->rpu_from_bl += rpu_on_bl; -+ p->rpu_from_el += !rpu_on_bl && bl->dovi; -+ p->rpu_missing += !bl->dovi; -+ } -+ p->paired++; -+ p->last_pair_pts = bl->pts; -+ if (p->log_first_pair && bl->params.repr.dovi) { -+ MP_INFO(f, "WebHTV FEL pair: BL=%.6f EL=%.6f NLQ=%d " -+ "software-EL=%d RPU-source=%s.\n", bl->pts, el->pts, -+ bl->params.repr.dovi->nlq_active, p->force_swdec, -+ rpu_on_bl ? "BL" : "EL"); -+ p->log_first_pair = false; -+ } -+ // MEL carries metadata but no residual pixels. Only optimize -+ // this in the new software-EL path; existing paths stay intact. -+ if (bl->params.no_enhancement_layer || -+ (p->force_swdec && bl->params.repr.dovi && -+ !bl->params.repr.dovi->nlq_active)) { - talloc_free(el); - } else { - bl->enhancement_layer = el; -@@ -158,13 +214,16 @@ - - // No EL match for the oldest BL. Hold BL unless we have affirmative - // evidence no EL is coming. -- bool give_up = p->el_eof || -+ bool give_up = p->force_swdec -+ ? mp_android_fel_can_skip_bl(p->el_eof, p->num_el_pending > 0, cmp) -+ : p->el_eof || - (p->num_el_pending > 0 && cmp > 0) || - p->num_bl_pending >= QUEUE_MAX; - if (!give_up) - return; - - take_head(&p->bl_pending, &p->num_bl_pending); -+ p->bl_only++; - bl->enhancement_layer = NULL; - mp_pin_in_write(out, MAKE_FRAME(MP_FRAME_VIDEO, bl)); - } -@@ -173,17 +232,24 @@ - static void pair_reset(struct mp_filter *f) - { - struct priv *p = f->priv; -+ log_pair_state(f, "reset", true); - while (p->num_bl_pending) - pop_head(&p->bl_pending, &p->num_bl_pending); - while (p->num_el_pending) - pop_head(&p->el_pending, &p->num_el_pending); - p->bl_eof = false; - p->el_eof = false; -+ p->log_first_pair = p->force_swdec; -+ p->peak_bl = p->peak_el = 0; -+ p->paired = p->bl_only = p->stale_el = 0; -+ p->rpu_from_bl = p->rpu_from_el = p->rpu_missing = 0; -+ p->last_pair_pts = MP_NOPTS_VALUE; - } - - static void pair_destroy(struct mp_filter *f) - { - struct priv *p = f->priv; -+ log_pair_state(f, "destroy", true); - while (p->num_bl_pending) - pop_head(&p->bl_pending, &p->num_bl_pending); - while (p->num_el_pending) -@@ -200,7 +266,8 @@ - }; - - struct mp_filter *mp_enhancement_pair_create(struct mp_filter *parent, -- struct sh_stream *el_sh) -+ struct sh_stream *el_sh, -+ bool force_swdec) - { - if (!el_sh) - return NULL; -@@ -212,6 +279,18 @@ - mp_filter_add_pin(f, MP_PIN_OUT, "out"); - - struct priv *p = f->priv; -+ p->force_swdec = force_swdec; -+ p->log_first_pair = force_swdec; -+ if (force_swdec) { -+ struct mp_stream_info *parent_info = mp_filter_find_stream_info(f); -+ struct mp_stream_info *info = talloc_zero(f, struct mp_stream_info); -+ if (parent_info) -+ *info = *parent_info; -+ info->force_swdec = true; -+ f->stream_info = info; -+ MP_INFO(f, "WebHTV Android FEL: software enhancement-layer decoder " -+ "enabled; MediaCodec Surface reserved for BL.\n"); -+ } - p->el_dec = mp_decoder_wrapper_create(f, el_sh); - if (!p->el_dec || !mp_decoder_wrapper_reinit(p->el_dec)) { - MP_WARN(f, "Failed to set up enhancement-layer decoder; " -diff --git a/filters/f_enhancement_pair.h b/filters/f_enhancement_pair.h ---- a/filters/f_enhancement_pair.h -+++ b/filters/f_enhancement_pair.h -@@ -35,4 +35,5 @@ - // 1 input pin (BL frames), 1 output pin (paired BL frames). Returns NULL - // on init failure. The caller should fall back to BL-only rendering. - struct mp_filter *mp_enhancement_pair_create(struct mp_filter *parent, -- struct sh_stream *el_sh); -+ struct sh_stream *el_sh, -+ bool force_swdec); -diff --git a/filters/f_output_chain.c b/filters/f_output_chain.c ---- a/filters/f_output_chain.c -+++ b/filters/f_output_chain.c -@@ -48,6 +48,7 @@ - // no EL is paired. - struct mp_user_filter *el_pair; - struct sh_stream *el_sh; -+ bool el_force_swdec; - - struct vo *vo; - struct ao *ao; -@@ -395,18 +396,21 @@ - p->stream_info.vflip = vo ? vo->driver->caps & VO_CAP_VFLIP : false; - p->stream_info.rotate90 = vo ? vo->driver->caps & VO_CAP_ROTATE90 : false; - p->stream_info.dr_vo = vo; -+ p->stream_info.android_fel = vo && vo->extra.android_dovi_fel; - p->vo = vo; - update_output_caps(p); - } - - void mp_output_chain_set_el_stream(struct mp_output_chain *c, -- struct sh_stream *el_sh) -+ struct sh_stream *el_sh, -+ bool force_swdec) - { - struct chain *p = c->f->priv; - - mp_assert(p->type == MP_OUTPUT_CHAIN_VIDEO); - -- if (p->el_sh == el_sh && (!el_sh || p->el_pair)) -+ if (p->el_sh == el_sh && p->el_force_swdec == force_swdec && -+ (!el_sh || p->el_pair)) - return; - - if (p->el_pair) { -@@ -424,7 +428,7 @@ - if (el_sh) { - struct mp_user_filter *u = create_wrapper_filter(p); - u->name = "el_pair"; -- u->f = mp_enhancement_pair_create(u->wrapper, el_sh); -+ u->f = mp_enhancement_pair_create(u->wrapper, el_sh, force_swdec); - if (!u->f) { - MP_WARN(p, "Failed to set up enhancement-layer pairing.\n"); - talloc_free(u->wrapper); -@@ -434,6 +438,7 @@ - p->el_sh = el_sh; - } - } -+ p->el_force_swdec = force_swdec; - - relink_filter_list(p); - } -diff --git a/filters/f_output_chain.h b/filters/f_output_chain.h ---- a/filters/f_output_chain.h -+++ b/filters/f_output_chain.h -@@ -90,7 +90,9 @@ - bool mp_output_chain_deinterlace_active(struct mp_output_chain *p); - - // Add an enhancement-layer pairing filter at the tail of the chain. --// Pass el_sh=NULL to remove. No-op if the same el_sh is already installed. -+// Pass el_sh=NULL to remove. No-op if the stream and decoder mode are -+// already installed. - struct sh_stream; - void mp_output_chain_set_el_stream(struct mp_output_chain *p, -- struct sh_stream *el_sh); -+ struct sh_stream *el_sh, -+ bool force_swdec); -diff --git a/filters/filter.h b/filters/filter.h ---- a/filters/filter.h -+++ b/filters/filter.h -@@ -408,6 +408,7 @@ - bool vflip; - bool rotate90; - bool force_swdec; -+ bool android_fel; - struct vo *dr_vo; // for calling vo_get_image() - }; - -diff --git a/options/options.c b/options/options.c ---- a/options/options.c -+++ b/options/options.c -@@ -252,6 +252,11 @@ - {"android-surface-size", OPT_SIZE_BOX(android_surface_size)}, - #endif - #if HAVE_ANDROID -+ {"android-dovi-fel", OPT_BOOL(android_dovi_fel), -+ .flags = UPDATE_VO}, -+ {"android-dovi-fel-vulkan", OPT_CHOICE(android_dovi_fel_vulkan, -+ {"no", 0}, {"yes", 1}, {"auto", 2}), -+ .flags = UPDATE_VO}, - {"android-dolby-vision-output", OPT_CHOICE(android_dolby_vision_output, - {"configured", ANDROID_DOLBY_VISION_OUTPUT_CONFIGURED}, - {"direct", ANDROID_DOLBY_VISION_OUTPUT_DIRECT}), .flags = UPDATE_VO}, -diff --git a/options/options.h b/options/options.h ---- a/options/options.h -+++ b/options/options.h -@@ -101,6 +101,8 @@ - - struct m_geometry android_surface_size; - int android_dolby_vision_output; -+ bool android_dovi_fel; -+ int android_dovi_fel_vulkan; // 0=OpenGL, 1=Vulkan, 2=Vulkan with GL fallback - int android_vulkan_aimagereader_backend; - int64_t android_osd_wid; - struct m_geometry android_osd_surface_size; -diff --git a/player/command.c b/player/command.c ---- a/player/command.c -+++ b/player/command.c -@@ -3071,6 +3071,58 @@ - mpctx->video_out && mpctx->video_out->config_ok); - } - -+static int mp_property_android_fel_active(void *ctx, struct m_property *prop, -+ int action, void *arg) -+{ -+ MPContext *mpctx = ctx; -+ return m_property_bool_ro(action, arg, mpctx->vo_chain && -+ mpctx->video_out && mpctx->video_out->extra.android_dovi_fel); -+} -+ -+static int mp_property_android_fel_bind_probe(void *ctx, struct m_property *prop, -+ int action, void *arg) -+{ -+ MPContext *mpctx = ctx; -+ struct vo *vo = mpctx->video_out; -+ struct mp_fel_bind_probe *probe = vo ? &vo->fel_bind_probe : NULL; -+ bool supported = probe && vo->extra.android_dovi_fel && -+ __atomic_load_n(&probe->supported, __ATOMIC_ACQUIRE); -+ if (action == M_PROPERTY_SET) { -+ const char *value = *(char **)arg; -+ if (!probe) -+ return M_PROPERTY_UNAVAILABLE; -+ if (value && !strcmp(value, "cancel")) { -+ mp_fel_probe_cancel(probe); -+ } else if (value && !strcmp(value, "start")) { -+ if (!supported || !mpctx->vo_chain || !vo->config_ok || -+ mpctx->video_status != STATUS_PLAYING || mpctx->paused || -+ !mp_fel_probe_start(probe, mp_time_ns())) -+ return M_PROPERTY_UNAVAILABLE; -+ } else { -+ return M_PROPERTY_ERROR; -+ } -+ mp_notify_property(mpctx, prop->name); -+ mp_wakeup_core(mpctx); -+ return M_PROPERTY_OK; -+ } -+ char status[64]; -+ snprintf(status, sizeof(status), "%" PRIu64 ":%s", probe ? probe->serial : 0, -+ supported || (probe && mp_fel_probe_active(probe)) -+ ? mp_fel_probe_name(mp_fel_probe_state(probe)) : "unavailable"); -+ return m_property_strdup_ro(action, arg, status); -+} -+ -+static int mp_property_video_frame_submitted(void *ctx, struct m_property *prop, -+ int action, void *arg) -+{ -+ MPContext *mpctx = ctx; -+ // vo_has_frame is reset on seek/reconfig. A retained window, metadata -+ // dimensions, or an audio-only playback-restart are not video evidence. -+ return m_property_bool_ro(action, arg, mpctx->vo_chain && -+ mpctx->video_out && mpctx->video_out->config_ok && -+ mpctx->video_status >= STATUS_READY && vo_has_frame(mpctx->video_out)); -+} -+ - static void get_frame_perf(struct mpv_node *node, struct mp_frame_perf *perf) - { - for (int i = 0; i < perf->count; i++) { -@@ -4809,6 +4861,9 @@ - M_PROPERTY_ALIAS("height", "video-params/h"), - {"current-window-scale", mp_property_current_window_scale}, - {"vo-configured", mp_property_vo_configured}, -+ {"android-dovi-fel-active", mp_property_android_fel_active}, -+ {"android-fel-bind-probe", mp_property_android_fel_bind_probe}, -+ {"video-frame-submitted", mp_property_video_frame_submitted}, - {"vo-passes", mp_property_vo_passes}, - {"perf-info", mp_property_perf_info}, - {"current-vo", mp_property_vo}, -@@ -4952,19 +5007,22 @@ - "decoder-frame-drop-count", "frame-drop-count", "video-frame-info", - "vf-metadata", "af-metadata", "sub-start", "sub-end", "secondary-sub-start", - "secondary-sub-end", "sub-lines", "secondary-sub-lines", "video-out-params", -- "video-dec-params", "video-params", "deinterlace-active", "video-target-params"), -+ "video-dec-params", "video-params", "deinterlace-active", "video-target-params", -+ "video-frame-submitted"), - E(MP_EVENT_DURATION_UPDATE, "duration"), - E(MPV_EVENT_VIDEO_RECONFIG, "video-out-params", "video-params", - "video-format", "video-codec", "video-bitrate", "dwidth", "dheight", - "width", "height", "container-fps", "aspect", "aspect-name", "vo-configured", "current-vo", - "video-dec-params", "osd-dimensions", "hwdec", "hwdec-current", "hwdec-interop", -- "window-id", "track-list", "current-tracks"), -+ "window-id", "track-list", "current-tracks", "android-dovi-fel-active", -+ "video-frame-submitted"), - E(MPV_EVENT_AUDIO_RECONFIG, "audio-format", "audio-codec", "audio-bitrate", - "samplerate", "channels", "audio", "volume", "volume-gain", "mute", - "current-ao", "audio-codec-name", "audio-params", "track-list", "current-tracks", - "audio-out-params", "volume-max", "volume-gain-min", "volume-gain-max", "mixer-active"), -- E(MPV_EVENT_SEEK, "seeking", "core-idle", "eof-reached"), -- E(MPV_EVENT_PLAYBACK_RESTART, "seeking", "core-idle", "eof-reached"), -+ E(MPV_EVENT_SEEK, "seeking", "core-idle", "eof-reached", "video-frame-submitted"), -+ E(MPV_EVENT_PLAYBACK_RESTART, "seeking", "core-idle", "eof-reached", -+ "android-dovi-fel-active", "video-frame-submitted"), - E(MP_EVENT_METADATA_UPDATE, "metadata", "filtered-metadata", "media-title"), - E(MP_EVENT_CHAPTER_CHANGE, "chapter", "chapter-metadata"), - E(MP_EVENT_CACHE_UPDATE, -diff --git a/player/loadfile.c b/player/loadfile.c ---- a/player/loadfile.c -+++ b/player/loadfile.c -@@ -1672,13 +1672,26 @@ - if (!mpctx->vo_chain || !mpctx->vo_chain->filter) - return; - -- struct track *track = mpctx->current_track[0][STREAM_VIDEO]; -+ struct track *track = mpctx->vo_chain->track; - struct sh_stream *el = - track ? sh_stream_dependent_sibling(track->stream) : NULL; - bool direct_dovi = - is_android_dolby_vision_direct_output_active(mpctx); - bool gpu_dovi_el = mpctx->video_out && - (mpctx->video_out->driver->caps & VO_CAP_GPU_DOVI_EL); -+ bool force_swdec = false; -+#if HAVE_ANDROID -+ // Keep the old gate closed unless the user requests reconstruction. -+ // EL must never acquire the BL's single MediaCodec producer Surface. -+ force_swdec = mpctx->video_out && -+ mpctx->video_out->extra.android_dovi_fel && el && -+ track->stream->codec && -+ (track->stream->codec->dv_profile == 7 || -+ track->stream->codec->source_dv_profile == 7) && -+ mpctx->video_out && -+ (mpctx->video_out->driver->caps & VO_CAP_GPU_DOVI_EL_SW); -+ gpu_dovi_el |= force_swdec; -+#endif - bool use_el = el && !direct_dovi && gpu_dovi_el; - - if (el && !direct_dovi && !gpu_dovi_el) { -@@ -1695,7 +1708,7 @@ - } - - mp_output_chain_set_el_stream(mpctx->vo_chain->filter, -- use_el ? el : NULL); -+ use_el ? el : NULL, force_swdec); - } - - void update_lavfi_complex(struct MPContext *mpctx) -diff --git a/player/video.c b/player/video.c ---- a/player/video.c -+++ b/player/video.c -@@ -24,6 +24,7 @@ - - #include - #include -+#include - - #include "mpv_talloc.h" - -@@ -273,11 +274,31 @@ - } - #endif - -+static bool should_use_android_fel_output(struct MPContext *mpctx, -+ struct track *track) -+{ -+#if HAVE_ANDROID -+ // track=NULL is a lavfi-complex output, not an invitation to guess an -+ // arbitrary available video stream. Cover art cannot carry a FEL chain. -+ if (!mpctx->opts->vo->android_dovi_fel || !track || track->attached_picture || -+ !track->stream || !track->stream->codec) -+ return false; -+ struct m_obj_settings *outputs = mpctx->opts->vo->video_driver_list; -+ if (outputs && outputs[0].name && strcmp(outputs[0].name, "null") == 0) -+ return false; // explicit output detach/teardown must remain effective -+ struct mp_codec_params *codec = track->stream->codec; -+ return codec->dv_profile == 7 || codec->source_dv_profile == 7; -+#else -+ return false; -+#endif -+} -+ - bool wants_android_dolby_vision_direct_output(struct MPContext *mpctx, - struct track *track) - { - #if HAVE_ANDROID - return is_android_dolby_vision_track(track) && -+ !should_use_android_fel_output(mpctx, track) && - mpctx->opts->vo->android_dolby_vision_output == - ANDROID_DOLBY_VISION_OUTPUT_DIRECT && - mpctx->android_dolby_vision_direct_failed_track != track; -@@ -324,11 +345,18 @@ - { - mp_assert(!mpctx->vo_chain); - -+ bool use_fel = should_use_android_fel_output(mpctx, track); -+ int use_fel_vulkan = 0; -+#if HAVE_ANDROID -+ use_fel_vulkan = use_fel ? mpctx->opts->vo->android_dovi_fel_vulkan : 0; -+#endif - bool use_dovi_direct = - should_use_android_dolby_vision_direct_output(mpctx, track); - bool replace_video_out = - mpctx->video_out && -- is_android_dolby_vision_direct_output_active(mpctx) != use_dovi_direct; -+ (is_android_dolby_vision_direct_output_active(mpctx) != use_dovi_direct || -+ mpctx->video_out->extra.android_dovi_fel != use_fel || -+ mpctx->video_out->extra.android_dovi_fel_vulkan != use_fel_vulkan); - bool prefer_hdr_output = false; - #if HAVE_ANDROID - prefer_hdr_output = should_prefer_android_hdr_output(mpctx, track); -@@ -347,8 +375,16 @@ - .wakeup_cb = mp_wakeup_core_cb, - .wakeup_ctx = mpctx, - .prefer_hdr_output = prefer_hdr_output, -+ .android_dovi_fel = use_fel, -+ .android_dovi_fel_vulkan = use_fel_vulkan, - }; -- if (use_dovi_direct) { -+ if (use_fel) { -+ MP_INFO(mpctx, "WebHTV FEL startup: selected-track=%d profile=7 " -+ "vo=gpu-next api=%s single-load=1.\n", -+ track->user_tid, use_fel_vulkan ? "vulkan" : "opengl"); -+ mpctx->video_out = -+ init_video_out_by_name(mpctx->global, &ex, "gpu-next"); -+ } else if (use_dovi_direct) { - MP_INFO(mpctx, "Using direct MediaCodec Surface output for " - "Dolby Vision.\n"); - mpctx->video_out = -@@ -360,7 +396,7 @@ - mpctx->android_dolby_vision_direct_failed_track = track; - } - } -- if (!mpctx->video_out) { -+ if (!mpctx->video_out && !use_fel) { - mpctx->video_out = init_best_video_out(mpctx->global, &ex); - } - if (!mpctx->video_out) { -@@ -464,7 +500,8 @@ - // check for frame-drop: - if (mpctx->video_status == STATUS_PLAYING && !mpctx->paused && - mpctx->audio_status == STATUS_PLAYING && !ao_untimed(mpctx->ao) && -- vo_c->track && vo_c->track->dec && (opts->frame_dropping & 2)) -+ vo_c->track && vo_c->track->dec && (opts->frame_dropping & 2) && -+ !vo_c->vo->extra.android_dovi_fel) - { - float fps = vo_c->filter->container_fps; - // it's a crappy heuristic; avoid getting upset by incorrect fps -@@ -604,6 +641,16 @@ - static bool have_new_frame(struct MPContext *mpctx, bool eof) - { - return mpctx->num_next_frames >= get_req_frames(mpctx, eof); -+} -+ -+static void trace_fel_core(struct MPContext *mpctx, enum mp_fel_stage stage, -+ unsigned inputs, unsigned outputs) -+{ -+ if (!mpctx->video_out || !mpctx->video_out->extra.android_dovi_fel) -+ return; -+ mp_fel_trace_record(&mpctx->video_out->fel_trace, MP_FEL_CORE, stage, -+ mp_time_ns() / 1000000, mpctx->video_pts, mpctx->num_next_frames, -+ inputs, outputs); - } - - // Fill mpctx->next_frames[] with a newly filtered or decoded image. -@@ -635,6 +682,33 @@ - - if (have_new_frame(mpctx, false)) - return VD_NEW_FRAME; -+ -+ // Before asking the decoder for another output, release hardware buffers -+ // held by core lookahead (including the saved hrseek/backstep frame). Only -+ // GPU pixel storage is detached; timing, RPU/EL and lookahead stay intact. -+ if (mpctx->video_out->extra.android_dovi_fel) { -+ for (int n = -1; n < mpctx->num_next_frames; n++) { -+ struct mp_image *held = n < 0 ? mpctx->saved_frame : mpctx->next_frames[n]; -+ if (!held) -+ continue; -+ int prepared = vo_prepare_fel_frame(mpctx->video_out, held); -+ if (prepared == VO_FALSE) { -+ trace_fel_core(mpctx, MP_FEL_CORE_PRELOAD, 0, 0); -+ mp_set_timeout(mpctx, 0.020); // also polls if the VO/GPU stalls -+ return VD_WAIT; -+ } -+ if (prepared != VO_TRUE && prepared != VO_NOTIMPL) { -+ char pipeline[1024]; -+ mp_fel_trace_format(&mpctx->video_out->fel_trace, -+ mp_time_ns() / 1000000, -+ pipeline, sizeof(pipeline)); -+ MP_ERR(mpctx, "WebHTV FEL fatal: core preload failed or timed out " -+ "(result=%d, pts=%.6f). WebHTV FEL pipeline: %s\n", -+ prepared, held->pts, pipeline); -+ return VD_ERROR; -+ } -+ } -+ } - - // Get a new frame if we need one. - int r = VD_PROGRESS; -@@ -655,6 +729,18 @@ - return VD_ERROR; - } - if (img) { -+ // Install the lease before any core/VO copy, even when enough -+ // frames are already available and this one skips early staging. -+ if (mpctx->video_out->extra.android_dovi_fel && -+ img->imgfmt == IMGFMT_MEDIACODEC && !img->android_fel_staging) -+ { -+ img->android_fel_staging = av_buffer_allocz(1); -+ if (!img->android_fel_staging) { -+ talloc_free(img); -+ return VD_ERROR; -+ } -+ } -+ trace_fel_core(mpctx, MP_FEL_CORE_PULL, 1, 0); - double endpts = get_play_end_pts(mpctx); - if (endpts != MP_NOPTS_VALUE) - endpts *= mpctx->play_dir; -@@ -1207,6 +1293,7 @@ - { - struct MPOpts *opts = mpctx->opts; - -+ trace_fel_core(mpctx, MP_FEL_CORE_BEGIN, 0, 0); - if (!mpctx->vo_chain) - return; - struct track *track = mpctx->vo_chain->track; -@@ -1227,13 +1314,18 @@ - } - - // Actual playback starts when both audio and video are ready. -- if (mpctx->video_status == STATUS_READY) -- return; -- -- if (mpctx->paused && mpctx->video_status >= STATUS_READY) -- return; -+ if (mpctx->video_status == STATUS_READY) { -+ trace_fel_core(mpctx, MP_FEL_CORE_SYNC, 0, 0); -+ return; -+ } -+ -+ if (mpctx->paused && mpctx->video_status >= STATUS_READY) { -+ trace_fel_core(mpctx, MP_FEL_CORE_SYNC, 0, 0); -+ return; -+ } - - bool logical_eof = false; -+ trace_fel_core(mpctx, MP_FEL_CORE_PULL, 0, 0); - int r = video_output_image(mpctx, &logical_eof); - MP_TRACE(mpctx, "video_output_image: r=%d/eof=%d/st=%s\n", r, logical_eof, - mp_status_str(mpctx->video_status)); -@@ -1242,6 +1334,7 @@ - goto error; - - if (r == VD_WAIT) { -+ trace_fel_core(mpctx, MP_FEL_CORE_WAIT, 0, 0); - // Heuristic to detect underruns. - if (mpctx->video_status == STATUS_PLAYING && !vo_still_displaying(vo) && - !vo_c->underrun_signaled) -@@ -1375,6 +1468,7 @@ - // Enforce timing subtitles to video frames. - osd_set_force_video_pts(mpctx->osd, MP_NOPTS_VALUE); - -+ trace_fel_core(mpctx, MP_FEL_CORE_SUBTITLE, 0, 0); - if (!update_subtitles(mpctx, mpctx->next_frames[0]->pts)) { - MP_VERBOSE(mpctx, "Video frame delayed due to waiting on subtitles.\n"); - return; -@@ -1386,6 +1480,7 @@ - // wait until VO wakes us up to get more frames - // (NB: in theory, the 1st frame after display sync mode change uses the - // wrong waiting mode) -+ trace_fel_core(mpctx, MP_FEL_CORE_READY, 0, 0); - if (!vo_is_ready_for_frame(vo, mpctx->display_sync_active ? -1 : pts)) - return; - -@@ -1411,7 +1506,7 @@ - .pts = pts, - .duration = -1, - .still = mpctx->step_frames > 0, -- .can_drop = opts->frame_dropping & 1, -+ .can_drop = vo->extra.android_dovi_fel || (opts->frame_dropping & 1), - .num_frames = MPMIN(mpctx->num_next_frames, req), - .num_vsyncs = 1, - }; -@@ -1438,7 +1533,9 @@ - mpctx->osd_force_update = true; - update_osd_msg(mpctx); - -+ trace_fel_core(mpctx, MP_FEL_CORE_QUEUE, 0, 0); - vo_queue_frame(vo, frame); -+ trace_fel_core(mpctx, MP_FEL_CORE_IDLE, 0, 1); - - check_framedrop(mpctx, vo_c); - -diff --git a/video/decode/android_fel_packet.h b/video/decode/android_fel_packet.h -new file mode 100644 ---- /dev/null -+++ b/video/decode/android_fel_packet.h -@@ -0,0 +1,119 @@ -+/* WebHTV Android FEL bitstream isolation. LGPL-2.1-or-later, like mpv. */ -+#pragma once -+ -+#include -+#include -+#include -+#include -+ -+struct mp_android_fel_packet_filter { -+ AVBSFContext *bsf; -+ AVPacket *packet; -+}; -+ -+static inline void mp_android_fel_remove_decoder_config( -+ AVPacketSideData **side_data, int *count) -+{ -+ av_packet_side_data_remove(*side_data, count, AV_PKT_DATA_DOVI_CONF); -+ av_packet_side_data_remove(*side_data, count, AV_PKT_DATA_HEVC_CONF); -+} -+ -+static inline void mp_android_fel_packet_uninit( -+ struct mp_android_fel_packet_filter *s) -+{ -+ av_packet_free(&s->packet); -+ av_bsf_free(&s->bsf); -+} -+ -+static inline void mp_android_fel_packet_reset( -+ struct mp_android_fel_packet_filter *s) -+{ -+ if (s->bsf) -+ av_bsf_flush(s->bsf); -+ if (s->packet) -+ av_packet_unref(s->packet); -+} -+ -+static inline int mp_android_fel_packet_init( -+ struct mp_android_fel_packet_filter *s, AVCodecContext *avctx, -+ bool fel, bool software_el, bool mediacodec, int source_profile) -+{ -+ if (!fel || software_el || !mediacodec || source_profile != 7 || -+ avctx->codec_id != AV_CODEC_ID_HEVC) -+ return 0; -+ -+ const AVBitStreamFilter *def = av_bsf_get_by_name("dovi_split"); -+ if (!def) -+ return AVERROR_BSF_NOT_FOUND; -+ -+ int ret = av_bsf_alloc(def, &s->bsf); -+ if (ret < 0) -+ goto fail; -+ ret = avcodec_parameters_from_context(s->bsf->par_in, avctx); -+ if (ret < 0) -+ goto fail; -+ s->bsf->time_base_in = avctx->pkt_timebase; -+ ret = av_opt_set(s->bsf, "mode", "bl", AV_OPT_SEARCH_CHILDREN); -+ if (ret < 0) -+ goto fail; -+ ret = av_bsf_init(s->bsf); -+ if (ret < 0) -+ goto fail; -+ s->packet = av_packet_alloc(); -+ if (!s->packet) { -+ ret = AVERROR(ENOMEM); -+ goto fail; -+ } -+ // The hardware decodes only HEVC pixels. Keep DV7 RPU/NLQ on the software -+ // EL path, whose metadata is inherited by the paired BL before rendering. -+ // Only this private decoder context is changed: the shared track and the -+ // BSF's independently owned original parameters still describe full FEL. -+ mp_android_fel_remove_decoder_config(&avctx->coded_side_data, -+ &avctx->nb_coded_side_data); -+ return 0; -+fail: -+ mp_android_fel_packet_uninit(s); -+ return ret; -+} -+ -+// 1: ready (also the disabled/drain path); 0: consumed, no BL NAL; -+// <0: invalid input/filter failure. The input is borrowed and never consumed. -+// The returned packet is valid until reset, uninit or the next prepare call. -+static inline int mp_android_fel_packet_prepare( -+ struct mp_android_fel_packet_filter *s, const AVPacket *input, -+ const AVPacket **output) -+{ -+ *output = input; -+ if (!s->bsf || !input) -+ return 1; -+ *output = NULL; -+ av_packet_unref(s->packet); -+ -+ // mp_set_av_packet borrows both data and side data. The BSF owns its -+ // input, so it must receive an independent ref, never the borrowed shell. -+ int ret = av_packet_ref(s->packet, input); -+ if (ret < 0) -+ goto fail; -+ ret = av_bsf_send_packet(s->bsf, s->packet); -+ if (ret < 0) { -+ // dovi_split is one-in/at-most-one-out and is drained below. A send -+ // EAGAIN here is a broken contract, not a reason to retry forever. -+ if (ret == AVERROR(EAGAIN)) -+ ret = AVERROR_BUG; -+ goto fail; -+ } -+ ret = av_bsf_receive_packet(s->bsf, s->packet); -+ if (ret == AVERROR(EAGAIN)) -+ return 0; -+ if (ret < 0) -+ goto fail; -+ // A packet can repeat the container configuration after a seek/change. -+ // Do not let it re-enable DV/EL processing in the base-layer decoder. -+ mp_android_fel_remove_decoder_config(&s->packet->side_data, -+ &s->packet->side_data_elems); -+ *output = s->packet; -+ return 1; -+fail: -+ mp_android_fel_packet_reset(s); -+ return ret; -+} -diff --git a/video/decode/vd_lavc.c b/video/decode/vd_lavc.c ---- a/video/decode/vd_lavc.c -+++ b/video/decode/vd_lavc.c -@@ -16,6 +16,7 @@ - */ - - #include -+#include - #include - #include - #include -@@ -24,6 +25,7 @@ - #include - #include - #include -+#include - #include - #include - #include -@@ -41,6 +43,8 @@ - #include "video/fmt-conversion.h" - - #include "filters/f_decoder_wrapper.h" -+#include "filters/f_android_fel.h" -+#include "osdep/timer.h" - #include "filters/filter_internal.h" - #include "video/hwdec.h" - #include "video/img_format.h" -@@ -55,6 +59,7 @@ - #include "video/out/vo.h" - - #include "options/m_option.h" -+#include "android_fel_packet.h" - - static void init_avctx(struct mp_filter *vd); - static void uninit_avctx(struct mp_filter *vd); -@@ -280,6 +285,14 @@ - struct vo *vo; - struct mp_hwdec_devices *hwdec_devs; - bool force_swdec; -+ bool android_fel; -+ int fel_decode_errors; -+ struct mp_android_fel_packet_filter fel_bl_filter; -+ unsigned long long fel_bl_packets, fel_bl_input_bytes, fel_bl_output_bytes; -+ unsigned long long fel_bl_empty_packets; -+ uint64_t fel_send_calls, fel_receive_calls, fel_decoded; -+ int64_t fel_send_ns, fel_receive_ns, fel_call_max_ns; -+ double fel_cost_log_at; - - // Wrapped AVHWDeviceContext* used for decoding. - AVBufferRef *hwdec_dev; -@@ -748,7 +761,10 @@ - "decoding; retrying with the configured video " - "output.\n"); - ctx->force_eof = true; -- } else if (ctx->hwdec_opts->software_fallback == INT_MAX) { -+ // An explicitly software-only EL is not a failed BL hardware -+ // decoder. Keep WebHTV's disabled BL fallback policy intact. -+ } else if (!ctx->force_swdec && -+ ctx->hwdec_opts->software_fallback == INT_MAX) { - MP_WARN(ctx, "Software decoding fallback is disabled.\n"); - ctx->force_eof = true; - } else { -@@ -988,6 +1004,20 @@ - goto error; - } - -+ int fel_ret = mp_android_fel_packet_init(&ctx->fel_bl_filter, avctx, -+ ctx->android_fel, ctx->force_swdec, -+ ctx->use_hwdec && ctx->hwdec.lavc_device == AV_HWDEVICE_TYPE_MEDIACODEC, -+ c->dv_profile); -+ if (fel_ret < 0) { -+ MP_ERR(vd, "WebHTV FEL fatal: BL bitstream filter initialization failed: %s\n", -+ av_err2str(fel_ret)); -+ goto error; -+ } -+ if (ctx->fel_bl_filter.bsf) -+ MP_INFO(vd, "WebHTV FEL BL input isolation: pure-bl; " -+ "MediaCodec receives no RPU/EL; RPU-source=software-EL, " -+ "shared track unchanged.\n"); -+ - #ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION - if (avctx->width > 8192 || avctx->height > 8192) { - MP_ERR(vd, "Frame size too big %" PRIu32 "x%" PRIu32 ".\n", avctx->width, avctx->height); -@@ -995,9 +1025,28 @@ - } - #endif - -+ // FEL residual reconstruction needs faithful decoding of both layers. -+ // Enforce it on this decoder after user AVOptions, without changing the -+ // configured shortcuts for ordinary content in the same player instance. -+ if (ctx->android_fel) { -+ avctx->flags2 &= ~AV_CODEC_FLAG2_FAST; -+ avctx->skip_loop_filter = AVDISCARD_DEFAULT; -+ avctx->skip_idct = AVDISCARD_DEFAULT; -+ avctx->skip_frame = AVDISCARD_DEFAULT; -+ } -+ - /* open it */ - if (avcodec_open2(avctx, lavc_codec, NULL) < 0) - goto error; -+ -+ if (ctx->android_fel) { -+ MP_INFO(vd, "WebHTV FEL decoder threads: layer=%s codec=%s requested=%d " -+ "actual=%d active-type=%d frame=%d slice=%d logical-cpus=%d.\n", -+ ctx->force_swdec ? "EL" : "BL", lavc_codec->name, threads, -+ avctx->thread_count, avctx->active_thread_type, -+ !!(avctx->active_thread_type & FF_THREAD_FRAME), -+ !!(avctx->active_thread_type & FF_THREAD_SLICE), av_cpu_count()); -+ } - - // Sometimes, the first packet contains information required for correct - // decoding of the rest of the stream. The only currently known case is the -@@ -1024,6 +1073,7 @@ - - if (ctx->avctx && avcodec_is_open(ctx->avctx)) - avcodec_flush_buffers(ctx->avctx); -+ mp_android_fel_packet_reset(&ctx->fel_bl_filter); - ctx->flushing = false; - ctx->hwdec_request_reinit = false; - // Wait for the first keyframe after reset to ensure the decoder state is -@@ -1063,6 +1113,14 @@ - clear_decoder_queues(ctx); - av_frame_free(&ctx->pic); - mp_free_av_packet(&ctx->avpkt); -+ if (ctx->fel_bl_filter.bsf) -+ MP_INFO(vd, "WebHTV FEL BL input stats: packets=%llu input-bytes=%llu " -+ "output-bytes=%llu empty=%llu (pure-bl; RPU-source=software-EL).\n", -+ ctx->fel_bl_packets, ctx->fel_bl_input_bytes, -+ ctx->fel_bl_output_bytes, ctx->fel_bl_empty_packets); -+ mp_android_fel_packet_uninit(&ctx->fel_bl_filter); -+ ctx->fel_bl_packets = ctx->fel_bl_input_bytes = ctx->fel_bl_output_bytes = 0; -+ ctx->fel_bl_empty_packets = 0; - av_buffer_unref(&ctx->cached_hw_frames_ctx); - - avcodec_free_context(&ctx->avctx); -@@ -1072,6 +1130,7 @@ - ctx->hwdec_failed = false; - ctx->hwdec_fail_count = 0; - ctx->max_delay_queue = 0; -+ ctx->fel_decode_errors = 0; - ctx->hw_probing = false; - ctx->hwdec = (struct hwdec_info){0}; - ctx->use_hwdec = false; -@@ -1310,7 +1369,9 @@ - return; - - int drop = ctx->framedrop_flags; -- if (drop == 1) { -+ if (ctx->android_fel) { -+ avctx->skip_frame = AVDISCARD_DEFAULT; -+ } else if (drop == 1) { - avctx->skip_frame = opts->framedrop; // normal framedrop - } else if (drop == 2) { - avctx->skip_frame = AVDISCARD_NONREF; // hr-seek framedrop -@@ -1325,6 +1386,45 @@ - reset_avctx(vd); - } - -+static void trace_fel_decoder(vd_ffmpeg_ctx *ctx, enum mp_fel_stage stage, -+ double pts, unsigned inputs, unsigned outputs) -+{ -+ if (!ctx->android_fel || !ctx->vo) -+ return; -+ mp_fel_trace_record(&ctx->vo->fel_trace, -+ ctx->force_swdec ? MP_FEL_EL : MP_FEL_BL, stage, mp_time_ns() / 1000000, -+ pts, ctx->num_delay_queue, inputs, outputs); -+} -+ -+static void record_fel_decode_cost(vd_ffmpeg_ctx *ctx, bool send, -+ int64_t started, int result) -+{ -+ if (!ctx->android_fel) -+ return; -+ int64_t elapsed = mp_time_ns() - started; -+ ctx->fel_call_max_ns = MPMAX(ctx->fel_call_max_ns, elapsed); -+ if (send) { -+ ctx->fel_send_calls++; -+ ctx->fel_send_ns += elapsed; -+ } else { -+ ctx->fel_receive_calls++; -+ ctx->fel_receive_ns += elapsed; -+ ctx->fel_decoded += result >= 0; -+ } -+ double now = mp_time_sec(); -+ if (now - ctx->fel_cost_log_at >= 3.0) { -+ MP_INFO(ctx, "WebHTV FEL decoder cost: layer=%s decoded=%" PRIu64 -+ " send-calls=%" PRIu64 " receive-calls=%" PRIu64 -+ " send-us=%lld receive-us=%lld max-call-us=%lld result=%d.\n", -+ ctx->force_swdec ? "EL" : "BL", ctx->fel_decoded, -+ ctx->fel_send_calls, ctx->fel_receive_calls, -+ (long long)(ctx->fel_send_ns / 1000), -+ (long long)(ctx->fel_receive_ns / 1000), -+ (long long)(ctx->fel_call_max_ns / 1000), result); -+ ctx->fel_cost_log_at = now; -+ } -+} -+ - static void handle_err(struct mp_filter *vd) - { - vd_ffmpeg_ctx *ctx = vd->priv; -@@ -1333,6 +1433,28 @@ - MP_WARN(vd, "Error while decoding frame%s!\n", - ctx->use_hwdec ? " (hardware decoding)" : ""); - -+ if (ctx->android_fel) { -+ ctx->fel_decode_errors++; -+ if (mp_android_fel_decode_failed(true, ctx->fel_decode_errors)) { -+ // "no software fallback" is not permission to retry forever. -+ // Success resets this budget; seek/reset starts a fresh budget. -+ char pipeline[1024]; -+ mp_fel_trace_format(ctx->vo ? &ctx->vo->fel_trace : NULL, -+ mp_time_ns() / 1000000, pipeline, sizeof(pipeline)); -+ MP_ERR(vd, "WebHTV FEL fatal: decoder made no progress " -+ "after %d consecutive errors; stopping (hardware=%d, " -+ "software-EL=%d, delay-held=%d, fallback policy unchanged). " -+ "WebHTV FEL pipeline: %s " -+ "BL-input={isolated=%d packets=%llu bytes=%llu->%llu empty=%llu}\n", -+ ctx->fel_decode_errors, ctx->use_hwdec, ctx->force_swdec, -+ ctx->num_delay_queue, pipeline, !!ctx->fel_bl_filter.bsf, -+ ctx->fel_bl_packets, ctx->fel_bl_input_bytes, -+ ctx->fel_bl_output_bytes, ctx->fel_bl_empty_packets); -+ ctx->force_eof = true; -+ mp_filter_internal_mark_failed(vd); -+ return; -+ } -+ } - if (ctx->use_hwdec) { - ctx->hwdec_fail_count += 1; - if (ctx->hwdec_fail_count >= hwdec_opts->software_fallback) -@@ -1351,7 +1473,7 @@ - if (ctx->hwdec_failed) - return AVERROR(EAGAIN); - -- if (!ctx->avctx) -+ if (!ctx->avctx || ctx->force_eof) - return AVERROR_EOF; - - prepare_decoding(vd); -@@ -1370,7 +1492,42 @@ - - mp_set_av_packet(ctx->avpkt, pkt, &ctx->codec_timebase); - -- int ret = avcodec_send_packet(avctx, pkt ? ctx->avpkt : NULL); -+ const AVPacket *decoder_packet = pkt ? ctx->avpkt : NULL; -+ int filtered = mp_android_fel_packet_prepare(&ctx->fel_bl_filter, -+ decoder_packet, &decoder_packet); -+ if (filtered < 0) { -+ MP_ERR(vd, "WebHTV FEL fatal: BL bitstream filtering failed: %s\n", -+ av_err2str(filtered)); -+ ctx->force_eof = true; -+ mp_filter_internal_mark_failed(vd); -+ return filtered; -+ } -+ if (!filtered) { -+ ctx->fel_bl_empty_packets++; -+ return 0; -+ } -+ -+ trace_fel_decoder(ctx, MP_FEL_SEND, pkt ? pkt->pts : MP_NOPTS_VALUE, 0, 0); -+ int64_t started = ctx->android_fel ? mp_time_ns() : 0; -+ int ret = avcodec_send_packet(avctx, decoder_packet); -+ record_fel_decode_cost(ctx, true, started, ret); -+ if (ctx->fel_bl_filter.bsf) { -+ if (ret >= 0 && pkt) { -+ ctx->fel_bl_packets++; -+ ctx->fel_bl_input_bytes += ctx->avpkt->size; -+ ctx->fel_bl_output_bytes += decoder_packet->size; -+ if (ctx->fel_bl_packets == 1) -+ MP_INFO(vd, "WebHTV FEL BL input first packet: input=%d " -+ "output=%d pts=%.6f (RPU/EL removed from hardware input).\n", -+ ctx->avpkt->size, decoder_packet->size, pkt->pts); -+ } -+ // The codec keeps its own ref on success. On EAGAIN the caller will -+ // retry the unchanged demux packet, not an already-consumed BSF input. -+ av_packet_unref(ctx->fel_bl_filter.packet); -+ } -+ if (ret >= 0 || ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) -+ trace_fel_decoder(ctx, MP_FEL_DECODE_IDLE, MP_NOPTS_VALUE, -+ ret >= 0 && pkt, 0); - if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) - return ret; - -@@ -1413,9 +1570,13 @@ - if (ctx->num_requeue_packets) - send_queued_packet(vd); - -+ trace_fel_decoder(ctx, MP_FEL_RECEIVE, MP_NOPTS_VALUE, 0, 0); -+ int64_t started = ctx->android_fel ? mp_time_ns() : 0; - int ret = avcodec_receive_frame(avctx, ctx->pic); -+ record_fel_decode_cost(ctx, false, started, ret); - if (ret < 0) { - if (ret == AVERROR_EOF) { -+ trace_fel_decoder(ctx, MP_FEL_EOF, MP_NOPTS_VALUE, 0, 0); - // If flushing was initialized earlier and has ended now, make it - // start over in case we get new packets at some point in the future. - // This must take the delay queue into account, so avctx returns EOF -@@ -1424,6 +1585,7 @@ - reset_avctx(vd); - } else if (ret == AVERROR(EAGAIN)) { - // just retry after caller writes a packet -+ trace_fel_decoder(ctx, MP_FEL_NEED_PACKET, MP_NOPTS_VALUE, 0, 0); - } else { - handle_err(vd); - } -@@ -1452,12 +1614,14 @@ - ctx->hwdec_fail_count = 0; - - mpi->pts = mp_pts_from_av(ctx->pic->pts, &ctx->codec_timebase); -+ ctx->fel_decode_errors = 0; - mpi->dts = mp_pts_from_av(ctx->pic->pkt_dts, &ctx->codec_timebase); - mpi->pkt_duration = mp_pts_from_av(ctx->pic->duration, &ctx->codec_timebase); - - av_frame_unref(ctx->pic); - - MP_TARRAY_APPEND(ctx, ctx->delay_queue, ctx->num_delay_queue, mpi); -+ trace_fel_decoder(ctx, MP_FEL_DECODED, mpi->pts, 0, 1); - return ret; - } - -@@ -1486,7 +1650,7 @@ - */ - do { - force_fallback(vd); -- } while (!ctx->avctx); -+ } while (!ctx->avctx && !ctx->force_eof); - - ctx->requeue_packets = pkts; - ctx->num_requeue_packets = num_pkts; -@@ -1595,6 +1759,8 @@ - - ctx->state = (struct lavc_state){0}; - ctx->framedrop_flags = 0; -+ ctx->hwdec_fail_count = 0; -+ ctx->fel_decode_errors = 0; - } - - static void vd_lavc_destroy(struct mp_filter *vd) -@@ -1618,6 +1784,16 @@ - struct mp_codec_params *codec, - const char *decoder) - { -+ struct mp_stream_info *info = mp_filter_find_stream_info(parent); -+ // Wrapper decoders such as hevc_mediacodec bypass hwaccel selection. -+ // Do not even look them up on the unchanged, non-forced path. -+ if (info && info->force_swdec) { -+ const AVCodec *lavc_codec = avcodec_find_decoder_by_name(decoder); -+ if (lavc_codec && (lavc_codec->capabilities & -+ (AV_CODEC_CAP_HARDWARE | AV_CODEC_CAP_HYBRID))) -+ return NULL; -+ } -+ - struct mp_filter *vd = mp_filter_create(parent, &vd_lavc_filter); - if (!vd) - return NULL; -@@ -1644,11 +1820,18 @@ - mp_mutex_init(&ctx->dr_lock); - - // hwdec/DR -- struct mp_stream_info *info = mp_filter_find_stream_info(vd); - if (info) { - ctx->hwdec_devs = info->hwdec_devs; - ctx->vo = info->dr_vo; - ctx->force_swdec = info->force_swdec; -+ ctx->android_fel = info->android_fel; -+ } -+ -+ if (ctx->android_fel && ctx->vo) { -+ atomic_store_explicit(&ctx->vo->fel_trace.enabled, true, memory_order_relaxed); -+ trace_fel_decoder(ctx, MP_FEL_RESET, MP_NOPTS_VALUE, 0, 0); -+ MP_INFO(vd, "WebHTV FEL pipeline diagnostics v=1 enabled; " -+ "fatal includes lock-free core/BL/EL/VO progress.\n"); - } - - reinit(vd); -diff --git a/video/mp_image.c b/video/mp_image.c ---- a/video/mp_image.c -+++ b/video/mp_image.c -@@ -234,6 +234,7 @@ - av_buffer_unref(&mpi->a53_cc); - av_buffer_unref(&mpi->dovi); - av_buffer_unref(&mpi->film_grain); -+ av_buffer_unref(&mpi->android_fel_staging); - for (int n = 0; n < mpi->num_ff_side_data; n++) - av_buffer_unref(&mpi->ff_side_data[n].buf); - talloc_free(mpi->ff_side_data); -@@ -332,6 +333,8 @@ - // allocated. In particular, mp_image.hwctx is preserved. - void mp_image_unref_data(struct mp_image *img) - { -+ av_buffer_unref(&img->android_fel_staging); -+ img->android_fel_prepared = 0; - for (int n = 0; n < MP_MAX_PLANES; n++) { - img->planes[n] = NULL; - img->stride[n] = 0; -@@ -369,6 +372,7 @@ - ref_buffer(&new->a53_cc); - ref_buffer(&new->dovi); - ref_buffer(&new->film_grain); -+ ref_buffer(&new->android_fel_staging); - - new->ff_side_data = talloc_memdup(NULL, new->ff_side_data, - new->num_ff_side_data * sizeof(new->ff_side_data[0])); -@@ -409,6 +413,8 @@ - new->a53_cc = NULL; - new->dovi = NULL; - new->film_grain = NULL; -+ new->android_fel_staging = NULL; -+ new->android_fel_prepared = 0; - new->num_ff_side_data = 0; - new->ff_side_data = NULL; - new->enhancement_layer = NULL; -diff --git a/video/mp_image.h b/video/mp_image.h ---- a/video/mp_image.h -+++ b/video/mp_image.h -@@ -131,6 +131,10 @@ - int num_ff_side_data; - // Optional decoded enhancement-layer frame - struct mp_image *enhancement_layer; -+ // Internal Android FEL staging lease. Pixel copies must not inherit it. -+ // The mapper owns one reference; live core/VO frames pin its GPU cache slot. -+ struct AVBufferRef *android_fel_staging; -+ uint64_t android_fel_prepared; // VO generation, set only by the core owner - } mp_image_t; - - struct mp_ff_side_data { -diff --git a/video/out/hwdec/hwdec_aimagereader.c b/video/out/hwdec/hwdec_aimagereader.c ---- a/video/out/hwdec/hwdec_aimagereader.c -+++ b/video/out/hwdec/hwdec_aimagereader.c -@@ -33,6 +33,7 @@ - #include - #include - -+#include "filters/f_android_fel.h" - #include "misc/jni.h" - #include "options/options.h" - #include "osdep/threads.h" -@@ -129,6 +130,8 @@ - struct priv { - struct mp_log *log; - enum mapper_backend backend; -+ uint64_t fel_map_frames, fel_release_ns, fel_acquire_ns, fel_gpu_map_ns; -+ int64_t fel_map_log_ns; - - GLuint gl_texture; - AImage *image; -@@ -431,6 +434,17 @@ - #if HAVE_VULKAN - mp_mutex_init(&p->vk_lock); - p->vk_backend = hw->ra_ctx->vo->opts->android_vulkan_aimagereader_backend; -+ if (!use_gl && mp_android_fel_stage_output( -+ hw->ra_ctx->vo->extra.android_dovi_fel, -+ p->vk_backend == ANDROID_VULKAN_AIMAGEREADER_BACKEND_AUTO || -+ p->vk_backend == ANDROID_VULKAN_AIMAGEREADER_BACKEND_DIRECT)) { -+ // FEL adds decoder/pair lookahead. Do not keep codec-owned images in -+ // the direct sampler's redraw ring while waiting for another output. -+ MP_INFO(hw, "WebHTV FEL GPU staging: using stable raw-YUV pool " -+ "instead of direct retention (requested backend=%d; " -+ "saved user setting unchanged).\n", p->vk_backend); -+ p->vk_backend = ANDROID_VULKAN_AIMAGEREADER_BACKEND_STABLE; -+ } - use_vulkan = aimagereader_vk_available(hw->ra_ctx, hw->log, - p->vk_backend); - #endif -@@ -909,6 +923,7 @@ - return 0; - } - } -+ bool fel = mapper->owner->ra_ctx->vo->extra.android_dovi_fel; - AVMediaCodecBuffer *buffer = (AVMediaCodecBuffer *)mapper->src->planes[3]; - int64_t presentation_time_us; - bool check_timestamp = -@@ -931,7 +946,10 @@ - expected_timestamp_ns = p->pending_timestamp_ns; - check_timestamp = p->pending_check_timestamp; - } else { -+ int64_t release_started = fel ? mp_time_ns() : 0; - int release_status = av_mediacodec_release_buffer_status(buffer, 1); -+ if (fel) -+ p->fel_release_ns += mp_time_ns() - release_started; - if (release_status < 0) { - MP_ERR(mapper, "Failed rendering MediaCodec output buffer\n"); - return -1; -@@ -956,10 +974,13 @@ - - AImage *image = NULL; - int acquire_fence = -1; -+ int64_t acquire_started = fel ? mp_time_ns() : 0; - media_status_t ret = - acquire_latest_image(mapper, &image, &acquire_fence, - expected_timestamp_ns, check_timestamp, - &image_sequence); -+ if (fel) -+ p->fel_acquire_ns += mp_time_ns() - acquire_started; - if (ret == AMEDIA_IMGREADER_NO_BUFFER_AVAILABLE) { - if (p->stats.acquire_timeouts % TRANSIENT_LOG_INTERVAL == 1) { - MP_WARN(mapper, "AImageReader frame acquisition timed out " -@@ -1028,9 +1049,25 @@ - return -1; - } - -+ int64_t map_started = fel ? mp_time_ns() : 0; - int result = aimagereader_vk_map( - p->vk, image, hwbuf, &crop, data_space, mapper->src, - &acquire_fence); -+ if (fel) { -+ int64_t now = mp_time_ns(); -+ p->fel_gpu_map_ns += now - map_started; -+ p->fel_map_frames += result >= 0; -+ if (p->fel_map_frames && -+ (!p->fel_map_log_ns || now - p->fel_map_log_ns >= MP_TIME_MS_TO_NS(3000))) -+ { -+ MP_INFO(mapper, "WebHTV FEL map cost: frames=%" PRIu64 -+ " release-us=%" PRIu64 " acquire-us=%" PRIu64 -+ " gpu-map-us=%" PRIu64 " (cumulative CPU wall time).\n", -+ p->fel_map_frames, p->fel_release_ns / 1000, -+ p->fel_acquire_ns / 1000, p->fel_gpu_map_ns / 1000); -+ p->fel_map_log_ns = now; -+ } -+ } - if (result < 0) - delete_image(o, image, acquire_fence); - else -diff --git a/video/out/hwdec/hwdec_aimagereader_vk_stable.c b/video/out/hwdec/hwdec_aimagereader_vk_stable.c ---- a/video/out/hwdec/hwdec_aimagereader_vk_stable.c -+++ b/video/out/hwdec/hwdec_aimagereader_vk_stable.c -@@ -10,30 +10,45 @@ - #include "config.h" - - #include -+#include - #include - #include - #include -+#include -+#include - #include -+#include - #include - - #include -+#include - #include - #include - #include - - #include "common/common.h" - #include "common/msg.h" -+#include "filters/f_android_fel.h" -+#include "filters/f_android_fel_perf.h" -+#include "options/options.h" -+#include "osdep/timer.h" - #include "video/out/gpu/context.h" - #include "video/out/gpu/hwdec.h" - #include "video/out/placebo/ra_pl.h" - #include "video/out/vulkan/context.h" -+#include "video/out/vo.h" - - #include "hwdec_aimagereader_vk_private.h" - - #define STABLE_WORKGROUP_X 16 - #define STABLE_WORKGROUP_Y 8 - #define OUTPUT_COUNT 4 -+#define FEL_OUTPUT_CAPACITY (2 * VO_MAX_REQ_FRAMES + OUTPUT_COUNT) - #define INPUT_CACHE_SIZE 8 -+#define FEL_INPUT_CACHE_SIZE 32 -+#define FEL_RECORD_CACHE_SIZE 128 -+#define FEL_ORDER_CAPACITY 8 -+#define FEL_LATENCY_SAMPLES 128 - #define EXTERNAL_FORMAT_DESCRIPTOR_COUNT 4 - #define CONVERSION_FENCE_TIMEOUT_NS UINT64_C(250000000) - #define POOL_LOG_INTERVAL 120 -@@ -60,6 +75,9 @@ - }; - - struct vk_output { -+ uint64_t fel_serial; -+ int fel_input_slot; -+ bool fel_query_recorded; - VkImage image; - VkDeviceMemory memory; - VkImageView view; -@@ -67,9 +85,11 @@ - struct ra_tex *ratex; - VkDescriptorSet descriptor; - VkCommandBuffer command; -+ VkCommandBuffer active_command; - VkSemaphore available; - VkSemaphore acquire; - VkSemaphore ready; -+ VkSemaphore source_release; - VkFence fence; - AImage *source_image; - struct mp_image *source_frame; -@@ -81,6 +101,63 @@ - bool released; - }; - -+// Cache bounded Vulkan object slots, not AImages or additional output pixels. -+// Descriptor contents describe views, not the pixels changed by the producer. -+// Reuse identical contents, but bind and record a fresh one-shot command every -+// frame. Input destruction invalidates records before handles can be recycled; -+// output/sampler/YCbCr/layout reconstruction destroys the entire recording pool. -+struct vk_recording { -+ VkCommandBuffer command; -+ VkDescriptorSet descriptor; -+ struct vk_input *input; -+ struct vk_output *output; -+ VkImageView input_view, output_view; -+ AImageCropRect crop; -+ uint32_t width, height; -+ uint64_t last_used; -+ bool valid, timestamps; -+}; -+ -+enum fel_api_op { -+ FEL_API_DESCRIBE, FEL_API_PROPERTIES, FEL_API_IMAGE, FEL_API_MEMORY, -+ FEL_API_BIND, FEL_API_VIEW, FEL_API_DESCRIPTOR, FEL_API_RESET, -+ FEL_API_BEGIN, FEL_API_RECORD, FEL_API_END, FEL_API_BARRIER_IN, -+ FEL_API_PIPELINE, FEL_API_DESCRIPTORS, FEL_API_PUSH, FEL_API_DISPATCH, -+ FEL_API_BARRIER_OUT, FEL_API_PUSH_DESCRIPTORS, FEL_API_COUNT, -+}; -+ -+static const char *const fel_api_names[FEL_API_COUNT] = { -+ "describe", "properties", "image", "memory", "bind", "view", -+ "descriptor", "reset", "begin", "record", "end", "barrier-in", -+ "pipeline", "bind-descriptors", "push", "dispatch", "barrier-out", "push-descriptors", -+}; -+ -+struct fel_api_clock { int64_t wall, cpu; }; -+ -+struct fel_latency_window { -+ uint64_t ns[FEL_LATENCY_SAMPLES]; -+ unsigned count, next; -+}; -+ -+struct fel_wait_snapshot { -+ uint64_t runtime, runqueue, slices, voluntary, involuntary; -+ int sched_error, usage_error, acquire; -+}; -+ -+struct fel_wait_sample { -+ bool active; -+ struct fel_api_clock clock; -+ struct fel_wait_snapshot before; -+ int64_t probe_ns; -+}; -+ -+struct fel_order_event { -+ uint64_t serial; -+ double requested_pts, mapped_pts; -+ int output, input; -+ char action; -+}; -+ - struct aimagereader_vk_stable { - struct mp_log *log; - struct ra_hwdec_mapper *mapper; -@@ -94,6 +171,10 @@ - PFN_vkGetAndroidHardwareBufferPropertiesANDROID GetAHBProperties; - PFN_vkGetPhysicalDeviceImageFormatProperties2 GetImageFormatProperties2; - PFN_vkImportSemaphoreFdKHR ImportSemaphoreFdKHR; -+ PFN_vkGetSemaphoreFdKHR GetSemaphoreFdKHR; -+ bool release_sync_fd; -+ PFN_vkCmdPushDescriptorSetKHR CmdPushDescriptorSetKHR; -+ bool push_descriptors; - - VkCommandPool command_pool; - VkFormat output_format; -@@ -116,16 +197,524 @@ - VkPipelineLayout pipeline_layout; - VkPipeline pipeline; - -- struct vk_input inputs[INPUT_CACHE_SIZE]; -+ struct vk_input inputs[FEL_INPUT_CACHE_SIZE]; - int num_inputs; - uint64_t input_serial; -- struct vk_output outputs[OUTPUT_COUNT]; -+ struct vk_output outputs[FEL_OUTPUT_CAPACITY]; -+ int output_count, output_capacity; - uint64_t submitted_outputs; - uint64_t completed_outputs; - uint64_t reclaimed_outputs; - uint64_t fence_timeouts; - uint64_t pool_retries; -+ bool android_fel; -+ double fel_log_at; -+ uint64_t fel_wait_ns, fel_max_wait_ns; -+ uint64_t fel_async_returns, fel_release_failures; -+ bool fel_profile, fel_profile_warm; -+ int64_t fel_map_started, fel_map_cpu_started, fel_map_checkpoint; -+ struct mp_fel_perf_stat fel_map_warm, fel_map_cold, fel_map_cpu; -+ struct mp_fel_perf_stat fel_map_stages[MP_FEL_PERF_COUNT]; -+ VkQueryPool fel_query_pool; -+ unsigned fel_timestamp_bits; -+ double fel_timestamp_period; -+ bool fel_query_failed; -+ struct mp_fel_perf_stat fel_gpu_copy; -+ uint64_t fel_query_unavailable; -+ struct mp_fel_perf_stat fel_api_wall[FEL_API_COUNT]; -+ struct mp_fel_perf_stat fel_api_cpu[FEL_API_COUNT]; -+ struct fel_latency_window fel_bind_window, fel_record_window, fel_map_window; -+ struct vk_recording recordings[FEL_RECORD_CACHE_SIZE]; -+ VkDescriptorPool recording_pool; -+ uint32_t sampler_descriptors; -+ int num_recordings; -+ bool recording_cache_disabled; -+ uint64_t recording_serial, recording_hits, recording_misses; -+ uint64_t recording_evictions, recording_invalidations, recording_fallbacks; -+ uint64_t recording_cold; -+ uint64_t descriptor_content_hits, descriptor_writes, descriptor_rebinds; -+ uint64_t input_hits, input_misses, input_evictions, input_removals; -+ uint64_t fresh_commands, fel_order_count, fel_pts_differences; -+ struct fel_order_event fel_order[FEL_ORDER_CAPACITY], fel_last_pts_difference; -+ double fel_active_pts; -+ int fel_active_input, fel_active_output; -+ int64_t fel_slow_window; -+ unsigned fel_slow_count; -+ int64_t fel_wait_sample_at; -+ int fel_sched_disabled_error, fel_acquire_fence; -+ VkFence fel_probe_drains[8]; -+ unsigned fel_probe_drain_count; -+ bool fel_probe_drain_failed; - }; -+ -+static int output_sample_depth(VkFormat format); -+ -+// Reuse already measured warm timings. No extra clocks, allocation or sampling -+// when profiling is off; sorting only happens at the existing log interval. -+static void fel_latency_add(struct fel_latency_window *window, uint64_t ns) -+{ -+ window->ns[window->next] = ns; -+ window->next = (window->next + 1) % FEL_LATENCY_SAMPLES; -+ window->count = MPMIN(window->count + 1, FEL_LATENCY_SAMPLES); -+} -+ -+static int fel_latency_compare(const void *a, const void *b) -+{ -+ uint64_t x = *(const uint64_t *)a, y = *(const uint64_t *)b; -+ return (x > y) - (x < y); -+} -+ -+static void fel_latency_summary(const struct fel_latency_window *window, -+ uint64_t summary[3]) -+{ -+ summary[0] = summary[1] = summary[2] = 0; -+ if (!window->count) -+ return; -+ uint64_t sorted[FEL_LATENCY_SAMPLES]; -+ memcpy(sorted, window->ns, window->count * sizeof(sorted[0])); -+ qsort(sorted, window->count, sizeof(sorted[0]), fel_latency_compare); -+ // Nearest-rank p50/p95 and maximum of the last <=128 measured calls. -+ summary[0] = sorted[(window->count * 50 + 99) / 100 - 1] / 1000; -+ summary[1] = sorted[(window->count * 95 + 99) / 100 - 1] / 1000; -+ summary[2] = sorted[window->count - 1] / 1000; -+} -+ -+static struct fel_api_clock fel_api_begin(struct aimagereader_vk_stable *p) -+{ -+ if (!p->fel_profile || !p->fel_profile_warm) -+ return (struct fel_api_clock){0}; -+ return (struct fel_api_clock){mp_time_ns(), mp_fel_thread_cpu_ns()}; -+} -+ -+// Linux schedstat: running ns, runnable-but-not-running ns, timeslices. -+// All zero means accounting is disabled, not proof of zero scheduling delay. -+static int fel_parse_schedstat(const char *text, struct fel_wait_snapshot *s) -+{ -+ uint64_t values[3]; -+ for (int n = 0; n < 3; n++) { -+ while (*text == ' ' || *text == '\t' || *text == '\n') -+ text++; -+ if (*text < '0' || *text > '9') -+ return EINVAL; -+ char *end; -+ errno = 0; -+ values[n] = strtoull(text, &end, 10); -+ if (errno == ERANGE) -+ return EOVERFLOW; -+ text = end; -+ if (n < 2 && *text != ' ' && *text != '\t' && *text != '\n') -+ return EINVAL; -+ } -+ while (*text == ' ' || *text == '\t' || *text == '\n') -+ text++; -+ if (*text) -+ return EINVAL; -+ if (!values[0] && !values[1] && !values[2]) -+ return ENODATA; -+ s->runtime = values[0]; -+ s->runqueue = values[1]; -+ s->slices = values[2]; -+ return 0; -+} -+ -+static int fel_read_schedstat(struct fel_wait_snapshot *s) -+{ -+ char path[80], text[160]; -+ snprintf(path, sizeof(path), "/proc/self/task/%ld/schedstat", (long)gettid()); -+ int fd = open(path, O_RDONLY | O_CLOEXEC); -+ if (fd < 0) -+ return errno; -+ ssize_t size = read(fd, text, sizeof(text) - 1); -+ int error = size < 0 ? errno : 0; -+ close(fd); -+ if (error) -+ return error; -+ if (size <= 0 || (size_t)size == sizeof(text) - 1) -+ return EINVAL; -+ text[size] = '\0'; -+ return fel_parse_schedstat(text, s); -+} -+ -+// Inspect the sync fd without consuming, importing, closing or waiting on it. -+// 0=no fence, 1=pending, 2=ready, negative=errno/invalid poll result. -+static int fel_probe_acquire_fence(int fd) -+{ -+ if (fd < 0) -+ return 0; -+ struct pollfd state = {.fd = fd, .events = POLLIN}; -+ int ret = poll(&state, 1, 0); -+ if (ret < 0) -+ return -errno; -+ if (!ret) -+ return 1; -+ if (state.revents & (POLLERR | POLLNVAL)) -+ return -EINVAL; -+ return state.revents & POLLIN ? 2 : -EIO; -+} -+ -+static struct fel_wait_snapshot fel_wait_snapshot(struct aimagereader_vk_stable *p) -+{ -+ int saved_errno = errno; -+ struct fel_wait_snapshot s = {0}; -+ s.sched_error = p->fel_sched_disabled_error; -+ if (!s.sched_error) { -+ s.sched_error = fel_read_schedstat(&s); -+ // Do not repeatedly probe a denied/missing kernel interface. -+ if (s.sched_error == EACCES || s.sched_error == EPERM || -+ s.sched_error == ENOENT || s.sched_error == ENODATA) -+ p->fel_sched_disabled_error = s.sched_error; -+ } -+ struct rusage usage; -+ if (getrusage(RUSAGE_THREAD, &usage) < 0) { -+ s.usage_error = errno; -+ } else if (usage.ru_nvcsw < 0 || usage.ru_nivcsw < 0) { -+ s.usage_error = EOVERFLOW; -+ } else { -+ s.voluntary = usage.ru_nvcsw; -+ s.involuntary = usage.ru_nivcsw; -+ } -+ s.acquire = fel_probe_acquire_fence(p->fel_acquire_fence); -+ errno = saved_errno; -+ return s; -+} -+ -+static int64_t fel_wait_delta(uint64_t before, uint64_t after, bool available) -+{ -+ if (!available || after < before || after - before > INT64_MAX) -+ return -1; -+ return after - before; -+} -+ -+static struct fel_wait_sample fel_wait_probe_begin(struct aimagereader_vk_stable *p) -+{ -+ struct fel_wait_sample sample = {0}; -+ if (!p->android_fel || !p->fel_profile || !p->fel_profile_warm || -+ !mp_msg_test(p->log, MSGL_INFO)) -+ return sample; -+ int64_t now = mp_time_ns(); -+ if (p->fel_wait_sample_at > 0 && now >= p->fel_wait_sample_at && -+ now - p->fel_wait_sample_at < MP_TIME_MS_TO_NS(3000)) -+ return sample; -+ p->fel_wait_sample_at = now; -+ sample.active = true; -+ sample.before = fel_wait_snapshot(p); -+ sample.probe_ns = MPMAX(0, mp_time_ns() - now); -+ sample.clock = fel_api_begin(p); -+ return sample; -+} -+ -+static void fel_wait_probe_end(struct aimagereader_vk_stable *p, -+ const struct fel_wait_sample *sample) -+{ -+ if (!sample->active) -+ return; -+ int64_t now = mp_time_ns(); -+ int64_t cpu = mp_fel_thread_cpu_ns(); -+ struct fel_wait_snapshot after = fel_wait_snapshot(p); -+ const struct fel_wait_snapshot *before = &sample->before; -+ int64_t probe_ns = sample->probe_ns + MPMAX(0, mp_time_ns() - now); -+ bool sched = !before->sched_error && !after.sched_error && -+ after.runtime >= before->runtime && after.slices >= before->slices; -+ bool usage = !before->usage_error && !after.usage_error; -+ int64_t runqueue = fel_wait_delta(before->runqueue, after.runqueue, sched); -+ int64_t running = fel_wait_delta(before->runtime, after.runtime, sched); -+ // Do not turn wall-cpu-runqueue into a claimed driver wait: snapshots bracket -+ // proc reads, and scheduler counters may update at a context switch. -+ mp_info(p->log, "WebHTV FEL wait sample: v=1 op=%s pts=%.6f input=%d output=%d " -+ "wall-us=%lld thread-cpu-us=%lld runqueue-us=%lld sched-running-us=%lld " -+ "slices=%lld voluntary=%lld involuntary=%lld " -+ "sched-error=%d/%d rusage-error=%d/%d acquire=%d/%d probe-us=%lld " -+ "(sampled; -1=unknown; acquire 0=none 1=pending 2=ready negative=error).\n", -+ p->push_descriptors ? "push-descriptors" : "bind-descriptors", -+ p->fel_active_pts, p->fel_active_input, p->fel_active_output, -+ (long long)(MPMAX(0, now - sample->clock.wall) / 1000), -+ (long long)(sample->clock.cpu > 0 && cpu >= sample->clock.cpu -+ ? (cpu - sample->clock.cpu) / 1000 : -1), -+ (long long)(runqueue < 0 ? -1 : runqueue / 1000), -+ (long long)(running < 0 ? -1 : running / 1000), -+ (long long)fel_wait_delta(before->slices, after.slices, sched), -+ (long long)fel_wait_delta(before->voluntary, after.voluntary, usage), -+ (long long)fel_wait_delta(before->involuntary, after.involuntary, usage), -+ before->sched_error, after.sched_error, before->usage_error, after.usage_error, -+ before->acquire, after.acquire, (long long)(probe_ns / 1000)); -+} -+ -+static void fel_api_end(struct aimagereader_vk_stable *p, enum fel_api_op op, -+ struct fel_api_clock start) -+{ -+ if (!start.wall) -+ return; -+ int64_t now = mp_time_ns(); -+ int64_t elapsed = MPMAX(0, now - start.wall); -+ int64_t cpu = mp_fel_thread_cpu_ns(); -+ mp_fel_perf_add(&p->fel_api_wall[op], elapsed); -+ if (op == FEL_API_DESCRIPTORS || op == FEL_API_PUSH_DESCRIPTORS) -+ fel_latency_add(&p->fel_bind_window, elapsed); -+ else if (op == FEL_API_RECORD) -+ fel_latency_add(&p->fel_record_window, elapsed); -+ if (start.cpu > 0 && cpu >= start.cpu) -+ mp_fel_perf_add(&p->fel_api_cpu[op], cpu - start.cpu); -+ // Aggregate RECORD contains the individual calls: do not log it twice. -+ // At most four slow calls per three seconds, independently of UI logging. -+ if (op != FEL_API_RECORD && elapsed >= MP_TIME_MS_TO_NS(100)) { -+ if (now < p->fel_slow_window || -+ now - p->fel_slow_window >= MP_TIME_MS_TO_NS(3000)) { -+ p->fel_slow_window = now; -+ p->fel_slow_count = 0; -+ } -+ if (p->fel_slow_count < 4) { -+ p->fel_slow_count++; -+ mp_info(p->log, "WebHTV FEL api slow: op=%s pts=%.6f input=%d " -+ "output=%d submitted=%" PRIu64 " wall-us=%lld thread-cpu-us=%lld.\n", -+ fel_api_names[op], p->fel_active_pts, p->fel_active_input, -+ p->fel_active_output, p->submitted_outputs, -+ (long long)(elapsed / 1000), -+ (long long)(start.cpu > 0 && cpu >= start.cpu -+ ? (cpu - start.cpu) / 1000 : -1)); -+ } -+ } -+} -+ -+static void trace_fel_frame_order(struct aimagereader_vk_stable *p, char action, -+ int output, int input, uint64_t serial, -+ double requested_pts, double mapped_pts) -+{ -+ if (!p->android_fel || !p->fel_profile) -+ return; -+ struct fel_order_event *event = &p->fel_order[p->fel_order_count % FEL_ORDER_CAPACITY]; -+ *event = (struct fel_order_event){serial, requested_pts, mapped_pts, output, input, action}; -+ p->fel_order_count++; -+ // This is a diagnostic difference, not proof of bad pixels: filters/seeks -+ // can alter display timestamps. Never drop or fail a frame on this counter. -+ if (action == 'R' && requested_pts != mapped_pts) { -+ p->fel_pts_differences++; -+ p->fel_last_pts_difference = *event; -+ } -+} -+ -+static void format_fel_frame_order(struct aimagereader_vk_stable *p, -+ char *line, size_t size) -+{ -+ if (!size) -+ return; -+ line[0] = '\0'; -+ size_t used = 0; -+ if (p->fel_pts_differences) { -+ const struct fel_order_event *e = &p->fel_last_pts_difference; -+ int written = snprintf(line, size, " last-diff=#%" PRIu64 "/o%d/i%d:%.6f>%.6f", -+ e->serial, e->output, e->input, e->requested_pts, e->mapped_pts); -+ if (written < 0 || (size_t)written >= size) -+ return; -+ used = written; -+ } -+ int count = MPMIN(p->fel_order_count, FEL_ORDER_CAPACITY); -+ for (int n = 0; n < count; n++) { -+ const struct fel_order_event *e = -+ &p->fel_order[(p->fel_order_count - count + n) % FEL_ORDER_CAPACITY]; -+ int written = snprintf(line + used, size - used, -+ " %c#%" PRIu64 "/o%d/i%d:%.6f>%.6f", e->action, e->serial, -+ e->output, e->input, e->requested_pts, e->mapped_pts); -+ if (written < 0 || (size_t)written >= size - used) -+ break; -+ used += written; -+ } -+} -+ -+static void fel_perf_checkpoint(struct aimagereader_vk_stable *p, -+ enum mp_fel_perf_stage stage) -+{ -+ if (!p->fel_map_checkpoint) -+ return; -+ int64_t now = mp_time_ns(); -+ if (p->fel_profile_warm) -+ mp_fel_perf_add(&p->fel_map_stages[stage], -+ MPMAX(0, now - p->fel_map_checkpoint)); -+ p->fel_map_checkpoint = now; -+} -+ -+static void fel_perf_finish_map(struct aimagereader_vk_stable *p) -+{ -+ if (!p->fel_map_started) -+ return; -+ fel_perf_checkpoint(p, MP_FEL_PERF_OTHER); -+ mp_fel_perf_add(p->fel_profile_warm ? &p->fel_map_warm : &p->fel_map_cold, -+ MPMAX(0, p->fel_map_checkpoint - p->fel_map_started)); -+ if (p->fel_profile_warm) -+ fel_latency_add(&p->fel_map_window, -+ MPMAX(0, p->fel_map_checkpoint - p->fel_map_started)); -+ int64_t cpu = mp_fel_thread_cpu_ns(); -+ if (p->fel_profile_warm && p->fel_map_cpu_started > 0 && cpu >= p->fel_map_cpu_started) -+ mp_fel_perf_add(&p->fel_map_cpu, cpu - p->fel_map_cpu_started); -+ p->fel_map_started = p->fel_map_checkpoint = 0; -+} -+ -+// Called only AFTER the existing copy fence has signalled. Without that fence -+// a recycled query could still expose the previous frame's available result. -+static void collect_fel_gpu_time(struct aimagereader_vk_stable *p, -+ struct vk_output *output) -+{ -+ if (!output->fel_query_recorded) -+ return; -+ output->fel_query_recorded = false; -+ uint64_t values[2] = {0}; -+ VkResult result = vkGetQueryPoolResults(p->device, p->fel_query_pool, -+ 2 * (uint32_t)(output - p->outputs), 2, sizeof(values), values, -+ sizeof(values[0]), VK_QUERY_RESULT_64_BIT); // deliberately no WAIT_BIT -+ uint64_t ns; -+ if (result == VK_SUCCESS && mp_fel_gpu_duration(values[0], values[1], -+ p->fel_timestamp_bits, p->fel_timestamp_period, &ns)) { -+ mp_fel_perf_add(&p->fel_gpu_copy, ns); -+ } else { -+ p->fel_query_unavailable++; -+ if (result != VK_NOT_READY) -+ p->fel_query_failed = true; // diagnostics must never fail playback -+ } -+} -+ -+static void init_fel_gpu_timer(struct aimagereader_vk_stable *p) -+{ -+ if (!p->fel_profile) -+ return; -+ uint32_t count = 0; -+ vkGetPhysicalDeviceQueueFamilyProperties(p->vk->phys_device, &count, NULL); -+ if (p->queue_family >= count) -+ return; -+ VkQueueFamilyProperties *families = talloc_array(NULL, VkQueueFamilyProperties, count); -+ if (!families) -+ return; -+ vkGetPhysicalDeviceQueueFamilyProperties(p->vk->phys_device, &count, families); -+ p->fel_timestamp_bits = p->queue_family < count -+ ? families[p->queue_family].timestampValidBits : 0; -+ talloc_free(families); -+ VkPhysicalDeviceProperties props; -+ vkGetPhysicalDeviceProperties(p->vk->phys_device, &props); -+ p->fel_timestamp_period = props.limits.timestampPeriod; -+ if (!p->fel_timestamp_bits || !isfinite(p->fel_timestamp_period) || -+ p->fel_timestamp_period <= 0) -+ return; -+ VkQueryPoolCreateInfo info = { -+ .sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO, -+ .queryType = VK_QUERY_TYPE_TIMESTAMP, -+ .queryCount = 2 * p->output_capacity, -+ }; -+ if (vkCreateQueryPool(p->device, &info, NULL, &p->fel_query_pool) != VK_SUCCESS) -+ p->fel_query_pool = VK_NULL_HANDLE; -+ mp_info(p->log, "WebHTV FEL perf: timer=%s bits=%u period-ns=%.3f " -+ "queue-family=%u compute-queues=%d graphics-family=%u " -+ "graphics-queues=%d no-query-wait=1.\n", -+ p->fel_query_pool ? "gpu-timestamp" : "unavailable", -+ p->fel_timestamp_bits, p->fel_timestamp_period, p->queue_family, -+ p->vk->queue_compute.count, p->vk->queue_graphics.index, -+ p->vk->queue_graphics.count); -+} -+ -+static void log_fel_pool(struct aimagereader_vk_stable *p, bool force) -+{ -+ if (!p->android_fel) -+ return; -+ double now = mp_time_sec(); -+ if (!force && p->submitted_outputs > 2 && now - p->fel_log_at < 3.0) -+ return; -+ int held = 0, pending = 0; -+ for (int n = 0; n < p->output_count; n++) { -+ held += p->outputs[n].source_image != NULL; -+ pending += p->outputs[n].pending; -+ } -+ mp_info(p->log, "WebHTV FEL GPU pool: submitted=%" PRIu64 -+ " completed=%" PRIu64 " source-held=%d pending=%d " -+ "output-depth=%d fence-timeouts=%" PRIu64 -+ " copy-wait-us=%" PRIu64 " max-copy-wait-us=%" PRIu64 -+ " cache-slots=%d final=%d async-returns=%" PRIu64 -+ " release-failures=%" PRIu64 "\n", p->submitted_outputs, p->completed_outputs, -+ held, pending, p->resources_ready ? output_sample_depth(p->output_format) : 0, -+ p->fence_timeouts, p->fel_wait_ns / 1000, p->fel_max_wait_ns / 1000, -+ p->output_count, force, p->fel_async_returns, p->fel_release_failures); -+ p->fel_log_at = now; -+ if (p->fel_profile) { -+ mp_info(p->log, "WebHTV FEL perf: warm-attempts=%" PRIu64 -+ " warm-map-us=%" PRIu64 " cpu-samples=%" PRIu64 -+ " thread-cpu-us=%" PRIu64 " cold-attempts=%" PRIu64 -+ " cold-map-us=%" PRIu64 " gpu-samples=%" PRIu64 -+ " gpu-copy-us=%" PRIu64 " gpu-copy-max-us=%" PRIu64 -+ " gpu-unavailable=%" PRIu64 " gpu-disabled=%d final=%d.\n", -+ p->fel_map_warm.count, p->fel_map_warm.total / 1000, -+ p->fel_map_cpu.count, p->fel_map_cpu.total / 1000, -+ p->fel_map_cold.count, p->fel_map_cold.total / 1000, -+ p->fel_gpu_copy.count, p->fel_gpu_copy.total / 1000, -+ p->fel_gpu_copy.peak / 1000, p->fel_query_unavailable, -+ !p->fel_query_pool || p->fel_query_failed, force); -+ mp_info(p->log, "WebHTV FEL perf stages: poll-us=%" PRIu64 -+ " import-us=%" PRIu64 " reclaim-us=%" PRIu64 -+ " record-us=%" PRIu64 " acquire-us=%" PRIu64 -+ " queue-lock-us=%" PRIu64 " submit-us=%" PRIu64 -+ " tex-release-us=%" PRIu64 " export-us=%" PRIu64 -+ " image-return-us=%" PRIu64 " other-us=%" PRIu64 -+ " (warm cumulative CPU wall time).\n", -+ p->fel_map_stages[MP_FEL_PERF_POLL].total / 1000, -+ p->fel_map_stages[MP_FEL_PERF_IMPORT].total / 1000, -+ p->fel_map_stages[MP_FEL_PERF_RECLAIM].total / 1000, -+ p->fel_map_stages[MP_FEL_PERF_RECORD].total / 1000, -+ p->fel_map_stages[MP_FEL_PERF_ACQUIRE].total / 1000, -+ p->fel_map_stages[MP_FEL_PERF_QUEUE_LOCK].total / 1000, -+ p->fel_map_stages[MP_FEL_PERF_SUBMIT].total / 1000, -+ p->fel_map_stages[MP_FEL_PERF_TEX_RELEASE].total / 1000, -+ p->fel_map_stages[MP_FEL_PERF_EXPORT].total / 1000, -+ p->fel_map_stages[MP_FEL_PERF_IMAGE_RETURN].total / 1000, -+ p->fel_map_stages[MP_FEL_PERF_OTHER].total / 1000); -+ const struct fel_latency_window *windows[] = { -+ &p->fel_bind_window, &p->fel_record_window, &p->fel_map_window, -+ }; -+ const char *const names[] = {"bind", "record", "map"}; -+ char latency[512]; -+ int latency_used = 0; -+ for (int n = 0; n < MP_ARRAY_SIZE(windows); n++) { -+ uint64_t summary[3]; -+ fel_latency_summary(windows[n], summary); -+ latency_used += snprintf(latency + latency_used, -+ sizeof(latency) - latency_used, " %s=%u/%" PRIu64 "/%" PRIu64 "/%" PRIu64, -+ names[n], windows[n]->count, summary[0], summary[1], summary[2]); -+ } -+ mp_info(p->log, "WebHTV FEL reuse: input-hit=%" PRIu64 -+ " input-miss=%" PRIu64 " input-evict=%" PRIu64 -+ " input-removed=%" PRIu64 " input-slots=%d input-limit=%d" -+ " command-hit=%" PRIu64 " command-miss=%" PRIu64 -+ " command-evict=%" PRIu64 " invalidated=%" PRIu64 -+ " fallback=%" PRIu64 " cold=%" PRIu64 -+ " records=%d record-limit=%d disabled=%d fresh-commands=%" PRIu64 -+ " descriptor-content-cache=1 content-hit=%" PRIu64 -+ " descriptor-writes=%" PRIu64 " descriptor-rebinds=%" PRIu64 -+ " command-mode=fresh-bind-record replay=0. latency-window:%s " -+ "(warm last<=128 n/p50-us/p95-us/max-us; overlapping stages).\n", -+ p->input_hits, p->input_misses, p->input_evictions, -+ p->input_removals, p->num_inputs, FEL_INPUT_CACHE_SIZE, -+ p->recording_hits, p->recording_misses, p->recording_evictions, -+ p->recording_invalidations, p->recording_fallbacks, -+ p->recording_cold, p->num_recordings, FEL_RECORD_CACHE_SIZE, -+ p->recording_cache_disabled, p->fresh_commands, -+ p->descriptor_content_hits, p->descriptor_writes, p->descriptor_rebinds, -+ latency); -+ char order[1024]; -+ format_fel_frame_order(p, order, sizeof(order)); -+ mp_info(p->log, "WebHTV FEL frame order: events=%" PRIu64 -+ " pts-different=%" PRIu64 " (M=map R=reselect A=alias X=miss; " -+ "serial/output/input:requested>stored; logical identity, not pixel checksum):%s\n", -+ p->fel_order_count, p->fel_pts_differences, order); -+ // Three bounded aggregate lines per three seconds, not per-frame logging. -+ for (int first = 0; first < FEL_API_COUNT; first += 6) { -+ char line[1024]; -+ int used = 0; -+ for (int i = first; i < MPMIN(first + 6, FEL_API_COUNT); i++) { -+ used += snprintf(line + used, sizeof(line) - used, -+ " %s=%" PRIu64 "/%" PRIu64 "/%" PRIu64 "/%" PRIu64, -+ fel_api_names[i], p->fel_api_wall[i].count, -+ p->fel_api_wall[i].total / 1000, -+ p->fel_api_cpu[i].count, p->fel_api_cpu[i].total / 1000); -+ } -+ mp_info(p->log, "WebHTV FEL api perf:%s " -+ "(warm n/wall-us/cpu-n/cpu-us).\n", line); -+ } -+ } -+} - - static bool vk_success(struct aimagereader_vk_stable *p, VkResult result, - const char *operation) -@@ -145,7 +734,7 @@ - return false; - } - --static bool can_import_sync_fd(pl_vulkan vk) -+static VkExternalSemaphoreFeatureFlags get_sync_fd_features(pl_vulkan vk) - { - PFN_vkGetPhysicalDeviceExternalSemaphoreProperties get_properties = - (PFN_vkGetPhysicalDeviceExternalSemaphoreProperties) -@@ -168,8 +757,66 @@ - .sType = VK_STRUCTURE_TYPE_EXTERNAL_SEMAPHORE_PROPERTIES, - }; - get_properties(vk->phys_device, &info, &properties); -- return properties.externalSemaphoreFeatures & -- VK_EXTERNAL_SEMAPHORE_FEATURE_IMPORTABLE_BIT; -+ return properties.externalSemaphoreFeatures; -+} -+ -+static VkSemaphore create_fel_release_semaphore(struct aimagereader_vk_stable *p) -+{ -+ if (!p->android_fel || !p->release_sync_fd) -+ return VK_NULL_HANDLE; -+ VkExportSemaphoreCreateInfo export_info = { -+ .sType = VK_STRUCTURE_TYPE_EXPORT_SEMAPHORE_CREATE_INFO, -+ .handleTypes = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT, -+ }; -+ VkSemaphoreCreateInfo info = { -+ .sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, -+ .pNext = &export_info, -+ }; -+ VkSemaphore semaphore = VK_NULL_HANDLE; -+ VkResult result = vkCreateSemaphore(p->device, &info, NULL, &semaphore); -+ if (result != VK_SUCCESS) { -+ p->release_sync_fd = false; -+ p->fel_release_failures++; -+ mp_warn(p->log, "WebHTV FEL source release semaphore unavailable: %d; " -+ "keeping bounded GPU wait.\n", result); -+ } -+ return semaphore; -+} -+ -+static bool release_fel_source_async(struct aimagereader_vk_stable *p, -+ struct vk_output *output) -+{ -+ if (!p->android_fel || !p->release_sync_fd || !output->source_release || -+ !output->pending || !output->source_image) -+ return false; -+ -+ int fd = -1; -+ VkSemaphoreGetFdInfoKHR info = { -+ .sType = VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR, -+ .semaphore = output->source_release, -+ .handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT, -+ }; -+ VkResult result = p->GetSemaphoreFdKHR(p->device, &info, &fd); -+ fel_perf_checkpoint(p, MP_FEL_PERF_EXPORT); -+ if (result != VK_SUCCESS) { -+ // No export consumed this signal. Do not signal it again on reuse; -+ // retain it until the existing VkFence proves safe destruction. -+ p->release_sync_fd = false; -+ p->fel_release_failures++; -+ mp_warn(p->log, "WebHTV FEL source release export failed: %d; " -+ "keeping bounded GPU wait.\n", result); -+ return false; -+ } -+ // SYNC_FD export consumes the signal payload. Android takes fd ownership -+ // and gates buffer reuse on GPU completion (-1 denotes already signaled). -+ p->api.AImage_deleteAsync(output->source_image, fd); -+ fel_perf_checkpoint(p, MP_FEL_PERF_IMAGE_RETURN); -+ output->source_image = NULL; -+ p->fel_async_returns++; -+ if (output->source_frame && output->source_frame->android_fel_staging) -+ mp_android_fel_staging_release_fenced( -+ output->source_frame->android_fel_staging->data); -+ return true; - } - - static bool has_ycbcr_conversion(pl_vulkan vk) -@@ -262,8 +909,25 @@ - return UINT32_MAX; - } - -+static void invalidate_input_recordings(struct aimagereader_vk_stable *p, -+ struct vk_input *input) -+{ -+ for (int n = 0; n < p->num_recordings; n++) { -+ struct vk_recording *record = &p->recordings[n]; -+ if (record->input == input) { -+ p->recording_invalidations += record->valid; -+ record->valid = false; -+ record->input = NULL; -+ } -+ } -+} -+ - static void destroy_input(struct aimagereader_vk_stable *p, struct vk_input *input) - { -+ // Called only after the last copy user has completed. Destroying a view -+ // invalidates every executable command which refers to it, even if a new -+ // AHB later happens to reuse the same pointer/slot. -+ invalidate_input_recordings(p, input); - if (input->view) - vkDestroyImageView(p->device, input->view, NULL); - if (input->image) -@@ -273,12 +937,70 @@ - *input = (struct vk_input){0}; - } - -+static void destroy_recording_cache(struct aimagereader_vk_stable *p) -+{ -+ VkCommandBuffer commands[FEL_RECORD_CACHE_SIZE]; -+ uint32_t count = 0; -+ for (int n = 0; n < p->num_recordings; n++) { -+ if (p->recordings[n].command) -+ commands[count++] = p->recordings[n].command; -+ } -+ if (count) -+ vkFreeCommandBuffers(p->device, p->command_pool, count, commands); -+ if (p->recording_pool) -+ vkDestroyDescriptorPool(p->device, p->recording_pool, NULL); -+ p->recording_pool = VK_NULL_HANDLE; -+ p->num_recordings = 0; -+ p->recording_cache_disabled = false; -+ memset(p->recordings, 0, sizeof(p->recordings)); -+} -+ - static void clear_output_frames(struct vk_output *output) - { - mp_image_unrefp(&output->source_frame); - for (int n = 0; n < output->num_source_aliases; n++) - mp_image_unrefp(&output->source_aliases[n]); - output->num_source_aliases = 0; -+} -+ -+static bool output_has_live_fel_frame(struct aimagereader_vk_stable *p, -+ struct vk_output *output) -+{ -+ if (!p->android_fel) -+ return false; -+ for (int n = -1; n < output->num_source_aliases; n++) { -+ struct mp_image *frame = n < 0 ? output->source_frame : output->source_aliases[n]; -+ AVBufferRef *lease = frame ? frame->android_fel_staging : NULL; -+ if (!lease) -+ continue; -+ // Cache references are not consumers. Count aliases of the same lease -+ // as well, so retaining an identity twice cannot pin a slot forever. -+ int owned = 0; -+ for (int k = -1; k < output->num_source_aliases; k++) { -+ struct mp_image *other = k < 0 ? output->source_frame : output->source_aliases[k]; -+ owned += other && other->android_fel_staging && -+ other->android_fel_staging->data == lease->data; -+ } -+ if (av_buffer_get_ref_count(lease) > owned) -+ return true; -+ } -+ return false; -+} -+ -+static int select_reusable_output(struct aimagereader_vk_stable *p, int *waiting) -+{ -+ int next = (p->output_index + 1) % p->output_count; -+ *waiting = -1; -+ for (int n = 0; n < p->output_count; n++) { -+ int candidate = (next + n) % p->output_count; -+ if (output_has_live_fel_frame(p, &p->outputs[candidate])) -+ continue; -+ if (!p->outputs[candidate].pending) -+ return candidate; -+ if (*waiting < 0) -+ *waiting = candidate; -+ } -+ return -1; - } - - static bool finish_output(struct aimagereader_vk_stable *p, -@@ -307,9 +1029,18 @@ - if (!vk_success(p, result, "waiting for AHardwareBuffer conversion")) - return false; - -+ collect_fel_gpu_time(p, output); - if (output->source_image) { - p->api.AImage_delete(output->source_image); - output->source_image = NULL; -+ } -+ if (p->android_fel) { -+ for (int n = -1; n < output->num_source_aliases; n++) { -+ struct mp_image *frame = n < 0 ? output->source_frame -+ : output->source_aliases[n]; -+ if (frame && frame->android_fel_staging) -+ mp_android_fel_staging_complete(frame->android_fel_staging->data); -+ } - } - if (output->input) { - if (output->input->users > 0) -@@ -347,6 +1078,8 @@ - vkDestroySemaphore(p->device, output->acquire, NULL); - if (output->ready) - vkDestroySemaphore(p->device, output->ready, NULL); -+ if (output->source_release) -+ vkDestroySemaphore(p->device, output->source_release, NULL); - if (output->fence) - vkDestroyFence(p->device, output->fence, NULL); - *output = (struct vk_output){0}; -@@ -354,21 +1087,23 @@ - - static void destroy_conversion_resources(struct aimagereader_vk_stable *p) - { -- for (int n = 0; n < OUTPUT_COUNT; n++) -+ for (int n = 0; n < p->output_count; n++) - finish_output(p, &p->outputs[n], UINT64_MAX); - -+ destroy_recording_cache(p); - if (p->descriptor_pool) - vkDestroyDescriptorPool(p->device, p->descriptor_pool, NULL); - p->descriptor_pool = VK_NULL_HANDLE; - -- VkCommandBuffer commands[OUTPUT_COUNT]; -+ VkCommandBuffer commands[FEL_OUTPUT_CAPACITY]; - uint32_t num_commands = 0; -- for (int n = 0; n < OUTPUT_COUNT; n++) { -+ for (int n = 0; n < p->output_capacity; n++) { - struct vk_output *output = &p->outputs[n]; - output->descriptor = VK_NULL_HANDLE; - if (output->command) - commands[num_commands++] = output->command; - output->command = VK_NULL_HANDLE; -+ output->active_command = VK_NULL_HANDLE; - } - if (num_commands) { - vkFreeCommandBuffers(p->device, p->command_pool, num_commands, -@@ -376,7 +1111,7 @@ - } - - p->mapper->tex[0] = NULL; -- for (int n = 0; n < OUTPUT_COUNT; n++) -+ for (int n = 0; n < p->output_count; n++) - destroy_output(p, &p->outputs[n]); - - for (int n = 0; n < p->num_inputs; n++) -@@ -509,6 +1244,9 @@ - VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT; - - for (int n = 0; n < num_candidates; n++) { -+ if (!mp_android_fel_preserve_depth( -+ p->android_fel, output_sample_depth(candidates[n]))) -+ continue; - VkFormatProperties props; - vkGetPhysicalDeviceFormatProperties(p->vk->phys_device, - candidates[n], &props); -@@ -648,6 +1386,7 @@ - "creating conversion fence")) - return false; - -+ output->source_release = create_fel_release_semaphore(p); - return true; - } - -@@ -670,7 +1409,7 @@ - - uint32_t queue_families[3]; - int num_queue_families = collect_queue_families(p, queue_families); -- for (int n = 0; n < OUTPUT_COUNT; n++) { -+ for (int n = 0; n < p->output_count; n++) { - if (!create_output_image(p, &p->outputs[n], queue_families, - num_queue_families)) - return false; -@@ -680,14 +1419,14 @@ - .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, - .commandPool = p->command_pool, - .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, -- .commandBufferCount = OUTPUT_COUNT, -- }; -- VkCommandBuffer commands[OUTPUT_COUNT]; -+ .commandBufferCount = p->output_capacity, -+ }; -+ VkCommandBuffer commands[FEL_OUTPUT_CAPACITY]; - if (!vk_success(p, vkAllocateCommandBuffers(p->device, &alloc_info, - commands), - "allocating conversion command buffers")) - return false; -- for (int n = 0; n < OUTPUT_COUNT; n++) -+ for (int n = 0; n < p->output_capacity; n++) - p->outputs[n].command = commands[n]; - - return true; -@@ -749,6 +1488,133 @@ - } - - return ycbcr_props.combinedImageSamplerDescriptorCount; -+} -+ -+static void log_fel_descriptor_capabilities(struct aimagereader_vk_stable *p) -+{ -+ if (!p->android_fel || !mp_msg_test(p->log, MSGL_INFO)) -+ return; -+ PFN_vkEnumerateDeviceExtensionProperties enumerate = -+ (PFN_vkEnumerateDeviceExtensionProperties)p->vk->get_proc_addr( -+ p->vk->instance, "vkEnumerateDeviceExtensionProperties"); -+ PFN_vkGetPhysicalDeviceProperties get_properties = -+ (PFN_vkGetPhysicalDeviceProperties)p->vk->get_proc_addr( -+ p->vk->instance, "vkGetPhysicalDeviceProperties"); -+ VkPhysicalDeviceProperties props = {0}; -+ if (get_properties) -+ get_properties(p->vk->phys_device, &props); -+ props.deviceName[sizeof(props.deviceName) - 1] = '\0'; -+ for (char *c = props.deviceName; *c; c++) { -+ if ((unsigned char)*c < 32 || (unsigned char)*c >= 127) -+ *c = '?'; -+ } -+ int supported = -1; -+ uint32_t count = 0; -+ VkResult result = enumerate ? enumerate(p->vk->phys_device, NULL, &count, NULL) -+ : VK_ERROR_EXTENSION_NOT_PRESENT; -+ if (result == VK_SUCCESS && count <= 512) { -+ uint32_t capacity = count; -+ VkExtensionProperties *extensions = count ? calloc(count, sizeof(*extensions)) : NULL; -+ if (count && !extensions) { -+ result = VK_ERROR_OUT_OF_HOST_MEMORY; -+ } else { -+ result = count ? enumerate(p->vk->phys_device, NULL, &count, extensions) : VK_SUCCESS; -+ if (count > capacity) { -+ count = capacity; -+ result = VK_INCOMPLETE; -+ } -+ supported = result == VK_SUCCESS ? 0 : -1; -+ if (result == VK_SUCCESS || result == VK_INCOMPLETE) { -+ for (uint32_t n = 0; n < count; n++) { -+ if (strncmp(extensions[n].extensionName, -+ VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME, -+ VK_MAX_EXTENSION_NAME_SIZE) == 0) -+ supported = 1; -+ } -+ } -+ } -+ free(extensions); -+ } else if (result == VK_SUCCESS) { -+ result = VK_INCOMPLETE; // Bound diagnostic allocation; support is unknown. -+ } -+ mp_info(p->log, "WebHTV FEL descriptors: capability-v=1 " -+ "api=%u.%u.%u device-api=%u.%u.%u vendor=0x%x device=0x%x driver=0x%x " -+ "gpu=\"%s\" properties=%d push-supported=%d push-enabled=%d " -+ "extension-query-result=%d (advertised extension; -1=unknown).\n", -+ VK_VERSION_MAJOR(p->vk->api_version), VK_VERSION_MINOR(p->vk->api_version), -+ VK_VERSION_PATCH(p->vk->api_version), VK_VERSION_MAJOR(props.apiVersion), -+ VK_VERSION_MINOR(props.apiVersion), VK_VERSION_PATCH(props.apiVersion), -+ props.vendorID, props.deviceID, props.driverVersion, props.deviceName, -+ get_properties != NULL, supported, -+ has_extension(p->vk, VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME), result); -+} -+ -+static bool create_conversion_descriptor_layout(struct aimagereader_vk_stable *p, -+ const VkDescriptorSetLayoutCreateInfo *base, uint32_t sampler_descriptors) -+{ -+ VkDescriptorSetLayoutCreateInfo info = *base; -+ const char *reason = "fel-disabled"; -+ uint32_t max_push = 0; -+ p->push_descriptors = false; -+ p->CmdPushDescriptorSetKHR = NULL; -+ // libplacebo's public extension list is the enabled device list, not merely -+ // advertised support. Follow its gpu_pass.c path only for explicit FEL. -+ if (p->android_fel) { -+ reason = "extension-not-enabled"; -+ if (has_extension(p->vk, VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME)) { -+ PFN_vkGetDeviceProcAddr get_device_proc = -+ (PFN_vkGetDeviceProcAddr)p->vk->get_proc_addr( -+ p->vk->instance, "vkGetDeviceProcAddr"); -+ PFN_vkGetPhysicalDeviceProperties2 get_properties = -+ (PFN_vkGetPhysicalDeviceProperties2)p->vk->get_proc_addr( -+ p->vk->instance, "vkGetPhysicalDeviceProperties2"); -+ if (!get_properties) -+ get_properties = (PFN_vkGetPhysicalDeviceProperties2)p->vk->get_proc_addr( -+ p->vk->instance, "vkGetPhysicalDeviceProperties2KHR"); -+ if (get_device_proc) -+ p->CmdPushDescriptorSetKHR = (PFN_vkCmdPushDescriptorSetKHR) -+ get_device_proc(p->device, "vkCmdPushDescriptorSetKHR"); -+ reason = "entrypoint-unavailable"; -+ if (get_properties && p->CmdPushDescriptorSetKHR) { -+ VkPhysicalDevicePushDescriptorPropertiesKHR push = { -+ .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PUSH_DESCRIPTOR_PROPERTIES_KHR, -+ }; -+ VkPhysicalDeviceProperties2 props = { -+ .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, -+ .pNext = &push, -+ }; -+ get_properties(p->vk->phys_device, &props); -+ max_push = push.maxPushDescriptors; -+ // Two bindings; conservatively also allow space for the -+ // implementation's multi-planar immutable YCbCr sampler. -+ p->push_descriptors = max_push >= base->bindingCount && -+ sampler_descriptors > 0 && sampler_descriptors < max_push; -+ reason = p->push_descriptors ? "enabled" : "descriptor-limit"; -+ } -+ } -+ } -+ if (p->push_descriptors) -+ info.flags |= VK_DESCRIPTOR_SET_LAYOUT_CREATE_PUSH_DESCRIPTOR_BIT_KHR; -+ VkDescriptorSetLayout layout = VK_NULL_HANDLE; -+ VkResult result = vkCreateDescriptorSetLayout(p->device, &info, NULL, &layout); -+ if (result != VK_SUCCESS && p->push_descriptors) { -+ // Optional layout optimization: allocation/driver rejection must not -+ // disable FEL on a device which worked with ordinary descriptor sets. -+ p->push_descriptors = false; -+ reason = "push-layout-failed"; -+ info = *base; -+ layout = VK_NULL_HANDLE; -+ result = vkCreateDescriptorSetLayout(p->device, &info, NULL, &layout); -+ } -+ if (p->android_fel) -+ mp_info(p->log, "WebHTV FEL descriptors: mode=%s reason=%s max-push=%u " -+ "bindings=%u sampler-descriptors=%u result=%d.\n", -+ p->push_descriptors ? "push" : "set", reason, max_push, -+ base->bindingCount, sampler_descriptors, result); -+ if (!vk_success(p, result, "creating conversion descriptor layout")) -+ return false; -+ p->descriptor_layout = layout; -+ return true; - } - - static bool create_pipeline( -@@ -850,10 +1716,8 @@ - .bindingCount = MP_ARRAY_SIZE(bindings), - .pBindings = bindings, - }; -- if (!vk_success(p, vkCreateDescriptorSetLayout( -- p->device, &descriptor_layout_info, NULL, -- &p->descriptor_layout), -- "creating conversion descriptor layout")) -+ if (!create_conversion_descriptor_layout(p, &descriptor_layout_info, -+ sampler_descriptors)) - return false; - - VkPushConstantRange push_constant = { -@@ -901,18 +1765,30 @@ - if (!vk_success(p, result, "creating AHardwareBuffer conversion pipeline")) - return false; - -+ p->sampler_descriptors = sampler_descriptors; -+ mp_info(p->log, "Using Vulkan AHardwareBuffer stable GPU conversion " -+ "(source format %d, external format 0x%" PRIx64 -+ ", output format %d, sampler descriptors %u, " -+ "chroma filter %s, workgroup %dx%d, " -+ "CPU-precomputed UV transform)\n", -+ p->source_format, p->external_format, p->output_format, -+ sampler_descriptors, -+ chroma_filter == VK_FILTER_LINEAR ? "linear" : "nearest", -+ STABLE_WORKGROUP_X, STABLE_WORKGROUP_Y); -+ if (p->push_descriptors) -+ return true; // No allocated descriptor sets are used by a push layout. - VkDescriptorPoolSize pool_sizes[] = { - { - .type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, -- .descriptorCount = OUTPUT_COUNT * sampler_descriptors, -+ .descriptorCount = p->output_capacity * sampler_descriptors, - }, { - .type = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, -- .descriptorCount = OUTPUT_COUNT, -+ .descriptorCount = p->output_capacity, - }, - }; - VkDescriptorPoolCreateInfo pool_info = { - .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO, -- .maxSets = OUTPUT_COUNT, -+ .maxSets = p->output_capacity, - .poolSizeCount = MP_ARRAY_SIZE(pool_sizes), - .pPoolSizes = pool_sizes, - }; -@@ -921,14 +1797,14 @@ - "creating conversion descriptor pool")) - return false; - -- VkDescriptorSetLayout layouts[OUTPUT_COUNT]; -- for (int n = 0; n < OUTPUT_COUNT; n++) -+ VkDescriptorSetLayout layouts[FEL_OUTPUT_CAPACITY]; -+ for (int n = 0; n < p->output_capacity; n++) - layouts[n] = p->descriptor_layout; -- VkDescriptorSet descriptors[OUTPUT_COUNT]; -+ VkDescriptorSet descriptors[FEL_OUTPUT_CAPACITY]; - VkDescriptorSetAllocateInfo descriptor_info = { - .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO, - .descriptorPool = p->descriptor_pool, -- .descriptorSetCount = OUTPUT_COUNT, -+ .descriptorSetCount = p->output_capacity, - .pSetLayouts = layouts, - }; - if (!vk_success(p, vkAllocateDescriptorSets( -@@ -936,9 +1812,11 @@ - "allocating conversion descriptors")) - return false; - -- for (int n = 0; n < OUTPUT_COUNT; n++) { -+ for (int n = 0; n < p->output_capacity; n++) { - struct vk_output *output = &p->outputs[n]; - output->descriptor = descriptors[n]; -+ if (!output->view) -+ continue; // FEL extra image slots are allocated only when needed. - VkDescriptorImageInfo target = { - .imageView = output->view, - .imageLayout = VK_IMAGE_LAYOUT_GENERAL, -@@ -954,15 +1832,6 @@ - vkUpdateDescriptorSets(p->device, 1, &write, 0, NULL); - } - -- mp_info(p->log, "Using Vulkan AHardwareBuffer stable GPU conversion " -- "(source format %d, external format 0x%" PRIx64 -- ", output format %d, sampler descriptors %u, " -- "chroma filter %s, workgroup %dx%d, " -- "CPU-precomputed UV transform)\n", -- p->source_format, p->external_format, p->output_format, -- sampler_descriptors, -- chroma_filter == VK_FILTER_LINEAR ? "linear" : "nearest", -- STABLE_WORKGROUP_X, STABLE_WORKGROUP_Y); - return true; - } - -@@ -1036,7 +1905,8 @@ - if (!p->inputs[n].buffer) - return &p->inputs[n]; - } -- if (p->num_inputs < INPUT_CACHE_SIZE) -+ int capacity = p->android_fel ? FEL_INPUT_CACHE_SIZE : INPUT_CACHE_SIZE; -+ if (p->num_inputs < capacity) - return &p->inputs[p->num_inputs]; - - struct vk_input *oldest = NULL; -@@ -1051,6 +1921,8 @@ - return NULL; - } - -+ if (p->android_fel) -+ p->input_evictions++; - destroy_input(p, oldest); - return oldest; - } -@@ -1076,7 +1948,10 @@ - .sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_PROPERTIES_ANDROID, - .pNext = &format_props, - }; -- if (!vk_success(p, p->GetAHBProperties(p->device, buffer, &buffer_props), -+ struct fel_api_clock clock = fel_api_begin(p); -+ VkResult api_result = p->GetAHBProperties(p->device, buffer, &buffer_props); -+ fel_api_end(p, FEL_API_PROPERTIES, clock); -+ if (!vk_success(p, api_result, - "querying Android hardware-buffer properties")) - return NULL; - if (format_props.format == VK_FORMAT_UNDEFINED && -@@ -1100,13 +1975,21 @@ - int output_height = p->mapper->src_params.h; - - if (!p->resources_ready) { -- if (!create_outputs(p, desc, &format_props, data_space) || -- !create_pipeline(p, &format_props)) { -+ int64_t started = p->android_fel ? mp_time_ns() : 0; -+ bool outputs_ready = create_outputs(p, desc, &format_props, data_space); -+ int64_t outputs_at = p->android_fel ? mp_time_ns() : 0; -+ if (!outputs_ready || !create_pipeline(p, &format_props)) { - destroy_conversion_resources(p); - return NULL; - } - configure_dst_params(p); - p->resources_ready = true; -+ if (p->android_fel) { -+ mp_info(p->log, "WebHTV FEL GPU init: resources-ready " -+ "outputs-ms=%lld pipeline-ms=%lld (CPU wall time).\n", -+ (long long)((outputs_at - started) / 1000000), -+ (long long)((mp_time_ns() - outputs_at) / 1000000)); -+ } - } else if (p->buffer_width != (int)desc->width || - p->buffer_height != (int)desc->height || - p->width != output_width || p->height != output_height || -@@ -1120,6 +2003,7 @@ - struct vk_input *input = select_input_slot(p); - if (!input) - return NULL; -+ p->fel_active_input = input - p->inputs; - - VkExternalFormatANDROID external_format = { - .sType = VK_STRUCTURE_TYPE_EXTERNAL_FORMAT_ANDROID, -@@ -1147,8 +2031,10 @@ - .sharingMode = VK_SHARING_MODE_EXCLUSIVE, - .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, - }; -- if (!vk_success(p, vkCreateImage(p->device, &image_info, NULL, -- &input->image), -+ clock = fel_api_begin(p); -+ api_result = vkCreateImage(p->device, &image_info, NULL, &input->image); -+ fel_api_end(p, FEL_API_IMAGE, clock); -+ if (!vk_success(p, api_result, - "creating imported Android hardware-buffer image")) - goto error; - -@@ -1175,12 +2061,15 @@ - .allocationSize = buffer_props.allocationSize, - .memoryTypeIndex = memory_type, - }; -- if (!vk_success(p, vkAllocateMemory(p->device, &alloc_info, NULL, -- &input->memory), -- "importing Android hardware-buffer memory") || -- !vk_success(p, vkBindImageMemory(p->device, input->image, -- input->memory, 0), -- "binding Android hardware-buffer memory")) -+ clock = fel_api_begin(p); -+ api_result = vkAllocateMemory(p->device, &alloc_info, NULL, &input->memory); -+ fel_api_end(p, FEL_API_MEMORY, clock); -+ if (!vk_success(p, api_result, "importing Android hardware-buffer memory")) -+ goto error; -+ clock = fel_api_begin(p); -+ api_result = vkBindImageMemory(p->device, input->image, input->memory, 0); -+ fel_api_end(p, FEL_API_BIND, clock); -+ if (!vk_success(p, api_result, "binding Android hardware-buffer memory")) - goto error; - - VkSamplerYcbcrConversionInfo conversion_info = { -@@ -1207,8 +2096,10 @@ - .layerCount = 1, - }, - }; -- if (!vk_success(p, vkCreateImageView(p->device, &view_info, NULL, -- &input->view), -+ clock = fel_api_begin(p); -+ api_result = vkCreateImageView(p->device, &view_info, NULL, &input->view); -+ fel_api_end(p, FEL_API_VIEW, clock); -+ if (!vk_success(p, api_result, - "creating Android hardware-buffer image view")) - goto error; - -@@ -1298,21 +2189,175 @@ - return wait_acquire_fence(p, fd) ? 0 : -1; - } - -+static bool recording_pending(const struct vk_recording *record) -+{ -+ return record->command && record->output && record->output->pending && -+ record->output->active_command == record->command; -+} -+ -+static bool recording_matches(const struct vk_recording *record, -+ struct vk_output *output, struct vk_input *input, -+ const AHardwareBuffer_Desc *desc, -+ const AImageCropRect *crop, bool timestamps) -+{ -+ return record->valid && record->input == input && record->output == output && -+ record->input_view == input->view && record->output_view == output->view && -+ record->width == desc->width && record->height == desc->height && -+ record->crop.left == crop->left && record->crop.top == crop->top && -+ record->crop.right == crop->right && record->crop.bottom == crop->bottom && -+ record->timestamps == timestamps; -+} -+ -+static struct vk_recording *select_recording(struct aimagereader_vk_stable *p, -+ struct vk_output *output, -+ struct vk_input *input, -+ const AHardwareBuffer_Desc *desc, -+ const AImageCropRect *crop, bool *hit) -+{ -+ *hit = false; -+ struct vk_recording *oldest = NULL; -+ bool timestamps = p->fel_query_pool && !p->fel_query_failed; -+ for (int n = 0; n < p->num_recordings; n++) { -+ struct vk_recording *record = &p->recordings[n]; -+ if (recording_pending(record)) -+ continue; -+ if (recording_matches(record, output, input, desc, crop, timestamps)) { -+ *hit = true; -+ return record; -+ } -+ if (!oldest || (!record->valid && oldest->valid) || -+ (record->valid == oldest->valid && record->last_used < oldest->last_used)) -+ oldest = record; -+ } -+ if ((!oldest || oldest->valid) && p->num_recordings < FEL_RECORD_CACHE_SIZE) -+ return &p->recordings[p->num_recordings++]; -+ if (oldest && oldest->valid) -+ p->recording_evictions++; -+ return oldest; // NULL means all entries are in flight: use the original path. -+} -+ -+static bool allocate_recording(struct aimagereader_vk_stable *p, -+ struct vk_recording *record) -+{ -+ if (!p->push_descriptors && !p->recording_pool) { -+ VkDescriptorPoolSize sizes[] = { -+ {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, -+ FEL_RECORD_CACHE_SIZE * p->sampler_descriptors}, -+ {VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, FEL_RECORD_CACHE_SIZE}, -+ }; -+ VkDescriptorPoolCreateInfo info = { -+ .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO, -+ .maxSets = FEL_RECORD_CACHE_SIZE, -+ .poolSizeCount = MP_ARRAY_SIZE(sizes), .pPoolSizes = sizes, -+ }; -+ VkDescriptorPool pool = VK_NULL_HANDLE; -+ if (vkCreateDescriptorPool(p->device, &info, NULL, &pool) != VK_SUCCESS) -+ goto unavailable; -+ p->recording_pool = pool; -+ } -+ if (!p->push_descriptors && !record->descriptor) { -+ VkDescriptorSetAllocateInfo info = { -+ .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO, -+ .descriptorPool = p->recording_pool, .descriptorSetCount = 1, -+ .pSetLayouts = &p->descriptor_layout, -+ }; -+ VkDescriptorSet descriptor = VK_NULL_HANDLE; -+ if (vkAllocateDescriptorSets(p->device, &info, &descriptor) != VK_SUCCESS) -+ goto unavailable; -+ record->descriptor = descriptor; -+ } -+ if (!record->command) { -+ VkCommandBufferAllocateInfo info = { -+ .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, -+ .commandPool = p->command_pool, .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, -+ .commandBufferCount = 1, -+ }; -+ VkCommandBuffer command = VK_NULL_HANDLE; -+ if (vkAllocateCommandBuffers(p->device, &info, &command) != VK_SUCCESS) -+ goto unavailable; -+ record->command = command; -+ } -+ return true; -+ -+unavailable: -+ // Optional optimization only. Retain any partially allocated metadata for -+ // teardown; do not retry a failing allocation on every frame or fail FEL. -+ p->recording_cache_disabled = true; -+ return false; -+} -+ -+static void update_conversion_descriptor(struct aimagereader_vk_stable *p, -+ VkCommandBuffer command, -+ VkDescriptorSet descriptor, -+ struct vk_input *input, -+ struct vk_output *output, bool both) -+{ -+ if (p->push_descriptors && !command) -+ return; // Push only while recording; no vkUpdateDescriptorSets on this layout. -+ VkDescriptorImageInfo images[] = { -+ {.imageView = input->view, .imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL}, -+ {.imageView = output->view, .imageLayout = VK_IMAGE_LAYOUT_GENERAL}, -+ }; -+ VkWriteDescriptorSet writes[] = { -+ { -+ .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, -+ .dstSet = descriptor, .dstBinding = 0, .descriptorCount = 1, -+ .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, -+ .pImageInfo = &images[0], -+ }, { -+ .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, -+ .dstSet = descriptor, .dstBinding = 1, .descriptorCount = 1, -+ .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, -+ .pImageInfo = &images[1], -+ }, -+ }; -+ struct fel_api_clock clock = fel_api_begin(p); -+ if (p->push_descriptors) { -+ // Both bindings, including the immutable YCbCr sampler, start undefined -+ // at each vkBeginCommandBuffer. Always push this frame's exact views. -+ p->CmdPushDescriptorSetKHR(command, VK_PIPELINE_BIND_POINT_COMPUTE, -+ p->pipeline_layout, 0, MP_ARRAY_SIZE(writes), writes); -+ fel_api_end(p, FEL_API_PUSH_DESCRIPTORS, clock); -+ } else { -+ vkUpdateDescriptorSets(p->device, both ? 2 : 1, writes, 0, NULL); -+ fel_api_end(p, FEL_API_DESCRIPTOR, clock); -+ if (p->android_fel) -+ p->descriptor_writes++; -+ } -+} -+ - static bool record_conversion(struct aimagereader_vk_stable *p, - struct vk_output *output, - struct vk_input *input, - const AHardwareBuffer_Desc *desc, -- const AImageCropRect *crop) --{ -+ const AImageCropRect *crop, -+ struct vk_recording *record) -+{ -+ VkCommandBuffer command = record ? record->command : output->command; -+ VkDescriptorSet descriptor = record ? record->descriptor : output->descriptor; - VkCommandBufferBeginInfo begin_info = { - .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, - .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, - }; -- if (!vk_success(p, vkResetCommandBuffer(output->command, 0), -- "resetting conversion command buffer") || -- !vk_success(p, vkBeginCommandBuffer(output->command, &begin_info), -- "beginning conversion command buffer")) -- return false; -+ struct fel_api_clock clock = fel_api_begin(p); -+ VkResult result = vkResetCommandBuffer(command, 0); -+ fel_api_end(p, FEL_API_RESET, clock); -+ if (!vk_success(p, result, "resetting conversion command buffer")) -+ return false; -+ clock = fel_api_begin(p); -+ result = vkBeginCommandBuffer(command, &begin_info); -+ fel_api_end(p, FEL_API_BEGIN, clock); -+ if (!vk_success(p, result, "beginning conversion command buffer")) -+ return false; -+ clock = fel_api_begin(p); -+ -+ output->fel_query_recorded = p->fel_query_pool && !p->fel_query_failed; -+ uint32_t query = 2 * (uint32_t)(output - p->outputs); -+ if (output->fel_query_recorded) { -+ vkCmdResetQueryPool(command, p->fel_query_pool, query, 2); -+ vkCmdWriteTimestamp(command, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, -+ p->fel_query_pool, query); -+ } - - VkImageMemoryBarrier acquire[] = { - { -@@ -1349,16 +2394,30 @@ - }, - }, - }; -- vkCmdPipelineBarrier(output->command, -+ struct fel_api_clock op_clock = fel_api_begin(p); -+ vkCmdPipelineBarrier(command, - VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, - VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, - 0, NULL, 0, NULL, MP_ARRAY_SIZE(acquire), acquire); -- -- vkCmdBindPipeline(output->command, VK_PIPELINE_BIND_POINT_COMPUTE, -+ fel_api_end(p, FEL_API_BARRIER_IN, op_clock); -+ -+ op_clock = fel_api_begin(p); -+ vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_COMPUTE, - p->pipeline); -- vkCmdBindDescriptorSets(output->command, VK_PIPELINE_BIND_POINT_COMPUTE, -- p->pipeline_layout, 0, 1, &output->descriptor, -- 0, NULL); -+ fel_api_end(p, FEL_API_PIPELINE, op_clock); -+ struct fel_wait_sample wait_sample = fel_wait_probe_begin(p); -+ if (p->push_descriptors) { -+ update_conversion_descriptor(p, command, VK_NULL_HANDLE, input, output, true); -+ } else { -+ op_clock = fel_api_begin(p); -+ vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_COMPUTE, -+ p->pipeline_layout, 0, 1, &descriptor, -+ 0, NULL); -+ fel_api_end(p, FEL_API_DESCRIPTORS, op_clock); -+ if (p->android_fel) -+ p->descriptor_rebinds++; -+ } -+ fel_wait_probe_end(p, &wait_sample); - const double source_width = desc->width; - const double source_height = desc->height; - const struct conversion_push_constants push = { -@@ -1374,14 +2433,18 @@ - }, - .output_size = {p->width, p->height}, - }; -- vkCmdPushConstants(output->command, p->pipeline_layout, -+ op_clock = fel_api_begin(p); -+ vkCmdPushConstants(command, p->pipeline_layout, - VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(push), &push); -- vkCmdDispatch(output->command, -+ fel_api_end(p, FEL_API_PUSH, op_clock); -+ op_clock = fel_api_begin(p); -+ vkCmdDispatch(command, - MP_ALIGN_UP(p->width, STABLE_WORKGROUP_X) / - STABLE_WORKGROUP_X, - MP_ALIGN_UP(p->height, STABLE_WORKGROUP_Y) / - STABLE_WORKGROUP_Y, - 1); -+ fel_api_end(p, FEL_API_DISPATCH, op_clock); - - VkImageMemoryBarrier release[] = { - { -@@ -1414,13 +2477,83 @@ - }, - }, - }; -- vkCmdPipelineBarrier(output->command, -+ op_clock = fel_api_begin(p); -+ vkCmdPipelineBarrier(command, - VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, - VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, - 0, NULL, 0, NULL, MP_ARRAY_SIZE(release), release); -- -- return vk_success(p, vkEndCommandBuffer(output->command), -- "ending conversion command buffer"); -+ fel_api_end(p, FEL_API_BARRIER_OUT, op_clock); -+ -+ if (output->fel_query_recorded) -+ vkCmdWriteTimestamp(command, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, -+ p->fel_query_pool, query + 1); -+ fel_api_end(p, FEL_API_RECORD, clock); -+ clock = fel_api_begin(p); -+ result = vkEndCommandBuffer(command); -+ fel_api_end(p, FEL_API_END, clock); -+ if (p->android_fel && result == VK_SUCCESS) -+ p->fresh_commands++; -+ return vk_success(p, result, "ending conversion command buffer"); -+} -+ -+static bool prepare_conversion(struct aimagereader_vk_stable *p, -+ struct vk_output *output, struct vk_input *input, -+ const AHardwareBuffer_Desc *desc, -+ const AImageCropRect *crop) -+{ -+ // The caller has already completed this output's copy fence and reclaimed -+ // its render semaphore. Submission always uses this frame's sync objects. -+ if (output->pending) -+ return false; -+ output->active_command = VK_NULL_HANDLE; -+ if (p->android_fel && input->initialized && !input->removed && output->written) { -+ bool hit = false; -+ struct vk_recording *record = p->recording_cache_disabled ? NULL : -+ select_recording(p, output, input, desc, crop, &hit); -+ if (hit) { -+ // Both views and their lifetimes match. The immutable sampler, -+ // YCbCr conversion and layouts live as long as this recording pool; -+ // imageLayout is fixed per binding. Only descriptor writes can be -+ // skipped: current pixels/UV, ownership and sync need fresh commands. -+ record->valid = false; -+ if (!record_conversion(p, output, input, desc, crop, record)) -+ return false; -+ record->valid = true; -+ p->recording_hits++; -+ if (!p->push_descriptors) -+ p->descriptor_content_hits++; -+ record->last_used = ++p->recording_serial; -+ output->active_command = record->command; -+ output->fel_query_recorded = record->timestamps; -+ return true; -+ } -+ p->recording_misses++; -+ if (record) { -+ record->valid = false; -+ if (allocate_recording(p, record)) { -+ update_conversion_descriptor(p, VK_NULL_HANDLE, record->descriptor, input, output, true); -+ if (!record_conversion(p, output, input, desc, crop, record)) -+ return false; -+ record->input = input; -+ record->output = output; -+ record->input_view = input->view; -+ record->output_view = output->view; -+ record->crop = *crop; -+ record->width = desc->width; -+ record->height = desc->height; -+ record->timestamps = output->fel_query_recorded; -+ record->last_used = ++p->recording_serial; -+ record->valid = true; -+ output->active_command = record->command; -+ return true; -+ } -+ } -+ p->recording_fallbacks++; -+ } else if (p->android_fel) { -+ p->recording_cold++; -+ } -+ update_conversion_descriptor(p, VK_NULL_HANDLE, output->descriptor, input, output, false); -+ return record_conversion(p, output, input, desc, crop, NULL); - } - - static bool submit_conversion(struct aimagereader_vk_stable *p, -@@ -1439,21 +2572,29 @@ - wait_semaphores[wait_count] = output->acquire; - wait_stages[wait_count++] = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT; - } -- -+ // The render-ready semaphore belongs to libplacebo. Export a separate -+ // signal for Android so exporting it cannot consume the render dependency. -+ VkSemaphore signal_semaphores[2] = {output->ready, output->source_release}; -+ uint32_t signal_count = p->android_fel && p->release_sync_fd && -+ output->source_release ? 2 : 1; -+ -+ VkCommandBuffer command = output->active_command ? output->active_command : output->command; - VkSubmitInfo submit_info = { - .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, - .waitSemaphoreCount = wait_count, - .pWaitSemaphores = wait_count ? wait_semaphores : NULL, - .pWaitDstStageMask = wait_count ? wait_stages : NULL, - .commandBufferCount = 1, -- .pCommandBuffers = &output->command, -- .signalSemaphoreCount = 1, -- .pSignalSemaphores = &output->ready, -+ .pCommandBuffers = &command, -+ .signalSemaphoreCount = signal_count, -+ .pSignalSemaphores = signal_semaphores, - }; - - p->vk->lock_queue(p->vk, p->queue_family, 0); -+ fel_perf_checkpoint(p, MP_FEL_PERF_QUEUE_LOCK); - VkResult result = vkQueueSubmit(p->queue, 1, &submit_info, output->fence); - p->vk->unlock_queue(p->vk, p->queue_family, 0); -+ fel_perf_checkpoint(p, MP_FEL_PERF_SUBMIT); - bool success = - vk_success(p, result, "submitting AHardwareBuffer conversion"); - if (success) -@@ -1461,6 +2602,8 @@ - return success; - } - -+#include "hwdec_aimagereader_vk_probe.inc" -+ - struct aimagereader_vk_stable *aimagereader_vk_stable_create( - struct ra_hwdec_mapper *mapper, const struct aimagereader_vk_api *api) - { -@@ -1469,6 +2612,10 @@ - p->log = mapper->log; - p->mapper = mapper; - p->api = *api; -+ p->android_fel = mapper->owner->ra_ctx->vo->extra.android_dovi_fel; -+ p->fel_profile = p->android_fel && mp_msg_test(p->log, MSGL_INFO); -+ p->output_count = OUTPUT_COUNT; -+ p->output_capacity = p->android_fel ? FEL_OUTPUT_CAPACITY : OUTPUT_COUNT; - - struct mpvk_ctx *ctx = ra_vk_ctx_get(mapper->owner->ra_ctx); - p->gpu = ra_pl_get(mapper->ra); -@@ -1499,11 +2646,19 @@ - get_device_proc( - p->device, - "vkGetAndroidHardwareBufferPropertiesANDROID"); -- if (has_extension(p->vk, "VK_KHR_external_semaphore_fd") && -- can_import_sync_fd(p->vk)) { -- p->ImportSemaphoreFdKHR = -- (PFN_vkImportSemaphoreFdKHR)get_device_proc( -- p->device, "vkImportSemaphoreFdKHR"); -+ if (has_extension(p->vk, "VK_KHR_external_semaphore_fd")) { -+ VkExternalSemaphoreFeatureFlags features = get_sync_fd_features(p->vk); -+ if (features & VK_EXTERNAL_SEMAPHORE_FEATURE_IMPORTABLE_BIT) { -+ p->ImportSemaphoreFdKHR = -+ (PFN_vkImportSemaphoreFdKHR)get_device_proc( -+ p->device, "vkImportSemaphoreFdKHR"); -+ } -+ if (p->android_fel && p->api.AImage_deleteAsync && -+ (features & VK_EXTERNAL_SEMAPHORE_FEATURE_EXPORTABLE_BIT)) { -+ p->GetSemaphoreFdKHR = (PFN_vkGetSemaphoreFdKHR)get_device_proc( -+ p->device, "vkGetSemaphoreFdKHR"); -+ p->release_sync_fd = p->GetSemaphoreFdKHR != NULL; -+ } - } - } - if (!p->queue || !p->GetAHBProperties || !p->GetImageFormatProperties2) -@@ -1519,12 +2674,23 @@ - "creating AHardwareBuffer conversion command pool")) - goto error; - -+ init_fel_gpu_timer(p); - mapper->dst_params = mapper->src_params; - mapper->dst_params.imgfmt = IMGFMT_RGB0; - mapper->dst_params.hw_subfmt = 0; - mapper->dst_params_ready = false; -- mp_info(p->log, "WebHTV Vulkan auto uses a queue-safe four-output " -- "bounded-fence pool\n"); -+ if (p->android_fel) { -+ log_fel_descriptor_capabilities(p); -+ mp_info(p->log, "WebHTV FEL GPU cache: %d initial slots, %d maximum, " -+ "lazy allocation and live-frame leases.\n", -+ p->output_count, p->output_capacity); -+ mp_info(p->log, "WebHTV FEL source release: %s; " -+ "render semaphore and output leases remain independent.\n", -+ p->release_sync_fd ? "async sync-fd" : "bounded CPU wait"); -+ } else { -+ mp_info(p->log, "WebHTV Vulkan auto uses a queue-safe four-output " -+ "bounded-fence pool\n"); -+ } - if (p->ImportSemaphoreFdKHR) { - MP_VERBOSE(mapper, "Using Vulkan sync_fd for AImage acquire fences\n"); - } else { -@@ -1544,6 +2710,11 @@ - if (!p) - return; - -+ struct mp_fel_bind_probe *probe = &p->mapper->owner->ra_ctx->vo->fel_bind_probe; -+ __atomic_store_n(&probe->supported, false, __ATOMIC_RELEASE); -+ mp_fel_probe_cancel(probe); -+ fel_probe_retire_drains(p, true); -+ log_fel_pool(p, true); - mp_verbose(p->log, "Vulkan conversion pool stats: submitted=%" PRIu64 - " completed=%" PRIu64 " reclaimed=%" PRIu64 - " fence-timeouts=%" PRIu64 " pool-retries=%" PRIu64 -@@ -1553,6 +2724,8 @@ - if (p->gpu) - pl_gpu_finish(p->gpu); - destroy_conversion_resources(p); -+ if (p->fel_query_pool) -+ vkDestroyQueryPool(p->device, p->fel_query_pool, NULL); - if (p->command_pool) - vkDestroyCommandPool(p->device, p->command_pool, NULL); - -@@ -1567,6 +2740,8 @@ - if (!input) - return; - input->removed = true; -+ if (p->android_fel) -+ p->input_removals++; - if (!input->users) - destroy_input(p, input); - } -@@ -1574,20 +2749,27 @@ - bool aimagereader_vk_stable_reuse(struct aimagereader_vk_stable *p, - struct mp_image *frame) - { -- for (int n = 0; n < OUTPUT_COUNT; n++) { -+ for (int n = 0; n < p->output_count; n++) { - struct vk_output *output = &p->outputs[n]; - if (output->source_frame && - output->source_frame->planes[3] == frame->planes[3]) { -+ finish_output(p, output, 0); -+ trace_fel_frame_order(p, 'R', n, output->fel_input_slot, output->fel_serial, -+ frame->pts, output->source_frame->pts); - p->mapper->tex[0] = output->ratex; - return true; - } - for (int i = 0; i < output->num_source_aliases; i++) { - if (output->source_aliases[i]->planes[3] == frame->planes[3]) { -+ finish_output(p, output, 0); -+ trace_fel_frame_order(p, 'A', n, output->fel_input_slot, output->fel_serial, -+ frame->pts, output->source_aliases[i]->pts); - p->mapper->tex[0] = output->ratex; - return true; - } - } - } -+ trace_fel_frame_order(p, 'X', -1, -1, 0, frame->pts, frame->pts); - return false; - } - -@@ -1605,6 +2787,8 @@ - return false; - MP_TARRAY_APPEND(p, output->source_aliases, - output->num_source_aliases, frame_ref); -+ trace_fel_frame_order(p, 'A', p->output_index, output->fel_input_slot, output->fel_serial, -+ frame->pts, output->source_frame ? output->source_frame->pts : frame->pts); - p->mapper->tex[0] = output->ratex; - return true; - } -@@ -1622,12 +2806,23 @@ - if (!crop || !p->mapper->src_params.w || !p->mapper->src_params.h) - return -1; - -- for (int n = 0; n < OUTPUT_COUNT; n++) -+ if (p->fel_profile) { -+ p->fel_acquire_fence = *acquire_fence; -+ p->fel_active_pts = frame->pts; -+ p->fel_active_input = p->fel_active_output = -1; -+ p->fel_profile_warm = p->resources_ready; -+ p->fel_map_started = p->fel_map_checkpoint = mp_time_ns(); -+ p->fel_map_cpu_started = mp_fel_thread_cpu_ns(); -+ } -+ for (int n = 0; n < p->output_count; n++) - finish_output(p, &p->outputs[n], 0); - purge_removed_inputs(p); -+ fel_perf_checkpoint(p, MP_FEL_PERF_POLL); - - AHardwareBuffer_Desc desc; -+ struct fel_api_clock clock = fel_api_begin(p); - p->api.AHardwareBuffer_describe(buffer, &desc); -+ fel_api_end(p, FEL_API_DESCRIBE, clock); - if (!desc.width || !desc.height || - desc.width > INT32_MAX || desc.height > INT32_MAX || - crop->left < 0 || crop->top < 0 || -@@ -1638,25 +2833,52 @@ - "(buffer %ux%u, crop %d,%d-%d,%d)\n", - desc.width, desc.height, crop->left, crop->top, - crop->right, crop->bottom); -- return -1; -+ goto error; -+ } -+ bool cold_init = p->android_fel && !p->resources_ready; -+ int64_t init_started = cold_init ? mp_time_ns() : 0; -+ if (cold_init) { -+ mp_android_fel_staging_begin_init(frame->android_fel_staging -+ ? frame->android_fel_staging->data : NULL); -+ mp_info(p->log, "WebHTV FEL GPU init: begin pts=%.6f " -+ "size=%dx%d; cancellable producer, frame deadline separate.\n", -+ frame->pts, p->mapper->src_params.w, p->mapper->src_params.h); - } - struct vk_input *input = find_input(p, buffer); -+ if (p->android_fel) { -+ p->input_hits += input != NULL; -+ p->input_misses += input == NULL; -+ } - if (!input) - input = create_input(p, buffer, &desc, data_space); - if (!input) - goto error; -- -- int next_output = (p->output_index + 1) % OUTPUT_COUNT; -- int selected_output = -1; -- for (int n = 0; n < OUTPUT_COUNT; n++) { -- int candidate = (next_output + n) % OUTPUT_COUNT; -- if (!p->outputs[candidate].pending) { -- selected_output = candidate; -- break; -+ p->fel_active_input = input - p->inputs; -+ if (cold_init) { -+ mp_android_fel_staging_end_init(frame->android_fel_staging -+ ? frame->android_fel_staging->data : NULL); -+ mp_info(p->log, "WebHTV FEL GPU init: ready pts=%.6f elapsed-ms=%lld.\n", -+ frame->pts, (long long)((mp_time_ns() - init_started) / 1000000)); -+ } -+ fel_perf_checkpoint(p, MP_FEL_PERF_IMPORT); -+ -+ if (p->android_fel) { -+ struct vo *vo = p->mapper->owner->ra_ctx->vo; -+ if (!__atomic_load_n(&vo->fel_bind_probe.supported, __ATOMIC_ACQUIRE)) { -+ __atomic_store_n(&vo->fel_bind_probe.supported, true, __ATOMIC_RELEASE); -+ vo->extra.wakeup_cb(vo->extra.wakeup_ctx); - } -- } -- if (selected_output < 0) { -- selected_output = next_output; -+ fel_probe_retire_drains(p, false); -+ if (fel_run_bind_probe(p, input, frame, *acquire_fence)) { -+ // An explicit diagnostic pause is not a production map latency. -+ p->fel_map_started = p->fel_map_checkpoint = 0; -+ } -+ } -+ -+ int waiting_output = -1; -+ int selected_output = select_reusable_output(p, &waiting_output); -+ if (selected_output < 0 && waiting_output >= 0) { -+ selected_output = waiting_output; - if (!finish_output(p, &p->outputs[selected_output], - CONVERSION_FENCE_TIMEOUT_NS)) { - p->pool_retries++; -@@ -1665,10 +2887,40 @@ - "output=%d retries=%" PRIu64 "\n", - selected_output, p->pool_retries); - } -+ fel_perf_finish_map(p); - return RA_HWDEC_MAP_RETRY; - } - } -+ if (selected_output < 0) { -+ if (p->output_count == p->output_capacity) { -+ mp_err(p->log, "FEL GPU cache exhausted by live frames (%d); " -+ "refusing to overwrite retained pixels\n", p->output_count); -+ goto error; -+ } -+ // Only explicit FEL can grow beyond four. Keep command/descriptor -+ // handles preallocated, but never reserve unused 4K pixel allocations. -+ selected_output = p->output_count++; -+ struct vk_output *extra = &p->outputs[selected_output]; -+ uint32_t queue_families[3]; -+ int families = collect_queue_families(p, queue_families); -+ if (!create_output_image(p, extra, queue_families, families)) -+ goto error; -+ VkDescriptorImageInfo target = { -+ .imageView = extra->view, .imageLayout = VK_IMAGE_LAYOUT_GENERAL, -+ }; -+ VkWriteDescriptorSet write = { -+ .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, -+ .dstSet = extra->descriptor, .dstBinding = 1, .descriptorCount = 1, -+ .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, -+ .pImageInfo = &target, -+ }; -+ if (!p->push_descriptors) -+ vkUpdateDescriptorSets(p->device, 1, &write, 0, NULL); -+ mp_info(p->log, "WebHTV FEL GPU cache grew to %d live-frame slots.\n", -+ p->output_count); -+ } - p->output_index = selected_output; -+ p->fel_active_output = selected_output; - output = &p->outputs[selected_output]; - - wait_for_output = output->released; -@@ -1685,24 +2937,12 @@ - p->reclaimed_outputs++; - } - clear_output_frames(output); -- -- VkDescriptorImageInfo source = { -- .imageView = input->view, -- .imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, -- }; -- VkWriteDescriptorSet write = { -- .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, -- .dstSet = output->descriptor, -- .dstBinding = 0, -- .descriptorCount = 1, -- .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, -- .pImageInfo = &source, -- }; -- vkUpdateDescriptorSets(p->device, 1, &write, 0, NULL); -+ fel_perf_checkpoint(p, MP_FEL_PERF_RECLAIM); - - frame_ref = mp_image_new_ref(frame); -- if (!frame_ref || !record_conversion(p, output, input, &desc, crop)) -+ if (!frame_ref || !prepare_conversion(p, output, input, &desc, crop)) - goto error; -+ fel_perf_checkpoint(p, MP_FEL_PERF_RECORD); - - if (*acquire_fence >= 0) { - int import_result = -@@ -1712,6 +2952,7 @@ - goto error; - acquire_imported = import_result > 0; - } -+ fel_perf_checkpoint(p, MP_FEL_PERF_ACQUIRE); - if (!submit_conversion(p, output, wait_for_output, acquire_imported)) { - if (acquire_imported) - reset_acquire_semaphore(p, output); -@@ -1720,6 +2961,8 @@ - - output->source_image = image; - output->source_frame = frame_ref; -+ output->fel_serial = p->submitted_outputs; -+ output->fel_input_slot = input - p->inputs; - output->input = input; - output->pending = true; - output->written = true; -@@ -1727,6 +2970,8 @@ - input->users++; - input->last_used = ++p->input_serial; - input->initialized = true; -+ trace_fel_frame_order(p, 'M', selected_output, output->fel_input_slot, output->fel_serial, -+ frame->pts, frame_ref->pts); - - pl_vulkan_release_ex(p->gpu, pl_vulkan_release_params( - .tex = output->pltex, -@@ -1735,11 +2980,25 @@ - .semaphore = (pl_vulkan_sem){.sem = output->ready}, - )); - p->mapper->tex[0] = output->ratex; -+ fel_perf_checkpoint(p, MP_FEL_PERF_TEX_RELEASE); -+ if (p->android_fel && !release_fel_source_async(p, output)) { -+ // Release the codec image after the actual GPU copy fence, not after -+ // a future decoded frame arrives. The independent texture remains -+ // alive for libplacebo's rendering/redraw queue. No CPU readback. -+ int64_t start = mp_time_ns(); -+ finish_output(p, output, MP_ANDROID_FEL_COPY_WAIT_NS); -+ uint64_t elapsed = MPMAX(0, mp_time_ns() - start); -+ p->fel_wait_ns += elapsed; -+ p->fel_max_wait_ns = MPMAX(p->fel_max_wait_ns, elapsed); -+ } -+ fel_perf_finish_map(p); -+ log_fel_pool(p, false); - return 0; - - error: - mp_image_unrefp(&frame_ref); - if (output) - release_output_after_error(p, output, wait_for_output); -+ fel_perf_finish_map(p); - return -1; - } -diff --git a/video/out/opengl/egl_helpers.c b/video/out/opengl/egl_helpers.c ---- a/video/out/opengl/egl_helpers.c -+++ b/video/out/opengl/egl_helpers.c -@@ -315,6 +315,8 @@ - STR_OR_ERR(version), STR_OR_ERR(vendor), STR_OR_ERR(apis)); - - enum gles_mode mode = ra_gl_ctx_get_glesmode(ctx); -+ if (ctx->vo->extra.android_dovi_fel) -+ mode = GLES_YES; - - void *tmp = talloc_new(NULL); - struct egl_opts *opts = mp_get_config_group(tmp, ctx->global, &egl_conf); -diff --git a/video/out/vo.c b/video/out/vo.c ---- a/video/out/vo.c -+++ b/video/out/vo.c -@@ -38,10 +38,12 @@ - #include "input/input.h" - #include "options/m_config.h" - #include "common/msg.h" -+#include "filters/f_android_fel.h" - #include "common/global.h" - #include "common/stats.h" - #include "video/hwdec.h" - #include "video/mp_image.h" -+#include - #include "sub/osd.h" - #include "osdep/io.h" - #include "osdep/threads.h" -@@ -177,6 +179,18 @@ - int frame_refs; // max frames the VO may reference at once - uint64_t current_frame_id; - -+ // One asynchronous core -> VO staging request; never wait for GPU under lock. -+ struct mp_image *fel_prepare_image; -+ uint64_t fel_prepare_generation; -+ int fel_prepare_result; -+ int64_t fel_prepare_started, fel_prepare_retry; -+ int64_t fel_prepare_phase_started; -+ unsigned char fel_prepare_phase; -+ // First actual draw may compile the FEL renderer after mapper preloading. -+ // This is once per VO lifetime, not renewed by seek/reconfig or polling. -+ bool fel_render_init_attempted, fel_render_initializing; -+ int64_t fel_render_init_started; -+ - double display_fps; - double reported_display_fps; - -@@ -186,6 +200,7 @@ - extern const struct m_sub_options gl_video_conf; - - static void forget_frames(struct vo *vo); -+static void wakeup_locked(struct vo *vo); - static MP_THREAD_VOID vo_thread(void *ptr); - - static bool get_desc(struct m_obj_desc *dst, int index) -@@ -293,12 +308,14 @@ - .probing = probing, - .in = talloc(vo, struct vo_internal), - }; -+ mp_fel_trace_init(&vo->fel_trace); - mp_mutex_init(&vo->params_mutex); - talloc_steal(vo, log); - *vo->in = (struct vo_internal) { - .dispatch = mp_dispatch_create(vo), - .req_frames = 1, - .frame_refs = 2, // current_frame + frame_queued -+ .fel_prepare_generation = 1, - .estimated_vsync_jitter = -1, - .stats = stats_ctx_create(vo, global, "vo"), - }; -@@ -380,6 +397,7 @@ - - void vo_destroy(struct vo *vo) - { -+ mp_fel_probe_cancel(&vo->fel_bind_probe); - struct vo_internal *in = vo->in; - mp_dispatch_run(in->dispatch, terminate_vo, vo); - mp_thread_join(vo->in->thread); -@@ -702,10 +720,219 @@ - mp_dispatch_enqueue_autofree(vo->in->dispatch, run_control, d); - } - -+// Must be called locked. A worker owns its own image ref and ignores an old -+// generation if a seek/reset cancels it while the GPU is running. -+static void cancel_fel_prepare(struct vo *vo) -+{ -+ struct vo_internal *in = vo->in; -+ mp_image_unrefp(&in->fel_prepare_image); -+ in->fel_prepare_generation++; -+ in->fel_prepare_result = VO_FALSE; -+ in->fel_prepare_started = in->fel_prepare_retry = 0; -+ in->fel_prepare_phase_started = 0; -+ in->fel_prepare_phase = 0; -+} -+ -+void vo_cancel_fel_frame(struct vo *vo, struct mp_image *image) -+{ -+ struct vo_internal *in = vo->in; -+ mp_mutex_lock(&in->lock); -+ if (in->fel_prepare_image && image->android_fel_staging && -+ in->fel_prepare_image->android_fel_staging->data == -+ image->android_fel_staging->data) -+ cancel_fel_prepare(vo); -+ mp_mutex_unlock(&in->lock); -+} -+ -+// Called locked, around the first actual hardware FEL draw (never around GPU -+// work with the lock held). Mapper initialization does not compile the renderer. -+static bool begin_fel_render_init(struct vo *vo, const struct vo_frame *frame) -+{ -+ struct vo_internal *in = vo->in; -+ if (in->fel_render_init_attempted || !vo->extra.android_dovi_fel || -+ !(vo->driver->caps & VO_CAP_GPU_DOVI_EL_SW) || !frame->current || -+ frame->current->imgfmt != IMGFMT_MEDIACODEC) -+ return false; -+ in->fel_render_init_attempted = in->fel_render_initializing = true; -+ in->fel_render_init_started = mp_time_ns(); -+ MP_INFO(vo, "WebHTV FEL renderer init: begin pts=%.6f limit-ms=%lld.\n", -+ frame->current->pts, -+ (long long)(MP_ANDROID_FEL_INIT_TIMEOUT_NS / 1000000)); -+ return true; -+} -+ -+// Called locked. Exclude only time actually spent behind this first draw, even -+// when the entire draw fell between two producer polls. Keep pre-draw wait time. -+static void end_fel_render_init(struct vo *vo) -+{ -+ struct vo_internal *in = vo->in; -+ int64_t now = mp_time_ns(); -+ int64_t elapsed = now - in->fel_render_init_started; -+ bool pending = in->fel_prepare_image && in->fel_prepare_result == VO_FALSE; -+ if (pending) { -+ if (elapsed >= MP_ANDROID_FEL_INIT_TIMEOUT_NS) { -+ in->fel_prepare_result = VO_ERROR; -+ atomic_fetch_add(&vo->fel_trace.core_prepare_errors, 1); -+ } else { -+ in->fel_prepare_phase_started += now - -+ MPMAX(in->fel_prepare_phase_started, in->fel_render_init_started); -+ } -+ } -+ in->fel_render_initializing = false; -+ MP_INFO(vo, "WebHTV FEL renderer init: end wall-ms=%lld pending=%d " -+ "limit-exceeded=%d.\n", (long long)(elapsed / 1000000), pending, -+ elapsed >= MP_ANDROID_FEL_INIT_TIMEOUT_NS); -+} -+ -+// Called locked. Do not charge shader compilation/resource allocation to a -+// frame's normal handoff deadline. Progress is monotonic, so polling cannot -+// keep a stuck request alive; only the mapper can enter/leave cold init. -+static bool fel_prepare_timed_out(struct vo *vo) -+{ -+ struct vo_internal *in = vo->in; -+ int64_t now = mp_time_ns(); -+ const char *phase_name = "renderer-init"; -+ int64_t phase_started = in->fel_render_init_started; -+ bool initializing = in->fel_render_initializing; -+ bool probing = false; -+ if (!initializing) { -+ unsigned char phase = mp_android_fel_staging_init_phase( -+ in->fel_prepare_image->android_fel_staging->data); -+ if (phase != in->fel_prepare_phase) { -+ in->fel_prepare_phase = phase; -+ in->fel_prepare_phase_started = now; -+ } -+ initializing = (phase & 12) == 4; -+ probing = (phase & 48) == 16; -+ phase_name = probing ? "bind-probe" : initializing ? "gpu-init" : "frame"; -+ phase_started = in->fel_prepare_phase_started; -+ } -+ int64_t limit = probing ? MP_FEL_PROBE_TIMEOUT_NS : -+ initializing ? MP_ANDROID_FEL_INIT_TIMEOUT_NS -+ : MP_ANDROID_FEL_FRAME_TIMEOUT_NS; -+ if (now - phase_started < limit) -+ return false; -+ MP_WARN(vo, "WebHTV FEL handoff timeout: phase=%s pts=%.6f " -+ "request-ms=%lld phase-ms=%lld source-returned=%d.\n", -+ phase_name, in->fel_prepare_image->pts, -+ (long long)((now - in->fel_prepare_started) / 1000000), -+ (long long)((now - phase_started) / 1000000), -+ mp_android_fel_staging_ready( -+ in->fel_prepare_image->android_fel_staging->data)); -+ return true; -+} -+ -+int vo_prepare_fel_frame(struct vo *vo, struct mp_image *image) -+{ -+ if (!vo->extra.android_dovi_fel || image->imgfmt != IMGFMT_MEDIACODEC || -+ !(vo->driver->caps & VO_CAP_GPU_DOVI_EL_SW)) -+ return VO_NOTIMPL; -+ -+ struct vo_internal *in = vo->in; -+ int result = VO_FALSE; -+ mp_mutex_lock(&in->lock); -+ bool same_request = in->fel_prepare_image && image->android_fel_staging && -+ image->android_fel_staging->data == -+ in->fel_prepare_image->android_fel_staging->data; -+ // Always acknowledge our own request before using the prepared fast path. -+ // A delayed fence can become ready between the worker and this call; do -+ // not leave a completed single-slot request blocking the following frame. -+ if (same_request) { -+ result = in->fel_prepare_result; -+ if (result == VO_FALSE && fel_prepare_timed_out(vo)) { -+ atomic_fetch_add(&vo->fel_trace.core_prepare_errors, 1); -+ cancel_fel_prepare(vo); -+ result = VO_ERROR; -+ } else if (result != VO_FALSE) { -+ if (result == VO_TRUE) -+ image->android_fel_prepared = in->fel_prepare_generation; -+ mp_image_unrefp(&in->fel_prepare_image); -+ } -+ } else if (image->android_fel_prepared == in->fel_prepare_generation && -+ image->android_fel_staging && -+ mp_android_fel_staging_ready(image->android_fel_staging->data)) { -+ result = VO_TRUE; -+ } else if (!in->fel_prepare_image) { -+ if (!image->android_fel_staging) -+ image->android_fel_staging = av_buffer_allocz(1); -+ if (!image->android_fel_staging) { -+ result = VO_ERROR; -+ } else { -+ in->fel_prepare_image = mp_image_new_ref(image); -+ in->fel_prepare_result = VO_FALSE; -+ in->fel_prepare_started = mp_time_ns(); -+ in->fel_prepare_retry = in->fel_prepare_started; -+ in->fel_prepare_phase_started = in->fel_prepare_started; -+ in->fel_prepare_phase = 0; -+ unsigned n = atomic_fetch_add(&vo->fel_trace.core_prepare_requests, 1); -+ if (!n) -+ MP_INFO(vo, "WebHTV FEL core preloading: async single-slot, " -+ "leased GPU cache, lookahead unchanged.\n"); -+ wakeup_locked(vo); -+ } -+ } -+ mp_mutex_unlock(&in->lock); -+ return result; -+} -+ -+// VO-thread only, after VOCTRL_RESET. Return the next retry time (0 = idle). -+static int64_t process_fel_prepare(struct vo *vo) -+{ -+ struct vo_internal *in = vo->in; -+ mp_mutex_lock(&in->lock); -+ if (!in->fel_prepare_image || in->fel_prepare_result != VO_FALSE) { -+ mp_mutex_unlock(&in->lock); -+ return 0; -+ } -+ int64_t retry = in->fel_prepare_retry; -+ if (in->send_reset || mp_time_ns() < retry) { -+ mp_mutex_unlock(&in->lock); -+ return retry; -+ } -+ struct mp_image *image = mp_image_new_ref(in->fel_prepare_image); -+ uint64_t generation = in->fel_prepare_generation; -+ mp_mutex_unlock(&in->lock); -+ -+ struct vo_frame frame = {.current = image, .frames = {image}, .num_frames = 1}; -+ mp_fel_trace_record(&vo->fel_trace, MP_FEL_VO, MP_FEL_VO_CORE_PRELOAD, -+ mp_time_ns() / 1000000, image->pts, 1, 0, 0); -+ update_opts(vo); -+ int result = vo->driver->control(vo, VOCTRL_PREPARE_FEL_FRAME, &frame); -+ // A mapped texture can still have an outstanding source copy. Keep the -+ // same request until the source was returned (possibly with a GPU fence). -+ if (result == VO_TRUE && -+ !mp_android_fel_staging_ready(image->android_fel_staging->data)) -+ result = VO_FALSE; -+ -+ mp_mutex_lock(&in->lock); -+ retry = 0; -+ if (generation == in->fel_prepare_generation && in->fel_prepare_image && -+ in->fel_prepare_image->android_fel_staging->data == -+ image->android_fel_staging->data) { -+ if (result == VO_FALSE && fel_prepare_timed_out(vo)) -+ result = VO_ERROR; -+ in->fel_prepare_result = result; -+ if (result == VO_FALSE) { -+ atomic_fetch_add(&vo->fel_trace.core_prepare_retries, 1); -+ retry = in->fel_prepare_retry = mp_time_ns() + MP_TIME_MS_TO_NS(5); -+ } else { -+ if (result == VO_TRUE) -+ atomic_fetch_add(&vo->fel_trace.core_prepare_completed, 1); -+ else if (result != VO_NOTIMPL) -+ atomic_fetch_add(&vo->fel_trace.core_prepare_errors, 1); -+ wakeup_core(vo); -+ } -+ } -+ mp_mutex_unlock(&in->lock); -+ mp_image_unrefp(&image); -+ return retry; -+} -+ - // must be called locked - static void forget_frames(struct vo *vo) - { - struct vo_internal *in = vo->in; -+ cancel_fel_prepare(vo); - in->hasframe = false; - in->hasframe_rendered = false; - in->drop_count = 0; -@@ -1023,6 +1250,48 @@ - bool request_redraw = in->request_redraw; - - if (in->dropped_frame) { -+ if (vo->extra.android_dovi_fel && -+ (vo->driver->caps & VO_CAP_GPU_DOVI_EL_SW) && frame->current && -+ frame->current->imgfmt == IMGFMT_MEDIACODEC) -+ { -+ // Dropping display must not skip returning codec buffers. We -+ // retain this frame and its lookahead until another frame arrives; -+ // that next decode may require the very buffer we still retain. -+ // Stage without compositing/presenting, and never under the VO lock. -+ in->rendering = true; -+ mp_mutex_unlock(&in->lock); -+ unsigned drops = atomic_fetch_add_explicit( -+ &vo->fel_trace.vo_drops, 1, memory_order_relaxed) + 1; -+ mp_fel_trace_record(&vo->fel_trace, MP_FEL_VO, -+ MP_FEL_VO_DROP_PRELOAD, mp_time_ns() / 1000000, -+ frame->current->pts, frame->num_frames, 0, 0); -+ int result = vo->driver->control(vo, VOCTRL_PREPARE_FEL_FRAME, frame); -+ if (result == VO_TRUE) -+ atomic_fetch_add_explicit(&vo->fel_trace.vo_drop_prepared, 1, -+ memory_order_relaxed); -+ if (result == VO_FALSE) -+ atomic_fetch_add_explicit(&vo->fel_trace.vo_drop_retries, 1, -+ memory_order_relaxed); -+ if (result == VO_ERROR) -+ vo_report_backend_error(vo); -+ mp_fel_trace_record(&vo->fel_trace, MP_FEL_VO, -+ MP_FEL_VO_DROP_DONE, mp_time_ns() / 1000000, -+ frame->current->pts, frame->num_frames, 0, 0); -+ if (drops == 1 || drops % 120 == 0) { -+ MP_INFO(vo, "WebHTV FEL dropped-frame staging: drops=%u " -+ "prepared=%u retries=%u frames=%d result=%d " -+ "(display skipped, lookahead unchanged).\n", drops, -+ atomic_load_explicit(&vo->fel_trace.vo_drop_prepared, -+ memory_order_relaxed), -+ atomic_load_explicit(&vo->fel_trace.vo_drop_retries, -+ memory_order_relaxed), -+ frame->num_frames, result); -+ } -+ mp_mutex_lock(&in->lock); -+ in->rendering = false; -+ if (result == VO_FALSE) -+ in->request_redraw = true; // Retry retained source, not a new decode. -+ } - in->drop_count += 1; - int64_t late_ns = end_time < now ? now - end_time : 0; - if (late_ns > 0) { -@@ -1054,6 +1323,7 @@ - // timer instead, but possibly benefits from preparing a frame early. - bool can_queue = !in->frame_queued && - (in->current_frame->num_vsyncs < 1 || !use_vsync); -+ bool fel_render_init = begin_fel_render_init(vo, frame); - mp_mutex_unlock(&in->lock); - - if (can_queue) -@@ -1062,6 +1332,12 @@ - stats_time_start(in->stats, "video-draw"); - - in->visible = vo->driver->draw_frame(vo, frame); -+ -+ if (fel_render_init) { -+ mp_mutex_lock(&in->lock); -+ end_fel_render_init(vo); -+ mp_mutex_unlock(&in->lock); -+ } - - stats_time_end(in->stats, "video-draw"); - -@@ -1239,6 +1515,9 @@ - vo->driver->control(vo, VOCTRL_RESET, NULL); - if (send_pause) - vo->driver->control(vo, vo_paused ? VOCTRL_PAUSE : VOCTRL_RESUME, NULL); -+ int64_t fel_retry = process_fel_prepare(vo); -+ if (fel_retry) -+ wait_until = MPMIN(wait_until, fel_retry); - if (wait_until > now && redraw) { - // Allow manual redraws at most at display fps. - int64_t max_interval = in->vsync_interval > 1 ? in->vsync_interval : 0; -@@ -1335,6 +1614,7 @@ - - void vo_seek_reset(struct vo *vo) - { -+ mp_fel_probe_cancel(&vo->fel_bind_probe); - struct vo_internal *in = vo->in; - mp_mutex_lock(&in->lock); - forget_frames(vo); -@@ -1401,6 +1681,10 @@ - in->flip_queue_offset = offset_ns; - in->req_frames = MPCLAMP(num_req_frames, 1, VO_MAX_REQ_FRAMES); - in->frame_refs = MPCLAMP(num_frame_refs, in->req_frames, 2 * VO_MAX_REQ_FRAMES); -+ atomic_store_explicit(&vo->fel_trace.requested, in->req_frames, -+ memory_order_relaxed); -+ atomic_store_explicit(&vo->fel_trace.references, in->frame_refs, -+ memory_order_relaxed); - mp_mutex_unlock(&in->lock); - } - -diff --git a/video/out/vo.h b/video/out/vo.h ---- a/video/out/vo.h -+++ b/video/out/vo.h -@@ -31,6 +31,8 @@ - #include "options/options.h" - #include "osdep/threads.h" - #include "player/clipboard/clipboard.h" -+#include "filters/f_android_fel_trace.h" -+#include "video/out/fel_bind_probe.h" - - enum { - // VO needs to redraw -@@ -139,6 +141,11 @@ - // Clipboard - VOCTRL_GET_CLIPBOARD, // struct voctrl_clipboard* - VOCTRL_SET_CLIPBOARD, -+ -+ // gpu-next/FEL only: stage retained hardware frames even if VO display is -+ // dropped. Called on the VO thread, without the VO lock; data: vo_frame*. -+ // TRUE: staged, FALSE: retry/redraw, ERROR: backend error, NOTIMPL: no-op. -+ VOCTRL_PREPARE_FEL_FRAME, - }; - - // Helper to expose what kind of content is currently playing to the VO. -@@ -220,6 +227,9 @@ - // VO can safely consume a separately decoded Dolby Vision enhancement - // layer in addition to the base layer. - VO_CAP_GPU_DOVI_EL = 1 << 10, -+ // VO can upload a software EL without another hardware output Surface. -+ // Android still requires an explicit per-playback opt-in. -+ VO_CAP_GPU_DOVI_EL_SW = 1 << 11, - }; - - enum { -@@ -242,6 +252,10 @@ - void (*wakeup_cb)(void *ctx); - void *wakeup_ctx; - bool prefer_hdr_output; -+ // Selected source contract, frozen for this VO's entire lifetime. The -+ // corresponding options express intent, including on non-DV7 files. -+ bool android_dovi_fel; -+ int android_dovi_fel_vulkan; - }; - - struct vo_frame { -@@ -494,6 +508,10 @@ - struct encode_lavc_context *encode_lavc_ctx; - struct vo_internal *in; - struct vo_extra extra; -+ struct mp_fel_bind_probe fel_bind_probe; -+ -+ // Instance-local, observational only; no decoder/core/VO locks required. -+ struct mp_fel_trace fel_trace; - - // --- The following fields are generally only changed during initialization. - -@@ -547,6 +565,10 @@ - - int vo_control(struct vo *vo, int request, void *data); - void vo_control_async(struct vo *vo, int request, void *data); -+// Nonblocking, core or independent FEL BL worker: VO_FALSE while staging. -+int vo_prepare_fel_frame(struct vo *vo, struct mp_image *image); -+// Cancel only this lease, without waiting for in-flight driver/GPU work. -+void vo_cancel_fel_frame(struct vo *vo, struct mp_image *image); - bool vo_is_ready_for_frame(struct vo *vo, int64_t next_pts); - bool vo_is_visible(struct vo *vo); - void vo_queue_frame(struct vo *vo, struct vo_frame *frame); -diff --git a/video/out/vo_gpu_next.c b/video/out/vo_gpu_next.c ---- a/video/out/vo_gpu_next.c -+++ b/video/out/vo_gpu_next.c -@@ -17,6 +17,7 @@ - * License along with mpv. If not, see . - */ - -+#include - #include - #include - -@@ -31,12 +32,15 @@ - #include "config.h" - #include "common/common.h" - #include "common/stats.h" -+#include "filters/f_android_fel_perf.h" - #include "misc/io_utils.h" - #include "options/m_config.h" -+#include "options/m_option.h" - #include "options/options.h" - #include "options/path.h" - #include "osdep/io.h" - #include "osdep/threads.h" -+#include "osdep/timer.h" - #include "stream/stream.h" - #include "sub/draw_bmp.h" - #include "video/fmt-conversion.h" -@@ -116,6 +120,7 @@ - struct mp_pass_perf hwdec_perf; - struct ra_hwdec_mapper *el_hwdec_mapper; - struct timer_pool *el_hwdec_timer; -+ bool fel_input_logged; - struct timer_pool *sw_upload_timer; - struct mp_pass_perf sw_upload_perf; - -@@ -170,6 +175,10 @@ - // Performance data of last frame - struct frame_info perf_fresh; - struct frame_info perf_redraw; -+ -+ // Explicit FEL diagnostics only; never wait for GPU timing results. -+ struct mp_fel_perf_stat fel_render_wall[4], fel_render_cpu[4]; -+ double fel_render_log_at; - - struct mp_image_params target_params; - }; -@@ -577,10 +586,40 @@ - return desc.num_planes; - } - -+static void restore_fel_display_params(struct vo *vo, struct mp_image *mpi, -+ struct mp_image_params *par) -+{ -+ if (!vo->extra.android_dovi_fel || mpi->imgfmt != IMGFMT_MEDIACODEC) -+ return; -+ // The mapper/cache describes storage, not the frame's presentation. Keep -+ // its actual pixel bounds/format, but never lose source rotation or aspect. -+ par->rotate = mpi->params.rotate; -+ par->vflip = mpi->params.vflip; -+ par->p_w = mpi->params.p_w; -+ par->p_h = mpi->params.p_h; -+ // The producer stages pure BL before EL supplies its RPU. Storage is raw -+ // YUV in either case; the actual frame decides DV vs HDR10 interpretation. -+ par->repr.sys = mpi->params.repr.sys; -+} -+ - static bool hwdec_reconfig(struct priv *p, struct ra_hwdec_mapper **mapper, - struct timer_pool **timer, struct ra_hwdec *hwdec, - const struct mp_image_params *par) - { -+ // FEL may stage before the core applies display-only crop/rotation/aspect. -+ // AImage's real crop drives conversion; gpu-next applies presentation crop. -+ // Do not destroy detached pixels merely because presentation params changed. -+ struct mp_image_params mapping = *par; -+ if (p->ra_ctx->vo->extra.android_dovi_fel && par->imgfmt == IMGFMT_MEDIACODEC) { -+ mapping.crop = (struct mp_rect){0, 0, par->w, par->h}; -+ mapping.rotate = 0; -+ mapping.vflip = false; -+ mapping.p_w = mapping.p_h = 1; -+ // Force raw-YUV sampling before as well as after EL RPU inheritance. -+ // No synthetic RPU is created, and the original frame is unchanged. -+ mapping.repr.sys = PL_COLOR_SYSTEM_DOLBYVISION; -+ par = &mapping; -+ } - if (*mapper) { - if (mp_image_params_static_equal(par, &(*mapper)->src_params)) { - (*mapper)->src_params.repr.dovi = par->repr.dovi; -@@ -964,6 +1003,7 @@ - } - - par = p->hwdec_mapper->dst_params; -+ restore_fel_display_params(vo, mpi, &par); - fp->mapped_coordinates = - p->hwdec_mapper->dst_params_map_coordinates; - fp->mapped_params = par; -@@ -1084,6 +1124,13 @@ - if (el_ok) { - pl_frame_set_chroma_location(&fp->el_frame, el_par.chroma_location); - frame->enhancement_layer = &fp->el_frame; -+ if (vo->extra.android_dovi_fel && !p->fel_input_logged && -+ frame->repr.sys == PL_COLOR_SYSTEM_DOLBYVISION && -+ frame->repr.dovi && frame->repr.dovi->nlq_active) { -+ MP_INFO(vo, "WebHTV FEL GPU input: matched EL uploaded " -+ "with active NLQ.\n"); -+ p->fel_input_logged = true; -+ } - } else { - MP_WARN(vo, "Failed setting up enhancement layer; " - "rendering base layer only.\n"); -@@ -1157,6 +1204,72 @@ - - frame->count = info->index + 1; - pl_dispatch_info_move(&frame->info[info->index], info->pass); -+} -+ -+enum { FEL_RENDER, FEL_FLUSH, FEL_SUBMIT, FEL_SWAP }; -+ -+static int64_t fel_render_begin(struct vo *vo, int64_t *cpu) -+{ -+ struct priv *p = vo->priv; -+ if (!vo->extra.android_dovi_fel || !p->fel_input_logged || -+ !mp_msg_test(vo->log, MSGL_INFO)) -+ return 0; -+ *cpu = mp_fel_thread_cpu_ns(); -+ return mp_time_ns(); -+} -+ -+static void fel_render_end(struct priv *p, int stage, int64_t start, int64_t cpu) -+{ -+ if (!start) -+ return; -+ mp_fel_perf_add(&p->fel_render_wall[stage], MPMAX(0, mp_time_ns() - start)); -+ int64_t end_cpu = mp_fel_thread_cpu_ns(); -+ if (cpu > 0 && end_cpu >= cpu) -+ mp_fel_perf_add(&p->fel_render_cpu[stage], end_cpu - cpu); -+} -+ -+static void log_fel_render_perf(struct vo *vo) -+{ -+ struct priv *p = vo->priv; -+ if (!vo->extra.android_dovi_fel || !p->fel_input_logged || -+ !mp_msg_test(vo->log, MSGL_INFO)) -+ return; -+ double now = mp_time_sec(); -+ if (now - p->fel_render_log_at < 3.0) -+ return; -+ p->fel_render_log_at = now; -+ -+ uint64_t gpu_ns[2] = {0}; -+ int gpu_passes[2] = {0}; -+ struct frame_info *frames[] = {&p->perf_fresh, &p->perf_redraw}; -+ for (int f = 0; f < 2; f++) { -+ for (int i = 0; i < frames[f]->count; i++) { -+ const struct pl_dispatch_info *info = &frames[f]->info[i]; -+ if (info->num_samples > 0) { -+ gpu_ns[f] += info->average; -+ gpu_passes[f]++; -+ } -+ } -+ } -+ MP_INFO(vo, "WebHTV FEL render perf: render-n=%" PRIu64 -+ " render-us=%" PRIu64 " render-cpu-us=%" PRIu64 -+ " render-max-us=%" PRIu64 " flush-n=%" PRIu64 -+ " flush-us=%" PRIu64 " flush-cpu-us=%" PRIu64 -+ " submit-n=%" PRIu64 " submit-us=%" PRIu64 -+ " submit-cpu-us=%" PRIu64 " swap-n=%" PRIu64 -+ " swap-us=%" PRIu64 " swap-cpu-us=%" PRIu64 -+ " gpu-frame-pass-avg-us=%" PRIu64 " gpu-frame-passes=%d" -+ " gpu-blend-pass-avg-us=%" PRIu64 " gpu-blend-passes=%d" -+ " (CPU cumulative; GPU last-known pass averages, not frame wall time).\n", -+ p->fel_render_wall[FEL_RENDER].count, p->fel_render_wall[FEL_RENDER].total / 1000, -+ p->fel_render_cpu[FEL_RENDER].total / 1000, p->fel_render_wall[FEL_RENDER].peak / 1000, -+ p->fel_render_wall[FEL_FLUSH].count, p->fel_render_wall[FEL_FLUSH].total / 1000, -+ p->fel_render_cpu[FEL_FLUSH].total / 1000, -+ p->fel_render_wall[FEL_SUBMIT].count, p->fel_render_wall[FEL_SUBMIT].total / 1000, -+ p->fel_render_cpu[FEL_SUBMIT].total / 1000, -+ p->fel_render_wall[FEL_SWAP].count, p->fel_render_wall[FEL_SWAP].total / 1000, -+ p->fel_render_cpu[FEL_SWAP].total / 1000, -+ gpu_ns[0] / 1000, gpu_passes[0], gpu_ns[1] / 1000, gpu_passes[1]); - } - - static void update_options(struct vo *vo) -@@ -1500,8 +1613,45 @@ - return 1; - } - -+static void trace_fel_vo(struct vo *vo, enum mp_fel_stage stage, -+ double pts, int held, unsigned inputs, unsigned outputs) -+{ -+ if (!vo->extra.android_dovi_fel) -+ return; -+ mp_fel_trace_record(&vo->fel_trace, MP_FEL_VO, stage, -+ mp_time_ns() / 1000000, pts, held, inputs, outputs); -+} -+ -+static int preload_dropped_fel_frame(struct vo *vo, struct vo_frame *frame) -+{ -+ if (!vo->extra.android_dovi_fel) -+ return VO_NOTIMPL; -+ struct priv *p = vo->priv; -+ for (int n = 0; n < frame->num_frames; n++) { -+ struct mp_image *mpi = frame->frames[n]; -+ if (mpi->imgfmt != IMGFMT_MEDIACODEC) -+ continue; -+ struct ra_hwdec *hwdec = ra_hwdec_get(&p->hwdec_ctx, mpi->imgfmt); -+ if (!hwdec || !hwdec_driver_uses_deferred_dst_params( -+ p, hwdec->driver->mapper)) -+ return VO_NOTIMPL; -+ // Reuse gpu-next's existing raw-YUV staging/cache. No queue insertion, -+ // target acquisition, FEL composition, presentation or EL decode here. -+ // Already staged frames only select their cached texture. Keep the -+ // source identities so later rendering/redraw can retrieve those pixels. -+ bool preloaded = false; -+ int ret = preload_hwdec_image(p, &p->hwdec_mapper, &p->hwdec_timer, -+ hwdec, mpi, &preloaded, true); -+ if (ret < 0) -+ return ret == RA_HWDEC_MAP_RETRY ? VO_FALSE : VO_ERROR; -+ } -+ return VO_TRUE; -+} -+ - static bool draw_frame(struct vo *vo, struct vo_frame *frame) - { -+ trace_fel_vo(vo, MP_FEL_VO_DRAW, frame->current ? frame->current->pts : MP_NOPTS_VALUE, -+ frame->num_frames, 1, 0); - struct priv *p = vo->priv; - pl_options pars = p->pars; - pl_gpu gpu = p->gpu; -@@ -1550,6 +1700,7 @@ - } - } - -+ trace_fel_vo(vo, MP_FEL_VO_PRELOAD, MP_NOPTS_VALUE, -1, 0, 0); - struct mp_image *preloaded = NULL; - if (p->want_reset && frame->num_frames && - frame->frame_id > p->last_id) -@@ -1639,6 +1790,7 @@ - - struct ra_swapchain *sw = p->ra_ctx->swapchain; - -+ trace_fel_vo(vo, MP_FEL_VO_TARGET, MP_NOPTS_VALUE, -1, 0, 0); - struct pl_color_space target_csp = {0}; - // TODO: Implement this for all backends - if (sw->fns->target_csp) -@@ -1697,6 +1849,7 @@ - external_params = set_colorspace_hint(p, NULL); - } - -+ trace_fel_vo(vo, MP_FEL_VO_ACQUIRE, MP_NOPTS_VALUE, -1, 0, 0); - struct pl_swapchain_frame swframe; - bool should_draw = sw->fns->start_frame(sw, NULL); // for wayland logic - pl_swapchain pl_sw = get_active_swapchain(p); -@@ -1808,6 +1961,7 @@ - - p->hwdec_map_retry = false; - p->hwdec_map_failed = false; -+ trace_fel_vo(vo, MP_FEL_VO_QUEUE, MP_NOPTS_VALUE, -1, 0, 0); - switch (pl_queue_update(p->queue, &mix, &qparams)) { - case PL_QUEUE_ERR: - if (p->hwdec_map_retry) { -@@ -1891,8 +2045,11 @@ - } - - // Render frame -+ trace_fel_vo(vo, MP_FEL_VO_RENDER, MP_NOPTS_VALUE, -1, 0, 0); - stats_time_start(p->stats, "render"); -+ int64_t render_cpu = 0, render_start = fel_render_begin(vo, &render_cpu); - bool render_ok = pl_render_image_mix(p->rr, &mix, &target, ¶ms); -+ fel_render_end(p, FEL_RENDER, render_start, render_cpu); - stats_time_end(p->stats, "render"); - if (!render_ok) { - MP_ERR(vo, "Failed rendering frame!\n"); -@@ -1930,7 +2087,10 @@ - if (!valid) - pl_tex_clear(gpu, swframe.fbo, (float[4]){0.0, 0.0, 0.0, 1.0}); - -+ trace_fel_vo(vo, MP_FEL_VO_FLUSH, MP_NOPTS_VALUE, -1, 0, 0); -+ int64_t flush_cpu = 0, flush_start = fel_render_begin(vo, &flush_cpu); - pl_gpu_flush(gpu); -+ fel_render_end(p, FEL_FLUSH, flush_start, flush_cpu); - p->frame_pending = true; - return VO_TRUE; - } -@@ -1941,13 +2101,21 @@ - struct ra_swapchain *sw = p->ra_ctx->swapchain; - pl_swapchain pl_sw = get_active_swapchain(p); - -+ trace_fel_vo(vo, MP_FEL_VO_SUBMIT, MP_NOPTS_VALUE, -1, 0, 0); - if (p->frame_pending) { -+ int64_t submit_cpu = 0, submit_start = fel_render_begin(vo, &submit_cpu); - if (pl_sw && !pl_swapchain_submit_frame(pl_sw)) - MP_ERR(vo, "Failed presenting frame!\n"); -+ fel_render_end(p, FEL_SUBMIT, submit_start, submit_cpu); - p->frame_pending = false; - } - -+ trace_fel_vo(vo, MP_FEL_VO_SWAP, MP_NOPTS_VALUE, -1, 0, 0); -+ int64_t swap_cpu = 0, swap_start = fel_render_begin(vo, &swap_cpu); - sw->fns->swap_buffers(sw); -+ fel_render_end(p, FEL_SWAP, swap_start, swap_cpu); -+ trace_fel_vo(vo, MP_FEL_VO_IDLE, MP_NOPTS_VALUE, -1, 0, 1); -+ log_fel_render_perf(vo); - } - - static void get_vsync(struct vo *vo, struct vo_vsync_info *info) -@@ -2351,6 +2519,8 @@ - struct priv *p = vo->priv; - - switch (request) { -+ case VOCTRL_PREPARE_FEL_FRAME: -+ return preload_dropped_fel_frame(vo, data); - case VOCTRL_SET_PANSCAN: - resize(vo); - return VO_TRUE; -@@ -2694,6 +2864,26 @@ - vo_control(ctx, VOCTRL_LOAD_HWDEC_API, params); - } - -+static void set_android_fel_context_options(struct ra_ctx_opts *opts, bool vulkan) -+{ -+ struct m_obj_settings context[] = { -+ {.name = vulkan ? "androidvk" : "android"}, {0} -+ }; -+ struct m_obj_settings api[] = { -+ {.name = vulkan ? "vulkan" : "opengl"}, {0} -+ }; -+ struct m_obj_settings *context_list = context; -+ struct m_obj_settings *api_list = api; -+ const struct m_option option = { -+ .type = &m_option_type_obj_settings_list, -+ }; -+ // The config owns these lists. Replace and deep-copy through their option -+ // type so both fallback and config destruction can free them normally. -+ m_option_copy(&option, &opts->context_list, &context_list); -+ m_option_copy(&option, &opts->context_type_list, &api_list); -+ opts->probing = false; -+} -+ - static int preinit(struct vo *vo) - { - struct priv *p = vo->priv; -@@ -2708,7 +2898,23 @@ - struct gl_video_opts *gl_opts = p->opts_cache->opts; - struct ra_ctx_opts *ctx_opts = mp_get_config_group(vo, vo->global, &ra_ctx_conf); - update_ra_ctx_options(vo, ctx_opts); -+ if (vo->extra.android_dovi_fel) { -+ // Override this context only, never the global option cache. A reused -+ // player can return to its configured direct/GPU output on the next file. -+ set_android_fel_context_options(ctx_opts, -+ vo->extra.android_dovi_fel_vulkan != 0); -+ } - p->context = gpu_ctx_create(vo, ctx_opts); -+ if (!p->context && vo->extra.android_dovi_fel && -+ vo->extra.android_dovi_fel_vulkan == 2) -+ { -+ // Preserve automatic-renderer fallback before creating any decoder. -+ // Explicit Vulkan remains strict. Both paths keep the full FEL chain. -+ set_android_fel_context_options(ctx_opts, false); -+ MP_WARN(vo, "WebHTV FEL startup: automatic Vulkan unavailable; " -+ "trying OpenGL in the same load.\n"); -+ p->context = gpu_ctx_create(vo, ctx_opts); -+ } - talloc_free(ctx_opts); - if (!p->context) - goto err_out; -@@ -3178,6 +3384,7 @@ - VO_CAP_FILM_GRAIN | - VO_CAP_VFLIP | - VO_CAP_GPU_DOVI | -+ VO_CAP_GPU_DOVI_EL_SW | - #if !HAVE_ANDROID - VO_CAP_GPU_DOVI_EL | - #endif -diff --git a/player/playloop.c b/player/playloop.c ---- a/player/playloop.c -+++ b/player/playloop.c -@@ -157,7 +157,9 @@ - - bool get_internal_paused(struct MPContext *mpctx) - { -- return mpctx->opts->pause || mpctx->paused_for_cache; -+ return mpctx->opts->pause || mpctx->paused_for_cache || -+ (mpctx->video_out && -+ mp_fel_probe_holds_playback(&mpctx->video_out->fel_bind_probe)); - } - - // The value passed here is the new value for mpctx->opts->pause -@@ -1271,6 +1273,32 @@ - mp_notify_property(mpctx, "clipboard"); - } - -+static void handle_fel_bind_probe(struct MPContext *mpctx) -+{ -+ struct vo *vo = mpctx->video_out; -+ struct mp_fel_bind_probe *probe = vo ? &vo->fel_bind_probe : NULL; -+ if (probe && mp_fel_probe_active(probe)) { -+ if (mp_time_ns() >= probe->deadline) -+ mp_fel_probe_cancel(probe); -+ // vo_set_paused only takes the short VO state lock. The mapper waits -+ // for this acknowledgement without holding that lock or core dispatch. -+ update_internal_pause_state(mpctx); -+ int expected = MP_FEL_PROBE_QUIESCING; -+ if (!__atomic_load_n(&probe->cancel, __ATOMIC_ACQUIRE)) -+ __atomic_compare_exchange_n(&probe->state, &expected, -+ MP_FEL_PROBE_RUNNING, false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE); -+ mp_set_timeout(mpctx, 0.05); -+ } -+ if (mpctx->paused != get_internal_paused(mpctx)) -+ update_internal_pause_state(mpctx); -+ if (probe && (probe->reported != mp_fel_probe_state(probe) || -+ probe->reported_supported != __atomic_load_n(&probe->supported, __ATOMIC_ACQUIRE))) { -+ probe->reported = mp_fel_probe_state(probe); -+ probe->reported_supported = __atomic_load_n(&probe->supported, __ATOMIC_ACQUIRE); -+ mp_notify_property(mpctx, "android-fel-bind-probe"); -+ } -+} -+ - void run_playloop(struct MPContext *mpctx) - { - if (encode_lavc_didfail(mpctx->encode_lavc_ctx)) { -@@ -1283,6 +1311,8 @@ - handle_cursor_autohide(mpctx); - handle_vo_events(mpctx); - handle_command_updates(mpctx); -+ -+ handle_fel_bind_probe(mpctx); - - if (mpctx->lavfi && mp_filter_has_failed(mpctx->lavfi)) - mpctx->stop_play = AT_END_OF_FILE; -diff --git a/video/out/fel_bind_probe.h b/video/out/fel_bind_probe.h -new file mode 100644 ---- /dev/null -+++ b/video/out/fel_bind_probe.h -@@ -0,0 +1,92 @@ -+/* Explicit, per-VO diagnostic requests. LGPL-2.1-or-later, like mpv. */ -+#pragma once -+ -+#include -+#include -+ -+#define MP_FEL_PROBE_TIMEOUT_NS INT64_C(30000000000) -+#define MP_FEL_PROBE_GROUP_NS INT64_C(5000000000) -+#define MP_FEL_PROBE_MEMORY_LIMIT (UINT64_C(96) * 1024 * 1024) -+#define MP_FEL_PROBE_WARMUP 8 -+#define MP_FEL_PROBE_SAMPLES 32 -+ -+enum mp_fel_probe_state { -+ MP_FEL_PROBE_IDLE, MP_FEL_PROBE_ARMED, MP_FEL_PROBE_QUIESCING, -+ MP_FEL_PROBE_RUNNING, MP_FEL_PROBE_DONE, MP_FEL_PROBE_CANCELLED, -+ MP_FEL_PROBE_FAILED, MP_FEL_PROBE_TIMED_OUT, -+}; -+ -+struct mp_fel_bind_probe { -+ // Zero-initialized with the VO. Core publishes a request; only its mapper -+ // finishes a claimed request, after releasing all diagnostic references. -+ int state; -+ bool cancel, supported, reported_supported; -+ int reported; // core only -+ uint64_t serial; -+ int64_t deadline; -+}; -+ -+static inline int mp_fel_probe_state(const struct mp_fel_bind_probe *p) -+{ -+ return __atomic_load_n(&p->state, __ATOMIC_ACQUIRE); -+} -+ -+static inline bool mp_fel_probe_active(const struct mp_fel_bind_probe *p) -+{ -+ int state = mp_fel_probe_state(p); -+ return state >= MP_FEL_PROBE_ARMED && state <= MP_FEL_PROBE_RUNNING; -+} -+ -+static inline bool mp_fel_probe_holds_playback(const struct mp_fel_bind_probe *p) -+{ -+ int state = mp_fel_probe_state(p); -+ return state == MP_FEL_PROBE_QUIESCING || state == MP_FEL_PROBE_RUNNING; -+} -+ -+static inline bool mp_fel_probe_start(struct mp_fel_bind_probe *p, int64_t now) -+{ -+ if (mp_fel_probe_active(p)) -+ return false; -+ p->serial++; -+ p->deadline = now + MP_FEL_PROBE_TIMEOUT_NS; -+ __atomic_store_n(&p->cancel, false, __ATOMIC_RELEASE); -+ __atomic_store_n(&p->state, MP_FEL_PROBE_ARMED, __ATOMIC_RELEASE); -+ return true; -+} -+ -+static inline bool mp_fel_probe_claim(struct mp_fel_bind_probe *p) -+{ -+ int expected = MP_FEL_PROBE_ARMED; -+ return __atomic_compare_exchange_n(&p->state, &expected, -+ MP_FEL_PROBE_QUIESCING, false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE); -+} -+ -+static inline void mp_fel_probe_cancel(struct mp_fel_bind_probe *p) -+{ -+ __atomic_store_n(&p->cancel, true, __ATOMIC_RELEASE); -+ int expected = MP_FEL_PROBE_ARMED; -+ // An unclaimed request owns no image. A claimed request remains paused -+ // until the mapper has unwound; a new start must not race its cleanup. -+ __atomic_compare_exchange_n(&p->state, &expected, MP_FEL_PROBE_CANCELLED, -+ false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE); -+} -+ -+static inline bool mp_fel_probe_live(const struct mp_fel_bind_probe *p, int64_t now) -+{ -+ return mp_fel_probe_active(p) && -+ !__atomic_load_n(&p->cancel, __ATOMIC_ACQUIRE) && now < p->deadline; -+} -+ -+static inline const char *mp_fel_probe_name(int state) -+{ -+ switch (state) { -+ case MP_FEL_PROBE_IDLE: return "ready"; -+ case MP_FEL_PROBE_ARMED: return "armed"; -+ case MP_FEL_PROBE_QUIESCING: return "quiescing"; -+ case MP_FEL_PROBE_RUNNING: return "running"; -+ case MP_FEL_PROBE_DONE: return "completed"; -+ case MP_FEL_PROBE_CANCELLED: return "cancelled"; -+ case MP_FEL_PROBE_TIMED_OUT: return "timed-out"; -+ default: return "failed"; -+ } -+} -diff --git a/video/out/hwdec/hwdec_aimagereader_vk_probe.inc b/video/out/hwdec/hwdec_aimagereader_vk_probe.inc -new file mode 100644 ---- /dev/null -+++ b/video/out/hwdec/hwdec_aimagereader_vk_probe.inc -@@ -0,0 +1,395 @@ -+/* Included by the stable mapper: user-requested, record-only diagnostics. -+ * LGPL-2.1-or-later. No diagnostic command is submitted for GPU execution. */ -+ -+struct fel_probe_target { -+ VkImage image; -+ VkDeviceMemory memory; -+ VkImageView view; -+ VkDeviceSize bytes; -+}; -+ -+struct fel_probe_group { -+ VkCommandPool commands; -+ VkCommandBuffer command; -+ VkDescriptorPool pool; -+ VkDescriptorSetLayout layout; -+ VkPipelineLayout pipeline_layout; -+ VkDescriptorSet set; -+}; -+ -+static void fel_probe_retire_drains(struct aimagereader_vk_stable *p, bool teardown) -+{ -+ unsigned keep = 0; -+ for (unsigned i = 0; i < p->fel_probe_drain_count; i++) { -+ VkFence fence = p->fel_probe_drains[i]; -+ // Only teardown may wait like the existing conversion teardown. Normal -+ // cancellation retains unfinished fences and returns to playback. -+ VkResult result = teardown -+ ? vkWaitForFences(p->device, 1, &fence, VK_TRUE, UINT64_MAX) -+ : vkGetFenceStatus(p->device, fence); -+ if (result != VK_SUCCESS && result != VK_NOT_READY && result != VK_TIMEOUT) -+ p->fel_probe_drain_failed = true; -+ if (result == VK_SUCCESS || result == VK_ERROR_DEVICE_LOST) -+ vkDestroyFence(p->device, fence, NULL); -+ else -+ p->fel_probe_drains[keep++] = fence; -+ } -+ p->fel_probe_drain_count = keep; -+} -+ -+static bool fel_probe_drain_queues(struct aimagereader_vk_stable *p, -+ struct mp_fel_bind_probe *probe, unsigned *submitted) -+{ -+ if (p->fel_probe_drain_count) -+ return false; -+ p->fel_probe_drain_failed = false; -+ struct pl_vulkan_queue queues[] = { -+ p->vk->queue_graphics, p->vk->queue_compute, p->vk->queue_transfer, -+ }; -+ unsigned count = 0; -+ for (unsigned i = 0; i < MP_ARRAY_SIZE(queues); i++) { -+ for (unsigned j = 0; j < i; j++) { -+ if (queues[i].index == queues[j].index && queues[j].count) { -+ queues[j].count = MPMAX(queues[j].count, queues[i].count); -+ queues[i].count = 0; -+ break; -+ } -+ } -+ } -+ for (unsigned i = 0; i < MP_ARRAY_SIZE(queues); i++) -+ count += queues[i].count; -+ if (!count || count > MP_ARRAY_SIZE(p->fel_probe_drains)) -+ return false; -+ pl_gpu_flush(p->gpu); -+ int64_t deadline = MPMIN(probe->deadline, mp_time_ns() + INT64_C(2000000000)); -+ for (unsigned i = 0; i < MP_ARRAY_SIZE(queues); i++) { -+ for (unsigned q = 0; q < queues[i].count; q++) { -+ if (!mp_fel_probe_live(probe, mp_time_ns())) -+ return false; -+ VkFence fence = VK_NULL_HANDLE; -+ VkFenceCreateInfo info = {.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO}; -+ if (vkCreateFence(p->device, &info, NULL, &fence) != VK_SUCCESS) -+ return false; -+ VkQueue queue; -+ vkGetDeviceQueue(p->device, queues[i].index, q, &queue); -+ p->vk->lock_queue(p->vk, queues[i].index, q); -+ // A fence-only submission orders after prior application work; -+ // no diagnostic dispatch/draw/copy is sent to the GPU. -+ VkResult result = vkQueueSubmit(queue, 0, NULL, fence); -+ p->vk->unlock_queue(p->vk, queues[i].index, q); -+ if (result != VK_SUCCESS) { -+ vkDestroyFence(p->device, fence, NULL); -+ return false; -+ } -+ p->fel_probe_drains[p->fel_probe_drain_count++] = fence; -+ (*submitted)++; -+ } -+ } -+ while (p->fel_probe_drain_count && mp_fel_probe_live(probe, mp_time_ns()) && -+ mp_time_ns() < deadline) { -+ fel_probe_retire_drains(p, false); -+ if (p->fel_probe_drain_count) -+ mp_sleep_ns(1000000); -+ } -+ return !p->fel_probe_drain_count && !p->fel_probe_drain_failed; -+} -+ -+static void fel_probe_destroy_target(struct aimagereader_vk_stable *p, -+ struct fel_probe_target *t) -+{ -+ if (t->view) vkDestroyImageView(p->device, t->view, NULL); -+ if (t->image) vkDestroyImage(p->device, t->image, NULL); -+ if (t->memory) vkFreeMemory(p->device, t->memory, NULL); -+ *t = (struct fel_probe_target){0}; -+} -+ -+static bool fel_probe_create_target(struct aimagereader_vk_stable *p, -+ struct mp_fel_bind_probe *probe, -+ struct fel_probe_target *t) -+{ -+ VkImageCreateInfo info = { -+ .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, -+ .imageType = VK_IMAGE_TYPE_2D, .format = p->output_format, -+ .extent = {p->width, p->height, 1}, .mipLevels = 1, .arrayLayers = 1, -+ .samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_OPTIMAL, -+ .usage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, -+ .sharingMode = VK_SHARING_MODE_EXCLUSIVE, -+ .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, -+ }; -+ if (!mp_fel_probe_live(probe, mp_time_ns()) || -+ vkCreateImage(p->device, &info, NULL, &t->image) != VK_SUCCESS) -+ return false; -+ VkMemoryRequirements requirements; -+ vkGetImageMemoryRequirements(p->device, t->image, &requirements); -+ t->bytes = requirements.size; -+ if (!t->bytes || t->bytes > MP_FEL_PROBE_MEMORY_LIMIT) -+ return false; -+ uint32_t memory_type = find_memory_type(p, requirements.memoryTypeBits, -+ VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); -+ if (memory_type == UINT32_MAX || !mp_fel_probe_live(probe, mp_time_ns())) -+ return false; -+ VkMemoryAllocateInfo alloc = { -+ .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, -+ .allocationSize = t->bytes, .memoryTypeIndex = memory_type, -+ }; -+ if (vkAllocateMemory(p->device, &alloc, NULL, &t->memory) != VK_SUCCESS || -+ !mp_fel_probe_live(probe, mp_time_ns()) || -+ vkBindImageMemory(p->device, t->image, t->memory, 0) != VK_SUCCESS) -+ return false; -+ VkImageViewCreateInfo view = { -+ .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, -+ .image = t->image, .viewType = VK_IMAGE_VIEW_TYPE_2D, -+ .format = p->output_format, -+ .subresourceRange = {.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, -+ .levelCount = 1, .layerCount = 1}, -+ }; -+ return mp_fel_probe_live(probe, mp_time_ns()) && -+ vkCreateImageView(p->device, &view, NULL, &t->view) == VK_SUCCESS; -+} -+ -+static void fel_probe_destroy_group(struct aimagereader_vk_stable *p, -+ struct fel_probe_group *g) -+{ -+ // Commands are never pending. Drop all command references before sets, -+ // pipeline layouts, immutable samplers or their image views are released. -+ if (g->commands) vkDestroyCommandPool(p->device, g->commands, NULL); -+ if (g->pool) vkDestroyDescriptorPool(p->device, g->pool, NULL); -+ if (g->pipeline_layout) vkDestroyPipelineLayout(p->device, g->pipeline_layout, NULL); -+ if (g->layout) vkDestroyDescriptorSetLayout(p->device, g->layout, NULL); -+ *g = (struct fel_probe_group){0}; -+} -+ -+static bool fel_probe_create_group(struct aimagereader_vk_stable *p, -+ struct mp_fel_bind_probe *probe, -+ struct vk_input *input, VkImageView target, -+ unsigned kind, struct fel_probe_group *g) -+{ -+ VkCommandPoolCreateInfo commands = { -+ .sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, -+ .flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, -+ .queueFamilyIndex = p->queue_family, -+ }; -+ if (!mp_fel_probe_live(probe, mp_time_ns()) || -+ vkCreateCommandPool(p->device, &commands, NULL, &g->commands) != VK_SUCCESS) -+ return false; -+ VkCommandBufferAllocateInfo command = { -+ .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, -+ .commandPool = g->commands, .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, -+ .commandBufferCount = 1, -+ }; -+ if (!mp_fel_probe_live(probe, mp_time_ns()) || -+ vkAllocateCommandBuffers(p->device, &command, &g->command) != VK_SUCCESS) -+ return false; -+ if (!kind) -+ return true; -+ VkDescriptorSetLayoutBinding bindings[2] = {0}; -+ VkDescriptorPoolSize sizes[2] = {0}; -+ VkDescriptorImageInfo images[2] = {0}; -+ VkWriteDescriptorSet writes[2] = {0}; -+ unsigned count = 0; -+ if (kind & 1) { -+ bindings[count] = (VkDescriptorSetLayoutBinding){ -+ .binding = 0, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, -+ .descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT, -+ .pImmutableSamplers = &p->sampler, -+ }; -+ sizes[count] = (VkDescriptorPoolSize){ -+ VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, p->sampler_descriptors, -+ }; -+ images[count++] = (VkDescriptorImageInfo){ -+ .sampler = p->sampler, .imageView = input->view, -+ .imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, -+ }; -+ } -+ if (kind & 2) { -+ bindings[count] = (VkDescriptorSetLayoutBinding){ -+ .binding = 1, .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, -+ .descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT, -+ }; -+ sizes[count] = (VkDescriptorPoolSize){VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 1}; -+ images[count++] = (VkDescriptorImageInfo){ -+ .imageView = target, .imageLayout = VK_IMAGE_LAYOUT_GENERAL, -+ }; -+ } -+ VkDescriptorSetLayoutCreateInfo layout = { -+ .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO, -+ .bindingCount = count, .pBindings = bindings, -+ }; -+ if (!mp_fel_probe_live(probe, mp_time_ns()) || -+ vkCreateDescriptorSetLayout(p->device, &layout, NULL, &g->layout) != VK_SUCCESS) -+ return false; -+ VkPipelineLayoutCreateInfo pipeline = { -+ .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, -+ .setLayoutCount = 1, .pSetLayouts = &g->layout, -+ }; -+ if (!mp_fel_probe_live(probe, mp_time_ns()) || -+ vkCreatePipelineLayout(p->device, &pipeline, NULL, &g->pipeline_layout) != VK_SUCCESS) -+ return false; -+ VkDescriptorPoolCreateInfo pool = { -+ .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO, -+ .maxSets = 1, .poolSizeCount = count, .pPoolSizes = sizes, -+ }; -+ if (!mp_fel_probe_live(probe, mp_time_ns()) || -+ vkCreateDescriptorPool(p->device, &pool, NULL, &g->pool) != VK_SUCCESS) -+ return false; -+ VkDescriptorSetAllocateInfo set = { -+ .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO, -+ .descriptorPool = g->pool, .descriptorSetCount = 1, .pSetLayouts = &g->layout, -+ }; -+ if (!mp_fel_probe_live(probe, mp_time_ns()) || -+ vkAllocateDescriptorSets(p->device, &set, &g->set) != VK_SUCCESS) -+ return false; -+ for (unsigned i = 0; i < count; i++) { -+ writes[i] = (VkWriteDescriptorSet){ -+ .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, -+ .dstSet = g->set, .dstBinding = bindings[i].binding, -+ .descriptorCount = 1, .descriptorType = bindings[i].descriptorType, -+ .pImageInfo = &images[i], -+ }; -+ } -+ vkUpdateDescriptorSets(p->device, count, writes, 0, NULL); -+ return mp_fel_probe_live(probe, mp_time_ns()); -+} -+ -+static bool fel_probe_measure_group(struct aimagereader_vk_stable *p, -+ struct mp_fel_bind_probe *probe, -+ struct fel_probe_group *g, unsigned kind, -+ int64_t cold_ns) -+{ -+ static const char *const names[] = {"empty", "sampler-only", "storage-only", "combined"}; -+ struct fel_latency_window bind = {0}, cpu = {0}, record = {0}, record_cpu = {0}; -+ int64_t started = mp_time_ns(); -+ int64_t deadline = MPMIN(probe->deadline, started + MP_FEL_PROBE_GROUP_NS); -+ unsigned warmup = 0; -+ bool ok = true, cpu_available = true; -+ for (unsigned i = 0; i < MP_FEL_PROBE_WARMUP + MP_FEL_PROBE_SAMPLES; i++) { -+ if (!mp_fel_probe_live(probe, mp_time_ns()) || mp_time_ns() >= deadline) -+ break; -+ int64_t r_cpu = mp_fel_thread_cpu_ns(), r_wall = mp_time_ns(); -+ VkCommandBufferBeginInfo begin = { -+ .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, -+ .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, -+ }; -+ if (vkResetCommandBuffer(g->command, 0) != VK_SUCCESS || -+ !mp_fel_probe_live(probe, mp_time_ns()) || -+ vkBeginCommandBuffer(g->command, &begin) != VK_SUCCESS) { -+ ok = false; -+ break; -+ } -+ if (!mp_fel_probe_live(probe, mp_time_ns())) -+ break; -+ int64_t b_cpu = mp_fel_thread_cpu_ns(), b_wall = mp_time_ns(); -+ if (kind) -+ vkCmdBindDescriptorSets(g->command, VK_PIPELINE_BIND_POINT_COMPUTE, -+ g->pipeline_layout, 0, 1, &g->set, 0, NULL); -+ int64_t b_elapsed = MPMAX(0, mp_time_ns() - b_wall); -+ int64_t b_cpu_end = mp_fel_thread_cpu_ns(); -+ if (vkEndCommandBuffer(g->command) != VK_SUCCESS) { -+ ok = false; -+ break; -+ } -+ int64_t r_elapsed = MPMAX(0, mp_time_ns() - r_wall); -+ int64_t r_cpu_end = mp_fel_thread_cpu_ns(); -+ cpu_available &= b_cpu > 0 && b_cpu_end >= b_cpu && -+ r_cpu > 0 && r_cpu_end >= r_cpu; -+ if (i < MP_FEL_PROBE_WARMUP) { -+ warmup++; -+ } else { -+ fel_latency_add(&bind, b_elapsed); -+ fel_latency_add(&record, r_elapsed); -+ if (cpu_available) { -+ fel_latency_add(&cpu, b_cpu_end - b_cpu); -+ fel_latency_add(&record_cpu, r_cpu_end - r_cpu); -+ } -+ } -+ } -+ uint64_t b[3], c[3], r[3], rc[3]; -+ fel_latency_summary(&bind, b); fel_latency_summary(&cpu, c); -+ fel_latency_summary(&record, r); fel_latency_summary(&record_cpu, rc); -+ mp_info(p->log, "WebHTV FEL bind probe: id=%" PRIu64 " group=%s stage=record-only " -+ "pipeline=none warmup=%u samples=%u complete=%d cold-us=%" PRId64 " " -+ "bind-us=%" PRIu64 "/%" PRIu64 "/%" PRIu64 " bind-cpu-us=%" PRIu64 "/%" PRIu64 "/%" PRIu64 " " -+ "record-us=%" PRIu64 "/%" PRIu64 "/%" PRIu64 " record-cpu-us=%" PRIu64 "/%" PRIu64 "/%" PRIu64 " " -+ "cpu-available=%d statistic=p50/p95/max probe-submits=0 gpu-time=unmeasured.\n", -+ probe->serial, names[kind], warmup, bind.count, -+ ok && bind.count == MP_FEL_PROBE_SAMPLES, cold_ns / 1000, -+ b[0], b[1], b[2], c[0], c[1], c[2], r[0], r[1], r[2], rc[0], rc[1], rc[2], cpu_available); -+ return ok && bind.count == MP_FEL_PROBE_SAMPLES; -+} -+ -+static bool fel_run_bind_probe(struct aimagereader_vk_stable *p, -+ struct vk_input *input, struct mp_image *frame, int acquire) -+{ -+ struct vo *vo = p->mapper->owner->ra_ctx->vo; -+ struct mp_fel_bind_probe *probe = &vo->fel_bind_probe; -+ if (!mp_fel_probe_claim(probe)) -+ return false; -+ unsigned char *lease = frame->android_fel_staging ? frame->android_fel_staging->data : NULL; -+ mp_android_fel_staging_begin_probe(lease); -+ vo->extra.wakeup_cb(vo->extra.wakeup_ctx); -+ int64_t started = mp_time_ns(); -+ int64_t handshake = MPMIN(probe->deadline, started + INT64_C(2000000000)); -+ const char *reason = "pause-handshake"; -+ unsigned drains = 0, complete = 0; -+ struct fel_probe_target target = {0}; -+ struct fel_probe_group group = {0}; -+ while (mp_fel_probe_state(probe) == MP_FEL_PROBE_QUIESCING && -+ mp_fel_probe_live(probe, mp_time_ns()) && mp_time_ns() < handshake) -+ mp_sleep_ns(1000000); -+ if (mp_fel_probe_state(probe) != MP_FEL_PROBE_RUNNING || -+ !mp_fel_probe_live(probe, mp_time_ns())) -+ goto done; -+ reason = "source-acquire"; -+ int64_t acquire_deadline = MPMIN(probe->deadline, mp_time_ns() + INT64_C(2000000000)); -+ int fence_state; -+ while ((fence_state = fel_probe_acquire_fence(acquire)) == 1 && -+ mp_fel_probe_live(probe, mp_time_ns()) && mp_time_ns() < acquire_deadline) -+ mp_sleep_ns(1000000); -+ if ((fence_state != 0 && fence_state != 2) || !mp_fel_probe_live(probe, mp_time_ns())) -+ goto done; -+ reason = "queue-drain"; -+ if (!fel_probe_drain_queues(p, probe, &drains)) -+ goto done; -+ reason = "target-allocation-or-budget"; -+ int64_t target_started = mp_time_ns(); -+ if (!fel_probe_create_target(p, probe, &target)) -+ goto done; -+ VkPhysicalDeviceProperties props; -+ vkGetPhysicalDeviceProperties(p->vk->phys_device, &props); -+ mp_info(p->log, "WebHTV FEL bind probe: id=%" PRIu64 " begin gpu=%s vendor=%u device=%u " -+ "driver=%u api=%u queue=%u source-format=%u external-format=0x%" PRIx64 " " -+ "target-format=%u size=%dx%d target-usage=storage+sampled target-bytes=%" PRIu64 " " -+ "target-cold-us=%" PRId64 " source=held-AImage acquire=signalled app-queues=drained " -+ "drain-submits=%u probe-submits=0 pixel-access=none.\n", -+ probe->serial, props.deviceName, props.vendorID, props.deviceID, props.driverVersion, -+ props.apiVersion, p->queue_family, p->source_format, p->external_format, -+ p->output_format, p->width, p->height, (uint64_t)target.bytes, -+ (mp_time_ns() - target_started) / 1000, drains); -+ reason = "group-incomplete"; -+ for (unsigned kind = 0; kind < 4 && mp_fel_probe_live(probe, mp_time_ns()); kind++) { -+ int64_t cold = mp_time_ns(); -+ if (!fel_probe_create_group(p, probe, input, target.view, kind, &group)) -+ break; -+ bool measured = fel_probe_measure_group(p, probe, &group, kind, mp_time_ns() - cold); -+ fel_probe_destroy_group(p, &group); -+ complete += measured; -+ if (!measured) -+ break; -+ } -+ if (complete == 4) -+ reason = "completed"; -+done: -+ fel_probe_destroy_group(p, &group); -+ fel_probe_destroy_target(p, &target); -+ int state = mp_time_ns() >= probe->deadline ? MP_FEL_PROBE_TIMED_OUT : -+ __atomic_load_n(&probe->cancel, __ATOMIC_ACQUIRE) ? MP_FEL_PROBE_CANCELLED : -+ complete == 4 ? MP_FEL_PROBE_DONE : MP_FEL_PROBE_FAILED; -+ mp_info(p->log, "WebHTV FEL bind probe: id=%" PRIu64 " end status=%s reason=%s " -+ "groups=%u/4 wall-ms=%" PRId64 " pending-drains=%u probe-submits=0 source=still-held " -+ "resume=current-user-pause-intent.\n", probe->serial, mp_fel_probe_name(state), reason, -+ complete, (mp_time_ns() - started) / 1000000, p->fel_probe_drain_count); -+ mp_android_fel_staging_end_probe(lease); -+ __atomic_store_n(&probe->state, state, __ATOMIC_RELEASE); -+ vo->extra.wakeup_cb(vo->extra.wakeup_ctx); -+ return true; -+} diff --git a/third_party/patches/mpv-android-vulkan-single-backend.patch b/third_party/patches/mpv-android-vulkan-single-backend.patch new file mode 100644 index 0000000000..a1086b364b --- /dev/null +++ b/third_party/patches/mpv-android-vulkan-single-backend.patch @@ -0,0 +1,120 @@ +diff --git a/DOCS/man/options.rst b/DOCS/man/options.rst +--- a/DOCS/man/options.rst ++++ b/DOCS/man/options.rst +@@ -7181,23 +7181,4 @@ them. + + Android with ``--gpu-context=android`` only. + +-``--android-vulkan-aimagereader-backend=`` +- Select how Android MediaCodec ``AHardwareBuffer`` frames are imported for +- Vulkan rendering: +- +- :auto: Prefer direct YCbCr sampling. If direct sampling is unavailable, +- select compute or fragment conversion while preferring the +- narrowest suitable output format. Compute wins when both backends +- support the same format. This is the default. +- :direct: Require direct YCbCr sampling without conversion fallback. +- :compute: Require compute conversion without direct or fragment fallback. +- :fragment: Require fragment conversion without direct or compute fallback. +- +- The forced modes are intended for device compatibility, power, and +- performance comparisons. If the selected backend is unsupported or fails, +- mpv does not silently switch to another AImageReader backend. Normal +- hardware-decoding fallback rules still apply. +- +- Android Vulkan while using the MediaCodec hardware decoder only. +- + ``--android-dolby-vision-output=`` +diff --git a/meson.build b/meson.build +--- a/meson.build ++++ b/meson.build +@@ -1415,9 +1415,7 @@ if features['android-media-ndk'] + sources += files('video/out/hwdec/hwdec_aimagereader.c') + endif + if features['android-media-ndk'] and features['vulkan'] +- sources += files('video/out/hwdec/hwdec_aimagereader_vk.c', +- 'video/out/hwdec/hwdec_aimagereader_vk_convert.c', +- 'video/out/hwdec/hwdec_aimagereader_vk_direct.c') ++ sources += files('video/out/hwdec/hwdec_aimagereader_vk.c') + endif + + d3d_hwaccel = get_option('d3d-hwaccel').require( +diff --git a/options/options.c b/options/options.c +--- a/options/options.c ++++ b/options/options.c +@@ -255,15 +255,6 @@ static const m_option_t mp_vo_opt_list[] = { + {"android-dolby-vision-output", OPT_CHOICE(android_dolby_vision_output, + {"configured", ANDROID_DOLBY_VISION_OUTPUT_CONFIGURED}, + {"direct", ANDROID_DOLBY_VISION_OUTPUT_DIRECT}), .flags = UPDATE_VO}, +- {"android-vulkan-aimagereader-backend", +- OPT_CHOICE(android_vulkan_aimagereader_backend, +- {"auto", ANDROID_VULKAN_AIMAGEREADER_BACKEND_AUTO}, +- {"direct", ANDROID_VULKAN_AIMAGEREADER_BACKEND_DIRECT}, +- {"compute", ANDROID_VULKAN_AIMAGEREADER_BACKEND_COMPUTE}, +- {"fragment", ANDROID_VULKAN_AIMAGEREADER_BACKEND_FRAGMENT}), +- .flags = UPDATE_VO}, +- {"android-vulkan-conversion-backend", +- OPT_REPLACED("android-vulkan-aimagereader-backend")}, + {"android-osd-wid", OPT_INT64(android_osd_wid), + .flags = UPDATE_VO}, + {"android-osd-surface-size", OPT_SIZE_BOX(android_osd_surface_size)}, +diff --git a/options/options.h b/options/options.h +--- a/options/options.h ++++ b/options/options.h +@@ -11,11 +11,4 @@ enum android_dolby_vision_output { + ANDROID_DOLBY_VISION_OUTPUT_DIRECT, + }; + +-enum android_vulkan_aimagereader_backend { +- ANDROID_VULKAN_AIMAGEREADER_BACKEND_AUTO = 0, +- ANDROID_VULKAN_AIMAGEREADER_BACKEND_DIRECT, +- ANDROID_VULKAN_AIMAGEREADER_BACKEND_COMPUTE, +- ANDROID_VULKAN_AIMAGEREADER_BACKEND_FRAGMENT, +-}; +- + typedef struct mp_vo_opts { +@@ -99,6 +92,5 @@ typedef struct mp_vo_opts { + + struct m_geometry android_surface_size; + int android_dolby_vision_output; +- int android_vulkan_aimagereader_backend; + int64_t android_osd_wid; + struct m_geometry android_osd_surface_size; +diff --git a/video/out/hwdec/hwdec_aimagereader.c b/video/out/hwdec/hwdec_aimagereader.c +--- a/video/out/hwdec/hwdec_aimagereader.c ++++ b/video/out/hwdec/hwdec_aimagereader.c +@@ -66,5 +66,4 @@ struct priv_owner { + #if HAVE_VULKAN + mp_mutex vk_lock; + struct aimagereader_vk *vk; +- enum android_vulkan_aimagereader_backend vk_backend; + #endif +@@ -406,7 +405,5 @@ static int init(struct ra_hwdec *hw) + + #if HAVE_VULKAN + mp_mutex_init(&p->vk_lock); +- p->vk_backend = hw->ra_ctx->vo->opts->android_vulkan_aimagereader_backend; +- use_vulkan = aimagereader_vk_available(hw->ra_ctx, hw->log, +- p->vk_backend); ++ use_vulkan = aimagereader_vk_available(hw->ra_ctx, hw->log); + #endif +@@ -593,9 +590,8 @@ static int mapper_init(struct ra_hwdec_mapper *mapper) + const struct aimagereader_vk_api api = { + .AImage_delete = o->AImage_delete, +- .AImage_deleteAsync = o->AImage_deleteAsync, + .AHardwareBuffer_describe = o->AHardwareBuffer_describe, + }; + p->backend = MAPPER_BACKEND_VULKAN; +- p->vk = aimagereader_vk_create(mapper, &api, o->vk_backend); ++ p->vk = aimagereader_vk_create(mapper, &api); + if (!p->vk) + return -1; +diff --git a/video/out/vulkan/context.c b/video/out/vulkan/context.c +--- a/video/out/vulkan/context.c ++++ b/video/out/vulkan/context.c +@@ -217,4 +217,5 @@ pl_vulkan mppl_create_vulkan(struct vulkan_opts *opts, + #if HAVE_ANDROID_MEDIA_NDK + "VK_ANDROID_external_memory_android_hardware_buffer", + "VK_EXT_queue_family_foreign", ++ "VK_KHR_external_semaphore_fd", + #endif diff --git a/third_party/patches/mpv-audiotrack-compressed-audio.patch b/third_party/patches/mpv-audiotrack-compressed-audio.patch deleted file mode 100644 index bd2a13f0d8..0000000000 --- a/third_party/patches/mpv-audiotrack-compressed-audio.patch +++ /dev/null @@ -1,1062 +0,0 @@ -diff --git a/audio/aframe.c b/audio/aframe.c -index fbba7a3..b22aae4 100644 ---- a/audio/aframe.c -+++ b/audio/aframe.c -@@ -34,6 +34,9 @@ struct mp_aframe { - struct mp_chmap chmap; - // We support spdif formats, which are allocated as AV_SAMPLE_FMT_S16. - int format; -+ // Raw encoded frames have a variable number of bytes independent of the -+ // audio sample count stored in AVFrame::nb_samples. -+ size_t data_size; - double pts; - double speed; - }; -@@ -67,6 +70,7 @@ struct mp_aframe *mp_aframe_new_ref(struct mp_aframe *frame) - - dst->chmap = frame->chmap; - dst->format = frame->format; -+ dst->data_size = frame->data_size; - dst->pts = frame->pts; - dst->speed = frame->speed; - -@@ -87,6 +91,7 @@ void mp_aframe_reset(struct mp_aframe *frame) - av_frame_unref(frame->av_frame); - frame->chmap.num = 0; - frame->format = 0; -+ frame->data_size = 0; - frame->pts = MP_NOPTS_VALUE; - frame->speed = 1.0; - } -@@ -306,9 +311,10 @@ bool mp_aframe_set_format(struct mp_aframe *frame, int format) - return false; - enum AVSampleFormat av_format = af_to_avformat(format); - if (av_format == AV_SAMPLE_FMT_NONE && format) { -- if (!af_fmt_is_spdif(format)) -+ if (!af_fmt_is_spdif(format) && !af_fmt_is_encoded(format)) - return false; -- av_format = AV_SAMPLE_FMT_S16; -+ if (af_fmt_is_spdif(format)) -+ av_format = AV_SAMPLE_FMT_S16; - } - frame->format = format; - frame->av_frame->format = av_format; -@@ -391,10 +397,28 @@ int mp_aframe_get_planes(struct mp_aframe *frame) - size_t mp_aframe_get_sstride(struct mp_aframe *frame) - { - int format = mp_aframe_get_format(frame); -+ if (af_fmt_is_encoded(format)) -+ return 1; - return af_fmt_to_bytes(format) * - (af_fmt_is_planar(format) ? 1 : mp_aframe_get_channels(frame)); - } - -+size_t mp_aframe_get_data_size(struct mp_aframe *frame) -+{ -+ if (af_fmt_is_encoded(frame->format)) -+ return frame->data_size; -+ return (size_t)mp_aframe_get_total_plane_samples(frame) * -+ af_fmt_to_bytes(frame->format); -+} -+ -+bool mp_aframe_set_data_size(struct mp_aframe *frame, size_t size) -+{ -+ if (!af_fmt_is_encoded(frame->format) || !mp_aframe_is_allocated(frame)) -+ return false; -+ frame->data_size = size; -+ return true; -+} -+ - // Return total number of samples on each plane. - int mp_aframe_get_total_plane_samples(struct mp_aframe *frame) - { -@@ -420,6 +444,16 @@ void mp_aframe_skip_samples(struct mp_aframe *f, int samples) - { - mp_assert(samples >= 0 && samples <= mp_aframe_get_size(f)); - -+ if (af_fmt_is_encoded(f->format)) { -+ // Raw access units are indivisible. Consumers must either keep the -+ // complete packet or drop it; partial byte slicing would corrupt it. -+ if (samples == mp_aframe_get_size(f)) { -+ f->av_frame->nb_samples = 0; -+ f->data_size = 0; -+ } -+ return; -+ } -+ - if (av_frame_make_writable(f->av_frame) < 0) - return; // go complain to ffmpeg - -@@ -500,7 +534,8 @@ void mp_aframe_clip_timestamps(struct mp_aframe *f, double start, double end) - if (f->pts >= end) { - f->av_frame->nb_samples = 0; - } else { -- if (af_fmt_is_spdif(mp_aframe_get_format(f))) -+ if (af_fmt_is_spdif(mp_aframe_get_format(f)) || -+ af_fmt_is_encoded(mp_aframe_get_format(f))) - return; - int new = (end - f->pts) * rate; - f->av_frame->nb_samples = MPCLAMP(new, 0, f->av_frame->nb_samples); -@@ -513,7 +548,8 @@ void mp_aframe_clip_timestamps(struct mp_aframe *f, double start, double end) - f->av_frame->nb_samples = 0; - f->pts = f_end; - } else { -- if (af_fmt_is_spdif(mp_aframe_get_format(f))) -+ if (af_fmt_is_spdif(mp_aframe_get_format(f)) || -+ af_fmt_is_encoded(mp_aframe_get_format(f))) - return; - int skip = (start - f->pts) * rate; - skip = MPCLAMP(skip, 0, f->av_frame->nb_samples); -@@ -527,6 +563,9 @@ bool mp_aframe_copy_samples(struct mp_aframe *dst, int dst_offset, - struct mp_aframe *src, int src_offset, - int samples) - { -+ if (af_fmt_is_encoded(mp_aframe_get_format(dst)) || -+ af_fmt_is_encoded(mp_aframe_get_format(src))) -+ return false; - if (!mp_aframe_config_equals(dst, src)) - return false; - -@@ -552,6 +591,8 @@ bool mp_aframe_copy_samples(struct mp_aframe *dst, int dst_offset, - - bool mp_aframe_set_silence(struct mp_aframe *f, int offset, int samples) - { -+ if (af_fmt_is_encoded(mp_aframe_get_format(f))) -+ return false; - if (mp_aframe_get_size(f) < offset + samples) - return false; - -@@ -610,6 +651,8 @@ bool mp_aframe_reverse(struct mp_aframe *f) - - int mp_aframe_approx_byte_size(struct mp_aframe *frame) - { -+ if (af_fmt_is_encoded(frame->format)) -+ return sizeof(*frame) + mp_aframe_get_data_size(frame); - // God damn, AVFrame is too fucking annoying. Just go with the size that - // allocating a new frame would use. - int planes = mp_aframe_get_planes(frame); -@@ -638,17 +681,33 @@ static void mp_aframe_pool_destructor(void *p) - // Like mp_aframe_allocate(), but use the pool to allocate data. - int mp_aframe_pool_allocate(struct mp_aframe_pool *pool, struct mp_aframe *frame, - int samples) -+{ -+ size_t data_size = af_fmt_is_encoded(frame->format) -+ ? (size_t)MPMAX(samples, 1) : 0; -+ return mp_aframe_pool_allocate_size(pool, frame, samples, data_size); -+} -+ -+int mp_aframe_pool_allocate_size(struct mp_aframe_pool *pool, -+ struct mp_aframe *frame, int samples, -+ size_t data_size) - { - int planes = mp_aframe_get_planes(frame); - size_t sstride = mp_aframe_get_sstride(frame); - // FFmpeg hardcodes similar hidden possibly-requirements in a number of - // places: av_frame_get_buffer(), libavcodec's get_buffer(), mem.c, - // probably more. -- int align_samples = MP_ALIGN_UP(MPMAX(samples, 1), 32); -- int plane_size = MP_ALIGN_UP(sstride * align_samples, 64); -- int size = plane_size * planes; -+ size_t plane_size; -+ if (af_fmt_is_encoded(frame->format)) { -+ if (!data_size || data_size > INT_MAX) -+ return -1; -+ plane_size = MP_ALIGN_UP(data_size, 64); -+ } else { -+ int align_samples = MP_ALIGN_UP(MPMAX(samples, 1), 32); -+ plane_size = MP_ALIGN_UP(sstride * align_samples, 64); -+ } -+ size_t size = plane_size * planes; - -- if (size <= 0 || mp_aframe_is_allocated(frame)) -+ if (size == 0 || size > INT_MAX || mp_aframe_is_allocated(frame)) - return -1; - - if (!pool->avpool || size > pool->element_size) { -@@ -679,7 +738,8 @@ int mp_aframe_pool_allocate(struct mp_aframe_pool *pool, struct mp_aframe *frame - av_frame->buf[0] = av_buffer_pool_get(pool->avpool); - if (!av_frame->buf[0]) - return -1; -- av_frame->linesize[0] = samples * sstride; -+ av_frame->linesize[0] = af_fmt_is_encoded(frame->format) -+ ? data_size : samples * sstride; - for (int n = 0; n < planes; n++) - av_frame->extended_data[n] = av_frame->buf[0]->data + n * plane_size; - if (planes > AV_NUM_DATA_POINTERS) { -@@ -687,6 +747,7 @@ int mp_aframe_pool_allocate(struct mp_aframe_pool *pool, struct mp_aframe *frame - av_frame->data[n] = av_frame->extended_data[n]; - } - av_frame->nb_samples = samples; -+ frame->data_size = af_fmt_is_encoded(frame->format) ? data_size : 0; - - return 0; - } -diff --git a/audio/aframe.h b/audio/aframe.h -index d19c7e8..dfb8d4a 100644 ---- a/audio/aframe.h -+++ b/audio/aframe.h -@@ -51,6 +51,8 @@ void mp_aframe_mul_speed(struct mp_aframe *frame, double factor); - int mp_aframe_get_planes(struct mp_aframe *frame); - int mp_aframe_get_total_plane_samples(struct mp_aframe *frame); - size_t mp_aframe_get_sstride(struct mp_aframe *frame); -+size_t mp_aframe_get_data_size(struct mp_aframe *frame); -+bool mp_aframe_set_data_size(struct mp_aframe *frame, size_t size); - - bool mp_aframe_reverse(struct mp_aframe *frame); - -@@ -73,3 +75,6 @@ struct mp_aframe_pool; - struct mp_aframe_pool *mp_aframe_pool_create(void *ta_parent); - int mp_aframe_pool_allocate(struct mp_aframe_pool *pool, struct mp_aframe *frame, - int samples); -+int mp_aframe_pool_allocate_size(struct mp_aframe_pool *pool, -+ struct mp_aframe *frame, int samples, -+ size_t data_size); -diff --git a/audio/decode/ad_spdif.c b/audio/decode/ad_spdif.c -index 0bce4c6..ccd83ad 100644 ---- a/audio/decode/ad_spdif.c -+++ b/audio/decode/ad_spdif.c -@@ -50,6 +50,7 @@ struct spdifContext { - uint8_t out_buffer[OUTBUF_SIZE]; - bool need_close; - bool use_dts_hd; -+ bool raw_compressed; - bool codec_params_probed; - int codec_profile; - int codec_rate; -@@ -350,6 +351,54 @@ fail: - return -1; - } - -+static int init_raw_filter(struct mp_filter *da) -+{ -+ struct spdifContext *ctx = da->priv; -+ struct mp_codec_params *codec = ctx->codec; -+ int format = ctx->codec_id == AV_CODEC_ID_AAC ? AF_FORMAT_AAC : AF_FORMAT_MP3; -+ int rate = codec->samplerate; -+ int channels = codec->channels.num; -+ -+ if ((!rate || !channels) && codec->lav_codecpar) { -+ if (!rate) -+ rate = codec->lav_codecpar->sample_rate; -+ if (!channels) -+ channels = codec->lav_codecpar->ch_layout.nb_channels; -+ } -+ if (rate <= 0) -+ rate = ctx->codec_id == AV_CODEC_ID_MP3 ? 44100 : 48000; -+ if (channels <= 0) -+ channels = 2; -+ -+ ctx->fmt = mp_aframe_create(); -+ talloc_steal(ctx, ctx->fmt); -+ struct mp_chmap chmap; -+ mp_chmap_from_channels(&chmap, channels); -+ if (!mp_aframe_set_chmap(ctx->fmt, &chmap) || -+ !mp_aframe_set_format(ctx->fmt, format) || -+ !mp_aframe_set_rate(ctx->fmt, rate)) -+ { -+ TA_FREEP(&ctx->fmt); -+ return -1; -+ } -+ ctx->codec_rate = rate; -+ return 0; -+} -+ -+static int raw_packet_samples(struct spdifContext *ctx, -+ const struct demux_packet *pkt) -+{ -+ int samples = 0; -+ if (ctx->codec->lav_codecpar) -+ samples = av_get_audio_frame_duration2(ctx->codec->lav_codecpar, pkt->len); -+ if (samples <= 0) { -+ // AAC-LC and MPEG audio packets used by AudioTrack are complete access -+ // units in the demuxer. These defaults cover packets without codecpar. -+ samples = ctx->codec_id == AV_CODEC_ID_AAC ? 1024 : 1152; -+ } -+ return samples; -+} -+ - static void ad_spdif_process(struct mp_filter *da) - { - struct spdifContext *spdif_ctx = da->priv; -@@ -374,6 +423,33 @@ static void ad_spdif_process(struct mp_filter *da) - double pts = mpkt->pts; - bool drop_packet = false; - -+ if (spdif_ctx->raw_compressed) { -+ if (!spdif_ctx->fmt && init_raw_filter(da) < 0) -+ goto done; -+ -+ int samples = raw_packet_samples(spdif_ctx, mpkt); -+ out = mp_aframe_new_ref(spdif_ctx->fmt); -+ if (!out || mp_aframe_pool_allocate_size(spdif_ctx->pool, out, -+ samples, mpkt->len) < 0) -+ { -+ TA_FREEP(&out); -+ goto done; -+ } -+ -+ uint8_t **data = mp_aframe_get_data_rw(out); -+ if (!data) { -+ TA_FREEP(&out); -+ goto done; -+ } -+ memcpy(data[0], mpkt->buffer, mpkt->len); -+ if (!mp_aframe_set_data_size(out, mpkt->len)) { -+ TA_FREEP(&out); -+ goto done; -+ } -+ mp_aframe_set_pts(out, pts); -+ goto done; -+ } -+ - if (!spdif_ctx->avpkt) { - spdif_ctx->avpkt = av_packet_alloc(); - MP_HANDLE_OOM(spdif_ctx->avpkt); -@@ -475,13 +551,16 @@ struct mp_decoder_list *select_spdif_codec(const char *codec, const char *pref) - return list; - - bool spdif_allowed = false, dts_hd_allowed = false; -+ bool raw_allowed = false; - bstr sel = bstr0(pref); - while (sel.len) { - bstr decoder; - bstr_split_tok(sel, ",", &decoder, &sel); - if (decoder.len) { -- if (bstr_equals0(decoder, codec)) -+ if (bstr_equals0(decoder, codec)) { - spdif_allowed = true; -+ raw_allowed = strcmp(codec, "aac") == 0 || strcmp(codec, "mp3") == 0; -+ } - if (bstr_equals0(decoder, "dts-hd") && strcmp(codec, "dts") == 0) - spdif_allowed = dts_hd_allowed = true; - } -@@ -490,11 +569,14 @@ struct mp_decoder_list *select_spdif_codec(const char *codec, const char *pref) - if (!spdif_allowed) - return list; - -- const char *suffix_name = dts_hd_allowed ? "dts_hd" : codec; -+ const char *suffix_name = raw_allowed ? -+ (strcmp(codec, "aac") == 0 ? "raw_aac" : "raw_mp3") : -+ (dts_hd_allowed ? "dts_hd" : codec); - char name[80]; - snprintf(name, sizeof(name), "spdif_%s", suffix_name); - mp_add_decoder(list, codec, name, -- "libavformat/spdifenc audio pass-through decoder"); -+ raw_allowed ? "AudioTrack compressed access-unit decoder" : -+ "libavformat/spdifenc audio pass-through decoder"); - return list; - } - -@@ -525,6 +607,9 @@ static struct mp_decoder *create(struct mp_filter *parent, - spdif_ctx->pool = mp_aframe_pool_create(spdif_ctx); - spdif_ctx->public.f = da; - -+ if (strcmp(decoder, "spdif_raw_aac") == 0 || -+ strcmp(decoder, "spdif_raw_mp3") == 0) -+ spdif_ctx->raw_compressed = true; - if (strcmp(decoder, "spdif_dts_hd") == 0) - spdif_ctx->use_dts_hd = true; - -diff --git a/audio/format.c b/audio/format.c -index 4441456..661f1fa 100644 ---- a/audio/format.c -+++ b/audio/format.c -@@ -35,6 +35,8 @@ int af_fmt_to_bytes(int format) - } - if (af_fmt_is_spdif(format)) - return 2; -+ if (af_fmt_is_encoded(format)) -+ return 1; - return 0; - } - -@@ -53,7 +55,8 @@ bool af_fmt_is_float(int format) - // true for both unsigned and signed ints - bool af_fmt_is_int(int format) - { -- return format && !af_fmt_is_spdif(format) && !af_fmt_is_float(format); -+ return format && !af_fmt_is_spdif(format) && !af_fmt_is_encoded(format) && -+ !af_fmt_is_float(format); - } - - bool af_fmt_is_spdif(int format) -@@ -61,9 +64,15 @@ bool af_fmt_is_spdif(int format) - return af_format_sample_alignment(format) > 1; - } - -+bool af_fmt_is_encoded(int format) -+{ -+ return format == AF_FORMAT_AAC || format == AF_FORMAT_MP3; -+} -+ - bool af_fmt_is_pcm(int format) - { -- return af_fmt_is_valid(format) && !af_fmt_is_spdif(format); -+ return af_fmt_is_valid(format) && !af_fmt_is_spdif(format) && -+ !af_fmt_is_encoded(format); - } - - static const int planar_formats[][2] = { -@@ -128,6 +137,8 @@ const char *af_fmt_to_str(int format) - case AF_FORMAT_S64P: return "s64p"; - case AF_FORMAT_FLOATP: return "floatp"; - case AF_FORMAT_DOUBLEP: return "doublep"; -+ case AF_FORMAT_AAC: return "aac"; -+ case AF_FORMAT_MP3: return "mp3"; - case AF_FORMAT_S_AAC: return "spdif-aac"; - case AF_FORMAT_S_AC3: return "spdif-ac3"; - case AF_FORMAT_S_DTS: return "spdif-dts"; -@@ -255,6 +266,9 @@ int af_select_best_samplerate(int src_samplerate, const int *available) - int af_format_sample_alignment(int format) - { - switch (format) { -+ case AF_FORMAT_AAC: -+ case AF_FORMAT_MP3: -+ return 1; - case AF_FORMAT_S_AAC: return 16384 / 4; - case AF_FORMAT_S_AC3: return 6144 / 4; - case AF_FORMAT_S_DTSHD: return 32768 / 16; -diff --git a/audio/format.h b/audio/format.h -index bdd4744..239375a 100644 ---- a/audio/format.h -+++ b/audio/format.h -@@ -39,6 +39,11 @@ enum af_format { - AF_FORMAT_FLOATP, - AF_FORMAT_DOUBLEP, - -+ // Raw compressed access units. Unlike AF_FORMAT_S_* these are not -+ // IEC61937 carriers and must not be treated as PCM samples. -+ AF_FORMAT_AAC, -+ AF_FORMAT_MP3, -+ - // All of these use IEC61937 framing, and otherwise pretend to be like PCM. - AF_FORMAT_S_AAC, - AF_FORMAT_S_AC3, -@@ -61,6 +66,7 @@ bool af_fmt_is_float(int format); - bool af_fmt_is_int(int format); - bool af_fmt_is_planar(int format); - bool af_fmt_is_spdif(int format); -+bool af_fmt_is_encoded(int format); - bool af_fmt_is_pcm(int format); - - int af_fmt_to_planar(int format); -diff --git a/audio/out/ao_audiotrack.c b/audio/out/ao_audiotrack.c -index 50b7ec4..ac1b5cb 100644 ---- a/audio/out/ao_audiotrack.c -+++ b/audio/out/ao_audiotrack.c -@@ -23,6 +23,7 @@ - #include "ao.h" - #include "internal.h" -+#include "audio/aframe.h" - #include "common/msg.h" - #include "audio/format.h" -@@ -62,6 +63,8 @@ struct priv { - - bool cfg_pcm_float; - int cfg_session_id; -+ bool compressed; -+ bool offload; - - bool thread_terminate; - bool thread_created; -@@ -190,6 +192,8 @@ static struct JNIAudioFormat { - jint ENCODING_PCM_16BIT; - jint ENCODING_PCM_FLOAT; - jint ENCODING_IEC61937; -+ jint ENCODING_MP3; -+ jint ENCODING_AAC_LC; - jint CHANNEL_OUT_MONO; - jint CHANNEL_OUT_STEREO; - jint CHANNEL_OUT_FRONT_CENTER; -@@ -205,6 +209,8 @@ static const struct MPJniField AudioFormat_mapping[] = { - {"ENCODING_PCM_16BIT", "I", MP_JNI_STATIC_FIELD_AS_INT, OFFSET(ENCODING_PCM_16BIT), 1}, - {"ENCODING_PCM_FLOAT", "I", MP_JNI_STATIC_FIELD_AS_INT, OFFSET(ENCODING_PCM_FLOAT), 1}, - {"ENCODING_IEC61937", "I", MP_JNI_STATIC_FIELD_AS_INT, OFFSET(ENCODING_IEC61937), 0}, -+ {"ENCODING_MP3", "I", MP_JNI_STATIC_FIELD_AS_INT, OFFSET(ENCODING_MP3), 0}, -+ {"ENCODING_AAC_LC", "I", MP_JNI_STATIC_FIELD_AS_INT, OFFSET(ENCODING_AAC_LC), 0}, - {"CHANNEL_OUT_MONO", "I", MP_JNI_STATIC_FIELD_AS_INT, OFFSET(CHANNEL_OUT_MONO), 1}, - {"CHANNEL_OUT_STEREO", "I", MP_JNI_STATIC_FIELD_AS_INT, OFFSET(CHANNEL_OUT_STEREO), 1}, - {"CHANNEL_OUT_FRONT_CENTER", "I", MP_JNI_STATIC_FIELD_AS_INT, OFFSET(CHANNEL_OUT_FRONT_CENTER), 1}, -@@ -235,3 +238,32 @@ static const struct MPJniField AudioFormatBuilder_mapping[] = { - {0} - }; - #undef OFFSET -+ -+static struct JNIAudioTrackBuilder { -+ jclass clazz; -+ jmethodID ctor; -+ jmethodID setAudioAttributes; -+ jmethodID setAudioFormat; -+ jmethodID setTransferMode; -+ jmethodID setBufferSizeInBytes; -+ jmethodID setSessionId; -+ jmethodID setOffloadedPlayback; -+ jmethodID build; -+} AudioTrackBuilder; -+#define OFFSET(member) offsetof(struct JNIAudioTrackBuilder, member) -+static const struct MPJniField AudioTrackBuilder_mapping[] = { -+ {"android/media/AudioTrack$Builder", NULL, MP_JNI_CLASS, OFFSET(clazz), 0}, -+ {"", "()V", MP_JNI_METHOD, OFFSET(ctor), 0}, -+ {"setAudioAttributes", "(Landroid/media/AudioAttributes;)Landroid/media/AudioTrack$Builder;", MP_JNI_METHOD, OFFSET(setAudioAttributes), 0}, -+ {"setAudioFormat", "(Landroid/media/AudioFormat;)Landroid/media/AudioTrack$Builder;", MP_JNI_METHOD, OFFSET(setAudioFormat), 0}, -+ {"setTransferMode", "(I)Landroid/media/AudioTrack$Builder;", MP_JNI_METHOD, OFFSET(setTransferMode), 0}, -+ {"setBufferSizeInBytes", "(I)Landroid/media/AudioTrack$Builder;", MP_JNI_METHOD, OFFSET(setBufferSizeInBytes), 0}, -+ {"setSessionId", "(I)Landroid/media/AudioTrack$Builder;", MP_JNI_METHOD, OFFSET(setSessionId), 0}, -+ {"setOffloadedPlayback", "(Z)Landroid/media/AudioTrack$Builder;", MP_JNI_METHOD, OFFSET(setOffloadedPlayback), 0}, -+ {"build", "()Landroid/media/AudioTrack;", MP_JNI_METHOD, OFFSET(build), 0}, -+ {0} -+}; -+#undef OFFSET - static struct JNIBuildVersion { - jclass clazz; - jint SDK_INT; -@@ -288,12 +320,46 @@ static const struct { - ENTRY(AudioAttributesBuilder), - ENTRY(AudioFormat), - ENTRY(AudioFormatBuilder), -+ ENTRY(AudioTrackBuilder), - ENTRY(BuildVersion), - ENTRY(AudioManager), - ENTRY(AudioTimestamp), - }; - #undef ENTRY - -+static jobject AudioTrack_Build(struct ao *ao, jobject attr, jobject format, -+ bool offload) -+{ -+ JNIEnv *env = MP_JNI_GET_ENV(ao); -+ if (!env) -+ return NULL; -+ struct priv *p = ao->priv; -+ jobject builder = MP_JNI_NEW(AudioTrackBuilder.clazz, AudioTrackBuilder.ctor); -+ if (MP_JNI_EXCEPTION_LOG(ao) < 0 || !builder) -+ return NULL; -+ -+ jobject tmp = MP_JNI_CALL_OBJECT(builder, AudioTrackBuilder.setAudioAttributes, attr); -+ MP_JNI_LOCAL_FREEP(&tmp); -+ tmp = MP_JNI_CALL_OBJECT(builder, AudioTrackBuilder.setAudioFormat, format); -+ MP_JNI_LOCAL_FREEP(&tmp); -+ tmp = MP_JNI_CALL_OBJECT(builder, AudioTrackBuilder.setTransferMode, -+ AudioTrack.MODE_STREAM); -+ MP_JNI_LOCAL_FREEP(&tmp); -+ tmp = MP_JNI_CALL_OBJECT(builder, AudioTrackBuilder.setBufferSizeInBytes, p->size); -+ MP_JNI_LOCAL_FREEP(&tmp); -+ tmp = MP_JNI_CALL_OBJECT(builder, AudioTrackBuilder.setSessionId, p->cfg_session_id); -+ MP_JNI_LOCAL_FREEP(&tmp); -+ if (AudioTrackBuilder.setOffloadedPlayback && BuildVersion.SDK_INT >= 29) -+ { -+ tmp = MP_JNI_CALL_OBJECT(builder, AudioTrackBuilder.setOffloadedPlayback, -+ offload ? JNI_TRUE : JNI_FALSE); -+ MP_JNI_LOCAL_FREEP(&tmp); -+ } -+ jobject result = MP_JNI_CALL_OBJECT(builder, AudioTrackBuilder.build); -+ MP_JNI_LOCAL_FREEP(&builder); -+ return result; -+} -+ - static int AudioTrack_New(struct ao *ao) - { - struct priv *p = ao->priv; -@@ -328,15 +391,28 @@ static int AudioTrack_New(struct ao *ao) - jobject attr = MP_JNI_CALL_OBJECT(attr_builder, AudioAttributesBuilder.build); - MP_JNI_LOCAL_FREEP(&attr_builder); - -- audiotrack = MP_JNI_NEW( -- AudioTrack.clazz, -- AudioTrack.ctorV21, -- attr, -- format, -- p->size, -- AudioTrack.MODE_STREAM, -- p->cfg_session_id -- ); -+ if (p->compressed && AudioTrackBuilder.clazz && -+ AudioTrackBuilder.ctor && AudioTrackBuilder.build) -+ { -+ bool can_offload = AudioTrackBuilder.setOffloadedPlayback && -+ BuildVersion.SDK_INT >= 29; -+ audiotrack = AudioTrack_Build(ao, attr, format, can_offload); -+ if (MP_JNI_EXCEPTION_LOG(ao) < 0 || -+ (audiotrack && MP_JNI_CALL_INT(audiotrack, AudioTrack.getState) -+ != AudioTrack.STATE_INITIALIZED)) { -+ if (audiotrack) { -+ MP_JNI_CALL_VOID(audiotrack, AudioTrack.release); -+ MP_JNI_EXCEPTION_LOG(ao); -+ MP_JNI_LOCAL_FREEP(&audiotrack); -+ } -+ MP_VERBOSE(ao, "Offloaded AudioTrack rejected; retrying direct encoded output\n"); -+ audiotrack = AudioTrack_Build(ao, attr, format, false); -+ if (MP_JNI_EXCEPTION_LOG(ao) < 0 || -+ (audiotrack && MP_JNI_CALL_INT(audiotrack, AudioTrack.getState) -+ != AudioTrack.STATE_INITIALIZED)) { -+ if (audiotrack) { -+ MP_JNI_CALL_VOID(audiotrack, AudioTrack.release); -+ MP_JNI_EXCEPTION_LOG(ao); -+ MP_JNI_LOCAL_FREEP(&audiotrack); -+ } -+ audiotrack = NULL; -+ } -+ p->offload = false; -+ } else { -+ p->offload = can_offload; -+ } -+ } else { -+ audiotrack = MP_JNI_NEW( -+ AudioTrack.clazz, -+ AudioTrack.ctorV21, -+ attr, -+ format, -+ p->size, -+ AudioTrack.MODE_STREAM, -+ p->cfg_session_id -+ ); -+ } - - MP_JNI_LOCAL_FREEP(&format); - MP_JNI_LOCAL_FREEP(&attr); -@@ -501,16 +577,26 @@ static double AudioTrack_getLatency(struct ao *ao) - return MPCLAMP(delay, 0.0, 2.0); - } - --static int AudioTrack_write(struct ao *ao, int len) -+static int AudioTrack_write(struct ao *ao, const void *source, int len) - { - struct priv *p = ao->priv; - if (!p->audiotrack) - return -1; - JNIEnv *env = MP_JNI_GET_ENV(ao); -- void *buf = p->chunk; -+ void *buf = source ? (void *)source : p->chunk; - - jint ret; -- if (p->format == AudioFormat.ENCODING_IEC61937) { -+ if (p->compressed) { -+ if (len > p->chunksize) -+ return -1; -+ memcpy(p->chunk, buf, len); -+ jobject bbuf = MP_JNI_CALL_OBJECT(p->bbuf, ByteBuffer.clear); -+ if (MP_JNI_EXCEPTION_LOG(ao) < 0) return -1; -+ MP_JNI_LOCAL_FREEP(&bbuf); -+ ret = MP_JNI_CALL_INT(p->audiotrack, AudioTrack.writeBufferV21, -+ p->bbuf, len, AudioTrack.WRITE_BLOCKING); -+ if (MP_JNI_EXCEPTION_LOG(ao) < 0) return -1; -+ } else if (p->format == AudioFormat.ENCODING_IEC61937) { - (*env)->SetShortArrayRegion(env, p->shortarray, 0, len / 2, buf); - if (MP_JNI_EXCEPTION_LOG(ao) < 0) return -1; - ret = MP_JNI_CALL_INT(p->audiotrack, AudioTrack.writeShortV23, p->shortarray, 0, len / 2, AudioTrack.WRITE_BLOCKING); -@@ -598,17 +684,43 @@ static MP_THREAD_VOID ao_thread(void *arg) - int64_t ts = mp_time_ns(); - ts += MP_TIME_S_TO_NS(read_samples / (double)(ao->samplerate)); - ts += MP_TIME_S_TO_NS(AudioTrack_getLatency(ao)); -- int samples = ao_read_data(ao, &p->chunk, read_samples, ts, NULL, false, false); -- int ret = AudioTrack_write(ao, samples * ao->sstride); -- if (ret >= 0) { -- p->written_frames += ret / ao->sstride; -- } else if (ret == AudioManager.ERROR_DEAD_OBJECT) { -- MP_WARN(ao, "AudioTrack.write failed with ERROR_DEAD_OBJECT. Recreating AudioTrack...\n"); -- if (AudioTrack_Recreate(ao) < 0) { -- MP_ERR(ao, "AudioTrack_Recreate failed\n"); -+ if (p->compressed) { -+ struct mp_aframe *frame = ao_read_frame(ao); -+ if (!frame) { -+ mp_cond_timedwait(&p->wakeup, &p->lock, MP_TIME_MS_TO_NS(20)); -+ continue; -+ } -+ uint8_t **data = mp_aframe_get_data_ro(frame); -+ size_t size = mp_aframe_get_data_size(frame); -+ int ret = data && size <= INT_MAX -+ ? AudioTrack_write(ao, data[0], size) : -1; -+ if (ret == (int)size) -+ p->written_frames += mp_aframe_get_size(frame); -+ else if (ret >= 0) -+ ret = -1; -+ talloc_free(frame); -+ if (ret == AudioManager.ERROR_DEAD_OBJECT) { -+ MP_WARN(ao, "Compressed AudioTrack died; requesting one-shot PCM fallback\n"); -+ ao_request_reload(ao); -+ break; -+ } else if (ret < 0) { -+ MP_ERR(ao, "Compressed AudioTrack.write failed with %d\n", ret); -+ ao_request_reload(ao); -+ break; - } - } else { -- MP_ERR(ao, "AudioTrack.write failed with %d\n", ret); -+ int samples = ao_read_data(ao, &p->chunk, read_samples, ts, NULL, false, false); -+ int ret = AudioTrack_write(ao, NULL, samples * ao->sstride); -+ if (ret >= 0) { -+ p->written_frames += ret / ao->sstride; -+ } else if (ret == AudioManager.ERROR_DEAD_OBJECT) { -+ MP_WARN(ao, "AudioTrack.write failed with ERROR_DEAD_OBJECT. Recreating AudioTrack...\n"); -+ if (AudioTrack_Recreate(ao) < 0) { -+ MP_ERR(ao, "AudioTrack_Recreate failed\n"); -+ } -+ } else { -+ MP_ERR(ao, "AudioTrack.write failed with %d\n", ret); -+ } - } - } else { - mp_cond_timedwait(&p->wakeup, &p->lock, MP_TIME_MS_TO_NS(300)); -@@ -672,7 +784,15 @@ static int init(struct ao *ao) - if (init_jni(ao) < 0) - return -1; - -- if (af_fmt_is_spdif(ao->format)) { -+ p->compressed = af_fmt_is_encoded(ao->format); -+ if (p->compressed) { -+ p->format = ao->format == AF_FORMAT_AAC -+ ? AudioFormat.ENCODING_AAC_LC : AudioFormat.ENCODING_MP3; -+ if (!p->format || !AudioTrack.writeBufferV21) { -+ MP_ERR(ao, "compressed AudioTrack requires API21 ByteBuffer writes\n"); -+ return -1; -+ } -+ } else if (af_fmt_is_spdif(ao->format)) { - p->format = AudioFormat.ENCODING_IEC61937; - if (!p->format || !AudioTrack.writeShortV23) { - MP_ERR(ao, "spdif passthrough not supported by API\n"); -@@ -713,13 +713,21 @@ static int init(struct ao *ao) - if (p->format == AudioFormat.ENCODING_IEC61937) { - // Android documents a stereo channel mask for IEC61937, but the - // mature VLC AudioTrack backend uses 7.1 for TrueHD because several - // Android TV firmwares reject or down-route its 8-channel carrier - // when it is declared as stereo. - if (ao->format == AF_FORMAT_S_TRUEHD) { - MP_VERBOSE(ao, "Using 7.1 IEC61937 carrier mask for TrueHD\n"); - p->channel_config = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND; - } else if (BuildVersion.SDK_INT >= ANDROID_API_LEVEL_S && - ao->channels.num == 8) { - MP_VERBOSE(ao, "Using 7.1 IEC61937 carrier mask for Android 12+ 8-channel stream\n"); - p->channel_config = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND; - } else { - p->channel_config = AudioFormat.CHANNEL_OUT_STEREO; - } - } else { -+ // Android AudioTrack exposes channel masks only through 7.1. AV3A -+ // can carry 9/12-channel layouts that cannot be represented here; -+ // force a deterministic stereo downmix instead of an arbitrary -+ // channel-map fallback (for example 9 -> 7). -+ if (ao->channels.num > 8) { -+ MP_VERBOSE(ao, "AudioTrack supports at most 8 channels; downmixing %d-channel audio to stereo\n", -+ ao->channels.num); -+ ao->channels = (struct mp_chmap) MP_CHMAP_INIT_STEREO; -+ } - struct mp_chmap_sel sel = {0}; - for (int i = 0; i < MP_ARRAY_SIZE(layouts); i++) { - if (layout_map[i]) - mp_chmap_sel_add_map(&sel, &layouts[i]); - } - if (!ao_chmap_sel_adjust(ao, &sel, &ao->channels)) - goto error; - p->channel_config = layout_map[ao->channels.num]; - mp_assert(p->channel_config); - } - -@@ -746,8 +754,14 @@ static int init(struct ao *ao) - min = MP_ALIGN_UP(min, bps); - max = MP_ALIGN_UP(max, bps); - p->size = MPCLAMP(buffer_size * 2, min, max); -+ if (p->compressed) { -+ // Compressed AudioTrack profiles commonly reject the tiny buffer -+ // calculated from encoded bytes. Keep one bounded 256 KiB packet -+ // window, matching the proven direct-output probe and Media3 path. -+ p->size = MPMAX(p->size, 256 * 1024); -+ } - MP_VERBOSE(ao, "Setting bufferSize = %d (driver=%d, min=%d, max=%d)\n", p->size, buffer_size, min, max); - mp_assert(p->size % bps == 0); - ao->device_buffer = p->size / bps; - -diff --git a/audio/out/buffer.c b/audio/out/buffer.c -index 277b524..16258b1 100644 ---- a/audio/out/buffer.c -+++ b/audio/out/buffer.c -@@ -227,6 +227,41 @@ int ao_read_data(struct ao *ao, void **data, int samples, int64_t out_time_ns, b - return pos; - } - -+struct mp_aframe *ao_read_frame(struct ao *ao) -+{ -+ struct buffer_state *p = ao->buffer_state; -+ struct mp_aframe *result = NULL; -+ -+ mp_mutex_lock(&p->lock); -+ if (!p->playing || p->paused) -+ goto done; -+ -+ while (!p->pending) { -+ struct mp_frame frame = mp_pin_out_read(p->input->pins[0]); -+ if (!frame.type) -+ break; -+ if (frame.type == MP_FRAME_AUDIO) { -+ p->pending = frame.data; -+ break; -+ } -+ if (frame.type == MP_FRAME_EOF) { -+ p->playing = false; -+ ao->wakeup_cb(ao->wakeup_ctx); -+ break; -+ } -+ mp_frame_unref(&frame); -+ } -+ -+ if (p->pending && af_fmt_is_encoded(mp_aframe_get_format(p->pending))) { -+ result = p->pending; -+ p->pending = NULL; -+ } -+ -+done: -+ mp_mutex_unlock(&p->lock); -+ return result; -+} -+ - // Same as ao_read_data(), but convert data according to *fmt. - // fmt->src_fmt and fmt->channels must be the same as the AO parameters. - int ao_read_data_converted(struct ao *ao, struct ao_convert_fmt *fmt, -diff --git a/audio/out/internal.h b/audio/out/internal.h -index 85fa777..861bd28 100644 ---- a/audio/out/internal.h -+++ b/audio/out/internal.h -@@ -202,6 +202,11 @@ struct ao_driver { - - int ao_read_data(struct ao *ao, void **data, int samples, int64_t out_time_ns, bool *eof, bool pad_silence, bool blocking); - -+// Pull one complete encoded audio access unit. The returned frame is owned by -+// the caller and must be freed with talloc_free(). This is only valid for -+// non-PCM formats; PCM AOs continue to use ao_read_data(). -+struct mp_aframe *ao_read_frame(struct ao *ao); -+ - bool ao_chmap_sel_adjust(struct ao *ao, const struct mp_chmap_sel *s, - struct mp_chmap *map); - bool ao_chmap_sel_adjust2(struct ao *ao, const struct mp_chmap_sel *s, -diff --git a/player/audio.c b/player/audio.c -index b3e975d..ecdc5df 100644 ---- a/player/audio.c -+++ b/player/audio.c -@@ -416,10 +416,11 @@ static int reinit_audio_filters_and_output(struct MPContext *mpctx) - mp_aframe_get_chmap(out_fmt, &out_channels); - - int ao_flags = 0; -- bool spdif_fallback = af_fmt_is_spdif(out_format) && -- ao_c->spdif_passthrough; -+ bool compressed_fallback = -+ (af_fmt_is_spdif(out_format) || af_fmt_is_encoded(out_format)) && -+ ao_c->spdif_passthrough; - -- if (opts->ao_null_fallback && !spdif_fallback) -+ if (opts->ao_null_fallback && !compressed_fallback) - ao_flags |= AO_INIT_NULL_FALLBACK; - - if (opts->audio_stream_silence) -@@ -465,13 +466,26 @@ static int reinit_audio_filters_and_output(struct MPContext *mpctx) - } - - if (!mpctx->ao) { -- // If spdif was used, try to fallback to PCM. -- if (spdif_fallback && ao_c->track && ao_c->track->dec) { -- MP_VERBOSE(mpctx, "Falling back to PCM output.\n"); -+ // If compressed output was used, fall back to the decoder's PCM path. -+ if (compressed_fallback && ao_c->track && ao_c->track->dec) { -+ MP_VERBOSE(mpctx, "Falling back to PCM output after compressed AO failure.\n"); -+ bool encoded = af_fmt_is_encoded(out_format); -+ if (encoded) { -+ // Drop queued raw access units before replacing the decoder. -+ mp_decoder_wrapper_suspend(ao_c->track->dec); -+ mp_output_chain_reset_harder(ao_c->filter); -+ } - ao_c->spdif_passthrough = false; -+ if (encoded) -+ mp_decoder_wrapper_set_audio_software(ao_c->track->dec, true); -- ao_c->spdif_failed = true; -+ ao_c->spdif_failed = af_fmt_is_spdif(out_format); - mp_decoder_wrapper_set_spdif_flag(ao_c->track->dec, false); -- if (!mp_decoder_wrapper_reinit(ao_c->track->dec)) -+ bool reinit_ok = mp_decoder_wrapper_reinit(ao_c->track->dec); -+ if (encoded) -+ mp_decoder_wrapper_resume(ao_c->track->dec); -+ if (encoded) -+ issue_refresh_seek(mpctx, MPSEEK_EXACT); -+ if (!reinit_ok) - goto init_error; - reset_audio_state(mpctx); - mp_output_chain_reset_harder(ao_c->filter); -@@ -767,6 +777,10 @@ void reload_audio_output(struct MPContext *mpctx) - if (!mpctx->ao) - return; - -+ bool encoded_output_failed = mpctx->ao_filter_fmt && -+ af_fmt_is_encoded(mp_aframe_get_format(mpctx->ao_filter_fmt)); -+ - ao_reset(mpctx->ao); - uninit_audio_out(mpctx); -- reinit_audio_filters(mpctx); // mostly to issue refresh seek -+ if (!encoded_output_failed) -+ reinit_audio_filters(mpctx); // mostly to issue refresh seek -- -+ - struct ao_chain *ao_c = mpctx->ao_chain; -@@ -778,6 +792,24 @@ void reload_audio_output(struct MPContext *mpctx) - mp_output_chain_reset_harder(ao_c->filter); - } - -+ if (encoded_output_failed && ao_c && ao_c->track && ao_c->track->dec) { -+ MP_VERBOSE(mpctx, "Falling back to PCM after compressed AudioTrack write failure.\n"); -+ mp_decoder_wrapper_suspend(ao_c->track->dec); -+ // Clear stale encoded frames before replacing the decoder. -+ mp_output_chain_reset_harder(ao_c->filter); -+ ao_c->spdif_passthrough = false; -+ mp_decoder_wrapper_set_audio_software(ao_c->track->dec, true); -+ ao_c->spdif_failed = false; -+ mp_decoder_wrapper_set_spdif_flag(ao_c->track->dec, false); -+ bool reinit_ok = mp_decoder_wrapper_reinit(ao_c->track->dec); -+ mp_decoder_wrapper_resume(ao_c->track->dec); -+ reinit_audio_filters(mpctx); // rebuild after PCM decoder is active -+ if (!reinit_ok) { -+ MP_ERR(mpctx, "Error reinitializing audio after compressed output failure.\n"); -+ error_on_track(mpctx, ao_c->track); -+ } -+ } -+ - // Whether we can use spdif might have changed. If we failed to use spdif - // in the previous initialization, try it with spdif again (we'll fallback - // to PCM again if necessary). -diff --git a/audio/out/ao_opensles.c b/audio/out/ao_opensles.c ---- a/audio/out/ao_opensles.c -+++ b/audio/out/ao_opensles.c -@@ -102,5 +102,10 @@ static int init(struct ao *ao) - { - struct priv *p = ao->priv; -+ if (af_fmt_is_encoded(ao->format)) { -+ MP_ERR(ao, "OpenSLES cannot output encoded audio; requesting PCM fallback\n"); -+ return -1; -+ } - SLDataLocator_BufferQueue locator_buffer_queue; - SLDataLocator_OutputMix locator_output_mix; - SLAndroidDataFormat_PCM_EX pcm; -diff --git a/filters/f_decoder_wrapper.h b/filters/f_decoder_wrapper.h ---- a/filters/f_decoder_wrapper.h -+++ b/filters/f_decoder_wrapper.h -@@ -50,6 +50,10 @@ double mp_decoder_wrapper_get_container_fps(struct mp_decoder_wrapper *d); - // Whether to prefer spdif wrapper over real decoders on next reinit. - void mp_decoder_wrapper_set_spdif_flag(struct mp_decoder_wrapper *d, bool spdif); -+ -+// Force the current audio track to use its named FFmpeg software decoder on -+// the next and subsequent reinitializations after compressed output failure. -+void mp_decoder_wrapper_set_audio_software(struct mp_decoder_wrapper *d, bool software); -- -+ - // Whether to decode only 1 frame and then stop, and cache the frame across resets. - void mp_decoder_wrapper_set_coverart_flag(struct mp_decoder_wrapper *d, bool c); - -diff --git a/filters/f_swresample.c b/filters/f_swresample.c -index 3a6a1b6..b0d65c2 100644 ---- a/filters/f_swresample.c -+++ b/filters/f_swresample.c -@@ -155,6 +155,9 @@ static bool configure_lavrr(struct priv *p, bool verbose) - struct mp_chmap map_in = p->in_channels; - struct mp_chmap map_out = p->out_channels; -- -+ bool unknown_stereo_downmix = -+ mp_chmap_is_unknown(&map_in) && map_in.num > 2 && -+ mp_chmap_is_stereo(&map_out); -+ - // Try not to do any remixing if at least one is "unknown". Some corner - // cases also benefit from disabling all channel handling logic if the - // src/dst layouts are the same (like fl-fr-na -> fl-fr-na). -@@ -161,5 +164,6 @@ static bool configure_lavrr(struct priv *p, bool verbose) -- if (mp_chmap_is_unknown(&map_in) || mp_chmap_is_unknown(&map_out) || -- mp_chmap_equals(&map_in, &map_out)) -+ if (!unknown_stereo_downmix && -+ (mp_chmap_is_unknown(&map_in) || mp_chmap_is_unknown(&map_out) || -+ mp_chmap_equals(&map_in, &map_out))) - { - mp_chmap_set_unknown(&map_in, map_in.num); - mp_chmap_set_unknown(&map_out, map_out.num); -@@ -242,4 +247,22 @@ static bool configure_lavrr(struct priv *p, bool verbose) - av_opt_set_int(p->avrctx, "out_sample_fmt", out_samplefmtp, 0); -- -+ if (unknown_stereo_downmix) { -+ // AV3A mixed content may append object channels to a sound bed. The -+ // decoder preserves their order but cannot describe them as fixed -+ // speakers, so fold every input index into both stereo outputs. -+ double matrix[2][MP_NUM_CHANNELS] = {{0}}; -+ double gain = 1.0 / map_in.num; -+ for (int n = 0; n < map_in.num; n++) { -+ matrix[0][n] = gain; -+ matrix[1][n] = gain; -+ } -+ if (swr_set_matrix(p->avrctx, &matrix[0][0], MP_NUM_CHANNELS) < 0) { -+ MP_ERR(p, "Failed to configure unknown %d-channel stereo downmix.\n", -+ map_in.num); -+ goto error; -+ } -+ MP_VERBOSE(p, "Using equal-gain stereo downmix for unknown %d-channel input.\n", -+ map_in.num); -+ } -+ - AVChannelLayout fake_layout; - av_channel_layout_default(&fake_layout, map_out.num); -diff --git a/filters/f_decoder_wrapper.c b/filters/f_decoder_wrapper.c ---- a/filters/f_decoder_wrapper.c -+++ b/filters/f_decoder_wrapper.c -@@ -230,6 +230,7 @@ struct priv { - // --- Protected by cache_lock. - char *cur_hwdec; - bool try_spdif; -+ bool force_software_audio; - bool attached_picture; - bool pts_reset; -@@ -465,6 +466,7 @@ static bool reinit_decoder(struct priv *p) - const struct mp_decoder_fns *driver = NULL; - struct mp_decoder_list *list = NULL; - char *user_list = NULL; -+ bool force_software_audio = false; - char *fallback = NULL; -@@ -477,8 +479,9 @@ static bool reinit_decoder(struct priv *p) - fallback = "aac"; -- -+ - mp_mutex_lock(&p->cache_lock); - bool try_spdif = p->try_spdif; -+ force_software_audio = p->force_software_audio; - mp_mutex_unlock(&p->cache_lock); -@@ -492,6 +495,20 @@ static bool reinit_decoder(struct priv *p) - } - } - } -- -+ -+ if (force_software_audio && p->codec->type == STREAM_AUDIO && -+ p->codec->codec) -+ { -+ // The previous attempt may have selected ad_spdif. Force the -+ // software decoder driver as well as its candidate entry; otherwise -+ // ad_spdif accepts the name but keeps producing encoded frames. -+ if (list) { -+ talloc_free(list); -+ list = NULL; -+ } -+ driver = &ad_lavc; -+ list = talloc_zero(NULL, struct mp_decoder_list); -+ if (list) -+ mp_add_decoder(list, p->codec->codec, p->codec->codec, -+ "FFmpeg software audio decoder"); -+ } -+ - if (!driver) - return false; -@@ -587,6 +599,16 @@ void mp_decoder_wrapper_set_spdif_flag(struct mp_decoder_wrapper *d, bool spdif) - mp_mutex_unlock(&p->cache_lock); - } -- -+ -+void mp_decoder_wrapper_set_audio_software(struct mp_decoder_wrapper *d, -+ bool software) -+{ -+ struct priv *p = d->f->priv; -+ mp_mutex_lock(&p->cache_lock); -+ p->force_software_audio = software; -+ mp_mutex_unlock(&p->cache_lock); -+} -+ - void mp_decoder_wrapper_set_coverart_flag(struct mp_decoder_wrapper *d, bool c) - { - struct priv *p = d->f->priv; diff --git a/third_party/patches/mpv-discnav.patch b/third_party/patches/mpv-discnav.patch deleted file mode 100644 index 60d3c6b334..0000000000 --- a/third_party/patches/mpv-discnav.patch +++ /dev/null @@ -1,2910 +0,0 @@ -diff --git a/player/discnav.c b/player/discnav.c -new file mode 100644 -index 0000000..52a9fd6 ---- /dev/null -+++ b/player/discnav.c -@@ -0,0 +1,504 @@ -+/* -+ * This file is part of mpv. -+ * -+ * mpv is free software; you can redistribute it and/or -+ * modify it under the terms of the GNU Lesser General Public -+ * License as published by the Free Software Foundation; either -+ * version 2.1 of the License, or (at your option) any later version. -+ * -+ * mpv is distributed in the hope that it will be useful, -+ * but WITHOUT ANY WARRANTY; without even the implied warranty of -+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -+ * GNU Lesser General Public License for more details. -+ * -+ * You should have received a copy of the GNU Lesser General Public -+ * License along with mpv. If not, see . -+ */ -+ -+#include -+#include -+ -+#include "mpv_talloc.h" -+ -+#include "common/common.h" -+#include "common/msg.h" -+#include "input/input.h" -+#include "player/command.h" -+ -+#include "stream/stream.h" -+#include "demux/demux.h" -+#include "sub/dec_sub.h" -+#include "sub/osd.h" -+#include "sub/osd_state.h" -+#include "video/mp_image.h" -+#include "video/out/vo.h" -+ -+#include "core.h" -+ -+struct disc_nav_state { -+ // True while the disc-menu input section is enabled (a menu is on screen). -+ bool overlay_visible; -+ -+ // Blu-ray HDMV menu staging. libbluray hands us a pre-composited BGRA IG -+ // plane via STREAM_CTRL_GET_NAV_OVERLAY; we copy it into bd_image and -+ // forward through osd_set_bitmaps (OSDTYPE_DISC_MENU). -+ struct mp_image *bd_image; -+ uint32_t bd_last_change_id; -+ struct mp_osd_res bd_last_vo_res; -+ -+ // Last menu-overlay change_id we forced an OSD redraw for. -+ uint32_t last_overlay_change_id; -+ bool overlay_change_seen; -+ -+ // Last observed disc-nav discontinuity counter (bumped by the stream -+ // backend on user nav actions and on libbluray/libdvdnav-internal -+ // playlist/title/cell transitions). -+ uint32_t last_discontinuity_id; -+ bool discontinuity_seen; -+ -+ // DVD-only: dvd_subtitle track we force-selected for the menu, and the -+ // selection it displaced (restored on menu close). -+ struct track *menu_selected_track; -+ struct track *menu_saved_track; -+ -+ // Last menu_active pushed through property-change notification. -+ bool last_menu_active; -+ bool menu_active_seen; -+}; -+ -+static bool is_dvd_sub_track(struct track *t) -+{ -+ return t && t->type == STREAM_SUB && t->stream && t->stream->codec && -+ t->stream->codec->codec && -+ strcmp(t->stream->codec->codec, "dvd_subtitle") == 0; -+} -+ -+static struct disc_nav_state *get_state(struct MPContext *mpctx) -+{ -+ if (!mpctx->disc_nav) -+ mpctx->disc_nav = talloc_zero(mpctx, struct disc_nav_state); -+ return mpctx->disc_nav; -+} -+ -+// Forget everything tied to the previous playlist entry. Stale state (e.g. -+// another disc's discontinuity counter) must not leak into the next file. -+void disc_nav_reset(struct MPContext *mpctx) -+{ -+ struct disc_nav_state *st = mpctx->disc_nav; -+ if (!st) -+ return; -+ if (st->overlay_visible) { -+ osd_set_bitmaps(mpctx->osd, OSDTYPE_DISC_MENU, NULL); -+ mp_input_disable_section(mpctx->input, "discnav"); -+ } -+ mp_image_unrefp(&st->bd_image); -+ *st = (struct disc_nav_state){0}; -+} -+ -+void disc_nav_destroy(struct MPContext *mpctx) -+{ -+ if (!mpctx->disc_nav) -+ return; -+ mp_image_unrefp(&mpctx->disc_nav->bd_image); -+ TA_FREEP(&mpctx->disc_nav); -+} -+ -+struct stream *disc_nav_get_stream(struct MPContext *mpctx) -+{ -+ if (!mpctx->demuxer || !mpctx->demuxer->stream) -+ return NULL; -+ struct stream *s = mpctx->demuxer->stream; -+ if (!s->info || !s->info->name) -+ return NULL; -+ const char *n = s->info->name; -+ if (strcmp(n, "dvdnav") == 0 || strcmp(n, "ifo_dvdnav") == 0 || -+ strcmp(n, "bd") == 0 || strcmp(n, "bdmv/bluray") == 0) -+ { -+ return s; -+ } -+ return NULL; -+} -+ -+bool disc_nav_mouse_pos_to_src(struct MPContext *mpctx, int src_w, int src_h, -+ int *out_x, int *out_y) -+{ -+ struct vo *vo = mpctx->video_out; -+ if (!vo || !vo->config_ok || src_w <= 0 || src_h <= 0) -+ return false; -+ int wx, wy, hover; -+ mp_input_get_mouse_pos(mpctx->input, &wx, &wy, &hover); -+ struct mp_rect src, dst; -+ struct mp_osd_res osd; // mandatory out param; ignored -+ vo_get_src_dst_rects(vo, &src, &dst, &osd); -+ int dw = dst.x1 - dst.x0; -+ int dh = dst.y1 - dst.y0; -+ if (dw <= 0 || dh <= 0) -+ return false; -+ double fx = (wx - dst.x0) / (double)dw; -+ double fy = (wy - dst.y0) / (double)dh; -+ if (fx < 0 || fx > 1 || fy < 0 || fy > 1) -+ return false; -+ *out_x = MPMIN((int)(fx * src_w), src_w - 1); -+ *out_y = MPMIN((int)(fy * src_h), src_h - 1); -+ return true; -+} -+ -+// Push the current menu/highlight state to the dvd_subtitle decoders. -+static void push_dvd_overlay(struct MPContext *mpctx, struct stream_nav_state *nav) -+{ -+ for (int n = 0; n < mpctx->num_tracks; n++) { -+ struct track *t = mpctx->tracks[n]; -+ if (!is_dvd_sub_track(t) || !t->d_sub) -+ continue; -+ sub_control(t->d_sub, SD_CTRL_APPLY_DVDNAV, nav); -+ } -+} -+ -+static void push_bd_overlay(struct MPContext *mpctx, struct stream *s, -+ struct stream_nav_state *nav, bool visible) -+{ -+ struct disc_nav_state *st = get_state(mpctx); -+ -+ if (!visible) { -+ if (st->overlay_visible) -+ osd_set_bitmaps(mpctx->osd, OSDTYPE_DISC_MENU, NULL); -+ st->bd_last_change_id = 0; -+ st->bd_last_vo_res = (struct mp_osd_res){0}; -+ return; -+ } -+ -+ if (nav->src_w <= 0 || nav->src_h <= 0) -+ return; -+ -+ // Skip the work when nothing the renderer cares about changed. -+ struct mp_osd_res vo_res = osd_get_vo_res(mpctx->osd); -+ if (st->overlay_visible && -+ st->bd_last_change_id == nav->change_id && -+ osd_res_equals(vo_res, st->bd_last_vo_res)) -+ return; -+ -+ if (!st->bd_image || -+ st->bd_image->w != nav->src_w || st->bd_image->h != nav->src_h) -+ { -+ mp_image_unrefp(&st->bd_image); -+ st->bd_image = mp_image_alloc(IMGFMT_BGRA, nav->src_w, nav->src_h); -+ if (!st->bd_image) -+ return; -+ talloc_steal(st, st->bd_image); -+ } -+ -+ // The OSD/VO may still hold references to the previous frame's pixels. -+ if (!mp_image_make_writeable(st->bd_image)) -+ return; -+ -+ struct mp_image *img = st->bd_image; -+ struct stream_nav_overlay_req req = { -+ .w = img->w, -+ .h = img->h, -+ .stride = img->stride[0], -+ .dst = img->planes[0], -+ }; -+ if (stream_control(s, STREAM_CTRL_GET_NAV_OVERLAY, &req) < 1) -+ return; -+ -+ struct sub_bitmap part = { -+ .bitmap = img->planes[0], -+ .stride = img->stride[0], -+ .w = req.w, -+ .h = req.h, -+ .dw = req.w, -+ .dh = req.h, -+ }; -+ struct sub_bitmaps imgs = { -+ .format = SUBBITMAP_BGRA, -+ .parts = &part, -+ .num_parts = 1, -+ .packed = img, -+ .packed_w = img->w, -+ .packed_h = img->h, -+ .change_id = nav->change_id ? (int)nav->change_id : 1, -+ .video_color_space = true, -+ }; -+ osd_rescale_bitmaps(&imgs, nav->src_w, nav->src_h, vo_res, 0); -+ osd_set_bitmaps(mpctx->osd, OSDTYPE_DISC_MENU, &imgs); -+ st->bd_last_change_id = nav->change_id; -+ st->bd_last_vo_res = vo_res; -+} -+ -+// Sync demuxer->edition with what the disc is actually playing. -+static void sync_current_edition(struct MPContext *mpctx, struct stream *s, -+ struct stream_nav_state *nav) -+{ -+ struct demuxer *demuxer = mpctx->demuxer; -+ if (!demuxer || demuxer->num_editions <= 0 || !demuxer->desc || -+ strcmp(demuxer->desc->name, "disc") != 0) -+ return; -+ -+ int desired = demuxer->edition; -+ if (nav->menu_active) { -+ desired = demuxer->num_editions - 1; -+ } else { -+ unsigned title; -+ if (stream_control(s, STREAM_CTRL_GET_CURRENT_TITLE, &title) >= 1 && -+ (int)title < demuxer->num_editions - 1) -+ { -+ desired = (int)title; -+ } -+ } -+ if (desired != demuxer->edition) { -+ MP_VERBOSE(s, "current-edition %d->%d " -+ "(menu_active=%d)\n", -+ demuxer->edition, desired, nav->menu_active); -+ demuxer->edition = desired; -+ mp_notify_property(mpctx, "current-edition"); -+ } -+} -+ -+// Catch async playlist/title hops driven by the disc itself (HDMV bytecode, -+// dvdnav HOP_CHANNEL, etc.). -+static void check_async_discontinuity(struct MPContext *mpctx, struct stream *s, -+ struct stream_nav_state *nav) -+{ -+ struct disc_nav_state *st = get_state(mpctx); -+ if (!mpctx->demuxer) -+ return; -+ if (!st->discontinuity_seen) { -+ st->last_discontinuity_id = nav->discontinuity_id; -+ st->discontinuity_seen = true; -+ return; -+ } -+ if (nav->discontinuity_id == st->last_discontinuity_id) -+ return; -+ MP_VERBOSE(s, "async discontinuity %u->%u, flushing\n", -+ st->last_discontinuity_id, nav->discontinuity_id); -+ st->last_discontinuity_id = nav->discontinuity_id; -+ reset_playback_state(mpctx); -+ demux_flush(mpctx->demuxer); -+} -+ -+#if 0 -+static void sync_disc_track_selection(struct MPContext *mpctx, struct stream *s, -+ struct stream_nav_state *nav) -+{ -+ struct disc_nav_state *st = get_state(mpctx); -+ bool first = !st->track_sync_seen; -+ bool hopped = nav->discontinuity_id != st->last_track_disc_id; -+ st->last_track_disc_id = nav->discontinuity_id; -+ st->track_sync_seen = true; -+ -+ if (!first && hopped && nav->no_audio && -+ mpctx->opts->stream_id[0][STREAM_AUDIO] != -2) -+ { -+ // The disc hopped into a domain without any audio stream. Deselect audio. -+ if (mpctx->current_track[0][STREAM_AUDIO]) -+ mp_switch_track_n(mpctx, 0, STREAM_AUDIO, NULL, 0); -+ st->last_audio_id = -1; -+ } else if (!first && (hopped || nav->active_audio_id != st->last_audio_id) && -+ mpctx->opts->stream_id[0][STREAM_AUDIO] == -1) -+ { -+ struct track *t = find_track_by_demuxer_id(mpctx, STREAM_AUDIO, -+ nav->active_audio_id); -+ if (t) { -+ if (mpctx->current_track[0][STREAM_AUDIO] != t) { -+ MP_VERBOSE(s, "disc audio -> demuxer_id 0x%x\n", -+ nav->active_audio_id); -+ mp_switch_track_n(mpctx, 0, STREAM_AUDIO, t, 0); -+ } -+ st->last_audio_id = nav->active_audio_id; -+ } else if (nav->active_audio_id >= 0) { -+ MP_TRACE(s, "disc audio 0x%x not in tracks yet\n", -+ nav->active_audio_id); -+ } -+ } else if (first) { -+ st->last_audio_id = nav->active_audio_id; -+ } -+ -+ if (!first && (hopped || -+ nav->active_sub_id != st->last_sub_id || -+ nav->sub_visible != st->last_sub_visible) && -+ !nav->menu_active && !st->menu_selected_track && -+ mpctx->opts->stream_id[0][STREAM_SUB] == -1) -+ { -+ if (nav->sub_visible) { -+ struct track *t = find_track_by_demuxer_id(mpctx, STREAM_SUB, -+ nav->active_sub_id); -+ if (t) { -+ if (mpctx->current_track[0][STREAM_SUB] != t) { -+ MP_VERBOSE(s, "disc sub -> demuxer_id 0x%x\n", -+ nav->active_sub_id); -+ mp_switch_track_n(mpctx, 0, STREAM_SUB, t, 0); -+ } -+ st->last_sub_id = nav->active_sub_id; -+ st->last_sub_visible = true; -+ } else if (nav->active_sub_id >= 0) { -+ MP_TRACE(s, "disc sub 0x%x not in tracks yet\n", -+ nav->active_sub_id); -+ } -+ } else { -+ if (mpctx->current_track[0][STREAM_SUB]) { -+ MP_VERBOSE(s, "disc sub -> off\n"); -+ mp_switch_track_n(mpctx, 0, STREAM_SUB, NULL, 0); -+ } -+ st->last_sub_id = nav->active_sub_id; -+ st->last_sub_visible = false; -+ } -+ } else if (first) { -+ st->last_sub_id = nav->active_sub_id; -+ st->last_sub_visible = nav->sub_visible; -+ } -+ -+ if (first) { -+ st->last_angle = nav->angle; -+ } else if (nav->angle > 0 && nav->angle != st->last_angle) { -+ MP_VERBOSE(s, "disc angle %d -> %d (of %d)\n", -+ st->last_angle, nav->angle, nav->num_angles); -+ st->last_angle = nav->angle; -+ mp_notify_property(mpctx, "angle"); -+ } -+} -+ -+#endif -+// Make sure a dvd_subtitle track is selected while a menu is visible, -+// so the SPU graphics decoded by sd_lavc reach the OSD. -+static void ensure_menu_sub_selection(struct MPContext *mpctx, bool menu_on) -+{ -+ struct disc_nav_state *st = get_state(mpctx); -+ struct track *cur = mpctx->current_track[0][STREAM_SUB]; -+ -+ // If the track list got rebuilt under us (e.g. another file/disc loaded -+ // mid-session) the cached pointers could be stale. Trust only what we -+ // can still see in mpctx->tracks. -+ for (int i = 0; i < 2; i++) { -+ struct track **slot = i ? &st->menu_saved_track : &st->menu_selected_track; -+ if (!*slot) -+ continue; -+ bool still_present = false; -+ for (int n = 0; n < mpctx->num_tracks; n++) { -+ if (mpctx->tracks[n] == *slot) { -+ still_present = true; -+ break; -+ } -+ } -+ if (!still_present) -+ *slot = NULL; -+ } -+ -+ if (menu_on) { -+ // Re-checked every frame; other selectors (stream auto-select, slave -+ // reopens) can change the sub under us. -+ if (st->menu_selected_track && cur == st->menu_selected_track) -+ return; -+ // A dvd_subtitle track is already active; nothing to force. -+ if (is_dvd_sub_track(cur)) -+ return; -+ if (mpctx->opts->stream_id[0][STREAM_SUB] == -2) -+ return; -+ struct track *pick = NULL; -+ for (int n = 0; n < mpctx->num_tracks; n++) { -+ if (is_dvd_sub_track(mpctx->tracks[n])) { -+ pick = mpctx->tracks[n]; -+ break; -+ } -+ } -+ if (!pick) -+ return; -+ // Remember the displaced selection only on the first override. -+ if (!st->menu_selected_track) -+ st->menu_saved_track = cur; -+ mp_switch_track_n(mpctx, 0, STREAM_SUB, pick, 0); -+ st->menu_selected_track = pick; -+ } else { -+ if (!st->menu_selected_track) -+ return; -+ // Only revert if our override is still the active selection. -+ if (cur == st->menu_selected_track) -+ mp_switch_track_n(mpctx, 0, STREAM_SUB, st->menu_saved_track, 0); -+ st->menu_selected_track = NULL; -+ st->menu_saved_track = NULL; -+ } -+} -+ -+void disc_nav_update(struct MPContext *mpctx) -+{ -+ struct stream *s = disc_nav_get_stream(mpctx); -+ if (!s && !mpctx->disc_nav) -+ return; -+ struct disc_nav_state *st = get_state(mpctx); -+ struct stream_nav_state nav = {0}; -+ bool have = s && stream_control(s, STREAM_CTRL_GET_NAV_STATE, &nav) >= 1; -+ bool still = have && nav.still_active; -+ if (s && still != mpctx->disc_nav_still_frame) -+ MP_VERBOSE(s, "still_frame %d->%d\n", -+ mpctx->disc_nav_still_frame, still); -+ mpctx->disc_nav_still_frame = still; -+ -+ // The BD-J VM progresses only on reads, pump them while playback idles -+ // at EOF or a held still, per libbluray's requirement. Our core wakes up -+ // at 1s intervals even at pause, but that's not enough for fluid button -+ // navigation. So, we always drive the VM at least at 20Hz. -+ if (have && mpctx->demuxer) { -+ bool bd_idle = (strcmp(s->info->name, "bd") == 0 || -+ strcmp(s->info->name, "bdmv/bluray") == 0) && -+ (still || (mpctx->video_status == STATUS_EOF && -+ mpctx->audio_status == STATUS_EOF)); -+ if (bd_idle || (nav.menu_active && mpctx->paused)) { -+ if (bd_idle) -+ demux_drive_nav(mpctx->demuxer); -+ mp_set_timeout(mpctx, 0.05); -+ } -+ } -+ -+ if (!have) { -+ if (st->overlay_visible) { -+ osd_set_bitmaps(mpctx->osd, OSDTYPE_DISC_MENU, NULL); -+ mp_input_disable_section(mpctx->input, "discnav"); -+ st->overlay_visible = false; -+ } -+ st->bd_last_change_id = 0; -+ st->bd_last_vo_res = (struct mp_osd_res){0}; -+ st->menu_selected_track = NULL; -+ st->menu_saved_track = NULL; -+ st->overlay_change_seen = false; -+ if (st->menu_active_seen && st->last_menu_active) { -+ st->last_menu_active = false; -+ mp_notify_property(mpctx, "disc-menu-active"); -+ } -+ return; -+ } -+ -+ if (!st->menu_active_seen || nav.menu_active != st->last_menu_active) { -+ st->menu_active_seen = true; -+ st->last_menu_active = nav.menu_active; -+ mp_notify_property(mpctx, "disc-menu-active"); -+ } -+ -+ check_async_discontinuity(mpctx, s, &nav); -+ sync_current_edition(mpctx, s, &nav); -+ -+ bool is_bd = strcmp(s->info->name, "bd") == 0 || strcmp(s->info->name, "bdmv/bluray") == 0; -+ bool visible = nav.menu_active && -+ (is_bd || (mp_rect_w(nav.hl.rect) > 0 && mp_rect_h(nav.hl.rect) > 0)); -+ -+ if (is_bd) { -+ push_bd_overlay(mpctx, s, &nav, visible); -+ } else { -+ push_dvd_overlay(mpctx, &nav); -+ ensure_menu_sub_selection(mpctx, nav.menu_active); -+ } -+ -+ if (!st->overlay_change_seen || nav.change_id != st->last_overlay_change_id) { -+ st->overlay_change_seen = true; -+ st->last_overlay_change_id = nav.change_id; -+ osd_changed(mpctx->osd); -+ } -+ -+ if (visible != st->overlay_visible) { -+ MP_VERBOSE(s, "overlay %s\n", visible ? "on" : "off"); -+ if (visible) { -+ mp_input_enable_section(mpctx->input, "discnav", MP_INPUT_ON_TOP); -+ } else { -+ mp_input_disable_section(mpctx->input, "discnav"); -+ } -+ st->overlay_visible = visible; -+ } -+} -diff --git a/demux/demux.c b/demux/demux.c -index dac126d..6538511 100644 ---- a/demux/demux.c -+++ b/demux/demux.c -@@ -209,6 +209,8 @@ struct demux_internal { - double hyst_secs; // stop reading till there's hyst_secs remaining - size_t hyst_bytes; // stop reading till there's hyst_bytes remaining - bool hyst_active; -+ bool nav_active; // disc navigation (DVD/BD menus) is active -+ bool nav_pump; // one-shot request to force a single read - size_t max_bytes; - size_t max_bytes_bw; - bool seekable_cache; -@@ -280,6 +282,15 @@ struct demux_internal { - // demux user state (user thread, somewhat similar to reader/decoder state) - double last_playback_pts; // last playback_pts from demux_update() - bool force_metadata_update; -+ -+ // Chapter/edition lists staged by the demux thread (demux_lists_changed), -+ // published into d_user by demux_update() on the user thread. -+ bool lists_changed; -+ struct demux_chapter *staged_chapters; -+ int num_staged_chapters; -+ struct demux_edition *staged_editions; -+ int num_staged_editions; -+ int staged_edition; - int cached_metadata_index; // speed up repeated lookups - - struct mp_recorder *dumper; -@@ -1274,6 +1285,18 @@ void demux_start_prefetch(struct demuxer *demuxer) - mp_mutex_unlock(&in->lock); - } - -+void demux_drive_nav(struct demuxer *demuxer) -+{ -+ struct demux_internal *in = demuxer->in; -+ mp_assert(demuxer == in->d_user); -+ -+ mp_mutex_lock(&in->lock); -+ in->nav_pump = true; -+ in->reading = true; -+ mp_cond_signal(&in->wakeup); -+ mp_mutex_unlock(&in->lock); -+} -+ - const char *stream_type_name(enum stream_type type) - { - switch (type) { -@@ -2329,6 +2352,13 @@ static bool read_packet(struct demux_internal *in) - prefetch_more |= true; - } - -+ // While interactive disc navigation is active, never read ahead. It would -+ // advance the disc VM past what the user is watching. Reads still happen -+ // whenever a selected stream demands data. -+ if (in->nav_active) -+ prefetch_more = false; -+ prefetch_more |= in->nav_pump; -+ - MP_TRACE(in, "bytes=%zd, read_more=%d prefetch_more=%d, refresh_more=%d\n", - (size_t)total_fw_bytes, read_more, prefetch_more, refresh_more); - if (total_fw_bytes >= in->max_bytes) { -@@ -2382,6 +2412,7 @@ static bool read_packet(struct demux_internal *in) - // Actually read a packet. Drop the lock while doing so, because waiting - // for disk or network I/O can take time. - in->reading = true; -+ in->nav_pump = false; - in->after_seek = false; - in->after_seek_to_start = false; - mp_mutex_unlock(&in->lock); -@@ -2603,6 +2634,8 @@ static void update_opts(struct demuxer *demuxer) - seekable = 1; - } - in->seekable_cache = seekable == 1; -+ if (in->d_thread->no_cache_seeking) -+ in->seekable_cache = false; - in->using_network_cache_opts = is_streaming && use_cache; - - if (!in->seekable_cache) -@@ -3207,6 +3240,53 @@ void demux_metadata_changed(demuxer_t *demuxer) - mp_mutex_unlock(&in->lock); - } - -+// Updates the duration should it need to be changed. Used for demuxers that -+// changes titles/playlists at runtime. -+void demux_set_duration(demuxer_t *demuxer, double duration) -+{ -+ mp_assert(demuxer == demuxer->in->d_thread); -+ struct demux_internal *in = demuxer->in; -+ -+ mp_mutex_lock(&in->lock); -+ in->duration = duration; -+ in->d_thread->duration = duration; -+ in->highest_av_pts = MP_NOPTS_VALUE; -+ in->events |= DEMUX_EVENT_DURATION; -+ mp_mutex_unlock(&in->lock); -+} -+ -+// Tell the cache whether interactive disc navigation is active. -+void demux_set_nav_active(demuxer_t *demuxer, bool active) -+{ -+ struct demux_internal *in = demuxer->in; -+ mp_assert(demuxer == in->d_thread); -+ -+ mp_mutex_lock(&in->lock); -+ if (in->nav_active != active) { -+ in->nav_active = active; -+ mp_cond_signal(&in->wakeup); -+ } -+ mp_mutex_unlock(&in->lock); -+} -+ -+// Updates the chapters/editions should it need to be changed. Used for demuxers -+// that changes titles/playlists at runtime. -+void demux_lists_changed(demuxer_t *demuxer) -+{ -+ mp_assert(demuxer == demuxer->in->d_thread); -+ struct demux_internal *in = demuxer->in; -+ -+ mp_mutex_lock(&in->lock); -+ in->lists_changed = true; -+ in->staged_chapters = in->d_thread->chapters; -+ in->num_staged_chapters = in->d_thread->num_chapters; -+ in->staged_editions = in->d_thread->editions; -+ in->num_staged_editions = in->d_thread->num_editions; -+ in->staged_edition = in->d_thread->edition; -+ in->events |= DEMUX_EVENT_LISTS; -+ mp_mutex_unlock(&in->lock); -+} -+ - // Called locked, with user demuxer. - static void update_final_metadata(demuxer_t *demuxer, struct timed_metadata *tm) - { -@@ -3304,6 +3384,14 @@ void demux_update(demuxer_t *demuxer, double pts) - - demuxer->events |= in->events; - in->events = 0; -+ if (in->lists_changed) { -+ in->lists_changed = false; -+ demuxer->chapters = in->staged_chapters; -+ demuxer->num_chapters = in->num_staged_chapters; -+ demuxer->editions = in->staged_editions; -+ demuxer->num_editions = in->num_staged_editions; -+ demuxer->edition = in->staged_edition; -+ } - if (demuxer->events & (DEMUX_EVENT_METADATA | DEMUX_EVENT_STREAMS)) - demux_update_replaygain(demuxer); - if (demuxer->events & DEMUX_EVENT_DURATION) -diff --git a/demux/demux.h b/demux/demux.h -index 1819e1e..b16be4e 100644 ---- a/demux/demux.h -+++ b/demux/demux.h -@@ -123,6 +123,7 @@ enum demux_event { - DEMUX_EVENT_DURATION = 1 << 3, // duration updated - DEMUX_EVENT_LIVE = 1 << 4, // live state updated - DEMUX_EVENT_SEEKABLE = 1 << 5, // seekable state updated -+ DEMUX_EVENT_LISTS = 1 << 6, // chapters / editions list changed - DEMUX_EVENT_ALL = 0xFFFF, - }; - -@@ -241,6 +242,10 @@ typedef struct demuxer { - double duration; // -1 if unknown - // File format allows PTS resets (even if the current file is without) - bool ts_resets_possible; -+ // The underlying source can switch to different content at any time, and -+ // timestamps restart per title, so cached packet ranges become stale -+ // without notice and must never be used to satisfy seeks. -+ bool no_cache_seeking; - // The file data was fully read, and there is no need to keep the stream - // open, keep the cache active, or to run the demuxer thread. Generating - // packets is not slow either (unlike e.g. libavdevice pseudo-demuxers). -@@ -331,6 +336,7 @@ void demux_start_thread(struct demuxer *demuxer); - void demux_stop_thread(struct demuxer *demuxer); - void demux_set_wakeup_cb(struct demuxer *demuxer, void (*cb)(void *ctx), void *ctx); - void demux_start_prefetch(struct demuxer *demuxer); -+void demux_drive_nav(struct demuxer *demuxer); - - bool demux_cancel_test(struct demuxer *demuxer); - bool demux_read_interrupted(struct demuxer *demuxer); -@@ -360,6 +366,9 @@ void demux_stream_tags_changed(struct demuxer *demuxer, struct sh_stream *sh, - void demux_close_stream(struct demuxer *demuxer); - - void demux_metadata_changed(demuxer_t *demuxer); -+void demux_set_duration(demuxer_t *demuxer, double duration); -+void demux_set_nav_active(demuxer_t *demuxer, bool active); -+void demux_lists_changed(demuxer_t *demuxer); - void demux_update(demuxer_t *demuxer, double playback_pts); - - bool demux_cache_dump_set(struct demuxer *demuxer, double start, double end, -diff --git a/demux/demux_disc.c b/demux/demux_disc.c -index 86557a7..327a13d 100644 ---- a/demux/demux_disc.c -+++ b/demux/demux_disc.c -@@ -29,38 +29,50 @@ - - #include "video/csputils.h" - -+// DVD-Video has 32 subpicture (SPU) streams, mapped to PES substream IDs 0x20..0x3F. -+#define MAX_DVD_SPU_STREAMS 32 -+ -+// If the timestamp difference between subsequent packets is this big, assume -+// a reset. It should be big enough to account for 1. low video framerates and -+// large audio frames, and 2. bad interleaving. -+#define DTS_RESET_THRESHOLD 5.0 -+ - struct priv { - struct demuxer *slave; -- // streams[slave_stream_index] == our_stream -- struct sh_stream **streams; -- int num_streams; -- // This contains each DVD sub stream, or NULL. Needed because DVD packets -- // can come arbitrarily late in the MPEG stream, so the slave demuxer -- // might add the streams only later. -- struct sh_stream *dvd_subs[32]; -+ -+ // All outer sh_streams we have ever surfaced to the parent demuxer. -+ struct sh_stream **outer_streams; -+ int num_outer_streams; -+ -+ // Maps the current slave's stream index to its matching outer sh_stream. -+ struct sh_stream **slave_to_outer; -+ int slave_to_outer_count; -+ -+ // DVD-only: pre-registered sub streams keyed by PES substream ID minus -+ // 0x20, carrying the disc-level CLUT as extradata. -+ struct sh_stream *dvd_subs[MAX_DVD_SPU_STREAMS]; -+ - // Used to rewrite the raw MPEG timestamps to playback time. - double base_time; // playback display start time of current segment - double base_dts; // packet DTS that maps to base_time - double last_dts; // DTS of previously demuxed packet - bool seek_reinit; // needs reinit after seek -+ uint32_t last_discontinuity_id; // Last source-position-jump id seen from the stream. -+ bool nav_active; // last interactive-nav state pushed to the cache - -- bool is_dvd, is_cdda; -+ bool is_dvd, is_cdda, is_bd; - }; - --// If the timestamp difference between subsequent packets is this big, assume --// a reset. It should be big enough to account for 1. low video framerates and --// large audio frames, and 2. bad interleaving. --#define DTS_RESET_THRESHOLD 5.0 -- - static void reselect_streams(demuxer_t *demuxer) - { - struct priv *p = demuxer->priv; -+ if (!p->slave) -+ return; - int num_slave = demux_get_num_stream(p->slave); -- for (int n = 0; n < MPMIN(num_slave, p->num_streams); n++) { -- if (p->streams[n]) { -- demuxer_select_track(p->slave, demux_get_stream(p->slave, n), -- MP_NOPTS_VALUE, demux_stream_is_selected(p->streams[n])); -- } -+ for (int n = 0; n < num_slave && n < p->slave_to_outer_count; n++) { -+ struct sh_stream *outer = p->slave_to_outer[n]; -+ demuxer_select_track(p->slave, demux_get_stream(p->slave, n), -+ MP_NOPTS_VALUE, outer && demux_stream_is_selected(outer)); - } - } - -@@ -82,15 +94,13 @@ static void add_dvd_streams(demuxer_t *demuxer) - return; - struct stream_dvd_info_req info; - if (stream_control(stream, STREAM_CTRL_GET_DVD_INFO, &info) > 0) { -- for (int n = 0; n < MPMIN(32, info.num_subs); n++) { -+ for (int n = 0; n < MPMIN(MAX_DVD_SPU_STREAMS, info.num_subs); n++) { - struct sh_stream *sh = demux_alloc_sh_stream(STREAM_SUB); - sh->demuxer_id = n + 0x20; - sh->codec->codec = "dvd_subtitle"; - get_disc_lang(stream, sh, true); -- // p->streams _must_ match with p->slave->streams, so we can't add -- // it yet - it has to be done when the real stream appears, which -- // could be right on start, or any time later. - p->dvd_subs[n] = sh; -+ MP_TARRAY_APPEND(p, p->outer_streams, p->num_outer_streams, sh); - - // emulate the extradata - struct mp_csp_params csp = MP_CSP_PARAMS_DEFAULTS; -@@ -120,71 +130,168 @@ static void add_dvd_streams(demuxer_t *demuxer) - } - } - --static void add_streams(demuxer_t *demuxer) -+// Take ownership of a slave sh_stream's codec params into the outer demuxer -+// so it survives a slave reopen. -+static void adopt_codec_params(struct sh_stream *outer, struct sh_stream *src) -+{ -+ if (outer->codec != src->codec) { -+ if (!outer->ds) -+ talloc_free(outer->codec); -+ outer->codec = src->codec; -+ talloc_steal(outer, outer->codec); -+ } -+ outer->codec->first_packet = NULL; -+ outer->codec->decoder = NULL; -+ outer->codec->decoder_desc = NULL; -+} -+ -+// Whether a slave stream carries a Blu-ray presentation stream the player -+// decodes itself. -+static bool bd_stream_pid_valid(struct sh_stream *src) -+{ -+ int id = src->demuxer_id; -+ switch (src->type) { -+ case STREAM_VIDEO: // primary (0x1011) + additional streams such as the -+ // Dolby Vision EL (0x1015), secondary video (PiP) -+ return (id >= 0x1011 && id <= 0x101F) || -+ (id >= 0x1B00 && id <= 0x1B1F); -+ case STREAM_AUDIO: // primary, secondary -+ return (id >= 0x1100 && id <= 0x111F) || -+ (id >= 0x1A00 && id <= 0x1A1F); -+ case STREAM_SUB: // PG, TextST -+ return (id >= 0x1200 && id <= 0x12FF) || -+ (id >= 0x1800 && id <= 0x181F); -+ default: -+ return false; -+ } -+} -+ -+// Find the outer stream for a slave stream. `ordinal` disambiguates multiple -+// slave streams sharing one (type, id): a BD TrueHD PID carries the TrueHD -+// stream and its embedded AC-3 compatibility core as two lavf streams with -+// the same id, and each must keep its own outer track (and decoder). -+static struct sh_stream *find_outer_for_slave(struct priv *p, -+ struct sh_stream *src, -+ int ordinal) -+{ -+ if (src->type == STREAM_SUB && src->demuxer_id >= 0x20 && -+ src->demuxer_id <= 0x3F) -+ { -+ struct sh_stream *sub = p->dvd_subs[src->demuxer_id - 0x20]; -+ if (sub) -+ return sub; -+ } -+ for (int i = 0; i < p->num_outer_streams; i++) { -+ struct sh_stream *sh = p->outer_streams[i]; -+ if (sh && sh->type == src->type && sh->demuxer_id == src->demuxer_id && -+ ordinal-- == 0) -+ return sh; -+ } -+ return NULL; -+} -+ -+// Build / rebuild the slave-index -> outer-sh map. For each slave stream reuse -+// or register a fresh outer sh_stream as follows and expose it to the parent demuxer. -+static void sync_streams(struct demuxer *demuxer) - { - struct priv *p = demuxer->priv; -- int old_num = p->num_streams; -+ int num_slave = demux_get_num_stream(p->slave); -+ -+ if (num_slave > p->slave_to_outer_count) { -+ MP_TARRAY_GROW(p, p->slave_to_outer, num_slave - 1); -+ for (int n = p->slave_to_outer_count; n < num_slave; n++) -+ p->slave_to_outer[n] = NULL; -+ p->slave_to_outer_count = num_slave; -+ } - -- for (int n = old_num; n < demux_get_num_stream(p->slave); n++) { -+ for (int n = 0; n < num_slave; n++) { - struct sh_stream *src = demux_get_stream(p->slave, n); -- if (src->type == STREAM_SUB) { -- struct sh_stream *sub = NULL; -- if (src->demuxer_id >= 0x20 && src->demuxer_id <= 0x3F) -- sub = p->dvd_subs[src->demuxer_id - 0x20]; -- if (sub) { -- mp_assert(p->num_streams == n); // directly mapped -- MP_TARRAY_APPEND(p, p->streams, p->num_streams, sub); -- continue; -- } -+ -+ // A TS padding PID is never an audio/video track, including when the -+ // Blu-ray bytes arrive through a custom stream callback instead of bd. -+ if (src->demuxer_id == 0x1FFF || (p->is_bd && !bd_stream_pid_valid(src))) { -+ MP_VERBOSE(demuxer, "ignoring non-presentation stream: " -+ "type=%s id=0x%x codec=%s\n", -+ stream_type_name(src->type), src->demuxer_id, -+ src->codec->codec ? src->codec->codec : "?"); -+ continue; - } -- struct sh_stream *sh = demux_alloc_sh_stream(src->type); -- mp_assert(p->num_streams == n); // directly mapped -- MP_TARRAY_APPEND(p, p->streams, p->num_streams, sh); -- // Copy all stream fields that might be relevant -- *sh->codec = *src->codec; -- sh->demuxer_id = src->demuxer_id; -- sh->dependent_track = src->dependent_track; -- if (src->type == STREAM_VIDEO) { -- double ar; -- if (stream_control(demuxer->stream, STREAM_CTRL_GET_ASPECT_RATIO, &ar) -- == STREAM_OK) -- { -- struct mp_image_params f = {.w = src->codec->disp_w, -- .h = src->codec->disp_h}; -- mp_image_params_set_dsize(&f, 1728 * ar, 1728); -- sh->codec->par_w = f.p_w; -- sh->codec->par_h = f.p_h; -+ -+ // Ordinal of this slave stream among those sharing its (type, id). -+ int ordinal = 0; -+ for (int m = 0; m < n; m++) { -+ struct sh_stream *prev = demux_get_stream(p->slave, m); -+ if (prev->type == src->type && prev->demuxer_id == src->demuxer_id) -+ ordinal++; -+ } -+ -+ struct sh_stream *outer = find_outer_for_slave(p, src, ordinal); -+ -+ if (!outer) { -+ outer = demux_alloc_sh_stream(src->type); -+ adopt_codec_params(outer, src); -+ outer->demuxer_id = src->demuxer_id; -+ outer->dependent_track = src->dependent_track; -+ if (src->type == STREAM_VIDEO) { -+ double ar; -+ if (stream_control(demuxer->stream, STREAM_CTRL_GET_ASPECT_RATIO, &ar) -+ == STREAM_OK) -+ { -+ struct mp_image_params f = {.w = src->codec->disp_w, -+ .h = src->codec->disp_h}; -+ mp_image_params_set_dsize(&f, 1728 * ar, 1728); -+ outer->codec->par_w = f.p_w; -+ outer->codec->par_h = f.p_h; -+ } -+ } -+ get_disc_lang(demuxer->stream, outer, p->is_dvd); -+ MP_TARRAY_APPEND(p, p->outer_streams, p->num_outer_streams, outer); -+ demux_add_sh_stream(demuxer, outer); -+ } else if (outer->type != STREAM_SUB && outer->codec && src->codec) { -+ // Codec change on a reused outer, mostly useful for BD menus, which -+ // may be MPEG-2 while the video track is H.264. -+ if (outer->codec != src->codec) { -+ MP_VERBOSE(demuxer, "stream %d codec parameters changed\n", n); -+ adopt_codec_params(outer, src); - } - } -- get_disc_lang(demuxer->stream, sh, p->is_dvd); -- demux_add_sh_stream(demuxer, sh); -+ -+ p->slave_to_outer[n] = outer; -+ } -+ -+ // Propagate outer selection state to the slave. Unmapped slave streams -+ // (ignored non-presentation streams) are kept deselected so they don't -+ // deliver packets. -+ for (int n = 0; n < num_slave; n++) { -+ struct sh_stream *outer = p->slave_to_outer[n]; -+ demuxer_select_track(p->slave, demux_get_stream(p->slave, n), -+ MP_NOPTS_VALUE, outer && demux_stream_is_selected(outer)); - } - -- // Mirror slave sh_stream_group onto the disc-level sh_streams. This is needed -+ // Mirror slave sh_stream_group onto the outer sh_streams. This is needed - // for the Dolby Vision BL+EL group, it's detected well by lavf. We could use - // the libbluray `dv_streams[]` info, but it's not available yet in release - // version, and mapping it through lavf is less code. -- for (int n = old_num; n < p->num_streams; n++) { -- struct sh_stream *disc_sh = p->streams[n]; -- if (!disc_sh || disc_sh->group) -+ for (int n = 0; n < num_slave; n++) { -+ struct sh_stream *outer = p->slave_to_outer[n]; -+ if (!outer || outer->group) - continue; - struct sh_stream *src = demux_get_stream(p->slave, n); - if (!src || !src->group) - continue; -- struct sh_stream_group *grp = talloc_zero(disc_sh, struct sh_stream_group); -+ struct sh_stream_group *grp = talloc_zero(outer, struct sh_stream_group); - for (int m = 0; m < src->group->num_members; m++) { -- struct sh_stream *sh = src->group->members[m]; -- if (!sh || sh->index < 0 || sh->index >= p->num_streams) -+ struct sh_stream *member = src->group->members[m]; -+ if (!member || member->index < 0 || -+ member->index >= p->slave_to_outer_count) - continue; -- struct sh_stream *disc_member = p->streams[sh->index]; -- if (!disc_member) -+ struct sh_stream *outer_member = p->slave_to_outer[member->index]; -+ if (!outer_member) - continue; -- MP_TARRAY_APPEND(grp, grp->members, grp->num_members, disc_member); -- disc_member->group = grp; -+ MP_TARRAY_APPEND(grp, grp->members, grp->num_members, outer_member); -+ outer_member->group = grp; - } - } -- -- reselect_streams(demuxer); - } - - static void d_seek(demuxer_t *demuxer, double seek_pts, int flags) -@@ -207,7 +314,7 @@ static void d_seek(demuxer_t *demuxer, double seek_pts, int flags) - double seek_arg[] = {seek_pts, flags}; - stream_control(demuxer->stream, STREAM_CTRL_SEEK_TO_TIME, seek_arg); - -- if (p->slave->desc->drop_buffers) -+ if (p->slave && p->slave->desc->drop_buffers) - p->slave->desc->drop_buffers(p->slave); - - p->seek_reinit = true; -@@ -228,31 +335,165 @@ static void reset_pts(demuxer_t *demuxer) - p->seek_reinit = false; - } - -+static void add_stream_chapters(struct demuxer *demuxer); -+ -+// Sync demuxer->edition with the disc's current playback position. -+static void sync_initial_edition(struct demuxer *demuxer) -+{ -+ unsigned title; -+ if (stream_control(demuxer->stream, STREAM_CTRL_GET_CURRENT_TITLE, &title) >= 1 && -+ title < (unsigned)demuxer->num_editions) -+ demuxer->edition = title; -+ struct stream_nav_state nav = {0}; -+ if (stream_control(demuxer->stream, STREAM_CTRL_GET_NAV_STATE, &nav) >= 1 -+ && nav.menu_active && demuxer->num_editions > 0) -+ { -+ demuxer->edition = demuxer->num_editions - 1; -+ } -+} -+ -+static bool reopen_slave(struct demuxer *demuxer) -+{ -+ struct priv *p = demuxer->priv; -+ -+ struct demuxer_params params = { -+ .force_format = "+lavf", -+ .external_stream = demuxer->stream, -+ .stream_flags = demuxer->stream_origin, -+ .depth = demuxer->depth + 1, -+ }; -+ if (p->is_cdda) -+ params.force_format = "+rawaudio"; -+ -+ MP_VERBOSE(demuxer, "reopening slave demuxer\n"); -+ demux_free(p->slave); -+ p->slave = NULL; -+ // Discard anything the stream wrapper buffered before the disc-nav -+ // discontinuity, and restart byte positions at 0: the post-jump data is -+ // a new stream, and the fresh slave expects to probe it from the start -+ // (the disc stream is linear and can't rewind to old positions anyway). -+ stream_rebase_position(demuxer->stream); -+ // A disc VM may briefly sit between playlists; pump the stream until the -+ // next bytes are available before probing a fresh slave demuxer. -+ uint8_t probe[192]; -+ if (stream_read_peek(demuxer->stream, probe, sizeof(probe)) <= 0) -+ return false; -+ p->slave = demux_open_url("-", ¶ms, demuxer->cancel, demuxer->global); -+ if (!p->slave) { -+ // Happens when the stream is between positions (e.g. the disc VM is -+ // mid-jump and probing hit EOF). last_discontinuity_id is left stale -+ // on purpose: the next read/seek retries the reopen. -+ MP_WARN(demuxer, "Failed to reopen slave demuxer, will retry.\n"); -+ return false; -+ } -+ -+ for (int n = 0; n < p->slave_to_outer_count; n++) -+ p->slave_to_outer[n] = NULL; -+ -+ sync_streams(demuxer); -+ -+ // Refresh duration / chapters / edition for the new playlist. -+ double len; -+ if (stream_control(demuxer->stream, STREAM_CTRL_GET_TIME_LENGTH, &len) >= 1) -+ demux_set_duration(demuxer, len); -+ else -+ demux_set_duration(demuxer, -1); -+ -+ for (int n = 0; n < demuxer->num_chapters; n++) -+ talloc_free(demuxer->chapters[n].metadata); -+ demuxer->num_chapters = 0; -+ add_stream_chapters(demuxer); -+ -+ sync_initial_edition(demuxer); -+ -+ demux_lists_changed(demuxer); -+ -+ return true; -+} -+ - static bool d_read_packet(struct demuxer *demuxer, struct demux_packet **out_pkt) - { - struct priv *p = demuxer->priv; - -- struct demux_packet *pkt = demux_read_any_packet(p->slave); -- if (!pkt) -+ struct stream_nav_state nav = {0}; -+ if (stream_control(demuxer->stream, STREAM_CTRL_GET_NAV_STATE, &nav) >= 1) { -+ if (nav.nav_active != p->nav_active) { -+ p->nav_active = nav.nav_active; -+ demux_set_nav_active(demuxer, nav.nav_active); -+ } -+ if (nav.discontinuity_id != p->last_discontinuity_id) { -+ MP_VERBOSE(demuxer, "discontinuity %u->%u, reopening slave\n", -+ p->last_discontinuity_id, nav.discontinuity_id); -+ if (!reopen_slave(demuxer)) -+ return false; -+ if (stream_control(demuxer->stream, STREAM_CTRL_GET_NAV_STATE, &nav) >= 1) -+ p->last_discontinuity_id = nav.discontinuity_id; -+ p->seek_reinit = true; -+ } -+ } -+ -+ // A discontinuity reopen failed and the retry above hasn't succeeded -+ // yet; report EOF until the stream settles and the reopen goes through. -+ if (!p->slave) - return false; - -+ struct demux_packet *pkt = demux_read_any_packet(p->slave); -+ if (!pkt) { -+ // The slave can hit EOF mid-playback when the stream layer breaks -+ // its read at a disc-driven discontinuity. -+ struct stream_nav_state nav2 = {0}; -+ if (stream_control(demuxer->stream, STREAM_CTRL_GET_NAV_STATE, &nav2) >= 1 -+ && nav2.discontinuity_id != p->last_discontinuity_id) -+ { -+ MP_VERBOSE(demuxer, "discontinuity %u->%u at EOF, reopening slave\n", -+ p->last_discontinuity_id, nav2.discontinuity_id); -+ if (!reopen_slave(demuxer)) -+ return false; -+ stream_control(demuxer->stream, STREAM_CTRL_GET_NAV_STATE, &nav2); -+ p->last_discontinuity_id = nav2.discontinuity_id; -+ p->seek_reinit = true; -+ pkt = demux_read_any_packet(p->slave); -+ } else if (p->is_bd && p->nav_active) { -+ // The slave latches the EOF and stops touching the stream. -+ // Peek to run the event loop, the VM progresses only on reads. -+ stream_read_peek(demuxer->stream, &(char){0}, 1); -+ } -+ if (!pkt) { -+ MP_VERBOSE(demuxer, "slave EOF (disc id %u)\n", -+ nav2.discontinuity_id); -+ return false; -+ } -+ } -+ - demux_update(p->slave, MP_NOPTS_VALUE); - - if (p->seek_reinit) - reset_pts(demuxer); - -- add_streams(demuxer); -- if (pkt->stream >= p->num_streams) { // out of memory? -- talloc_free(pkt); -- return true; -+ int slave_index = pkt->stream; -+ if (demux_get_num_stream(p->slave) > p->slave_to_outer_count || -+ slave_index >= p->slave_to_outer_count || -+ !p->slave_to_outer[slave_index]) -+ { -+ sync_streams(demuxer); - } - -- struct sh_stream *sh = p->streams[pkt->stream]; -- if (!demux_stream_is_selected(sh)) { -+ struct sh_stream *sh = slave_index < p->slave_to_outer_count -+ ? p->slave_to_outer[slave_index] : NULL; -+ if (!sh || !demux_stream_is_selected(sh)) { - talloc_free(pkt); - return true; - } - -+ // Tag every a/v packet with the outer stream's codec params, like -+ // demux_timeline does: disc jumps can change the codec on a reused -+ // stream, and a seek flush can discard the first post-change packet, -+ // so every packet must carry the signal. -+ if (p->nav_active && (sh->type == STREAM_VIDEO || sh->type == STREAM_AUDIO)) { -+ pkt->segmented = true; -+ pkt->codec = sh->codec; -+ } -+ - pkt->stream = sh->index; - - if (p->is_cdda) { -@@ -304,10 +545,6 @@ static void add_stream_editions(struct demuxer *demuxer) - if (stream_control(demuxer->stream, STREAM_CTRL_GET_NUM_TITLES, &titles) != STREAM_OK) - return; - for (unsigned title = 0; title < titles; ++title) { -- double duration = title; -- if (stream_control(demuxer->stream, STREAM_CTRL_GET_TITLE_LENGTH, &duration) != STREAM_OK) -- continue; -- - struct demux_edition new = { - .demuxer_id = title, - .default_edition = false, -@@ -315,6 +552,13 @@ static void add_stream_editions(struct demuxer *demuxer) - }; - MP_TARRAY_APPEND(demuxer, demuxer->editions, demuxer->num_editions, new); - -+ double duration = title; -+ if (stream_control(demuxer->stream, STREAM_CTRL_GET_TITLE_LENGTH, &duration) != STREAM_OK) { -+ mp_tags_set_str(new.metadata, "TITLE", -+ mp_tprintf(42, "title: %u", title + 1)); -+ continue; -+ } -+ - char *time = mp_format_time(duration, true); - double playlist = title; - if (stream_control(demuxer->stream, STREAM_CTRL_GET_TITLE_PLAYLIST, &playlist) == STREAM_OK) -@@ -323,6 +567,19 @@ static void add_stream_editions(struct demuxer *demuxer) - mp_tprintf(42, "title: %u (%s)", title + 1, time)); - talloc_free(time); - } -+ -+ // Append a synthetic "Disc Menu" entry, if the disc has menu support. -+ struct stream_nav_state nav = {0}; -+ if (stream_control(demuxer->stream, STREAM_CTRL_GET_NAV_STATE, &nav) < 1 || -+ !nav.nav_active) -+ return; -+ struct demux_edition menu = { -+ .demuxer_id = titles, -+ .default_edition = false, -+ .metadata = talloc_zero(demuxer, struct mp_tags), -+ }; -+ MP_TARRAY_APPEND(demuxer, demuxer->editions, demuxer->num_editions, menu); -+ mp_tags_set_str(menu.metadata, "TITLE", "Disc Menu"); - } - - static void add_stream_chapters(struct demuxer *demuxer) -@@ -360,6 +617,8 @@ static int d_open(demuxer_t *demuxer, enum demux_check check) - p->is_cdda = strcmp(sname, "cdda") == 0; - p->is_dvd = strcmp(sname, "dvdnav") == 0 || - strcmp(sname, "ifo_dvdnav") == 0; -+ p->is_bd = strcmp(sname, "bd") == 0 || -+ strcmp(sname, "bdmv/bluray") == 0; - - if (p->is_cdda) - params.force_format = "+rawaudio"; -@@ -382,9 +641,11 @@ static int d_open(demuxer_t *demuxer, enum demux_check check) - - // Can be seekable even if the stream isn't. - demuxer->seekable = true; -+ demuxer->partially_seekable = !p->is_cdda; -+ demuxer->no_cache_seeking = !p->is_cdda; - - add_dvd_streams(demuxer); -- add_streams(demuxer); -+ sync_streams(demuxer); - add_stream_chapters(demuxer); - add_stream_editions(demuxer); - -@@ -392,9 +653,16 @@ static int d_open(demuxer_t *demuxer, enum demux_check check) - if (stream_control(demuxer->stream, STREAM_CTRL_GET_TIME_LENGTH, &len) >= 1) - demuxer->duration = len; - -- unsigned title; -- if (stream_control(demuxer->stream, STREAM_CTRL_GET_CURRENT_TITLE, &title) >= 1) -- demuxer->edition = title; -+ sync_initial_edition(demuxer); -+ -+ // Opening/probing already consumed the initial title/playlist events. -+ // They are the baseline, not a jump that should discard the first clip. -+ struct stream_nav_state nav = {0}; -+ if (stream_control(demuxer->stream, STREAM_CTRL_GET_NAV_STATE, &nav) >= 1) { -+ p->last_discontinuity_id = nav.discontinuity_id; -+ p->nav_active = nav.nav_active; -+ demux_set_nav_active(demuxer, nav.nav_active); -+ } - - return 0; - } -diff --git a/demux/demux_lavf.c b/demux/demux_lavf.c -index 1be9e9f..7330204 100644 ---- a/demux/demux_lavf.c -+++ b/demux/demux_lavf.c -@@ -1900,6 +1900,9 @@ static void demux_drop_buffers_lavf(demuxer_t *demuxer) - stream_drop_buffers(priv->stream); - avio_flush(priv->avfc->pb); - avformat_flush(priv->avfc); -+ // Clear sticky EOF/error to reuse this demuxer. -+ priv->avfc->pb->eof_reached = 0; -+ priv->avfc->pb->error = 0; - reset_dovi_split_state(demuxer); - } - -diff --git a/etc/input.conf b/etc/input.conf -index 9519bdb..315b5b6 100644 ---- a/etc/input.conf -+++ b/etc/input.conf -@@ -234,6 +234,22 @@ - #Ctrl+KP_UP add video-align-y -0.01 # align video up - #Ctrl+KP_PGUP add video-align-x 0.01; add video-align-y -0.01 # align video right and up - -+# -+# The "{discnav}" section is enabled only while a disc menu with a button -+# highlight is actually on screen. -+# -+#UP {discnav} discnav up -+#DOWN {discnav} discnav down -+#LEFT {discnav} discnav left -+#RIGHT {discnav} discnav right -+#ENTER {discnav} discnav select -+#ESC {discnav} discnav prev -+#BS {discnav} discnav prev -+#MOUSE_MOVE {discnav} discnav mouse-move -+#MBTN_LEFT {discnav} discnav mouse-click -+#Ctrl+m discnav menu # jump to the disc's root menu -+#Ctrl+M discnav popup # show/hide Blu-ray popup menu -+ - # - # Legacy bindings (may or may not be removed in the future) - # -diff --git a/meson.build b/meson.build -index 433dea0..229f7bc 100644 ---- a/meson.build -+++ b/meson.build -@@ -170,6 +170,7 @@ sources = files( - 'player/client.c', - 'player/command.c', - 'player/configfiles.c', -+ 'player/discnav.c', - 'player/external_files.c', - 'player/loadfile.c', - 'player/main.c', -diff --git a/options/options.c b/options/options.c -index 78ac3e9..497b1d2 100644 ---- a/options/options.c -+++ b/options/options.c -@@ -610,6 +610,7 @@ static const m_option_t mp_opts[] = { - {"force-iso", OPT_BOOL(force_iso)}, - {"edition", OPT_CHOICE(edition_id, {"auto", -1}), M_RANGE(0, 8190)}, - {"flatten-editions", OPT_BOOL(flatten_editions)}, -+ {"disc-menu", OPT_BOOL(disc_menu)}, - {"show-dependent-tracks", OPT_BOOL(show_dependent_tracks)}, - #if HAVE_LIBBLURAY - {"bluray", OPT_SUBSTRUCT(stream_bluray_opts, stream_bluray_conf)}, -diff --git a/options/options.h b/options/options.h -index 707a853..4087970 100644 ---- a/options/options.h -+++ b/options/options.h -@@ -272,6 +272,7 @@ typedef struct MPOpts { - int hls_bitrate; - int edition_id; - bool flatten_editions; -+ bool disc_menu; - bool show_dependent_tracks; - bool initial_audio_sync; - double sync_max_video_change; -diff --git a/player/command.c b/player/command.c -index bd82f2d..f0285a0 100644 ---- a/player/command.c -+++ b/player/command.c -@@ -1142,6 +1142,21 @@ static int mp_property_current_edition(void *ctx, struct m_property *prop, - return m_property_int_ro(action, arg, demuxer->edition); - } - -+static int mp_property_disc_menu_active(void *ctx, struct m_property *prop, -+ int action, void *arg) -+{ -+ MPContext *mpctx = ctx; -+ struct stream *s = disc_nav_get_stream(mpctx); -+ if (!s) -+ return M_PROPERTY_UNAVAILABLE; -+ struct stream_nav_state st = {0}; -+ if (stream_control(s, STREAM_CTRL_GET_NAV_STATE, &st) < 1) -+ return M_PROPERTY_UNAVAILABLE; -+ bool active = strcmp(prop->name, "disc-nav-active") == 0 -+ ? st.nav_active : st.menu_active; -+ return m_property_bool_ro(action, arg, active); -+} -+ - static int mp_property_edition(void *ctx, struct m_property *prop, - int action, void *arg) - { -@@ -1177,6 +1190,26 @@ static int mp_property_edition(void *ctx, struct m_property *prop, - } - return M_PROPERTY_OK; - } -+ case M_PROPERTY_SET: { -+ // For disc demuxers, jump the title directly, we sometimes need to -+ // react even if the actual edition "value" doesn't change. -+ if (disc_nav_get_stream(mpctx)) { -+ int new_ed = *(int *)arg; -+ if (new_ed < 0 || new_ed >= demuxer->num_editions) -+ return M_PROPERTY_ERROR; -+ unsigned new_title = new_ed; -+ if (stream_control(demuxer->stream, STREAM_CTRL_SET_CURRENT_TITLE, -+ &new_title) < 1) -+ return M_PROPERTY_ERROR; -+ mpctx->opts->edition_id = new_ed; -+ m_config_notify_change_opt_ptr(mpctx->mconfig, -+ &mpctx->opts->edition_id); -+ mp_notify_property(mpctx, "edition"); -+ mp_wakeup_core(mpctx); -+ return M_PROPERTY_OK; -+ } -+ return mp_property_generic_option(mpctx, prop, action, arg); -+ } - default: - return mp_property_generic_option(mpctx, prop, action, arg); - } -@@ -4696,6 +4731,8 @@ static const struct m_property mp_properties_base[] = { - {"chapter", mp_property_chapter}, - {"edition", mp_property_edition}, - {"current-edition", mp_property_current_edition}, -+ {"disc-menu-active", mp_property_disc_menu_active}, -+ {"disc-nav-active", mp_property_disc_menu_active}, - {"chapters", mp_property_chapters}, - {"editions", mp_property_editions}, - {"metadata", mp_property_metadata}, -@@ -5417,7 +5451,7 @@ done: - new->num_parts = 0; - } - -- osd_set_external2(mpctx->osd, new); -+ osd_set_bitmaps(mpctx->osd, OSDTYPE_EXTERNAL2, new); - mp_wakeup_core(mpctx); - cmd->overlay_osd_current = overlay_next; - } -@@ -5555,7 +5589,7 @@ static void overlay_uninit(struct MPContext *mpctx) - return; - for (int id = 0; id < cmd->num_overlays; id++) - replace_overlay(mpctx, id, &(struct overlay){0}); -- osd_set_external2(mpctx->osd, NULL); -+ osd_set_bitmaps(mpctx->osd, OSDTYPE_EXTERNAL2, NULL); - for (int n = 0; n < 2; n++) - mp_image_unrefp(&cmd->overlay_osd[n].packed); - } -@@ -7467,6 +7501,50 @@ static void cmd_context_menu(void *p) - vo_control(vo, VOCTRL_SHOW_MENU, NULL); - } - -+static void cmd_discnav(void *p) -+{ -+ struct mp_cmd_ctx *cmd = p; -+ struct MPContext *mpctx = cmd->mpctx; -+ int action = cmd->args[0].v.i; -+ -+ struct stream *s = disc_nav_get_stream(mpctx); -+ if (!s) { -+ cmd->success = false; -+ return; -+ } -+ -+ struct stream_nav_cmd nc = { .action = action }; -+ -+ struct stream_nav_state pre = {0}; -+ stream_control(s, STREAM_CTRL_GET_NAV_STATE, &pre); -+ -+ if (action == STREAM_NAV_MOUSE_MOVE || action == STREAM_NAV_MOUSE_CLICK) { -+ double nx = cmd->args[1].v.d; -+ double ny = cmd->args[2].v.d; -+ if (nx >= 0 && ny >= 0) { -+ if (pre.src_w <= 0 || pre.src_h <= 0 || nx > 1 || ny > 1) { -+ cmd->success = false; -+ return; -+ } -+ nc.x = (int)(nx * pre.src_w); -+ nc.y = (int)(ny * pre.src_h); -+ } else if (!disc_nav_mouse_pos_to_src(mpctx, pre.src_w, pre.src_h, -+ &nc.x, &nc.y)) -+ { -+ cmd->success = false; -+ return; -+ } -+ } -+ -+ if (stream_control(s, STREAM_CTRL_NAV_CMD, &nc) < 1) { -+ cmd->success = false; -+ } else if (mpctx->demuxer && stream_nav_action_activates(action)) { -+ demux_drive_nav(mpctx->demuxer); -+ } -+ -+ mp_wakeup_core(mpctx); -+} -+ - static void cmd_flush_status_line(void *p) - { - struct mp_cmd_ctx *cmd = p; -@@ -8023,6 +8101,23 @@ const struct mp_cmd_def mp_cmds[] = { - - { "context-menu", cmd_context_menu }, - -+ { "discnav", cmd_discnav, -+ { {"action", OPT_CHOICE(v.i, -+ {"up", STREAM_NAV_UP}, -+ {"down", STREAM_NAV_DOWN}, -+ {"left", STREAM_NAV_LEFT}, -+ {"right", STREAM_NAV_RIGHT}, -+ {"select", STREAM_NAV_SELECT}, -+ {"menu", STREAM_NAV_MENU_ROOT}, -+ {"title-menu", STREAM_NAV_MENU_TITLE}, -+ {"popup", STREAM_NAV_MENU_POPUP}, -+ {"prev", STREAM_NAV_PREV_MENU}, -+ {"mouse-move", STREAM_NAV_MOUSE_MOVE}, -+ {"mouse-click", STREAM_NAV_MOUSE_CLICK})}, -+ {"x", OPT_DOUBLE(v.d), OPTDEF_DOUBLE(-1)}, -+ {"y", OPT_DOUBLE(v.d), OPTDEF_DOUBLE(-1)} }, -+ }, -+ - { "flush-status-line", cmd_flush_status_line, { {"clear", OPT_BOOL(v.b)} } }, - - { "notify-property", cmd_notify_property, { {"property", OPT_STRING(v.s)} } }, -@@ -8558,6 +8653,10 @@ void mp_option_run_callback(struct MPContext *mpctx, struct mp_option_callback * - mp_notify_property(mpctx, "current-edition"); - print_track_list(mpctx, - mp_tprintf(42, "Selected edition %d:", demuxer->edition)); -+ } else if (disc_nav_get_stream(mpctx)) { -+ unsigned new_title = opts->edition_id; -+ stream_control(demuxer->stream, -+ STREAM_CTRL_SET_CURRENT_TITLE, &new_title); - } else { - if (!mpctx->stop_play) - mpctx->stop_play = PT_CURRENT_ENTRY; -diff --git a/player/core.h b/player/core.h -index 09c667d..6dd668a 100644 ---- a/player/core.h -+++ b/player/core.h -@@ -447,6 +447,9 @@ typedef struct MPContext { - - struct screenshot_ctx *screenshot_ctx; - struct command_ctx *command_ctx; -+ struct disc_nav_state *disc_nav; -+ // Demuxer parked on an indefinite still frame (DVD/BD menu). -+ bool disc_nav_still_frame; - struct encode_lavc_context *encode_lavc_ctx; - - struct mp_option_callback *option_callbacks; -@@ -672,6 +675,16 @@ void uninit_sub_all(struct MPContext *mpctx); - void update_osd_msg(struct MPContext *mpctx); - bool update_subtitles(struct MPContext *mpctx, double video_pts); - -+// discnav.c -+struct stream; -+struct stream_nav_state; -+void disc_nav_update(struct MPContext *mpctx); -+void disc_nav_reset(struct MPContext *mpctx); -+void disc_nav_destroy(struct MPContext *mpctx); -+struct stream *disc_nav_get_stream(struct MPContext *mpctx); -+bool disc_nav_mouse_pos_to_src(struct MPContext *mpctx, int src_w, int src_h, -+ int *out_x, int *out_y); -+ - // video.c - void reset_video_state(struct MPContext *mpctx); - int init_video_decoder(struct MPContext *mpctx, struct track *track); -diff --git a/player/loadfile.c b/player/loadfile.c -index d6f44c8..5d3d721 100644 ---- a/player/loadfile.c -+++ b/player/loadfile.c -@@ -367,6 +367,23 @@ void update_demuxer_properties(struct MPContext *mpctx) - add_demuxer_tracks(mpctx, tracks); - print_track_list(mpctx, NULL); - tracks->events &= ~DEMUX_EVENT_STREAMS; -+ -+ // Streams surfaced after play_current_file's selection phase (e.g. -+ // disc-nav playlist hop, or lavf identifying a PES stream only once -+ // its first packet arrives) wouldn't otherwise get auto-selected. -+ if (mpctx->playback_initialized && mpctx->opts->stream_auto_sel) { -+ for (int t = 0; t < STREAM_TYPE_COUNT; t++) { -+ for (int i = 0; i < num_ptracks[t]; i++) { -+ if (mpctx->current_track[i][t]) -+ continue; -+ if (mpctx->opts->stream_id[i][t] == -2) -+ continue; -+ struct track *sel = select_default_track(mpctx, i, t); -+ if (sel) -+ mp_switch_track_n(mpctx, i, t, sel, 0); -+ } -+ } -+ } - } - if (events & DEMUX_EVENT_METADATA) { - struct mp_tags *info = -@@ -414,6 +431,26 @@ void update_demuxer_properties(struct MPContext *mpctx) - mp_notify_property(mpctx, "media-live"); - if (events & DEMUX_EVENT_SEEKABLE) - mp_notify_property(mpctx, "seekable"); -+ if (events & DEMUX_EVENT_LISTS) { -+ // Demuxer just published a new chapter / edition list at runtime. -+ TA_FREEP(&mpctx->chapters); -+ mpctx->num_chapters = demuxer->num_chapters; -+ if (demuxer->num_chapters > 0) { -+ mpctx->chapters = demux_copy_chapter_data(demuxer->chapters, -+ demuxer->num_chapters); -+ if (mpctx->opts->rebase_start_time) { -+ for (int n = 0; n < mpctx->num_chapters; n++) -+ mpctx->chapters[n].pts -= demuxer->start_time; -+ } -+ } -+ mp_notify(mpctx, MP_EVENT_CHAPTER_CHANGE, NULL); -+ mp_notify_property(mpctx, "chapter-list"); -+ mp_notify_property(mpctx, "chapters"); -+ mp_notify_property(mpctx, "edition"); -+ mp_notify_property(mpctx, "edition-list"); -+ mp_notify_property(mpctx, "current-edition"); -+ mp_notify_property(mpctx, "editions"); -+ } - demuxer->events = 0; - } - -@@ -1224,6 +1261,32 @@ static void load_per_file_options(m_config_t *conf, - } - } - -+static void load_disc_navigation_options(struct MPContext *mpctx) -+{ -+ struct stream *s = disc_nav_get_stream(mpctx); -+ if (!s || (strcmp(s->info->name, "bd") != 0 && -+ strcmp(s->info->name, "bdmv/bluray") != 0)) -+ return; -+ -+ struct stream_nav_state state = {0}; -+ if (stream_control(s, STREAM_CTRL_GET_NAV_STATE, &state) < 1 || -+ !state.nav_active) -+ return; -+ -+ // HDMV clips may use a codec/profile unsupported by the device. Keep -+ // trying hardware first, but allow software on outputs that can render it. -+ // In particular, mediacodec_embed accepts hardware frames only. -+ struct m_obj_settings *vos = mpctx->opts->vo->video_driver_list; -+ if (!vos || !vos[0].name || -+ (strcmp(vos[0].name, "gpu") != 0 && -+ strcmp(vos[0].name, "gpu-next") != 0)) -+ return; -+ -+ m_config_set_option_cli(mpctx->mconfig, bstr0("hwdec-software-fallback"), -+ bstr0("yes"), M_SETOPT_BACKUP); -+ MP_VERBOSE(mpctx, "HDMV navigation: enabling file-local decoder fallback.\n"); -+} -+ - static MP_THREAD_VOID open_demux_thread(void *ctx) - { - struct MPContext *mpctx = ctx; -@@ -1802,6 +1865,7 @@ static void play_current_file(struct MPContext *mpctx) - // let get_current_time() show 0 as start time (before playback_pts is set) - mpctx->last_seek_pts = 0.0; - mpctx->seek = (struct seek_params){ 0 }; -+ disc_nav_reset(mpctx); - mpctx->filter_root = mp_filter_create_root(mpctx->global); - mp_filter_graph_set_wakeup_cb(mpctx->filter_root, mp_wakeup_core_cb, mpctx); - mp_filter_graph_set_max_run_time(mpctx->filter_root, 0.1); -@@ -1916,6 +1980,8 @@ static void play_current_file(struct MPContext *mpctx) - if (mpctx->stop_play) - goto terminate_playback; - -+ load_disc_navigation_options(mpctx); -+ - if (reinit_complex_filters(mpctx, false) < 0) - goto terminate_playback; - -diff --git a/player/main.c b/player/main.c -index 6891c9d..0ef3d7f 100644 ---- a/player/main.c -+++ b/player/main.c -@@ -190,6 +190,7 @@ void mp_destroy(struct MPContext *mpctx) - mpctx->encode_lavc_ctx = NULL; - - command_uninit(mpctx); -+ disc_nav_destroy(mpctx); - - mp_clients_destroy(mpctx); - -diff --git a/player/playloop.c b/player/playloop.c -index e5dc80c..76f7a88 100644 ---- a/player/playloop.c -+++ b/player/playloop.c -@@ -1248,6 +1248,10 @@ static void handle_eof(struct MPContext *mpctx) - bool prevent_eof = - mpctx->paused && mpctx->video_out && vo_has_frame(mpctx->video_out) && - !mpctx->vo_chain->is_coverart; -+ /* A disc menu parked on an infinite still frame reports EOF so the decoder -+ * drains and the menu frame is shown. Hold it until the user navigates, -+ * rather than ending the file. */ -+ prevent_eof |= mpctx->disc_nav_still_frame; - /* It's possible for the user to simultaneously switch both audio - * and video streams to "disabled" at runtime. Handle this by waiting - * rather than immediately stopping playback due to EOF. -@@ -1298,6 +1302,8 @@ void run_playloop(struct MPContext *mpctx) - - handle_update_subtitles(mpctx); - -+ disc_nav_update(mpctx); -+ - handle_each_frame_screenshot(mpctx); - - handle_eof(mpctx); -diff --git a/stream/stream.c b/stream/stream.c -index 85d9650..190008a 100644 ---- a/stream/stream.c -+++ b/stream/stream.c -@@ -732,6 +732,15 @@ void stream_drop_buffers(stream_t *s) - stream_resize_buffer(s, 0, 0); - } - -+// Declare the current position the new logical start of the stream. Used for -+// streams whose content is replaced mid-stream (disc navigation jumps). -+// Discards buffered data and clears EOF. -+void stream_rebase_position(stream_t *s) -+{ -+ stream_drop_buffers(s); -+ s->pos = 0; -+} -+ - // Seek function bypassing the local stream buffer. - static bool stream_seek_unbuffered(stream_t *s, int64_t newpos) - { -@@ -973,6 +982,9 @@ bool stream_has_proto(const char *proto) - for (int i = 0; i < MP_ARRAY_SIZE(stream_list); i++) { - const stream_info_t *stream_info = stream_list[i]; -+ if (stream_info == &stream_info_iso && -+ strcmp(proto, "webhtv-dvdiso") == 0) -+ continue; - - bool match = false; - char **get_protocols = stream_info->get_protocols ? stream_info->get_protocols() : NULL; - char **protocols = get_protocols ? get_protocols : (char **)stream_info->protocols; -diff --git a/stream/stream.h b/stream/stream.h -index e849b53..ebd6d05 100644 ---- a/stream/stream.h -+++ b/stream/stream.h -@@ -25,6 +25,7 @@ - #include - #include - -+#include "common/common.h" - #include "misc/bstr.h" - - // Minimum guaranteed buffer and seek-back size. For any reads <= of this size, -@@ -93,6 +94,71 @@ enum stream_ctrl { - STREAM_CTRL_GET_LANG, - STREAM_CTRL_GET_CURRENT_TITLE, - STREAM_CTRL_SET_CURRENT_TITLE, -+ STREAM_CTRL_NAV_CMD, // struct stream_nav_cmd* -+ STREAM_CTRL_GET_NAV_STATE, // struct stream_nav_state* -+ STREAM_CTRL_GET_NAV_OVERLAY, // struct stream_nav_overlay_req* -+}; -+ -+// Fetch a BGRA snapshot of the disc-menu overlay (used for Blu-ray HDMV). -+struct stream_nav_overlay_req { -+ int w, h; // input: caller's plane dimensions; output: actual -+ int stride; // input: dest row stride in bytes -+ uint8_t *dst; // input: dest buffer (caller-allocated, BGRA) -+ uint32_t change_id; // output: latched change_id of the snapshot -+}; -+ -+// In-disc navigation (DVD/BD menu) actions, used with STREAM_CTRL_NAV_CMD. -+enum stream_nav_action { -+ STREAM_NAV_UP, -+ STREAM_NAV_DOWN, -+ STREAM_NAV_LEFT, -+ STREAM_NAV_RIGHT, -+ STREAM_NAV_SELECT, // activate currently highlighted button -+ STREAM_NAV_MENU_ROOT, // jump to the disc's root/title menu -+ STREAM_NAV_MENU_TITLE, // jump to the current title's menu -+ STREAM_NAV_MENU_POPUP, // BD popup menu (chapter menu on DVD) -+ STREAM_NAV_PREV_MENU, // return to previous menu / leave still -+ STREAM_NAV_MOUSE_MOVE, // mouse moved; .x,.y are video-space coords -+ STREAM_NAV_MOUSE_CLICK, // mouse button activated at .x,.y -+}; -+ -+struct stream_nav_cmd { -+ enum stream_nav_action action; -+ int x, y; // for MOUSE_* -+}; -+ -+// Whether the action can activate a button / run a disc VM command. -+static inline bool stream_nav_action_activates(enum stream_nav_action a) -+{ -+ switch (a) { -+ case STREAM_NAV_SELECT: -+ case STREAM_NAV_MOUSE_CLICK: -+ case STREAM_NAV_MENU_ROOT: -+ case STREAM_NAV_MENU_TITLE: -+ case STREAM_NAV_MENU_POPUP: -+ case STREAM_NAV_PREV_MENU: -+ return true; -+ default: -+ return false; -+ } -+} -+ -+// A DVD subpicture button highlight. -+struct mp_dvdnav_highlight { -+ struct mp_rect rect; // button rectangle in SPU/source coords -+ uint32_t palette[4]; // replacement colors for SPU pixel values 0..3 -+}; -+ -+// Snapshot of the stream's menu state. -+struct stream_nav_state { -+ bool nav_active; // interactive disc navigation is enabled -+ bool menu_active; // a selectable menu/highlight is currently visible -+ bool has_popup; // disc supports a popup menu (BD only) -+ bool still_active; // holding an indefinite still frame -+ int src_w, src_h; // dimensions of the coordinate space mouse uses -+ struct mp_dvdnav_highlight hl; // focused button highlight (DVD only) -+ uint32_t change_id; // Bumped whenever any of the above changes -+ uint32_t discontinuity_id; // Bumped when the stream's source position jumps - }; - - struct stream_lang_req { -@@ -233,6 +299,7 @@ int stream_read_partial(stream_t *s, void *buf, int buf_size); - int stream_peek(stream_t *s, int forward_size); - int stream_read_peek(stream_t *s, void *buf, int buf_size); - void stream_drop_buffers(stream_t *s); -+void stream_rebase_position(stream_t *s); - int64_t stream_get_size(stream_t *s); - - struct mpv_global; -diff --git a/stream/stream_bluray.c b/stream/stream_bluray.c -index 79ee67c..ae6dbb6 100644 ---- a/stream/stream_bluray.c -+++ b/stream/stream_bluray.c -@@ -42,9 +42,11 @@ - #include "mpv_talloc.h" - #include "common/common.h" - #include "common/msg.h" -+#include "misc/thread_tools.h" - #include "options/m_config.h" - #include "options/options.h" - #include "options/path.h" -+#include "osdep/threads.h" - #include "stream.h" - #include "osdep/io.h" - #include "osdep/timer.h" -@@ -99,6 +101,9 @@ struct bluray_priv_s { - int current_title; - int current_playlist; - -+ // Cached map from filtered title index (0..num_titles-1) to mpls_id. -+ uint32_t *title_to_playlist; -+ - int cfg_title; - int cfg_playlist; - char *cfg_device; -@@ -107,13 +112,210 @@ struct bluray_priv_s { - - struct mp_bluray_opts *opts; - struct m_config_cache *opts_cache; -+ -+ // HDMV menu support (enabled when cfg_title == BLURAY_MENU_TITLE). -+ // libbluray delivers compressed YUV+RLE overlay primitives via -+ // bd_register_overlay_proc for the HDMV graphics controller. We decompress -+ // into ig_plane and snapshot to ig_publish on every FLUSH so the player -+ // thread can read a coherent image under overlay_lock. -+ // -+ // The HDMV path intentionally consumes only libbluray's YUV/RLE overlay -+ // callback; no ARGB/JVM menu runtime is registered here. -+ bool hdmv_mode; -+ uint32_t *ig_plane; // working BGRA plane, written from callback -+ uint32_t *ig_publish; // last FLUSHed snapshot, read by player -+ int plane_w, plane_h; // current allocation size (0 if unallocated) -+ mp_mutex overlay_lock; // guards ig_publish + visibility flags -+ -+ bool overlay_ig_visible; // IG plane was FLUSHED with non-empty content -+ bool menu_event_active; // BD_EVENT_MENU == 1 -+ bool popup_supported; // BD_EVENT_POPUP == 1 -+ uint32_t nav_change_id; // bumped on FLUSH/HIDE/MENU/POPUP events -+ uint32_t discontinuity_id; // bumped on actions that may hop (SELECT...) -+ bool data_delivered; // any byte returned from fill_buffer yet -+ bool still_active; // holding an indefinite still. - }; - - extern const stream_info_t stream_info_ffmpeg; -+extern const stream_info_t stream_info_cb; - extern const stream_info_t stream_info_bdmv_dir; - - static int bluray_stream_open_internal(stream_t *s); - -+// Lazy (re-)allocation for the IG-plane working/publish buffers. -+static bool bd_ensure_plane(struct bluray_priv_s *priv, int w, int h) -+{ -+ if (w <= 0 || h <= 0) -+ return false; -+ if (priv->ig_plane && w <= priv->plane_w && h <= priv->plane_h) -+ return true; -+ int nw = MPMAX(w, priv->plane_w); -+ int nh = MPMAX(h, priv->plane_h); -+ size_t bytes = (size_t)nw * nh * 4; -+ uint32_t *plane = talloc_realloc(priv, priv->ig_plane, uint32_t, nw * nh); -+ uint32_t *publish = talloc_realloc(priv, priv->ig_publish, uint32_t, nw * nh); -+ if (!plane || !publish) -+ return false; -+ memset(plane, 0, bytes); -+ memset(publish, 0, bytes); -+ priv->ig_plane = plane; -+ priv->ig_publish = publish; -+ priv->plane_w = nw; -+ priv->plane_h = nh; -+ return true; -+} -+ -+static void bd_palette_to_bgra(const BD_PG_PALETTE_ENTRY *pg, uint32_t out[256]) -+{ -+ for (int i = 0; i < 256; i++) { -+ int Y = pg[i].Y, Cb = pg[i].Cb, Cr = pg[i].Cr, T = pg[i].T; -+ // BT.709 limited->full. -+ int y_ = (Y - 16) * 1192; // 1.164 << 10 -+ int cr = Cr - 128; -+ int cb = Cb - 128; -+ int r = (y_ + 1836 * cr + 512) >> 10; // 1.793 -+ int g = (y_ - 547 * cr - 218 * cb + 512) >> 10; // 0.534 / 0.213 -+ int b = (y_ + 2166 * cb + 512) >> 10; // 2.115 -+ r = MPCLAMP(r, 0, 255); -+ g = MPCLAMP(g, 0, 255); -+ b = MPCLAMP(b, 0, 255); -+ // Pre-multiply RGB by alpha so the OSD layer can composite directly. -+ r = r * T / 255; -+ g = g * T / 255; -+ b = b * T / 255; -+ out[i] = ((uint32_t)T << 24) | ((uint32_t)r << 16) | -+ ((uint32_t)g << 8) | (uint32_t)b; -+ } -+ // palette index 0xFF is always transparent. -+ out[0xFF] = 0; -+} -+ -+// Composite one RLE-encoded sub-bitmap into the IG plane at (ov->x, ov->y). -+static void bd_overlay_draw_rle(struct bluray_priv_s *priv, const BD_OVERLAY *ov) -+{ -+ if (!ov->img || !ov->palette || ov->w <= 0 || ov->h <= 0) -+ return; -+ uint32_t pal[256]; -+ bd_palette_to_bgra(ov->palette, pal); -+ -+ const BD_PG_RLE_ELEM *rle = ov->img; -+ for (int y = 0; y < ov->h; y++) { -+ int dst_y = ov->y + y; -+ bool in_plane = dst_y >= 0 && dst_y < priv->plane_h; -+ uint32_t *dst_row = in_plane -+ ? priv->ig_plane + (size_t)dst_y * priv->plane_w : NULL; -+ int x = 0; -+ int cols = ov->w; -+ while (x < ov->w && cols-- >= 0) { -+ int len = rle->len; -+ int color = rle->color; -+ rle++; -+ if (len == 0) -+ continue; // stray EOL, skip -+ int dst_x = ov->x + x; -+ int run = len; -+ if (dst_x < 0) { -+ int skip = MPMIN(-dst_x, run); -+ run -= skip; -+ dst_x += skip; -+ } -+ if (dst_x + run > priv->plane_w) -+ run = priv->plane_w - dst_x; -+ if (dst_row && run > 0) { -+ uint32_t c = pal[color & 0xFF]; -+ for (int i = 0; i < run; i++) -+ dst_row[dst_x + i] = c; -+ } -+ x += len; -+ } -+ if (rle->len == 0) -+ rle++; -+ } -+} -+ -+// Snapshot the working plane to ig_publish and recompute visibility. -+// Must be called with priv->overlay_lock held. -+static void bd_publish_overlay_flush(struct bluray_priv_s *priv) -+{ -+ if (!priv->ig_plane) -+ return; -+ bool any = false; -+ size_t n = (size_t)priv->plane_w * priv->plane_h; -+ memcpy(priv->ig_publish, priv->ig_plane, n * 4); -+ for (size_t i = 0; i < n; i++) { -+ if (priv->ig_publish[i] & 0xFF000000) { -+ any = true; -+ break; -+ } -+ } -+ priv->overlay_ig_visible = any; -+ priv->nav_change_id++; -+} -+ -+// Called by libbluray's HDMV graphics controller for every overlay primitive -+// on either plane. We only render the IG (menu) plane; PG (subtitles) flows -+// through the regular demuxer/sd_lavc pipeline. -+static void bd_yuv_overlay_cb(void *handle, const struct bd_overlay_s *ov) -+{ -+ struct bluray_priv_s *priv = handle; -+ if (!ov) -+ return; -+ if (ov->plane != BD_OVERLAY_IG) -+ return; -+ -+ mp_mutex_lock(&priv->overlay_lock); -+ switch (ov->cmd) { -+ case BD_OVERLAY_INIT: -+ bd_ensure_plane(priv, ov->w, ov->h); -+ if (priv->ig_plane) { -+ memset(priv->ig_plane, 0, -+ (size_t)priv->plane_w * priv->plane_h * 4); -+ } -+ priv->overlay_ig_visible = false; -+ priv->nav_change_id++; -+ break; -+ case BD_OVERLAY_CLOSE: -+ priv->overlay_ig_visible = false; -+ priv->nav_change_id++; -+ break; -+ case BD_OVERLAY_CLEAR: -+ if (priv->ig_plane) { -+ memset(priv->ig_plane, 0, -+ (size_t)priv->plane_w * priv->plane_h * 4); -+ } -+ break; -+ case BD_OVERLAY_WIPE: -+ if (priv->ig_plane) { -+ for (int y = 0; y < ov->h; y++) { -+ int dy = ov->y + y; -+ if (dy < 0 || dy >= priv->plane_h) -+ continue; -+ int dx = MPMAX(ov->x, 0); -+ int run = MPMIN(ov->w, priv->plane_w - dx); -+ if (run > 0) { -+ memset(priv->ig_plane + (size_t)dy * priv->plane_w + dx, -+ 0, run * 4); -+ } -+ } -+ } -+ break; -+ case BD_OVERLAY_DRAW: -+ if (priv->ig_plane) -+ bd_overlay_draw_rle(priv, ov); -+ break; -+ case BD_OVERLAY_HIDE: -+ priv->overlay_ig_visible = false; -+ priv->nav_change_id++; -+ break; -+ case BD_OVERLAY_FLUSH: -+ bd_publish_overlay_flush(priv); -+ break; -+ default: -+ break; -+ } -+ mp_mutex_unlock(&priv->overlay_lock); -+} -+ - inline static int play_playlist(struct bluray_priv_s *priv, int playlist) - { - return bd_select_playlist(priv->bd, playlist); -@@ -132,8 +334,13 @@ static void bluray_stream_close(stream_t *s) - - if (priv->title_info) - bd_free_title_info(priv->title_info); -- if (priv->bd) -+ if (priv->bd) { -+ if (priv->hdmv_mode) -+ bd_register_overlay_proc(priv->bd, NULL, NULL); - bd_close(priv->bd); -+ } -+ if (priv->hdmv_mode) -+ mp_mutex_destroy(&priv->overlay_lock); - if (priv->iso_stream) - free_stream(priv->iso_stream); - } -@@ -163,14 +370,14 @@ static int open_bluray_from_stream(stream_t *s, const char *url) - .cancel = s->cancel, - .url = url, - .flags = STREAM_READ | (s->stream_origin & STREAM_ORIGIN_MASK), -- .sinfo = &stream_info_ffmpeg, -+ .sinfo = bstr_startswith0(bstr0(url), "webhtv-dvdiso://") -+ ? &stream_info_cb : &stream_info_ffmpeg, - }; - - stream_t *iso_stream = NULL; - int r = stream_create_with_args(&args, &iso_stream); - if (r != STREAM_OK || !iso_stream) - return STREAM_UNSUPPORTED; -- - if (!iso_stream->seekable) { - MP_ERR(s, "Blu-ray ISO stream must be seekable.\n"); - free_stream(iso_stream); -@@ -182,13 +389,11 @@ static int open_bluray_from_stream(stream_t *s, const char *url) - free_stream(iso_stream); - return STREAM_ERROR; - } -- - if (!bd_open_stream(bd, iso_stream, read_iso_blocks)) { - bd_close(bd); - free_stream(iso_stream); - return STREAM_UNSUPPORTED; - } -- - b->bd = bd; - b->iso_stream = iso_stream; - return STREAM_OK; -@@ -204,7 +409,6 @@ static int open_bluray_from_device(stream_t *s, const char *device) - MP_ERR(s, "Couldn't open Blu-ray device: %s\n", device); - return STREAM_UNSUPPORTED; - } -- - b->bd = bd; - return STREAM_OK; - } -@@ -212,33 +416,76 @@ static int open_bluray_from_device(stream_t *s, const char *device) - static void handle_event(stream_t *s, const BD_EVENT *ev) - { - struct bluray_priv_s *b = s->priv; -+ if (b->hdmv_mode) -+ MP_VERBOSE(s, "bdnav: event %d param %u\n", ev->event, ev->param); - switch (ev->event) { - case BD_EVENT_MENU: -+ // ev->param: 1 if the disc is currently in an HDMV menu, 0 otherwise. -+ if (b->hdmv_mode) { -+ mp_mutex_lock(&b->overlay_lock); -+ b->menu_event_active = ev->param != 0; -+ b->nav_change_id++; -+ mp_mutex_unlock(&b->overlay_lock); -+ } - break; - case BD_EVENT_STILL: -+ mp_mutex_lock(&b->overlay_lock); -+ b->still_active = ev->param != 0; -+ mp_mutex_unlock(&b->overlay_lock); - break; - case BD_EVENT_STILL_TIME: -- bd_read_skip_still(b->bd); -+ if (ev->param == 0) { -+ mp_mutex_lock(&b->overlay_lock); -+ b->still_active = true; -+ mp_mutex_unlock(&b->overlay_lock); -+ } else { -+ bd_read_skip_still(b->bd); -+ } - break; - case BD_EVENT_END_OF_TITLE: -+ mp_mutex_lock(&b->overlay_lock); -+ b->still_active = false; -+ mp_mutex_unlock(&b->overlay_lock); - break; - case BD_EVENT_PLAYLIST: - b->current_playlist = ev->param; - b->current_title = bd_get_current_title(b->bd); -+ if (b->title_to_playlist) { -+ for (int i = 0; i < b->num_titles; i++) { -+ if (b->title_to_playlist[i] == (uint32_t)ev->param) { -+ b->current_title = i; -+ break; -+ } -+ } -+ } - if (b->title_info) - bd_free_title_info(b->title_info); - b->title_info = bd_get_playlist_info(b->bd, b->current_playlist, - b->current_angle); -+ if (b->hdmv_mode) { -+ mp_mutex_lock(&b->overlay_lock); -+ b->still_active = false; -+ b->discontinuity_id++; -+ mp_mutex_unlock(&b->overlay_lock); -+ } - break; - case BD_EVENT_TITLE: -- if (ev->param == BLURAY_TITLE_FIRST_PLAY) { -- b->current_title = bd_get_current_title(b->bd); -- } else -- b->current_title = ev->param; -+ b->current_title = bd_get_current_title(b->bd); - if (b->title_info) { - bd_free_title_info(b->title_info); - b->title_info = NULL; - } -+ if (b->hdmv_mode) { -+ mp_mutex_lock(&b->overlay_lock); -+ b->still_active = false; -+ b->discontinuity_id++; -+ mp_mutex_unlock(&b->overlay_lock); -+ } -+ break; -+ case BD_EVENT_PLAYITEM: -+ mp_mutex_lock(&b->overlay_lock); -+ b->still_active = false; -+ mp_mutex_unlock(&b->overlay_lock); - break; - case BD_EVENT_ANGLE: - b->current_angle = ev->param; -@@ -249,6 +496,13 @@ static void handle_event(stream_t *s, const BD_EVENT *ev) - } - break; - case BD_EVENT_POPUP: -+ // ev->param: 1 if popup menu is currently available, 0 otherwise. -+ if (b->hdmv_mode) { -+ mp_mutex_lock(&b->overlay_lock); -+ b->popup_supported = ev->param != 0; -+ b->nav_change_id++; -+ mp_mutex_unlock(&b->overlay_lock); -+ } - break; - #if BLURAY_VERSION >= BLURAY_VERSION_CODE(0, 5, 0) - case BD_EVENT_DISCONTINUITY: -@@ -260,10 +514,81 @@ static void handle_event(stream_t *s, const BD_EVENT *ev) - } - } - -+static int bluray_poll_hdmv_events(stream_t *s) -+{ -+ struct bluray_priv_s *b = s->priv; -+ // User input queues VM commands, not just BD_EVENTs. A zero-byte read -+ // executes those commands even while the current clip holds a still. -+ for (int i = 0; i < 200; i++) { -+ BD_EVENT event = {0}; -+ if (mp_cancel_test(s->cancel) || -+ bd_read_ext(b->bd, NULL, 0, &event) < 0) -+ return -1; -+ if (event.event == BD_EVENT_NONE) -+ return 0; -+ handle_event(s, &event); -+ } -+ return 0; -+} -+ - static int bluray_stream_fill_buffer(stream_t *s, void *buf, int len) - { - struct bluray_priv_s *b = s->priv; - BD_EVENT event; -+ -+ if (b->hdmv_mode) { -+ // bd_read() doesn't drive the HDMV VM, so the disc's first-play -+ // bytecode would never run and we'd be stuck with "no valid title" -+ // forever. bd_read_ext() runs the VM between event drains and also -+ // delivers one event per call, which we hand off to handle_event. -+ uint32_t disc_before_poll = b->discontinuity_id; -+ if (bluray_poll_hdmv_events(s) < 0) -+ return -1; -+ // Do not feed the first bytes of a new clip into the old demuxer. -+ if (b->data_delivered && b->discontinuity_id != disc_before_poll) -+ return 0; -+ if (b->still_active) -+ return 0; -+ int total = 0; -+ int events_seen = 0; -+ // Loop briefly to absorb event-only returns (where bd_read_ext -+ // returns 0 with a freshly produced event) before reporting EOF. -+ // If an event bumps discontinuity_id (PLAYLIST/TITLE) *after* we -+ // have already delivered data to the slave demuxer, stop here even -+ // if no data was read: the next bd_read_ext would deliver data from -+ // the new playlist, but the slave must be reopened first so it -+ // parses with the correct codec context. -+ for (int i = 0; i < 200; i++) { -+ uint32_t disc_before = b->discontinuity_id; -+ int n = bd_read_ext(b->bd, (uint8_t *)buf + total, len - total, &event); -+ if (n < 0) { -+ MP_VERBOSE(s, "bdnav: bd_read_ext err iter=%d\n", i); -+ return -1; -+ } -+ if (event.event != BD_EVENT_NONE) { -+ handle_event(s, &event); -+ events_seen++; -+ } -+ if (n > 0) { -+ total += n; -+ b->still_active = false; -+ break; -+ } -+ if (b->still_active) -+ break; -+ if (b->data_delivered && b->discontinuity_id != disc_before) -+ break; -+ if (mp_cancel_test(s->cancel)) -+ return 0; -+ mp_sleep_ns(MP_TIME_MS_TO_NS(5)); -+ } -+ if (total > 0) -+ b->data_delivered = true; -+ if (total == 0) -+ MP_VERBOSE(s, "bdnav: fill returned 0 (events=%d)\n", events_seen); -+ return total; -+ } -+ - while (bd_get_event(b->bd, &event)) - handle_event(s, &event); - return bd_read(b->bd, buf, len); -@@ -296,6 +621,12 @@ static int bluray_stream_control(stream_t *s, int cmd, void *arg) - } - case STREAM_CTRL_SET_CURRENT_TITLE: { - const uint32_t title = *((unsigned int*)arg); -+ // demux_disc appends a synthetic "Disc Menu" edition at index num_titles. -+ if (title == b->num_titles) { -+ if (!b->hdmv_mode || !bd_menu_call(b->bd, -1)) -+ return STREAM_UNSUPPORTED; -+ return STREAM_OK; -+ } - if (title >= b->num_titles || !play_title(b, title)) - return STREAM_UNSUPPORTED; - b->current_title = title; -@@ -404,6 +735,103 @@ static int bluray_stream_control(stream_t *s, int cmd, void *arg) - *(char**)arg = talloc_strdup(NULL, meta->di_name); - return STREAM_OK; - } -+ case STREAM_CTRL_NAV_CMD: { -+ if (!b->hdmv_mode) -+ return STREAM_UNSUPPORTED; -+ struct stream_nav_cmd *nav = arg; -+ uint32_t key = BD_VK_NONE; -+ switch (nav->action) { -+ case STREAM_NAV_UP: -+ key = BD_VK_UP; -+ break; -+ case STREAM_NAV_DOWN: -+ key = BD_VK_DOWN; -+ break; -+ case STREAM_NAV_LEFT: -+ key = BD_VK_LEFT; -+ break; -+ case STREAM_NAV_RIGHT: -+ key = BD_VK_RIGHT; -+ break; -+ case STREAM_NAV_SELECT: -+ key = BD_VK_ENTER; -+ break; -+ case STREAM_NAV_MENU_ROOT: -+ case STREAM_NAV_MENU_TITLE: -+ // BD doesn't distinguish "title menu", both map to disc root. -+ bd_menu_call(b->bd, -1); -+ break; -+ case STREAM_NAV_MENU_POPUP: -+ key = BD_VK_POPUP; -+ break; -+ case STREAM_NAV_PREV_MENU: -+ // No dedicated "previous menu" key; popup-toggle is the closest -+ // equivalent and behaves like "dismiss current menu" on most -+ // discs when already in popup. -+ key = BD_VK_POPUP; -+ break; -+ case STREAM_NAV_MOUSE_MOVE: -+ bd_mouse_select(b->bd, -1, nav->x, nav->y); -+ break; -+ case STREAM_NAV_MOUSE_CLICK: -+ bd_mouse_select(b->bd, -1, nav->x, nav->y); -+ key = BD_VK_MOUSE_ACTIVATE; -+ break; -+ } -+ if (key != BD_VK_NONE) -+ bd_user_input(b->bd, -1, key); -+ // The next demux read runs pending VM commands. Only actual disc -+ // events may change the generation; a page/button change is no seek. -+ return STREAM_OK; -+ } -+ case STREAM_CTRL_GET_NAV_STATE: { -+ struct stream_nav_state *st = arg; -+ if (!b->hdmv_mode) { -+ *st = (struct stream_nav_state){0}; -+ return STREAM_OK; -+ } -+ mp_mutex_lock(&b->overlay_lock); -+ bool visible = b->menu_event_active && b->overlay_ig_visible; -+ *st = (struct stream_nav_state){ -+ .nav_active = true, -+ .menu_active = visible, -+ .has_popup = b->popup_supported, -+ .still_active = b->still_active, -+ .src_w = b->plane_w, -+ .src_h = b->plane_h, -+ .change_id = b->nav_change_id, -+ .discontinuity_id = b->discontinuity_id, -+ }; -+ mp_mutex_unlock(&b->overlay_lock); -+ return STREAM_OK; -+ } -+ case STREAM_CTRL_GET_NAV_OVERLAY: { -+ if (!b->hdmv_mode) -+ return STREAM_UNSUPPORTED; -+ struct stream_nav_overlay_req *req = arg; -+ if (!req->dst || req->w <= 0 || req->h <= 0) -+ return STREAM_ERROR; -+ mp_mutex_lock(&b->overlay_lock); -+ int copy_w = MPMIN(req->w, b->plane_w); -+ int copy_h = MPMIN(req->h, b->plane_h); -+ if (b->ig_publish && b->overlay_ig_visible) { -+ for (int y = 0; y < copy_h; y++) { -+ memcpy(req->dst + y * req->stride, -+ b->ig_publish + y * b->plane_w, -+ copy_w * 4); -+ } -+ } else { -+ // Plane is hidden / pre-init; clear the caller's buffer so a -+ // stale image doesn't linger after the menu closes. -+ for (int y = 0; y < copy_h; y++) -+ memset(req->dst + y * req->stride, 0, copy_w * 4); -+ } -+ req->change_id = b->nav_change_id; -+ req->w = copy_w; -+ req->h = copy_h; -+ mp_mutex_unlock(&b->overlay_lock); -+ return STREAM_OK; -+ } - default: - break; - } -@@ -426,34 +854,26 @@ static const char *aacs_strerr(int err) - } - } - --static int check_disc_info(stream_t *s) -+static bool check_disc_info(stream_t *s) - { - struct bluray_priv_s *b = s->priv; - const BLURAY_DISC_INFO *info = bd_get_disc_info(b->bd); -- if (!info) { -- MP_ERR(s, "Couldn't read Blu-ray disc information.\n"); -- return STREAM_ERROR; -- } - - // check Blu-ray - if (!info->bluray_detected) { -- if (b->quiet_not_bluray) { -- MP_VERBOSE(s, "Given stream is not a Blu-ray.\n"); -- } else { -- MP_ERR(s, "Given stream is not a Blu-ray.\n"); -- } -- return STREAM_UNSUPPORTED; -+ MP_ERR(s, "Given stream is not a Blu-ray.\n"); -+ return false; - } - - // check AACS - if (info->aacs_detected) { - if (!info->libaacs_detected) { - MP_ERR(s, "AACS encryption detected but cannot find libaacs.\n"); -- return STREAM_ERROR; -+ return false; - } - if (!info->aacs_handled) { - MP_ERR(s, "AACS error: %s\n", aacs_strerr(info->aacs_error_code)); -- return STREAM_ERROR; -+ return false; - } - } - -@@ -461,15 +881,15 @@ static int check_disc_info(stream_t *s) - if (info->bdplus_detected) { - if (!info->libbdplus_detected) { - MP_ERR(s, "BD+ encryption detected but cannot find libbdplus.\n"); -- return STREAM_ERROR; -+ return false; - } - if (!info->bdplus_handled) { - MP_ERR(s, "Cannot decrypt BD+ encryption.\n"); -- return STREAM_ERROR; -+ return false; - } - } - -- return STREAM_OK; -+ return true; - } - - static void select_initial_title(stream_t *s, int title_guess) { -@@ -502,42 +922,45 @@ static int bluray_stream_open_internal(stream_t *s) - struct bluray_priv_s *b = s->priv; - - struct m_config_cache *opts_cache = -- m_config_cache_alloc(b, s->global, &stream_bluray_conf); -+ m_config_cache_alloc(s, s->global, &stream_bluray_conf); - - b->opts_cache = opts_cache; - b->opts = opts_cache->opts; - -+ int ret = 0; - if (!mp_msg_test(s->log, MSGL_DEBUG)) - bd_set_debug_mask(0); - -- int ret; - if (b->cfg_stream_url && b->cfg_stream_url[0]) { - ret = open_bluray_from_stream(s, b->cfg_stream_url); - } else { - char *device = NULL; -- /* find the requested device */ -- if (b->cfg_device && b->cfg_device[0]) { -+ if (b->cfg_device && b->cfg_device[0]) - device = b->cfg_device; -- } else if (b->opts->bluray_device && b->opts->bluray_device[0]) { -+ else if (b->opts->bluray_device && b->opts->bluray_device[0]) - device = b->opts->bluray_device; -- } else { -+ else - device = DEFAULT_OPTICAL_DEVICE; -- } -- - if (!device || !device[0]) { - MP_ERR(s, "No Blu-ray device/location was specified ...\n"); - ret = STREAM_UNSUPPORTED; - goto err; - } -- - ret = open_bluray_from_device(s, device); - } - if (ret != STREAM_OK) - goto err; - -- ret = check_disc_info(s); -- if (ret != STREAM_OK) -+ if (!check_disc_info(s)) { -+ ret = STREAM_UNSUPPORTED; - goto err; -+ } -+ -+ const BLURAY_DISC_INFO *disc_info = bd_get_disc_info(b->bd); -+ if (b->cfg_title == BLURAY_MENU_TITLE && -+ (!disc_info || !disc_info->top_menu_supported || -+ !disc_info->top_menu || disc_info->top_menu->bdj)) -+ b->cfg_title = BLURAY_DEFAULT_TITLE; - - /* check for available titles on disc */ - b->num_titles = bd_get_titles(b->bd, TITLES_RELEVANT, 0); -@@ -550,13 +973,17 @@ static int bluray_stream_open_internal(stream_t *s) - MP_INFO(s, "List of available titles:\n"); - - /* parse titles information */ -+ b->title_to_playlist = talloc_array(b, uint32_t, b->num_titles); - for (int i = 0; i < b->num_titles; i++) { -+ b->title_to_playlist[i] = (uint32_t)-1; - /* the information we're accessing (duration, playlist, angle count) - * doesn't depend on the angle */ - BLURAY_TITLE_INFO *ti = bd_get_title_info(b->bd, i, 0); - if (!ti) - continue; - -+ b->title_to_playlist[i] = ti->playlist; -+ - char *time = mp_format_time(ti->duration / 90000, false); - MP_INFO(s, "idx: %3d duration: %s angles: %2d (playlist: %05d.mpls)\n", - i, time, ti->angle_count, ti->playlist); -@@ -572,10 +999,35 @@ static int bluray_stream_open_internal(stream_t *s) - // initialize libbluray event queue - bd_get_event(b->bd, NULL); - -- select_initial_title(s, bd_get_main_title(b->bd)); -+ b->hdmv_mode = b->cfg_title == BLURAY_MENU_TITLE; -+ MP_VERBOSE(s, "bdnav: cfg_title=%d hdmv_mode=%d\n", b->cfg_title, b->hdmv_mode); -+ if (b->hdmv_mode) { -+ mp_mutex_init(&b->overlay_lock); -+ bd_register_overlay_proc(b->bd, b, bd_yuv_overlay_cb); -+ if (!bd_play(b->bd)) { -+ bd_register_overlay_proc(b->bd, NULL, NULL); -+ mp_mutex_destroy(&b->overlay_lock); -+ b->hdmv_mode = false; -+ b->cfg_title = BLURAY_DEFAULT_TITLE; -+ MP_VERBOSE(s, "bdnav: menu start failed; using main title.\n"); -+ } else { -+ // bd_play() schedules FIRST PLAY; the VM executes on read_ext(). -+ // Jumping to TOP MENU here skips its register/menu initialization -+ // and can trap authored discs in a title-0 loop with no playlist. -+ b->current_title = bd_get_current_title(b->bd); -+ MP_VERBOSE(s, "bdnav: HDMV entered; current title=%d\n", -+ b->current_title); -+ } -+ } -+ if (!b->hdmv_mode) { -+ select_initial_title(s, bd_get_main_title(b->bd)); -+ } - -- if (!bd_select_angle(b->bd, b->opts->angle - 1)) -- MP_WARN(s, "Couldn't select angle '%d'.\n", b->opts->angle - 1); -+ // Angle selection is only valid once a playlist has been picked. -+ if (!b->hdmv_mode) { -+ if (!bd_select_angle(b->bd, b->opts->angle - 1)) -+ MP_WARN(s, "Couldn't select angle '%d'.\n", b->opts->angle - 1); -+ } - - b->current_angle = bd_get_current_angle(b->bd); - -@@ -606,12 +1058,15 @@ static int bluray_stream_open(stream_t *s) - - struct MPOpts *opts = mp_get_config_group(s, s->global, &mp_opt_root); - int edition_id = opts->edition_id; -+ bool disc_menu = opts->disc_menu; - talloc_free(opts); - - if (edition_id >= 0) { - b->cfg_title = edition_id; -- } else if (bstr_equals0(title, "longest") || bstr_equals0(title, "first")) { -- b->cfg_title = BLURAY_DEFAULT_TITLE; -+ } else if (title.len == 0 || bstr_equals0(title, "longest") || -+ bstr_equals0(title, "first")) -+ { -+ b->cfg_title = disc_menu ? BLURAY_MENU_TITLE : BLURAY_DEFAULT_TITLE; - } else if (bstr_equals0(title, "menu")) { - b->cfg_title = BLURAY_MENU_TITLE; - } else if (bstr_equals0(title, "mpls")) { -@@ -658,7 +1113,8 @@ int stream_open_bluray_iso(stream_t *stream, const char *url, bool local_path) - stream->priv = b; - - struct MPOpts *opts = mp_get_config_group(stream, stream->global, &mp_opt_root); -- b->cfg_title = opts->edition_id >= 0 ? opts->edition_id : BLURAY_DEFAULT_TITLE; -+ b->cfg_title = opts->edition_id >= 0 ? opts->edition_id : -+ (opts->disc_menu ? BLURAY_MENU_TITLE : BLURAY_DEFAULT_TITLE); - talloc_free(opts); - - if (local_path) -@@ -673,7 +1129,6 @@ int stream_open_bluray_iso(stream_t *stream, const char *url, bool local_path) - stream->priv = NULL; - return r; - } -- - stream->info = &stream_info_bdmv_dir; - return STREAM_OK; - } -@@ -722,8 +1177,11 @@ static int bdmv_dir_stream_open(stream_t *stream) - struct bluray_priv_s *priv = talloc_ptrtype(stream, priv); - stream->priv = priv; - struct MPOpts *opts = mp_get_config_group(NULL, stream->global, &mp_opt_root); -+ int default_title = opts->edition_id >= 0 ? opts->edition_id -+ : opts->disc_menu ? BLURAY_MENU_TITLE -+ : BLURAY_DEFAULT_TITLE; - *priv = (struct bluray_priv_s){ -- .cfg_title = opts->edition_id >= 0 ? opts->edition_id : BLURAY_DEFAULT_TITLE, -+ .cfg_title = default_title, - }; - talloc_free(opts); - -diff --git a/stream/stream_dvdnav.c b/stream/stream_dvdnav.c -index 985c40e..dacb8e1 100644 ---- a/stream/stream_dvdnav.c -+++ b/stream/stream_dvdnav.c -@@ -606,6 +606,7 @@ static bool open_dvdnav_path(stream_t *stream, const char *filename) - } - - extern const stream_info_t stream_info_ffmpeg; -+extern const stream_info_t stream_info_cb; - - static int open_dvdnav_iso_stream(stream_t *stream, const char *url) - { -@@ -615,7 +616,8 @@ static int open_dvdnav_iso_stream(stream_t *stream, const char *url) - .cancel = stream->cancel, - .url = url, - .flags = STREAM_READ | (stream->stream_origin & STREAM_ORIGIN_MASK), -- .sinfo = &stream_info_ffmpeg, -+ .sinfo = bstr_startswith0(bstr0(url), "webhtv-dvdiso://") -+ ? &stream_info_cb : &stream_info_ffmpeg, - }; - - if (stream_create_with_args(&args, &priv->iso_stream) != STREAM_OK || -diff --git a/stream/stream_iso.c b/stream/stream_iso.c -index dcf1481..dac0e7d 100644 ---- a/stream/stream_iso.c -+++ b/stream/stream_iso.c -@@ -40,9 +40,12 @@ static int iso_stream_open(stream_t *stream) - { - struct MPOpts *opts = mp_get_config_group(stream, stream->global, &mp_opt_root); - bool force_iso = opts->force_iso; -+ bool disc_menu = opts->disc_menu; - talloc_free(opts); - -- bool is_iso_candidate = force_iso || has_iso_extension(bstr0(stream->url)); -+ bool is_webhtv_iso = bstr_startswith0(bstr0(stream->url), "webhtv-dvdiso://"); -+ bool is_iso_candidate = force_iso || has_iso_extension(bstr0(stream->url)) || -+ (disc_menu && is_webhtv_iso); - if (!is_iso_candidate) - return STREAM_UNSUPPORTED; - if (!stream->access_references) -@@ -101,7 +104,7 @@ static int iso_stream_open(stream_t *stream) - const stream_info_t stream_info_iso = { - .name = "iso", - .open = iso_stream_open, -- .protocols = (const char*const[]){ "file", "http", "https", "proxy", "", -+ .protocols = (const char*const[]){ "file", "http", "https", "proxy", "webhtv-dvdiso", "", - NULL }, - .stream_origin = STREAM_ORIGIN_UNSAFE, - }; -diff --git a/sub/dec_sub.h b/sub/dec_sub.h -index d8f1539..63832cb 100644 ---- a/sub/dec_sub.h -+++ b/sub/dec_sub.h -@@ -5,6 +5,7 @@ - #include - - #include "player/core.h" -+#include "stream/stream.h" - #include "osd.h" - - struct sh_stream; -@@ -21,6 +22,7 @@ enum sd_ctrl { - SD_CTRL_SET_VIDEO_DEF_FPS, - SD_CTRL_RESET_SOFT, - SD_CTRL_UPDATE_OPTS, -+ SD_CTRL_APPLY_DVDNAV, // const struct stream_nav_state * - }; - - enum sd_text_type { -diff --git a/sub/osd.c b/sub/osd.c -index ef646b6..0a073bd 100644 ---- a/sub/osd.c -+++ b/sub/osd.c -@@ -192,7 +192,8 @@ void osd_free(struct osd_state *osd) - if (!osd) - return; - osd_destroy_backend(osd); -- talloc_free(osd->objs[OSDTYPE_EXTERNAL2]->external2); -+ talloc_free(osd->objs[OSDTYPE_EXTERNAL2]->image_overlay); -+ talloc_free(osd->objs[OSDTYPE_DISC_MENU]->image_overlay); - mp_mutex_destroy(&osd->lock); - talloc_free(osd); - } -@@ -274,12 +275,12 @@ void osd_set_progbar(struct osd_state *osd, struct osd_progbar_state *s) - mp_mutex_unlock(&osd->lock); - } - --void osd_set_external2(struct osd_state *osd, struct sub_bitmaps *imgs) -+void osd_set_bitmaps(struct osd_state *osd, int type, struct sub_bitmaps *imgs) - { - mp_mutex_lock(&osd->lock); -- struct osd_object *obj = osd->objs[OSDTYPE_EXTERNAL2]; -- talloc_free(obj->external2); -- obj->external2 = sub_bitmaps_copy(NULL, imgs); -+ struct osd_object *obj = osd->objs[type]; -+ talloc_free(obj->image_overlay); -+ obj->image_overlay = sub_bitmaps_copy(NULL, imgs); - obj->vo_change_id += 1; - osd->want_redraw_notification = true; - mp_mutex_unlock(&osd->lock); -@@ -306,7 +307,8 @@ static void check_obj_resize(struct osd_state *osd, struct mp_osd_res res, - void osd_resize(struct osd_state *osd, struct mp_osd_res res) - { - mp_mutex_lock(&osd->lock); -- int types[] = {OSDTYPE_OSD, OSDTYPE_EXTERNAL, OSDTYPE_EXTERNAL2, -1}; -+ int types[] = {OSDTYPE_OSD, OSDTYPE_EXTERNAL, OSDTYPE_EXTERNAL2, -+ OSDTYPE_DISC_MENU, -1}; - for (int n = 0; types[n] >= 0; n++) - check_obj_resize(osd, res, osd->objs[types[n]]); - mp_mutex_unlock(&osd->lock); -@@ -331,10 +333,11 @@ static struct sub_bitmaps *render_object(struct osd_state *osd, - } else if (obj->type == OSDTYPE_SUB2) { - if (obj->sub && sub_is_secondary_visible(obj->sub)) - res = sub_get_bitmaps(obj->sub, obj->vo_res, format, video_pts); -- } else if (obj->type == OSDTYPE_EXTERNAL2) { -- if (obj->external2 && obj->external2->format) { -- res = sub_bitmaps_copy(NULL, obj->external2); // need to be owner -- obj->external2->change_id = 0; -+ } else if (obj->type == OSDTYPE_EXTERNAL2 || -+ obj->type == OSDTYPE_DISC_MENU) { -+ if (obj->image_overlay && obj->image_overlay->format) { -+ res = sub_bitmaps_copy(NULL, obj->image_overlay); // need to be owner -+ obj->image_overlay->change_id = 0; - } - } else { - res = osd_object_get_bitmaps(osd, obj, format); -diff --git a/sub/osd.h b/sub/osd.h -index 44cf2ed..d693872 100644 ---- a/sub/osd.h -+++ b/sub/osd.h -@@ -113,7 +113,7 @@ struct mp_osd_res { - bool osd_res_equals(struct mp_osd_res a, struct mp_osd_res b); - - // 0 <= sub_bitmaps.render_index < MAX_OSD_PARTS --#define MAX_OSD_PARTS 5 -+#define MAX_OSD_PARTS 6 - - // Start of OSD symbols in osd_font.pfb - #define OSD_CODEPOINTS 0xE000 -@@ -208,7 +208,7 @@ struct osd_progbar_state { - }; - void osd_set_progbar(struct osd_state *osd, struct osd_progbar_state *s); - --void osd_set_external2(struct osd_state *osd, struct sub_bitmaps *imgs); -+void osd_set_bitmaps(struct osd_state *osd, int type, struct sub_bitmaps *imgs); - - enum mp_osd_draw_flags { - OSD_DRAW_SUB_FILTER = (1 << 0), -diff --git a/sub/osd_state.h b/sub/osd_state.h -index cd4ca15..57bd565 100644 ---- a/sub/osd_state.h -+++ b/sub/osd_state.h -@@ -10,6 +10,8 @@ enum mp_osdtype { - OSDTYPE_SUB, - OSDTYPE_SUB2, // IDs must be numerically successive - -+ OSDTYPE_DISC_MENU, -+ - OSDTYPE_OSD, - - OSDTYPE_EXTERNAL, -@@ -44,8 +46,8 @@ struct osd_object { - struct osd_external **externals; - int num_externals; - -- // OSDTYPE_EXTERNAL2 -- struct sub_bitmaps *external2; -+ // OSDTYPE_EXTERNAL2 / OSDTYPE_DISC_MENU -+ struct sub_bitmaps *image_overlay; - - // VO cache state - int vo_change_id; -diff --git a/sub/sd_lavc.c b/sub/sd_lavc.c -index 5096648..ce9416e 100644 ---- a/sub/sd_lavc.c -+++ b/sub/sd_lavc.c -@@ -71,6 +71,11 @@ struct sd_lavc_priv { - struct seekpoint *seekpoints; - int num_seekpoints; - struct bitmap_packer *packer; -+ -+ // DVD-nav menu/highlight overlay state. -+ bool menu_active; // in-menu: gates the highlight overlay -+ struct mp_dvdnav_highlight hl; // focused button rect + palette -+ uint32_t hli_change_id; - }; - - static int init(struct sd *sd) -@@ -229,7 +234,8 @@ static void read_sub_bitmaps(struct sd *sd, struct sub *sub) - if (r->w <= 0 || r->h <= 0) - continue; - -- b->bitmap = r; // save for later (dumb hack to avoid more complexity) -+ // save the rect for later (dumb hack to avoid more complexity) -+ *b = (struct sub_bitmap){ .bitmap = r }; - - priv->packer->in[sub->count] = (struct pos){r->w + (align - 1), r->h}; - sub->count++; -@@ -294,6 +300,22 @@ static void read_sub_bitmaps(struct sd *sd, struct sub *sub) - memcpy(pal, data[1], r->nb_colors * 4); - convert_pal(pal, 256, opts->sub_gray); - -+ // DVD navigation highlight -+ struct mp_rect hlr = priv->hl.rect; -+ bool hli_active = priv->menu_active && -+ mp_rect_w(hlr) > 0 && mp_rect_h(hlr) > 0 && -+ r->x < hlr.x1 && r->y < hlr.y1 && -+ r->x + r->w > hlr.x0 && r->y + r->h > hlr.y0; -+ uint32_t hli_pal[4] = {0}; -+ if (hli_active) { -+ memcpy(hli_pal, priv->hl.palette, sizeof(hli_pal)); -+ convert_pal(hli_pal, 4, opts->sub_gray); -+ } -+ int hli_x0 = hlr.x0 - r->x; -+ int hli_y0 = hlr.y0 - r->y; -+ int hli_x1 = hlr.x1 - r->x; -+ int hli_y1 = hlr.y1 - r->y; -+ - for (int y = -padding; y < b->h + padding; y++) { - uint32_t *out = (uint32_t*)((char*)b->bitmap + y * b->stride); - int start = 0; -@@ -301,8 +323,14 @@ static void read_sub_bitmaps(struct sd *sd, struct sub *sub) - out[x] = 0; - if (y >= 0 && y < b->h) { - uint8_t *in = data[0] + y * linesize[0]; -- for (int x = 0; x < b->w; x++) -- *out++ = pal[*in++]; -+ bool y_in_hli = hli_active && y >= hli_y0 && y < hli_y1; -+ for (int x = 0; x < b->w; x++) { -+ uint8_t pv = *in++; -+ if (y_in_hli && x >= hli_x0 && x < hli_x1 && pv < 4) -+ *out++ = hli_pal[pv]; -+ else -+ *out++ = pal[pv]; -+ } - start = b->w; - } - for (int x = start; x < b->w + padding; x++) -@@ -322,6 +350,21 @@ static void read_sub_bitmaps(struct sd *sd, struct sub *sub) - } - } - -+static void rerender_queued_subs(struct sd *sd) -+{ -+ struct sd_lavc_priv *priv = sd->priv; -+ for (int n = 0; n < MAX_QUEUE; n++) { -+ struct sub *sub = &priv->subs[n]; -+ if (!sub->valid) -+ continue; -+ sub->count = 0; -+ sub->src_w = 0; -+ sub->src_h = 0; -+ sub->id = priv->new_id++; -+ read_sub_bitmaps(sd, sub); -+ } -+} -+ - static void decode(struct sd *sd, struct demux_packet *packet) - { - struct mp_subtitle_opts *opts = sd->opts; -@@ -465,6 +508,7 @@ static struct sub_bitmaps *get_bitmaps(struct sd *sd, struct mp_osd_res d, - res->packed_w = current->bound_w; - res->packed_h = current->bound_h; - res->format = SUBBITMAP_BGRA; -+ res->video_color_space = true; - - double video_par = 0; - if (priv->avctx->codec_id == AV_CODEC_ID_DVD_SUBTITLE && -@@ -715,6 +759,23 @@ static int control(struct sd *sd, enum sd_ctrl cmd, void *arg) - case SD_CTRL_SET_VIDEO_PARAMS: - priv->video_params = *(struct mp_image_params *)arg; - return CONTROL_OK; -+ case SD_CTRL_APPLY_DVDNAV: { -+ const struct stream_nav_state *nav = arg; -+ bool menu_closed = priv->menu_active && !nav->menu_active; -+ bool changed = priv->hli_change_id != nav->change_id; -+ priv->menu_active = nav->menu_active; -+ priv->hl = nav->hl; -+ priv->hli_change_id = nav->change_id; -+ // Don't let menu subpictures leak into title playback. -+ if (menu_closed) { -+ for (int n = 0; n < MAX_QUEUE; n++) -+ clear_sub(&priv->subs[n]); -+ } -+ // Re-render any decoded subtitles, after style update. -+ if (changed) -+ rerender_queued_subs(sd); -+ return CONTROL_OK; -+ } - default: - return CONTROL_UNKNOWN; - } diff --git a/third_party/patches/mpv-dovi-profile8-hdr10-base-layer.patch b/third_party/patches/mpv-dovi-profile8-hdr10-base-layer.patch deleted file mode 100644 index 5a0a2672a3..0000000000 --- a/third_party/patches/mpv-dovi-profile8-hdr10-base-layer.patch +++ /dev/null @@ -1,177 +0,0 @@ -diff --git a/demux/demux.c b/demux/demux.c ---- a/demux/demux.c -+++ b/demux/demux.c -@@ -137,6 +137,9 @@ - {"preserve", DEMUX_DOVI_PROFILE7_PRESERVE}, - {"hdr10", DEMUX_DOVI_PROFILE7_HDR10}, - {"p81", DEMUX_DOVI_PROFILE7_P81})}, -+ {"demuxer-dovi-profile8", OPT_CHOICE(dovi_profile8_mode, -+ {"preserve", DEMUX_DOVI_PROFILE8_PRESERVE}, -+ {"hdr10", DEMUX_DOVI_PROFILE8_HDR10})}, - {0} - }, - .size = sizeof(struct demux_opts), -@@ -163,6 +166,7 @@ - "video", "audio", "image", "archive", "playlist", NULL - }, - .dovi_profile7_mode = DEMUX_DOVI_PROFILE7_PRESERVE, -+ .dovi_profile8_mode = DEMUX_DOVI_PROFILE8_PRESERVE, - }, - .get_sub_options = get_demux_sub_opts, - }; -diff --git a/demux/demux.h b/demux/demux.h ---- a/demux/demux.h -+++ b/demux/demux.h -@@ -90,12 +90,18 @@ - char **directory_filter; - int autocreate_playlist; - int dovi_profile7_mode; -+ int dovi_profile8_mode; - }; - - enum demux_dovi_profile7_mode { - DEMUX_DOVI_PROFILE7_PRESERVE = 0, - DEMUX_DOVI_PROFILE7_HDR10 = 1, - DEMUX_DOVI_PROFILE7_P81 = 2, -+}; -+ -+enum demux_dovi_profile8_mode { -+ DEMUX_DOVI_PROFILE8_PRESERVE = 0, -+ DEMUX_DOVI_PROFILE8_HDR10 = 1, - }; - - #define SEEK_FACTOR (1 << 1) // argument is in range [0,1] -diff --git a/demux/dovi_split.c b/demux/dovi_split.c ---- a/demux/dovi_split.c -+++ b/demux/dovi_split.c -@@ -60,6 +60,10 @@ - bool base_only = bl && bl->codec && bl->codec->dv_profile == 7 && - demuxer->opts && demuxer->opts->dovi_profile7_mode == - DEMUX_DOVI_PROFILE7_HDR10; -+ bool profile8_base_only = bl && bl->codec && bl->codec->dv_profile == 8 && -+ demuxer->opts && demuxer->opts->dovi_profile8_mode == -+ DEMUX_DOVI_PROFILE8_HDR10; -+ base_only = base_only || profile8_base_only; - bool has_p81_config = false; - if (p81_requested && bl->codec->lav_codecpar) { - for (int i = 0; i < bl->codec->lav_codecpar->nb_coded_side_data; i++) { -@@ -157,6 +157,15 @@ - if (s->base_only || s->convert_p81) { -+ if (base_only) { -+ // Remove the DOVI configuration before copying par_out to the -+ // decoder-visible codec parameters. -+ av_packet_side_data_remove(s->bsf->par_out->coded_side_data, -+ &s->bsf->par_out->nb_coded_side_data, -+ AV_PKT_DATA_DOVI_CONF); -+ s->bsf->par_out->profile = AV_PROFILE_HEVC_MAIN_10; -+ } - if (bl->codec->lav_codecpar) { -@@ -165,7 +174,16 @@ - MPSWAP(AVCodecParameters, *bl->codec->lav_codecpar, *filtered); - avcodec_parameters_free(&filtered); - } -- bl->codec->dv_el_present = false; -+ if (base_only) { -+ // The BSF updates the DOVI flags but keeps the configuration -+ // record attached to par_out. Leaving it there makes the -+ // MediaCodec wrapper select the Dolby Vision MIME/profile again, -+ // even though the access unit is now an ordinary HEVC base layer. -+ av_packet_side_data_remove(s->bsf->par_out->coded_side_data, -+ &s->bsf->par_out->nb_coded_side_data, -+ AV_PKT_DATA_DOVI_CONF); -+ s->bsf->par_out->profile = AV_PROFILE_HEVC_MAIN_10; -+ bl->codec->source_dv_profile = bl->codec->dv_profile; -+ bl->codec->source_dv_level = bl->codec->dv_level; -+ bl->codec->dovi = false; -+ bl->codec->dv_profile = 0; -+ bl->codec->dv_level = 0; -+ bl->codec->dv_el_present = false; -+ } else { -+ bl->codec->dv_el_present = false; -+ } - if (s->convert_p81) { - bl->codec->dovi = true; - bl->codec->source_dv_profile = bl->codec->dv_profile; -@@ -174,6 +187,10 @@ - MP_INFO(demuxer, "DV7 P8.1 conversion: using FFmpeg dovi_rpu BSF.\n"); - MP_VERBOSE(demuxer, "DV7 P8.1 conversion: synchronized Profile 8.1 " - "decoder parameters.\n"); -+ } else if (profile8_base_only) { -+ MP_INFO(demuxer, "P8.1 HDR10 fallback: stripping RPU before decoder.\n"); -+ MP_VERBOSE(demuxer, "P8.1 HDR10 fallback: synchronized decoder " -+ "parameters to the HDR10 base layer.\n"); - } else { - MP_INFO(demuxer, "DV7 HDR10 fallback: stripping EL/RPU before decoder.\n"); - MP_VERBOSE(demuxer, "DV7 HDR10 fallback: synchronized decoder " -@@ -330,7 +347,9 @@ - if (s->convert_p81) - MP_ERR(s->demuxer, "DV7 P8.1 conversion: failed to produce converted packet.\n"); - else -- MP_ERR(s->demuxer, "DV7 HDR10 fallback: failed to produce base-layer packet.\n"); -+ MP_ERR(s->demuxer, s->bl->codec->source_dv_profile == 8 -+ ? "P8.1 HDR10 fallback: failed to produce base-layer packet.\n" -+ : "DV7 HDR10 fallback: failed to produce base-layer packet.\n"); - return false; - } - -diff --git a/video/decode/vd_lavc.c b/video/decode/vd_lavc.c ---- a/video/decode/vd_lavc.c -+++ b/video/decode/vd_lavc.c -@@ -567,8 +567,29 @@ - { - return ctx->codec && ctx->codec->dv_profile == 7 && - dovi_profile7_mode(vd) == DEMUX_DOVI_PROFILE7_HDR10; -+} -+ -+static int dovi_profile8_mode(struct mp_filter *vd) -+{ -+ struct demux_opts *opts = mp_get_config_group(vd, vd->global, &demux_conf); -+ return opts ? opts->dovi_profile8_mode : DEMUX_DOVI_PROFILE8_PRESERVE; - } - -+static bool dovi_profile8_hdr10_fallback(struct mp_filter *vd, -+ const vd_ffmpeg_ctx *ctx) -+{ -+ return ctx->codec && ctx->codec->source_dv_profile == 8 && -+ !ctx->codec->dovi && -+ dovi_profile8_mode(vd) == DEMUX_DOVI_PROFILE8_HDR10; -+} -+ -+static bool dovi_hdr10_base_layer_fallback(struct mp_filter *vd, -+ const vd_ffmpeg_ctx *ctx) -+{ -+ return dovi_profile7_hdr10_fallback(vd, ctx) -+ || dovi_profile8_hdr10_fallback(vd, ctx); -+} -+ - // Select if and which hwdec to use. Also makes sure to get the decode device. - static void select_and_set_hwdec(struct mp_filter *vd) - { -@@ -576,7 +597,7 @@ - const char *codec = ctx->codec->codec; - bool native_dovi_sink = - ctx->vo && (ctx->vo->driver->caps & VO_CAP_NATIVE_DOVI) && -- !dovi_profile7_hdr10_fallback(vd, ctx); -+ !dovi_hdr10_base_layer_fallback(vd, ctx); - - m_config_cache_update(ctx->hwdec_opts_cache); - -@@ -947,10 +968,14 @@ - bool native_dovi_sink = - ctx->vo && (ctx->vo->driver->caps & VO_CAP_NATIVE_DOVI); - bool decoder_native_dovi_sink = native_dovi_sink && -- !dovi_profile7_hdr10_fallback(vd, ctx); -- if (native_dovi_sink && !decoder_native_dovi_sink) -- MP_INFO(vd, "DV7 HDR10 fallback: using MediaCodec base-layer decoder " -- "with direct Surface output.\n"); -+ !dovi_hdr10_base_layer_fallback(vd, ctx); -+ if (native_dovi_sink && !decoder_native_dovi_sink) { -+ MP_INFO(vd, dovi_profile8_hdr10_fallback(vd, ctx) -+ ? "P8.1 HDR10 fallback: using MediaCodec base-layer decoder " -+ "with direct Surface output.\n" -+ : "DV7 HDR10 fallback: using MediaCodec base-layer decoder " -+ "with direct Surface output.\n"); -+ } - bool gpu_dovi_mapping = supports_gpu_dovi_mapping(ctx); - set_decoder_avopts(vd->log, avctx, lavc_param->avopts, - decoder_native_dovi_sink, gpu_dovi_mapping); diff --git a/third_party/patches/mpv-matroska-segment-end.patch b/third_party/patches/mpv-matroska-segment-end.patch index 69d0a20ad4..0ec0266f2f 100644 --- a/third_party/patches/mpv-matroska-segment-end.patch +++ b/third_party/patches/mpv-matroska-segment-end.patch @@ -4,7 +4,7 @@ index b60c158cf..870c0d141 100644 +++ b/demux/demux_mkv.c @@ -2543,6 +2543,13 @@ static int demux_mkv_open(demuxer_t *demuxer, enum demux_check check) } - + int64_t end = stream_get_size(s); + if (end < 0 && s->seekable && mkv_d->segment_end > 0) { + // Matroska carries an exact boundary for a known-size Segment. Use @@ -13,6 +13,6 @@ index b60c158cf..870c0d141 100644 + end = mkv_d->segment_end; + MP_VERBOSE(demuxer, "Using declared Matroska segment end for seek metadata.\n"); + } - + // Read headers that come after the first cluster (i.e. require seeking). // Note: reading might increase ->num_headers. diff --git a/third_party/patches/mpv-mediacodec-embed-reset.patch b/third_party/patches/mpv-mediacodec-embed-reset.patch deleted file mode 100644 index b3ea849187..0000000000 --- a/third_party/patches/mpv-mediacodec-embed-reset.patch +++ /dev/null @@ -1,18 +0,0 @@ ---- a/video/out/vo_mediacodec_embed.c -+++ b/video/out/vo_mediacodec_embed.c -@@ -356,6 +356,15 @@ - static int control(struct vo *vo, uint32_t request, void *data) - { - struct priv *p = vo->priv; -+ if (request == VOCTRL_RESET) { -+ mp_image_unrefp(&p->next_image); -+ p->last_pts = MP_NOPTS_VALUE; -+ p->next_pts = MP_NOPTS_VALUE; -+ p->next_duration = 0; -+ p->last_submit_start = 0; -+ MP_VERBOSE(vo, "WebHTV MediaCodec reset discards pending output\n"); -+ return VO_TRUE; -+ } - if (request == VOCTRL_UPDATE_RENDER_OPTS || - request == VOCTRL_UPDATE_OSD_SIZE) - { diff --git a/third_party/patches/mpv-playback-diagnostics.patch b/third_party/patches/mpv-playback-diagnostics.patch deleted file mode 100644 index 4631fae196..0000000000 --- a/third_party/patches/mpv-playback-diagnostics.patch +++ /dev/null @@ -1,158 +0,0 @@ ---- a/audio/out/ao_audiotrack.c -+++ b/audio/out/ao_audiotrack.c -@@ -21,6 +21,7 @@ - * License along with mpv. If not, see . - */ - -+#include - #include "ao.h" - #include "internal.h" - #include "audio/aframe.h" -@@ -33,6 +34,7 @@ - - static mp_static_mutex jni_static_lock = MP_STATIC_MUTEX_INITIALIZER; - static int jni_static_use_count = 0; -+static atomic_uint diagnostic_output_ids = 1; - enum { - ANDROID_API_LEVEL_S = 31, - }; -@@ -53,6 +55,9 @@ - uint32_t playhead_pos; - uint32_t playhead_offset; - bool reset_pending; -+ uint32_t diag_output_id; -+ int64_t diag_last_ns, diag_write_calls, diag_requested, diag_accepted; -+ int64_t diag_short_writes, diag_zero_writes, diag_errors, diag_max_write_ns; - - void *chunk; - int chunksize; -@@ -103,6 +108,7 @@ - jmethodID getBufferSizeInFramesV23; - jmethodID getPlaybackHeadPosition; - jmethodID getTimestamp; -+ jmethodID getRoutedDevice; - jmethodID getLatency; - jmethodID getMinBufferSize; - jmethodID getNativeOutputSampleRate; -@@ -135,6 +141,7 @@ - {"write", "(Ljava/nio/ByteBuffer;II)I", MP_JNI_METHOD, OFFSET(writeBufferV21), 1}, - {"getBufferSizeInFrames", "()I", MP_JNI_METHOD, OFFSET(getBufferSizeInFramesV23), 0}, - {"getTimestamp", "(Landroid/media/AudioTimestamp;)Z", MP_JNI_METHOD, OFFSET(getTimestamp), 1}, -+ {"getRoutedDevice", "()Landroid/media/AudioDeviceInfo;", MP_JNI_METHOD, OFFSET(getRoutedDevice), 0}, - {"getPlaybackHeadPosition", "()I", MP_JNI_METHOD, OFFSET(getPlaybackHeadPosition), 1}, - {"getLatency", "()I", MP_JNI_METHOD, OFFSET(getLatency), 1}, - {"getMinBufferSize", "(III)I", MP_JNI_STATIC_METHOD, OFFSET(getMinBufferSize), 1}, -@@ -149,6 +156,20 @@ - {"ERROR", "I", MP_JNI_STATIC_FIELD_AS_INT, OFFSET(ERROR), 1}, - {"ERROR_BAD_VALUE", "I", MP_JNI_STATIC_FIELD_AS_INT, OFFSET(ERROR_BAD_VALUE), 1}, - {"ERROR_INVALID_OPERATION", "I", MP_JNI_STATIC_FIELD_AS_INT, OFFSET(ERROR_INVALID_OPERATION), 1}, -+ {0} -+}; -+#undef OFFSET -+ -+static struct JNIAudioDeviceInfo { -+ jclass clazz; -+ jmethodID getId; -+ jmethodID getType; -+} AudioDeviceInfo; -+#define OFFSET(member) offsetof(struct JNIAudioDeviceInfo, member) -+static const struct MPJniField AudioDeviceInfo_mapping[] = { -+ {"android/media/AudioDeviceInfo", NULL, MP_JNI_CLASS, OFFSET(clazz), 0}, -+ {"getId", "()I", MP_JNI_METHOD, OFFSET(getId), 0}, -+ {"getType", "()I", MP_JNI_METHOD, OFFSET(getType), 0}, - {0} - }; - #undef OFFSET -@@ -317,6 +338,7 @@ - } jclass_list[] = { - ENTRY(ByteBuffer), - ENTRY(AudioTrack), -+ ENTRY(AudioDeviceInfo), - ENTRY(AudioAttributes), - ENTRY(AudioAttributesBuilder), - ENTRY(AudioFormat), -@@ -479,6 +501,11 @@ - if (!p->audiotrack) - return -1; - -+ p->diag_output_id = atomic_fetch_add_explicit(&diagnostic_output_ids, 1, memory_order_relaxed); -+ p->diag_last_ns = p->diag_write_calls = p->diag_requested = p->diag_accepted = 0; -+ p->diag_short_writes = p->diag_zero_writes = p->diag_errors = p->diag_max_write_ns = 0; -+ MP_INFO(ao, "WebHTV AV-DIAG audio-config v=1 output=%u rate=%d channels=%d mask=%d encoding=%d bufferBytes=%d compressed=%d offload=%d sessionRequested=%d\n", -+ p->diag_output_id, p->samplerate, ao->channels.num, p->channel_config, p->format, p->size, p->compressed, p->offload, p->cfg_session_id); - return 0; - } - -@@ -487,6 +514,7 @@ - struct priv *p = ao->priv; - JNIEnv *env = MP_JNI_GET_ENV(ao); - -+ MP_INFO(ao, "WebHTV AV-DIAG audio-lifecycle v=1 output=%u operation=recreate phase=begin\n", p->diag_output_id); - MP_JNI_CALL_VOID(p->audiotrack, AudioTrack.release); - MP_JNI_EXCEPTION_LOG(ao); - MP_JNI_GLOBAL_FREEP(&p->audiotrack); -@@ -607,6 +635,10 @@ - return -1; - JNIEnv *env = MP_JNI_GET_ENV(ao); - void *buf = source ? (void *)source : p->chunk; -+ bool diagnostic = mp_msg_test(ao->log, MSGL_INFO); -+ int64_t diag_start = diagnostic ? mp_time_ns() : 0; -+ jint raw_ret = 0; -+ const char *raw_unit = "bytes"; - - jint ret; - if (p->compressed) { -@@ -624,6 +656,7 @@ - if (MP_JNI_EXCEPTION_LOG(ao) < 0) return -1; - ret = MP_JNI_CALL_INT(p->audiotrack, AudioTrack.writeShortV23, p->shortarray, 0, len / 2, AudioTrack.WRITE_BLOCKING); - if (MP_JNI_EXCEPTION_LOG(ao) < 0) return -1; -+ raw_ret = ret; raw_unit = "int16-samples"; - if (ret > 0) ret *= 2; - - } else if (AudioTrack.writeBufferV21) { -@@ -639,6 +672,7 @@ - if (MP_JNI_EXCEPTION_LOG(ao) < 0) return -1; - ret = MP_JNI_CALL_INT(p->audiotrack, AudioTrack.writeFloat, p->floatarray, 0, len / sizeof(float), AudioTrack.WRITE_BLOCKING); - if (MP_JNI_EXCEPTION_LOG(ao) < 0) return -1; -+ raw_ret = ret; raw_unit = "float-samples"; - if (ret > 0) ret *= sizeof(float); - - } else { -@@ -648,6 +682,37 @@ - if (MP_JNI_EXCEPTION_LOG(ao) < 0) return -1; - } - -+ if (diagnostic) { -+ int64_t now = mp_time_ns(); -+ p->diag_write_calls++; p->diag_requested += len; -+ if (ret < 0) p->diag_errors++; -+ else if (!ret) p->diag_zero_writes++; -+ else { p->diag_accepted += ret; if (ret < len) p->diag_short_writes++; } -+ p->diag_max_write_ns = MPMAX(p->diag_max_write_ns, now - diag_start); -+ if (ret < 0 || p->diag_write_calls == 1 || now - p->diag_last_ns >= MP_TIME_S_TO_NS(5)) { -+ p->diag_last_ns = now; -+ MP_INFO(ao, "WebHTV AV-DIAG audio-write v=1 output=%u calls=%lld requested=%lld accepted=%lld short=%lld zero=%lld errors=%lld raw=%d rawUnit=%s maxUs=%lld writtenFrames=%u headRaw=%u timestampValid=%d timestampAgeMs=%lld\n", -+ p->diag_output_id, (long long)p->diag_write_calls, (long long)p->diag_requested, (long long)p->diag_accepted, -+ (long long)p->diag_short_writes, (long long)p->diag_zero_writes, (long long)p->diag_errors, -+ raw_unit[0] == 'b' ? ret : raw_ret, raw_unit, (long long)(p->diag_max_write_ns / 1000), -+ p->written_frames, p->playhead_pos, p->timestamp_set, -+ p->timestamp_fetched ? (long long)((now - p->timestamp_fetched) / 1000000) : -1LL); -+ if (AudioTrack.getRoutedDevice && AudioDeviceInfo.getId && AudioDeviceInfo.getType) { -+ jobject route = MP_JNI_CALL_OBJECT(p->audiotrack, AudioTrack.getRoutedDevice); -+ if (MP_JNI_EXCEPTION_LOG(ao) == 0 && route) { -+ jint id = MP_JNI_CALL_INT(route, AudioDeviceInfo.getId); -+ if (MP_JNI_EXCEPTION_LOG(ao) == 0) { -+ jint type = MP_JNI_CALL_INT(route, AudioDeviceInfo.getType); -+ if (MP_JNI_EXCEPTION_LOG(ao) == 0) -+ MP_INFO(ao, "WebHTV AV-DIAG audio-route v=1 output=%u role=actual id=%d type=%d\n", p->diag_output_id, id, type); -+ } -+ } else { -+ MP_INFO(ao, "WebHTV AV-DIAG audio-route v=1 output=%u role=actual status=unavailable\n", p->diag_output_id); -+ } -+ MP_JNI_LOCAL_FREEP(&route); -+ } -+ } -+ } - return ret; - } - diff --git a/third_party/patches/nextlib-ape-support.patch b/third_party/patches/nextlib-ape-support.patch deleted file mode 100644 index 4d9ed8391e..0000000000 --- a/third_party/patches/nextlib-ape-support.patch +++ /dev/null @@ -1,79 +0,0 @@ -diff --git a/ffmpeg/setup.sh b/ffmpeg/setup.sh -index 32d1674..b432ca2 100755 ---- a/ffmpeg/setup.sh -+++ b/ffmpeg/setup.sh -@@ -18,7 +18,7 @@ MBEDTLS_DIR=$SOURCES_DIR/mbedtls-$MBEDTLS_VERSION - # Configuration - ANDROID_ABIS="armeabi-v7a arm64-v8a" - ANDROID_PLATFORM=21 --ENABLED_DECODERS="vorbis opus flac alac pcm_mulaw pcm_alaw mp3 amrnb amrwb aac ac3 eac3 dca mlp truehd libarcdav3a h264 hevc mpeg2video mpegvideo libvpx_vp8 libvpx_vp9" -+ENABLED_DECODERS="vorbis opus flac alac pcm_mulaw pcm_alaw mp3 amrnb amrwb aac ac3 eac3 dca mlp truehd ape libarcdav3a h264 hevc mpeg2video mpegvideo libvpx_vp8 libvpx_vp9" - JOBS=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || sysctl -n hw.pysicalcpu || echo 4) - -diff --git a/media3ext/src/main/cpp/ffaudio.cpp b/media3ext/src/main/cpp/ffaudio.cpp -index e1aeb9e..02087d8 100644 ---- a/media3ext/src/main/cpp/ffaudio.cpp -+++ b/media3ext/src/main/cpp/ffaudio.cpp -@@ -109,6 +109,27 @@ AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, jbyteArray extraData, - context->ch_layout.nb_channels = rawChannelCount; - av_channel_layout_default(&context->ch_layout, rawChannelCount); - } -+ if (context->codec_id == AV_CODEC_ID_APE) { -+ if (rawSampleRate <= 0 || rawChannelCount <= 0 || rawChannelCount > 2) { -+ LOGE("Invalid APE stream parameters: sampleRate=%d channels=%d.", -+ rawSampleRate, rawChannelCount); -+ releaseContext(context); -+ return nullptr; -+ } -+ context->sample_rate = rawSampleRate; -+ context->ch_layout.nb_channels = rawChannelCount; -+ av_channel_layout_default(&context->ch_layout, rawChannelCount); -+ if (!extraData || env->GetArrayLength(extraData) < 6) { -+ LOGE("APE decoder requires six-byte extradata."); -+ releaseContext(context); -+ return nullptr; -+ } -+ jbyte flags[2]; -+ env->GetByteArrayRegion(extraData, 4, 2, flags); -+ const int formatFlags = (flags[0] & 0xff) | ((flags[1] & 0xff) << 8); -+ context->bits_per_coded_sample = (formatFlags & 1) != 0 -+ ? 8 : ((formatFlags & 8) != 0 ? 24 : 16); -+ } - context->err_recognition = AV_EF_IGNORE_ERR; - int result = avcodec_open2(context, codec, nullptr); - if (result < 0) { -diff --git a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegAudioDecoder.java b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegAudioDecoder.java -index c9a1e4a..f5136f4 100644 ---- a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegAudioDecoder.java -+++ b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegAudioDecoder.java -@@ -204,6 +204,7 @@ final class FfmpegAudioDecoder - return switch (mimeType) { - case MimeTypes.AUDIO_AAC, MimeTypes.AUDIO_OPUS -> initializationData.get(0); - case MimeTypes.AUDIO_ALAC -> getAlacExtraData(initializationData); -+ case "audio/ape" -> initializationData.get(0); - case MimeTypes.AUDIO_VORBIS -> getVorbisExtraData(initializationData); - // Other codecs do not require extra data. - default -> null; -diff --git a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java -index 11f225b..68c83fd 100644 ---- a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java -+++ b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java -@@ -116,6 +116,7 @@ public final class FfmpegLibrary { - case MimeTypes.AUDIO_AMR_WB -> "amrwb"; - case MimeTypes.AUDIO_FLAC -> "flac"; - case MimeTypes.AUDIO_ALAC -> "alac"; -+ case "audio/ape" -> "ape"; - case MimeTypes.AUDIO_MLAW -> "pcm_mulaw"; - case MimeTypes.AUDIO_ALAW -> "pcm_alaw"; - case AUDIO_AV3A -> "libarcdav3a"; -diff --git a/build.gradle.kts b/build.gradle.kts -index ba1e427..c4f5e93 100644 ---- a/build.gradle.kts -+++ b/build.gradle.kts -@@ -11,7 +11,7 @@ subprojects { - coordinates( - groupId = "io.github.anilbeesetti", - artifactId = property("POM_ARTIFACT_ID") as String, -- version = "1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2" -+ version = "1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3" - ) diff --git a/third_party/patches/nextlib-av3a.patch b/third_party/patches/nextlib-av3a.patch index 0c62eed67e..fbd2b657a8 100644 --- a/third_party/patches/nextlib-av3a.patch +++ b/third_party/patches/nextlib-av3a.patch @@ -7,15 +7,15 @@ index 18acf58..0366f56 100644 groupId = "io.github.anilbeesetti", artifactId = property("POM_ARTIFACT_ID") as String, - version = "1.10.0-0.12.1-fongmi-softload" -+ version = "1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r2" ++ version = "1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r1" ) pom { diff --git a/ffmpeg/setup.sh b/ffmpeg/setup.sh -index 09f6fcd..1c764ce 100755 +index e68e4d0..aad587a 100755 --- a/ffmpeg/setup.sh +++ b/ffmpeg/setup.sh -@@ -3,21 +3,22 @@ +@@ -5,21 +5,22 @@ set -euo pipefail # Versions VPX_VERSION=1.13.0 MBEDTLS_VERSION=3.4.1 @@ -34,28 +34,15 @@ index 09f6fcd..1c764ce 100755 MBEDTLS_DIR=$SOURCES_DIR/mbedtls-$MBEDTLS_VERSION # Configuration --ANDROID_ABIS="x86 x86_64 armeabi-v7a arm64-v8a" -+ANDROID_ABIS="armeabi-v7a arm64-v8a" +-ANDROID_ABIS="${ANDROID_ABIS:-x86 x86_64 armeabi-v7a arm64-v8a}" ++ANDROID_ABIS="${ANDROID_ABIS:-armeabi-v7a arm64-v8a}" ANDROID_PLATFORM=21 --ENABLED_DECODERS="vorbis opus flac alac pcm_mulaw pcm_alaw mp3 amrnb amrwb aac ac3 eac3 dca mlp truehd h264 hevc mpeg2video mpegvideo libvpx_vp8 libvpx_vp9" -+ENABLED_DECODERS="vorbis opus flac alac pcm_mulaw pcm_alaw mp3 amrnb amrwb aac ac3 eac3 dca mlp truehd libarcdav3a h264 hevc mpeg2video mpegvideo libvpx_vp8 libvpx_vp9" - JOBS=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || sysctl -n hw.pysicalcpu || echo 4) +-ENABLED_DECODERS="vorbis opus flac alac pcm_mulaw pcm_alaw mp3 amrnb amrwb aac ac3 eac3 dca mlp truehd h264 hevc mpeg2video mpegvideo vc1 libvpx_vp8 libvpx_vp9" ++ENABLED_DECODERS="vorbis opus flac alac pcm_mulaw pcm_alaw mp3 amrnb amrwb aac ac3 eac3 dca mlp truehd libarcdav3a h264 hevc mpeg2video mpegvideo vc1 libvpx_vp8 libvpx_vp9" + JOBS="${JOBS:-$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || sysctl -n hw.pysicalcpu || echo 4)}" # Set up host platform variables -@@ -37,8 +38,10 @@ esac - TOOLCHAIN_PREFIX="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/${HOST_PLATFORM}" - CMAKE_EXECUTABLE="${ANDROID_SDK_HOME}/cmake/${ANDROID_CMAKE_VERSION}/bin/cmake" - --# Check if sdkmanager is in PATH --if command -v sdkmanager &> /dev/null; then -+# Install CMake only when the requested version is not already available. -+if [[ -x "$CMAKE_EXECUTABLE" ]]; then -+ echo "Using installed CMake ${ANDROID_CMAKE_VERSION}" -+elif command -v sdkmanager &> /dev/null; then - # Use sdkmanager from PATH - echo "Using sdkmanager from PATH" - echo y | sdkmanager --sdk_root="${ANDROID_SDK_HOME}" "cmake;${ANDROID_CMAKE_VERSION}" -@@ -80,15 +83,42 @@ function downloadMbedTLS() { +@@ -95,15 +96,42 @@ function downloadMbedTLS() { function downloadFfmpeg() { pushd $SOURCES_DIR @@ -101,7 +88,7 @@ index 09f6fcd..1c764ce 100755 function buildLibVpx() { pushd $VPX_DIR -@@ -232,7 +262,7 @@ function buildFfmpeg() { +@@ -252,7 +280,7 @@ function buildFfmpeg() { DEP_LD_FLAGS="-L$BUILD_DIR/external/$ABI/lib" # Configure FFmpeg build @@ -110,7 +97,7 @@ index 09f6fcd..1c764ce 100755 --prefix=$BUILD_DIR/$ABI \ --enable-cross-compile \ --arch=$ARCH \ -@@ -245,6 +275,7 @@ function buildFfmpeg() { +@@ -268,6 +296,7 @@ function buildFfmpeg() { --extra-cflags="-O3 -fPIC $DEP_CFLAGS" \ --extra-ldflags="$DEP_LD_FLAGS -Wl,-z,max-page-size=16384" \ --pkg-config="$(which pkg-config)" \ @@ -118,23 +105,15 @@ index 09f6fcd..1c764ce 100755 --target-os=android \ --enable-shared \ --disable-static \ -@@ -254,7 +285,6 @@ function buildFfmpeg() { +@@ -277,7 +306,6 @@ function buildFfmpeg() { --disable-vulkan \ --disable-avdevice \ --disable-avformat \ - --disable-postproc \ --disable-avfilter \ --disable-symver \ - --enable-parsers \ -@@ -262,6 +292,7 @@ function buildFfmpeg() { - --enable-swresample \ - --enable-avformat \ - --enable-libvpx \ -+ --enable-libarcdav3a \ - --enable-protocol=file,http,https,mmsh,mmst,pipe,rtmp,rtmps,rtmpt,rtmpts,rtp,tls \ - --enable-version3 \ - --enable-mbedtls \ -@@ -288,6 +319,11 @@ function buildFfmpeg() { + --disable-network \ +@@ -308,6 +336,11 @@ function buildFfmpeg() { popd } @@ -143,18 +122,18 @@ index 09f6fcd..1c764ce 100755 + exit $? +fi + - if [[ ! -d "$OUTPUT_DIR" && ! -d "$BUILD_DIR" ]]; then - # Download MbedTLS source code if it doesn't exist - if [[ ! -d "$MBEDTLS_DIR" ]]; then -@@ -307,5 +343,6 @@ if [[ ! -d "$OUTPUT_DIR" && ! -d "$BUILD_DIR" ]]; then + if [[ "${FORCE_BUILD:-0}" == "1" || ( ! -d "$OUTPUT_DIR" && ! -d "$BUILD_DIR" ) ]]; then + # Download Vpx source code if it doesn't exist + if [[ ! -d "$VPX_DIR" ]]; then +@@ -321,5 +354,6 @@ if [[ "${FORCE_BUILD:-0}" == "1" || ( ! -d "$OUTPUT_DIR" && ! -d "$BUILD_DIR" ) + # Building library - buildMbedTLS buildLibVpx + buildArcDav3a buildFfmpeg fi diff --git a/media3ext/build.gradle.kts b/media3ext/build.gradle.kts -index 3bb9d4f..fc17421 100644 +index 3bb9d4f..8196d4c 100644 --- a/media3ext/build.gradle.kts +++ b/media3ext/build.gradle.kts @@ -22,7 +22,7 @@ android { @@ -166,25 +145,15 @@ index 3bb9d4f..fc17421 100644 } } -@@ -63,4 +63,4 @@ dependencies { - implementation(libs.androidx.annotation) - compileOnly(libs.checker.qual) - compileOnly(libs.kotlin.annotations.jvm) --} -\ No newline at end of file -+} diff --git a/media3ext/src/main/cpp/ffaudio.cpp b/media3ext/src/main/cpp/ffaudio.cpp -index 6d6169a..443faef 100644 +index 6d6169a..a1dd551 100644 --- a/media3ext/src/main/cpp/ffaudio.cpp +++ b/media3ext/src/main/cpp/ffaudio.cpp -@@ -2,6 +2,10 @@ +@@ -2,6 +2,7 @@ #include #include #include +#include -+#include -+#include -+#include #include #include #include "ffcommon.h" @@ -218,7 +187,7 @@ index 6d6169a..443faef 100644 int result = 0; // Queue input data. result = avcodec_send_packet(context, packet); -@@ -140,45 +146,127 @@ int decodePacket(AVCodecContext *context, AVPacket *packet, +@@ -140,45 +146,83 @@ int decodePacket(AVCodecContext *context, AVPacket *packet, return transformError(result); } @@ -260,9 +229,9 @@ index 6d6169a..443faef 100644 - result = swr_init(resampleContext); + AVChannelLayout inputLayout{}; + AVChannelLayout outputLayout{}; -+ bool inputLayoutUnknown = false; + result = av_channel_layout_copy(&inputLayout, &frame->ch_layout); -+ if (result < 0) { + if (result < 0) { +- logError("swr_init", result); + logError("av_channel_layout_copy", result); + av_frame_free(&frame); + return transformError(result); @@ -275,7 +244,6 @@ index 6d6169a..443faef 100644 + LOGD("Downmixing FFmpeg audio from %d to %d channels.", + inputChannelCount, outputChannelCount); + } -+ inputLayoutUnknown = inputLayout.order == AV_CHANNEL_ORDER_UNSPEC; + if (result >= 0) { + result = swr_alloc_set_opts2( + &resampleContext, @@ -288,66 +256,12 @@ index 6d6169a..443faef 100644 + 0, + nullptr); + } -+ if (result >= 0 && inputLayoutUnknown && -+ outputChannelCount != inputChannelCount) { -+ const size_t matrixSize = static_cast(outputChannelCount) * -+ static_cast(inputChannelCount); -+ std::vector matrix(matrixSize, 0.0); -+ if (context->codec != nullptr && context->codec->name != nullptr && -+ std::strcmp(context->codec->name, "libarcdav3a") == 0 && -+ inputChannelCount == 9 && outputChannelCount == 6) { -+ // AV3A mixed content is six sound-bed channels followed by -+ // three object channels. Keep the bed order and spread the -+ // objects uniformly because their speaker positions are not -+ // available in this decoder contract. -+ for (int channel = 0; channel < 6; ++channel) { -+ matrix[static_cast(channel) * inputChannelCount + channel] = 1.0; -+ } -+ const double objectGain = 1.0 / outputChannelCount; -+ for (int channel = 6; channel < 9; ++channel) { -+ for (int output = 0; output < outputChannelCount; ++output) { -+ matrix[static_cast(output) * inputChannelCount + channel] = -+ objectGain; -+ } -+ } -+ LOGD("Using AV3A mixed-content 9-to-6 channel matrix."); -+ } else { -+ // Unknown layouts carry channel count and order only. Fold -+ // every input index into each output instead of assigning -+ // unknown channels fake speaker positions. -+ const double gain = 1.0 / inputChannelCount; -+ for (int output = 0; output < outputChannelCount; ++output) { -+ for (int channel = 0; channel < inputChannelCount; ++channel) { -+ matrix[static_cast(output) * inputChannelCount + channel] = -+ gain; -+ } -+ } -+ LOGD("Using equal-gain unknown-layout matrix from %d to %d channels.", -+ inputChannelCount, outputChannelCount); -+ } -+ for (int output = 0; output < outputChannelCount; ++output) { -+ double rowSum = 0.0; -+ for (int channel = 0; channel < inputChannelCount; ++channel) { -+ rowSum += std::abs(matrix[static_cast(output) * inputChannelCount + channel]); -+ } -+ if (rowSum > 1.0) { -+ for (int channel = 0; channel < inputChannelCount; ++channel) { -+ matrix[static_cast(output) * inputChannelCount + channel] /= rowSum; -+ } -+ } -+ } -+ result = swr_set_matrix(resampleContext, matrix.data(), inputChannelCount); -+ if (result < 0) { -+ logError("swr_set_matrix", result); -+ } -+ } + av_channel_layout_uninit(&inputLayout); + av_channel_layout_uninit(&outputLayout); + if (result >= 0) { + result = swr_init(resampleContext); + } - if (result < 0) { -- logError("swr_init", result); ++ if (result < 0) { + logError("swr initialization", result); + swr_free(&resampleContext); av_frame_free(&frame); @@ -441,13 +355,6 @@ index 6d6169a..443faef 100644 av_packet_free(&packet); return decodedPacket; } -@@ -338,4 +392,4 @@ Java_io_github_anilbeesetti_nextlib_media3ext_ffdecoder_FfmpegAudioDecoder_ffmpe - if (context) { - releaseContext((AVCodecContext *) context); - } --} -\ No newline at end of file -+} diff --git a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegAudioDecoder.java b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegAudioDecoder.java index 962a408..c9a1e4a 100644 --- a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegAudioDecoder.java @@ -617,7 +524,7 @@ index f6ce6c1..90a910d 100644 case SINK_FORMAT_SUPPORTED_DIRECTLY: // AC-3 is always 16-bit, so there's no point using floating point. Assume that it's worth diff --git a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java -index ccaf284..11f225b 100644 +index c0c67fb..7618ea9 100644 --- a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java +++ b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java @@ -13,6 +13,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; diff --git a/third_party/patches/nextlib-avs3.patch b/third_party/patches/nextlib-avs3.patch deleted file mode 100644 index f9430a2664..0000000000 --- a/third_party/patches/nextlib-avs3.patch +++ /dev/null @@ -1,183 +0,0 @@ -diff --git a/build.gradle.kts b/build.gradle.kts ---- a/build.gradle.kts -+++ b/build.gradle.kts -@@ -11,7 +11,7 @@ - coordinates( - groupId = "io.github.anilbeesetti", - artifactId = property("POM_ARTIFACT_ID") as String, -- version = "1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3" -+ version = "1.10.0-0.12.1-fongmi-softload-av3a-avs3-ffmpeg901-r5" - ) - - pom { -diff --git a/ffmpeg/setup.sh b/ffmpeg/setup.sh ---- a/ffmpeg/setup.sh -+++ b/ffmpeg/setup.sh -@@ -1,4 +1,5 @@ - #!/bin/bash -+set -e - - # Versions - VPX_VERSION=1.13.0 -@@ -8,6 +9,7 @@ - - # Directories - BASE_DIR=$(cd "$(dirname "$0")" && pwd) -+WEBHTV_ROOT="${WEBHTV_ROOT:-$(cd "$BASE_DIR/../../../.." && pwd)}" - BUILD_DIR=$BASE_DIR/build - OUTPUT_DIR=$BASE_DIR/output - SOURCES_DIR=$BASE_DIR/sources -@@ -18,7 +20,7 @@ - # Configuration - ANDROID_ABIS="armeabi-v7a arm64-v8a" - ANDROID_PLATFORM=21 --ENABLED_DECODERS="vorbis opus flac alac pcm_mulaw pcm_alaw mp3 amrnb amrwb aac ac3 eac3 dca mlp truehd ape libarcdav3a h264 hevc mpeg2video mpegvideo libvpx_vp8 libvpx_vp9" -+ENABLED_DECODERS="vorbis opus flac alac pcm_mulaw pcm_alaw mp3 amrnb amrwb aac ac3 eac3 dca mlp truehd ape libarcdav3a h264 hevc mpeg2video mpegvideo libvpx_vp8 libvpx_vp9 libuavs3d" - JOBS=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || sysctl -n hw.pysicalcpu || echo 4) - - # Set up host platform variables -@@ -213,6 +215,22 @@ - - done - popd -+} -+ -+function buildUavs3d() { -+ # Apply only this video wrapper correction, preserving the pinned AV3A source. -+ if ! patch -d "$FFMPEG_DIR" -p1 -R --dry-run < "$WEBHTV_ROOT/third_party/patches/ffmpeg-avs3.patch" > /dev/null 2>&1; then -+ patch -d "$FFMPEG_DIR" -p1 --forward < "$WEBHTV_ROOT/third_party/patches/ffmpeg-avs3.patch" -+ fi -+ for ABI in $ANDROID_ABIS; do -+ bash "$WEBHTV_ROOT/scripts/build_avs3_native.sh" \ -+ --abi "$ABI" --ndk "$ANDROID_NDK_HOME" \ -+ --prefix "$BUILD_DIR/external/$ABI" \ -+ --work-dir "$BUILD_DIR/uavs3d/$ABI" --jobs "$JOBS" -+ done -+ mkdir -p "$BASE_DIR/../media3ext/src/main/assets/licenses" -+ cp "$WEBHTV_ROOT/third_party/avs3-hpm/LICENSE.HPM" "$BASE_DIR/../media3ext/src/main/assets/licenses/hpm-avs3.txt" -+ cp "$BUILD_DIR/external/arm64-v8a/share/licenses/hpm-avs3/SSE2NEON" "$BASE_DIR/../media3ext/src/main/assets/licenses/sse2neon.txt" - } - - function buildFfmpeg() { -@@ -293,6 +311,7 @@ - --enable-avformat \ - --enable-libvpx \ - --enable-libarcdav3a \ -+ --enable-libuavs3d \ - --enable-protocol=file,http,https,mmsh,mmst,pipe,rtmp,rtmps,rtmpt,rtmpts,rtp,tls \ - --enable-version3 \ - --enable-mbedtls \ -@@ -320,11 +339,12 @@ - } - - if [[ "${1:-}" == "--ffmpeg-only" ]]; then -+ buildUavs3d - buildFfmpeg - exit $? - fi - --if [[ ! -d "$OUTPUT_DIR" && ! -d "$BUILD_DIR" ]]; then -+if [[ ! -f "$OUTPUT_DIR/.webhtv-avs3-hpm15" ]]; then - # Download MbedTLS source code if it doesn't exist - if [[ ! -d "$MBEDTLS_DIR" ]]; then - downloadMbedTLS -@@ -344,5 +364,7 @@ - buildMbedTLS - buildLibVpx - buildArcDav3a -+ buildUavs3d - buildFfmpeg -+ touch "$OUTPUT_DIR/.webhtv-avs3-hpm15" - fi -diff --git a/media3ext/src/main/assets/licenses/uavs3d.txt b/media3ext/src/main/assets/licenses/uavs3d.txt -new file mode 100644 ---- /dev/null -+++ b/media3ext/src/main/assets/licenses/uavs3d.txt -@@ -0,0 +1,29 @@ -+uavs3/uavs3d -+https://github.com/uavs3/uavs3d -+Source: 0e20d2c291853f196c68922a264bcd8471d75b68 -+ -+Copyright (c) 2018-2022 ["Peking University Shenzhen Graduate School", "Peng Cheng Laboratory", and "Guangdong Bohua UHD Innovation Corporation"] -+ -+All rights reserved. -+ -+Redistribution and use in source and binary forms, with or without -+modification, are permitted provided that the following conditions are met: -+1. Redistributions of source code must retain the above copyright -+ notice, this list of conditions and the following disclaimer. -+2. Redistributions in binary form must reproduce the above copyright -+ notice, this list of conditions and the following disclaimer in the -+ documentation and/or other materials provided with the distribution. -+3. Neither the name of the organizations (Peking University Shenzhen Graduate School, Peng Cheng Laboratory and Guangdong Bohua UHD Innovation Corporation) -+ nor the names of its contributors may be used to endorse or promote products -+ derived from this software without specific prior written permission. -+ -+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ''AS IS'' AND ANY -+EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY -+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -diff --git a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java ---- a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java -+++ b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java -@@ -122,6 +122,7 @@ - case AUDIO_AV3A -> "libarcdav3a"; - - // Video codecs -+ case "video/avs3" -> "libuavs3d"; - case MimeTypes.VIDEO_H264 -> "h264"; - case MimeTypes.VIDEO_H265 -> "hevc"; - case MimeTypes.VIDEO_MPEG -> "mpegvideo"; -diff --git a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoDecoder.java b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoDecoder.java ---- a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoDecoder.java -+++ b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoDecoder.java -@@ -79,6 +79,13 @@ - codecName = Assertions.checkNotNull(FfmpegLibrary.getCodecName(format.sampleMimeType)); - extraData = getExtraData(format.sampleMimeType, format.initializationData); - this.format = format; -+ // AVS3 has no reduced-resolution decode contract. Keep its in-loop -+ // filters and references even when the general soft-video tune is on. -+ if ("video/avs3".equals(format.sampleMimeType)) { -+ skipFrame = 0; -+ skipLoopFilter = 0; -+ lowres = 0; -+ } - nativeContext = ffmpegInitialize(codecName, extraData, threads, skipFrame, skipLoopFilter, lowres); - if (nativeContext == 0) { - throw new FfmpegDecoderException("Failed to initialize decoder."); -@@ -102,7 +109,7 @@ - System.arraycopy(pps, 0, extraData, sps.length, pps.length); - return extraData; - } -- case MimeTypes.VIDEO_H265 -> { -+ case MimeTypes.VIDEO_H265, "video/avs3" -> { - return initializationData.get(0); - } - default -> { -diff --git a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoRenderer.java b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoRenderer.java ---- a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoRenderer.java -+++ b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoRenderer.java -@@ -23,7 +23,7 @@ - - - @UnstableApi --public final class FfmpegVideoRenderer extends DecoderVideoRenderer { -+public class FfmpegVideoRenderer extends DecoderVideoRenderer { - - private static final String TAG = "FfmpegVideoRenderer"; - -@@ -146,7 +146,7 @@ - - @Override - @RendererCapabilities.Capabilities -- public final int supportsFormat(Format format) { -+ public int supportsFormat(Format format) { - String mimeType = Assertions.checkNotNull(format.sampleMimeType); - if (!FfmpegLibrary.isAvailable() || !MimeTypes.isVideo(mimeType)) { - return C.FORMAT_UNSUPPORTED_TYPE; diff --git a/third_party/patches/nextlib-ffmpeg-soft-load-shedding.patch b/third_party/patches/nextlib-ffmpeg-soft-load-shedding.patch index ceb22764a6..5e58200250 100644 --- a/third_party/patches/nextlib-ffmpeg-soft-load-shedding.patch +++ b/third_party/patches/nextlib-ffmpeg-soft-load-shedding.patch @@ -17,24 +17,105 @@ index 1413f8d..18acf58 100644 pom { diff --git a/ffmpeg/setup.sh b/ffmpeg/setup.sh -index 7389538..09f6fcd 100755 +index 7389538..e68e4d0 100755 --- a/ffmpeg/setup.sh +++ b/ffmpeg/setup.sh -@@ -120,9 +120,10 @@ function buildLibVpx() { +@@ -1,5 +1,7 @@ + #!/bin/bash + ++set -euo pipefail ++ + # Versions + VPX_VERSION=1.13.0 + MBEDTLS_VERSION=3.4.1 +@@ -15,10 +17,10 @@ VPX_DIR=$SOURCES_DIR/libvpx-$VPX_VERSION + MBEDTLS_DIR=$SOURCES_DIR/mbedtls-$MBEDTLS_VERSION + + # Configuration +-ANDROID_ABIS="x86 x86_64 armeabi-v7a arm64-v8a" ++ANDROID_ABIS="${ANDROID_ABIS:-x86 x86_64 armeabi-v7a arm64-v8a}" + ANDROID_PLATFORM=21 +-ENABLED_DECODERS="vorbis opus flac alac pcm_mulaw pcm_alaw mp3 amrnb amrwb aac ac3 eac3 dca mlp truehd h264 hevc mpeg2video mpegvideo libvpx_vp8 libvpx_vp9" +-JOBS=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || sysctl -n hw.pysicalcpu || echo 4) ++ENABLED_DECODERS="vorbis opus flac alac pcm_mulaw pcm_alaw mp3 amrnb amrwb aac ac3 eac3 dca mlp truehd h264 hevc mpeg2video mpegvideo vc1 libvpx_vp8 libvpx_vp9" ++JOBS="${JOBS:-$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || sysctl -n hw.pysicalcpu || echo 4)}" + + # Set up host platform variables + HOST_PLATFORM="linux-x86_64" +@@ -37,20 +39,33 @@ esac + TOOLCHAIN_PREFIX="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/${HOST_PLATFORM}" + CMAKE_EXECUTABLE="${ANDROID_SDK_HOME}/cmake/${ANDROID_CMAKE_VERSION}/bin/cmake" + +-# Check if sdkmanager is in PATH +-if command -v sdkmanager &> /dev/null; then +- # Use sdkmanager from PATH +- echo "Using sdkmanager from PATH" +- echo y | sdkmanager --sdk_root="${ANDROID_SDK_HOME}" "cmake;${ANDROID_CMAKE_VERSION}" +-else +- # Use sdkmanager from Android SDK +- SDKMANAGER_EXECUTABLE="${ANDROID_SDK_HOME}/cmdline-tools/latest/bin/sdkmanager" +- if [[ -x "$SDKMANAGER_EXECUTABLE" ]]; then +- echo "Using sdkmanager from Android SDK" +- echo y | "$SDKMANAGER_EXECUTABLE" --sdk_root="${ANDROID_SDK_HOME}" "cmake;${ANDROID_CMAKE_VERSION}" ++function setCompilerCommands() { ++ TARGET=${TOOLCHAIN%-} ++ if [[ "$HOST_PLATFORM" == windows* ]]; then ++ CC_BIN="${TOOLCHAIN_PREFIX}/bin/clang.exe --target=${TARGET}" ++ CXX_BIN="${TOOLCHAIN_PREFIX}/bin/clang++.exe --target=${TARGET}" ++ else ++ CC_BIN="${TOOLCHAIN_PREFIX}/bin/${TOOLCHAIN}clang" ++ CXX_BIN="${CC_BIN}++" ++ fi ++} ++ ++if [[ ! -x "$CMAKE_EXECUTABLE" && ! -x "${CMAKE_EXECUTABLE}.exe" ]]; then ++ # Check if sdkmanager is in PATH ++ if command -v sdkmanager &> /dev/null; then ++ # Use sdkmanager from PATH ++ echo "Using sdkmanager from PATH" ++ echo y | sdkmanager --sdk_root="${ANDROID_SDK_HOME}" "cmake;${ANDROID_CMAKE_VERSION}" + else +- echo "Error: sdkmanager not found in PATH or Android SDK" +- exit 1 ++ # Use sdkmanager from Android SDK ++ SDKMANAGER_EXECUTABLE="${ANDROID_SDK_HOME}/cmdline-tools/latest/bin/sdkmanager" ++ if [[ -x "$SDKMANAGER_EXECUTABLE" ]]; then ++ echo "Using sdkmanager from Android SDK" ++ echo y | "$SDKMANAGER_EXECUTABLE" --sdk_root="${ANDROID_SDK_HOME}" "cmake;${ANDROID_CMAKE_VERSION}" ++ else ++ echo "Error: sdkmanager not found in PATH or Android SDK" ++ exit 1 ++ fi + fi + fi + +@@ -92,8 +107,8 @@ function downloadFfmpeg() { + function buildLibVpx() { + pushd $VPX_DIR + +- VPX_AS=${TOOLCHAIN_PREFIX}/bin/llvm-as + for ABI in $ANDROID_ABIS; do ++ VPX_AS=${TOOLCHAIN_PREFIX}/bin/llvm-as + # Set up environment variables + case $ABI in + armeabi-v7a) +@@ -120,9 +135,12 @@ function buildLibVpx() { ;; esac - CC=${TOOLCHAIN_PREFIX}/bin/${TOOLCHAIN}clang \ - CXX=${CC}++ \ - LD=${CC} \ -+ CC_BIN=${TOOLCHAIN_PREFIX}/bin/${TOOLCHAIN}clang ++ make clean >/dev/null 2>&1 || true ++ ++ setCompilerCommands + CC=${CC_BIN} \ -+ CXX=${CC_BIN}++ \ ++ CXX=${CXX_BIN} \ + LD=${CC_BIN} \ AR=${TOOLCHAIN_PREFIX}/bin/llvm-ar \ AS=${VPX_AS} \ STRIP=${TOOLCHAIN_PREFIX}/bin/llvm-strip \ -@@ -137,6 +138,7 @@ function buildLibVpx() { +@@ -137,6 +155,7 @@ function buildLibVpx() { --disable-shared \ --disable-examples \ --disable-docs \ @@ -42,7 +123,15 @@ index 7389538..09f6fcd 100755 --enable-realtime-only \ --enable-install-libs \ --enable-multithread \ -@@ -156,6 +158,8 @@ function buildLibVpx() { +@@ -146,7 +165,6 @@ function buildLibVpx() { + --disable-runtime-cpu-detect \ + ${EXTRA_BUILD_FLAGS} + +- make clean + make -j$JOBS + make install + done +@@ -156,6 +174,8 @@ function buildLibVpx() { function buildMbedTLS() { pushd $MBEDTLS_DIR @@ -51,7 +140,12 @@ index 7389538..09f6fcd 100755 for ABI in $ANDROID_ABIS; do CMAKE_BUILD_DIR=$MBEDTLS_DIR/mbedtls_build_${ABI} -@@ -168,7 +172,10 @@ function buildMbedTLS() { +@@ -164,11 +184,15 @@ function buildMbedTLS() { + cd ${CMAKE_BUILD_DIR} + + ${CMAKE_EXECUTABLE} .. \ ++ -G "Unix Makefiles" \ + -DANDROID_PLATFORM=${ANDROID_PLATFORM} \ -DANDROID_ABI=$ABI \ -DCMAKE_TOOLCHAIN_FILE=${ANDROID_NDK_HOME}/build/cmake/android.toolchain.cmake \ -DCMAKE_INSTALL_PREFIX=$BUILD_DIR/external/$ABI \ @@ -62,6 +156,71 @@ index 7389538..09f6fcd 100755 -DENABLE_TESTING=0 make -j$JOBS +@@ -190,6 +214,7 @@ function buildFfmpeg() { + + # Build FFmpeg for each architecture and platform + for ABI in $ANDROID_ABIS; do ++ EXTRA_BUILD_CONFIGURATION_FLAGS="" + + # Set up environment variables + case $ABI in +@@ -220,6 +245,8 @@ function buildFfmpeg() { + ;; + esac + ++ setCompilerCommands ++ + # Referencing dependencies without pkgconfig + DEP_CFLAGS="-I$BUILD_DIR/external/$ABI/include" + DEP_LD_FLAGS="-L$BUILD_DIR/external/$ABI/lib" +@@ -231,6 +258,9 @@ function buildFfmpeg() { + --arch=$ARCH \ + --cpu=$CPU \ + --cross-prefix="${TOOLCHAIN_PREFIX}/bin/$TOOLCHAIN" \ ++ --cc="${CC_BIN}" \ ++ --cxx="${CXX_BIN}" \ ++ --ld="${CC_BIN}" \ + --nm="${TOOLCHAIN_PREFIX}/bin/llvm-nm" \ + --ar="${TOOLCHAIN_PREFIX}/bin/llvm-ar" \ + --ranlib="${TOOLCHAIN_PREFIX}/bin/llvm-ranlib" \ +@@ -250,14 +280,11 @@ function buildFfmpeg() { + --disable-postproc \ + --disable-avfilter \ + --disable-symver \ ++ --disable-network \ + --enable-parsers \ +- --enable-demuxers \ + --enable-swresample \ +- --enable-avformat \ + --enable-libvpx \ +- --enable-protocol=file,http,https,mmsh,mmst,pipe,rtmp,rtmps,rtmpt,rtmpts,rtp,tls \ + --enable-version3 \ +- --enable-mbedtls \ + --extra-ldexeflags=-pie \ + --disable-debug \ + ${EXTRA_BUILD_CONFIGURATION_FLAGS} \ +@@ -281,12 +308,7 @@ function buildFfmpeg() { + popd + } + +-if [[ ! -d "$OUTPUT_DIR" && ! -d "$BUILD_DIR" ]]; then +- # Download MbedTLS source code if it doesn't exist +- if [[ ! -d "$MBEDTLS_DIR" ]]; then +- downloadMbedTLS +- fi +- ++if [[ "${FORCE_BUILD:-0}" == "1" || ( ! -d "$OUTPUT_DIR" && ! -d "$BUILD_DIR" ) ]]; then + # Download Vpx source code if it doesn't exist + if [[ ! -d "$VPX_DIR" ]]; then + downloadLibVpx +@@ -298,7 +320,6 @@ if [[ ! -d "$OUTPUT_DIR" && ! -d "$BUILD_DIR" ]]; then + fi + + # Building library +- buildMbedTLS + buildLibVpx + buildFfmpeg + fi diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a3cf0d..4892413 100644 --- a/gradle/libs.versions.toml @@ -83,7 +242,7 @@ index 3a3cf0d..4892413 100644 [libraries] diff --git a/media3ext/src/main/cpp/ffvideo.cpp b/media3ext/src/main/cpp/ffvideo.cpp -index 4c37d03..33a7727 100644 +index 4c37d03..3ed7f07 100644 --- a/media3ext/src/main/cpp/ffvideo.cpp +++ b/media3ext/src/main/cpp/ffvideo.cpp @@ -84,7 +84,10 @@ struct JniContext { @@ -133,15 +292,20 @@ index 4c37d03..33a7727 100644 } extern "C" -@@ -390,4 +404,4 @@ Java_io_github_anilbeesetti_nextlib_media3ext_ffdecoder_FfmpegVideoDecoder_ffmpe - av_frame_free(&frame); - - return result; --} -\ No newline at end of file -+} +diff --git a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java +index ccaf284..c0c67fb 100644 +--- a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java ++++ b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegLibrary.java +@@ -124,6 +124,7 @@ public final class FfmpegLibrary { + case MimeTypes.VIDEO_MPEG2 -> "mpeg2video"; + case MimeTypes.VIDEO_VP8 -> "libvpx"; + case MimeTypes.VIDEO_VP9 -> "libvpx-vp9"; ++ case "video/wvc1" -> "vc1"; + default -> null; + }; + } diff --git a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoDecoder.java b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoDecoder.java -index b4e2f3f..d430b34 100644 +index b4e2f3f..284d9d3 100644 --- a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoDecoder.java +++ b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoDecoder.java @@ -48,11 +48,28 @@ final class FfmpegVideoDecoder extends @@ -183,7 +347,17 @@ index b4e2f3f..d430b34 100644 if (nativeContext == 0) { throw new FfmpegDecoderException("Failed to initialize decoder."); } -@@ -197,7 +214,7 @@ final class FfmpegVideoDecoder extends +@@ -88,6 +105,9 @@ final class FfmpegVideoDecoder extends + case MimeTypes.VIDEO_H265 -> { + return initializationData.get(0); + } ++ case "video/wvc1" -> { ++ return initializationData.get(0); ++ } + default -> { + // Other codecs do not require extra data. + return null; +@@ -197,7 +217,7 @@ final class FfmpegVideoDecoder extends } } @@ -192,13 +366,6 @@ index b4e2f3f..d430b34 100644 private native long ffmpegReset(long context); -@@ -232,4 +249,4 @@ final class FfmpegVideoDecoder extends - private native int ffmpegReceiveFrame( - long context, int outputMode, VideoDecoderOutputBuffer outputBuffer, boolean decodeOnly); - --} -\ No newline at end of file -+} diff --git a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoRenderer.java b/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoRenderer.java index 0e0991b..2f9ab29 100644 --- a/media3ext/src/main/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVideoRenderer.java